refactor(roadtrip): roadtrip tables through repositories

This commit is contained in:
jubnl
2026-09-23 14:44:10 +02:00
parent 93324e71c4
commit 3a26d03b00
39 changed files with 1790 additions and 607 deletions
@@ -147,6 +147,78 @@ interface AssignmentTimeSortKyselyDB {
};
}
/**
* RPL2's row, exactly as `RoadtripPlanService.context`'s old local `VisitRow`
* interface declared it — the stay the visit stands for on its check-in day
* (never `a.accommodation_id`, see the method docstring), and the checkout
* day's `day_number`, both `null` for a visit with no linked stay.
*/
export interface RoadtripVisitRow {
id: number;
day_id: number;
place_id: number;
name: string;
lat: number | null;
lng: number | null;
time: string | null;
end_time: string | null;
duration_minutes: number | null;
end_day: number;
leg_transport_mode: string | null;
incoming_leg_transport_mode: string | null;
stop_type: string | null;
fill_percent: number | null;
stay_id: number | null;
check_in: string | null;
check_out: string | null;
checkout_day: number | null;
}
/**
* `day_assignments`/`days`/`places`/`day_accommodations`'s shape for RPL2
* (`listRoadtripVisits`), the same Kysely-typed-DB-interface shape
* `AssignmentTimeSortKyselyDB` above uses. `days` is joined twice under two
* aliases (`d`, `checkout`) — both resolve against this one `days` entry.
*/
interface RoadtripVisitsKyselyDB {
day_assignments: {
id: number;
day_id: number;
place_id: number;
order_index: number | null;
created_at: string | null;
assignment_time: string | null;
assignment_end_time: string | null;
end_day: number;
leg_transport_mode: string | null;
incoming_leg_transport_mode: string | null;
};
days: {
id: number;
trip_id: number;
day_number: number;
};
places: {
id: number;
name: string;
lat: number | null;
lng: number | null;
place_time: string | null;
end_time: string | null;
duration_minutes: number | null;
stop_type: string | null;
fill_percent: number | null;
};
day_accommodations: {
id: number;
place_id: number;
start_day_id: number;
end_day_id: number | null;
check_in: string | null;
check_out: string | null;
};
}
export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
/** The DY1/DY3/AS1/AS3 projection's SELECT list, shared by all three query shapes below. */
private assignmentWithPlaceSelect(platform: Platform) {
@@ -624,4 +696,88 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
async setAccommodation(id: number, accommodation_id: number): Promise<void> {
await this.nativeUpdate({ id }, { accommodation_id });
}
// ---------------------------------------------------------------------------
// Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own
// file-ownership rule ("all additive methods on Days/Places/DayAssignments
// repositories").
// ---------------------------------------------------------------------------
/**
* RPL2 (`roadtrip-plan.service.ts::context`'s `visits` read) — the
* cluster's most dialect-specific SELECT (inventory §17b):
*
* ```sql
* SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
* COALESCE(a.assignment_time, p.place_time) AS time,
* COALESCE(a.assignment_end_time, p.end_time) AS end_time,
* p.duration_minutes, a.end_day,
* a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent,
* stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
* FROM day_assignments a
* JOIN days d ON d.id = a.day_id
* JOIN places p ON p.id = a.place_id
* LEFT JOIN day_accommodations stay
* ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
* LEFT JOIN days checkout ON checkout.id = stay.end_day_id
* WHERE d.trip_id = ?
* ORDER BY d.day_number, a.order_index, a.created_at
* ```
*
* A correlated scalar subquery INSIDE a `LEFT JOIN … ON` clause — no
* QueryBuilder shape expresses this (§17b), and no MikroORM relation
* connects a place+check-in-day pair to its `day_accommodations` row (the
* stay is matched by `place_id`+`start_day_id`, not by
* `a.accommodation_id` — deliberately: the inventory's own RPL2 note),
* so this is `this.kysely()` from the start, same escape-hatch order as
* `reanchorToDay`/`effectiveStart`/`listForTimeSort` above. `days` is
* joined twice under two different aliases (`d`, `checkout`) — ordinary
* Kysely self-join aliasing, no separate interface entry needed since both
* resolve against the same `days` table shape.
*/
async listRoadtripVisits(trip_id: number): Promise<RoadtripVisitRow[]> {
const rows = await this.kysely<RoadtripVisitsKyselyDB>()
.selectFrom('day_assignments as a')
.innerJoin('days as d', 'd.id', 'a.day_id')
.innerJoin('places as p', 'p.id', 'a.place_id')
.leftJoin('day_accommodations as stay', (join) =>
join.on('stay.id', '=', (eb) =>
eb
.selectFrom('day_accommodations as da2')
.select('da2.id')
.whereRef('da2.place_id', '=', 'p.id')
.whereRef('da2.start_day_id', '=', 'd.id')
.orderBy('da2.id', 'asc')
.limit(1),
),
)
.leftJoin('days as checkout', 'checkout.id', 'stay.end_day_id')
.select((eb) => [
'a.id as id',
'a.day_id as day_id',
'a.place_id as place_id',
'p.name as name',
'p.lat as lat',
'p.lng as lng',
eb.fn.coalesce('a.assignment_time', 'p.place_time').as('time'),
eb.fn.coalesce('a.assignment_end_time', 'p.end_time').as('end_time'),
'p.duration_minutes as duration_minutes',
'a.end_day as end_day',
'a.leg_transport_mode as leg_transport_mode',
'a.incoming_leg_transport_mode as incoming_leg_transport_mode',
'p.stop_type as stop_type',
'p.fill_percent as fill_percent',
'stay.id as stay_id',
'stay.check_in as check_in',
'stay.check_out as check_out',
'checkout.day_number as checkout_day',
])
.where('d.trip_id', '=', trip_id)
.orderBy('d.day_number', 'asc')
.orderBy('a.order_index', 'asc')
.orderBy('a.created_at', 'asc')
.$castTo<RoadtripVisitRow>()
.execute();
return rows;
}
}
@@ -31,6 +31,15 @@ export interface DayIdAndNumberRow {
day_number: number;
}
/** RPL1's projection: `SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number`. */
export interface PlanDayRow {
id: number;
day_number: number;
date: string | null;
title: string | null;
default_transport_mode: string | null;
}
export class DaysRepository extends TrekRepository<Days> {
/** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */
async listByTrip(trip_id: number): Promise<DayRow[]> {
@@ -210,6 +219,25 @@ export class DaysRepository extends TrekRepository<Days> {
return row ?? undefined;
}
// ---------------------------------------------------------------------------
// Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own
// file-ownership rule ("all additive methods on Days/Places/DayAssignments
// repositories").
// ---------------------------------------------------------------------------
/**
* RPL1 (`roadtrip-plan.service.ts::context`) — `SELECT id, day_number,
* date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY
* day_number`.
*/
async listPlanDays(trip_id: number): Promise<PlanDayRow[]> {
return await this.qb('d')
.select(['d.id', 'd.day_number', 'd.date', 'd.title', 'd.default_transport_mode'])
.where({ trip: trip_id })
.orderBy({ day_number: 'asc' })
.execute<PlanDayRow[]>('all', false);
}
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService`) — appended per the task's own
// file-ownership rule ("additive methods on Days/Places repositories if
@@ -832,4 +832,37 @@ export class PlacesRepository extends TrekRepository<Places> {
fill_percent: input.fill_percent,
});
}
// ---------------------------------------------------------------------------
// Plan 3d Task 1 (`RoadtripService`/`ChargingService`) — additive, per this
// task's own file-ownership rule ("all additive methods on Days/Places/
// DayAssignments repositories").
// ---------------------------------------------------------------------------
/**
* RT13 (`RoadtripService.trackExists`) — `SELECT id FROM places WHERE id =
* ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry !=
* ''`. Two `.andWhere()` calls (rule 23 — typed conditions, not an inline
* SQL string) rather than one `$and` array: the two operators (`$ne: null`
* for `IS NOT NULL`, `$ne: ''` for `!= ''`) target the SAME property, so a
* single filter object literal cannot carry both keys.
*/
async isTrackInTrip(id: number, trip_id: number): Promise<boolean> {
const row = await this.qb('p')
.select(['p.id'])
.where({ id, trip: trip_id })
.andWhere({ route_geometry: { $ne: null } })
.andWhere({ route_geometry: { $ne: '' } })
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/** CH1 (`ChargingService.read`) — `SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?`. */
async findChargingProbe(id: number, trip_id: number): Promise<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined> {
const row = await this.qb('p')
.select(['p.name', 'p.lat', 'p.lng', 'p.stop_type'])
.where({ id, trip: trip_id })
.execute<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined>('get', false);
return row ?? undefined;
}
}
@@ -1,4 +1,65 @@
import type { RoadtripDayBoundary } from '@trek/shared';
import type { RoadtripDayBoundaries } from '../entities/RoadtripDayBoundaries.entity';
import { columnRef } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class RoadtripDayBoundariesRepository extends TrekRepository<RoadtripDayBoundaries> {}
/**
* `roadtrip_day_boundaries` — where a manual day ending sits (a stop, or a
* fraction along the driving leg between two consecutive stops), overriding
* the automatic day-ending rule daily travel times uses.
*/
export class RoadtripDayBoundariesRepository extends TrekRepository<RoadtripDayBoundaries> {
/**
* RB1 — `SELECT day_number, from_assignment_id, to_assignment_id, fraction
* FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number`.
*
* `columnRef` for `from_assignment_id`/`to_assignment_id` — both are
* `persist(false)` mirrors of the `fromAssignment`/`toAssignment`
* relations, and selecting either bare (`'b.from_assignment_id'`) drops it
* from the result silently, EVEN with no active join on that relation
* (verified directly here, not assumed from `PlaceRatingsRepository
* .listForPlaces`'s narrower, join-only framing of the same trap).
*/
async listForTrip(trip_id: number): Promise<RoadtripDayBoundary[]> {
const platform = this.getEntityManager().getPlatform();
return await this.qb('b')
.select(['b.day_number', columnRef(platform, 'b.from_assignment_id'), columnRef(platform, 'b.to_assignment_id'), 'b.fraction'])
.where({ trip: trip_id })
.orderBy({ day_number: 'asc' })
.execute<RoadtripDayBoundary[]>('all', false);
}
/**
* RB3 — `INSERT INTO roadtrip_day_boundaries (trip_id, day_number,
* from_assignment_id, to_assignment_id, fraction) VALUES (?×5) ON CONFLICT
* (trip_id, day_number) DO UPDATE SET from_assignment_id =
* excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id,
* fraction = excluded.fraction`.
*
* `onConflictFields: ['trip', 'day_number']` — the composite PK the
* migration and the entity's own `[PrimaryKeyProp]?: ['trip',
* 'day_number']` both declare; `onConflictMergeFields` names the three
* relation/scalar properties the legacy SET list names (`fromAssignment`/
* `toAssignment`, not their `persist(false)` `_id` mirrors —
* `OauthConsentsRepository.upsertGrant`'s precedent), so a conflict never
* touches `trip`/`day_number` themselves (the key) or any future column
* this entity gains.
*/
async upsertBoundary(trip_id: number, boundary: RoadtripDayBoundary): Promise<void> {
await this.upsert(
{
trip: trip_id,
day_number: boundary.day_number,
fromAssignment: boundary.from_assignment_id,
toAssignment: boundary.to_assignment_id,
fraction: boundary.fraction,
},
{ onConflictFields: ['trip', 'day_number'], onConflictAction: 'merge', onConflictMergeFields: ['fromAssignment', 'toAssignment', 'fraction'] },
);
}
/** RB4 — `DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?`, silent on a miss. */
async deleteForDay(trip_id: number, day_number: number): Promise<void> {
await this.nativeDelete({ trip: trip_id, day_number });
}
}
@@ -1,4 +1,71 @@
import type { RoadtripDayTrack } from '@trek/shared';
import type { RoadtripDayTracks } from '../entities/RoadtripDayTracks.entity';
import { columnRef } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class RoadtripDayTracksRepository extends TrekRepository<RoadtripDayTracks> {}
/**
* `roadtrip_day_tracks` — which recorded track (a `places` row with route
* geometry) a day's drive was fitted to, and how far the fitted route still
* strayed from it.
*/
export class RoadtripDayTracksRepository extends TrekRepository<RoadtripDayTracks> {
/** RT7 — `DELETE FROM roadtrip_day_tracks WHERE day_id = ?` (`createMany`'s explicit `track: null` clear). */
async deleteForDay(day_id: number): Promise<void> {
await this.nativeDelete({ day: day_id });
}
/**
* RT8 — `INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km)
* VALUES (?, ?, ?) ON CONFLICT(day_id) DO UPDATE SET place_id =
* excluded.place_id, stray_km = excluded.stray_km`.
*
* `onConflictFields: ['day']` — the RELATION property, not the `day_id`
* scalar mirror (`persist(false)`, not a real column MikroORM's upsert
* builder can target) — the same naming `PlaceRatingsRepository
* .upsertRating`/`TripMembersRepository`'s own composite-key upserts use
* for a relation-backed conflict target. `RoadtripDayTracks.day` is this
* table's PRIMARY KEY (a nullable one-to-one, per Task 0's `RULE12`
* finding — ruled NOT a generator defect, matching the `VacayUserSettings`
* precedent), so the single-element list targets the table's actual PK,
* `roadtrip_day_tracks(day_id)`. Proven, not assumed: `RoadtripDayTracks
* .repository.test.ts` (`UPSERTTRACKREPO-*`) upserts the SAME `day_id`
* twice with different `place_id`/`stray_km` and asserts exactly one row
* survives with the SECOND call's values — if `onConflictFields: ['day']`
* resolved to anything other than the physical `day_id` column, SQLite's
* own `ON CONFLICT` target-matching would either throw (no matching
* unique index) or let the second call's row collide on the real PK and
* raise `UNIQUE constraint failed`, so the test is a genuine proof, not a
* behavioural coincidence. `created_at` is named in neither
* `onConflictMergeFields` NOR the insert payload beyond its own default,
* so a conflict never refreshes it (pinned — the legacy statement's SET
* list never named it either).
*/
async upsertTrack(day_id: number, place_id: number, stray_km: number | null): Promise<void> {
await this.upsert(
{ day: day_id, place: place_id, stray_km },
{ onConflictFields: ['day'], onConflictAction: 'merge', onConflictMergeFields: ['place', 'stray_km'] },
);
}
/**
* RT12 — `SELECT t.day_id, t.place_id, t.stray_km FROM roadtrip_day_tracks
* t JOIN days d ON d.id = t.day_id WHERE d.trip_id = ? ORDER BY t.day_id`.
*
* `columnRef` for both `day_id` (actively joined via `t.day`) and
* `place_id` (not joined here, but selected the same defensive way for
* consistency with every other persist(false) mirror in this cluster —
* `PlaceRatingsRepository.listForPlaces`'s docstring is the verified trap
* this sidesteps). Ordered by `d.id` (`= t.day_id` by the join condition)
* rather than the mirror column itself, the same reason
* `RoadtripViasRepository.listForTrip` orders by `d.id`.
*/
async listForTrip(trip_id: number): Promise<RoadtripDayTrack[]> {
const platform = this.getEntityManager().getPlatform();
return await this.qb('t')
.join('t.day', 'd')
.select([columnRef(platform, 't.day_id'), columnRef(platform, 't.place_id'), 't.stray_km'])
.where({ 'd.trip': trip_id })
.orderBy({ 'd.id': 'asc' })
.execute<RoadtripDayTrack[]>('all', false);
}
}
@@ -1,4 +1,35 @@
import type { RoadtripPreferences } from '../entities/RoadtripPreferences.entity';
import { TrekRepository } from './_shared/trek-repository';
export class RoadtripPreferencesRepository extends TrekRepository<RoadtripPreferences> {}
/** RPF1's raw row — the JSON parse (with its raw-string fallback) stays in the service. */
export interface RoadtripPreferenceRow {
key: string;
value: string;
}
/**
* `roadtrip_preferences` — a trip-scoped key/value store, one row per
* preference key, values JSON-encoded in the service.
*/
export class RoadtripPreferencesRepository extends TrekRepository<RoadtripPreferences> {
/** RPF1 — `SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?` (no ORDER BY, matching the legacy statement). */
async listForTrip(trip_id: number): Promise<RoadtripPreferenceRow[]> {
return await this.qb('r')
.select(['r.key', 'r.value'])
.where({ trip: trip_id })
.execute<RoadtripPreferenceRow[]>('all', false);
}
/**
* RPF3 — `INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES
* (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value`.
*
* `onConflictFields: ['trip', 'key']` — the composite PK
* (`[PrimaryKeyProp]?: ['trip', 'key']`); `onConflictMergeFields:
* ['value']` matches the legacy SET list exactly (never touches `trip`/
* `key`, the key itself).
*/
async upsertValue(trip_id: number, key: string, value: string): Promise<void> {
await this.upsert({ trip: trip_id, key, value }, { onConflictFields: ['trip', 'key'], onConflictAction: 'merge', onConflictMergeFields: ['value'] });
}
}
@@ -1,4 +1,175 @@
import type { RoadtripVia } from '@trek/shared';
import type { AnchoredVia } from '@trek/shared/roadtrip';
import type { RoadtripVias } from '../entities/RoadtripVias.entity';
import { columnRef, maxOf } from '../dialect/sql-functions';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class RoadtripViasRepository extends TrekRepository<RoadtripVias> {}
const _roadtripViaRowKeys: AssertRowKeys<RoadtripVia, RoadtripVias> = true;
/** RT18/AC13 — the OLD anchor a re-anchor reads before any write. */
export interface RoadtripViaAnchorRow {
id: number;
after_order_index: number;
sequence: number;
}
/** RT21 — the same day's vias without `sequence` (the renumber pass reads it separately). */
export interface RoadtripViaLegRow {
id: number;
after_order_index: number;
}
/**
* `roadtrip_vias` — the points a day's drive is routed through without stopping.
*
* `RT2` (`listForDay`) is the ONE copy of that projection: `AccommodationsService`
* (AC16, Task 3) and `AssignmentsService` (AS23, `listDayVias`) both call it now,
* retiring the "deliberate duplicate" the legacy `AssignmentsService` docstring
* used to carry.
*/
export class RoadtripViasRepository extends TrekRepository<RoadtripVias> {
/** RT2/AC16/AS23 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE day_id = ? ORDER BY after_order_index, sequence, id`. */
async listForDay(day_id: number): Promise<RoadtripVia[]> {
const vias = await this.find({ day: day_id }, { orderBy: { after_order_index: 'asc', sequence: 'asc', id: 'asc' } });
return vias.map((v) => toRow(v) as RoadtripVia);
}
/**
* RT3 — `SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng,
* v.created_at FROM roadtrip_vias v JOIN days d ON d.id = v.day_id WHERE
* d.trip_id = ? ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`.
*
* Ordered by `d.id` rather than the joined table's own `v.day_id` (always the
* same value, by the join condition): `v.day_id` is a `persist(false)` mirror
* of the `day` relation and selecting or ordering by it directly, alongside an
* active join on that same relation, is the exact trap `PlaceRatingsRepository
* .listForPlaces`'s docstring documents (the column silently aliases off the
* JOINED entity instead) — `columnRef` below sidesteps it for the SELECT list
* the same way `AssignmentWithPlaceRow`'s `category_id` does; ordering by
* `d.id` sidesteps it for the ORDER BY without needing a third mechanism.
*/
async listForTrip(trip_id: number): Promise<RoadtripVia[]> {
const platform = this.getEntityManager().getPlatform();
return await this.qb('v')
.join('v.day', 'd')
.select(['v.id', columnRef(platform, 'v.day_id'), 'v.after_order_index', 'v.sequence', 'v.lat', 'v.lng', 'v.created_at'])
.where({ 'd.trip': trip_id })
.orderBy({ 'd.id': 'asc', 'v.after_order_index': 'asc', 'v.sequence': 'asc', 'v.id': 'asc' })
.execute<RoadtripVia[]>('all', false);
}
/**
* RT4/RT10 — `SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM
* roadtrip_vias WHERE day_id = ? AND after_order_index = ?`, read as a plain
* `MAX()` (`maxOf`) folded to the same result in JS: no rows (or an
* all-`NULL` `sequence`, impossible here since the column is `NOT NULL
* DEFAULT 0`) reads back `null`, and `(null ?? -1) + 1 === 0`, matching
* `COALESCE(MAX(sequence) + 1, 0)`'s own null branch; a real max `m` reads
* back `m`, and `(m ?? -1) + 1 === m + 1`, matching `MAX(sequence) + 1`.
*/
async nextSequence(day_id: number, after_order_index: number): Promise<number> {
const platform = this.getEntityManager().getPlatform();
const row = await this.qb('v')
.select([maxOf(platform, 'v.sequence', 'max_seq')])
.where({ day: day_id, after_order_index })
.execute<{ max_seq: number | null } | undefined>('get', false);
return (row?.max_seq ?? -1) + 1;
}
/** RT5/RT11/TP43 — `INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?×5)`, returning the new id. */
async insertVia(input: { day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }): Promise<number> {
return await this.insert({
day: input.day_id,
after_order_index: input.after_order_index,
sequence: input.sequence,
lat: input.lat,
lng: input.lng,
});
}
/** RT9 — `DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?` (one leg's own vias, `replace_legs`). */
async deleteLeg(day_id: number, after_order_index: number): Promise<void> {
await this.nativeDelete({ day: day_id, after_order_index });
}
/** RT14 — `SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?`, the scoping check `move`'s two un-transacted UPDATEs (R7) rely on. */
async existsInDay(id: number, day_id: number): Promise<boolean> {
const row = await this.qb('v')
.select(['v.id'])
.where({ id, day: day_id })
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/**
* RT15 — `UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?`. No
* `day_id` scoping, matching the legacy statement exactly (R7 — an
* un-transacted check-then-act behind {@link existsInDay}; flagged, not
* fixed).
*/
async moveCoordinates(id: number, lat: number, lng: number): Promise<void> {
await this.nativeUpdate({ id }, { lat, lng });
}
/** RT16 — `UPDATE roadtrip_vias SET lat = ?, lng = ?, after_order_index = ? WHERE id = ?`. Same R7 scoping note as {@link moveCoordinates}. */
async moveCoordinatesAndAnchor(id: number, lat: number, lng: number, after_order_index: number): Promise<void> {
await this.nativeUpdate({ id }, { lat, lng, after_order_index });
}
/** RT18/AC13 — `SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — matches the legacy statement). */
async listAnchors(day_id: number): Promise<RoadtripViaAnchorRow[]> {
return await this.qb('v')
.select(['v.id', 'v.after_order_index', 'v.sequence'])
.where({ day: day_id })
.execute<RoadtripViaAnchorRow[]>('all', false);
}
/** RT21 — `SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — the caller's own JS sort is total, §18's RT21 note). */
async listLegs(day_id: number): Promise<RoadtripViaLegRow[]> {
return await this.qb('v')
.select(['v.id', 'v.after_order_index'])
.where({ day: day_id })
.execute<RoadtripViaLegRow[]>('all', false);
}
/** RT19/AS21 — `DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?`, silent on a miss (`reanchor`'s per-id remove). */
async deleteInDay(id: number, day_id: number): Promise<void> {
await this.nativeDelete({ id, day: day_id });
}
/** RT23 — the same statement as {@link deleteInDay}, but the caller (`remove`) needs the row count to answer 404 on a miss. */
async deleteInDayCounted(id: number, day_id: number): Promise<number> {
return await this.nativeDelete({ id, day: day_id });
}
/** RT20/AC15/AS22 — `UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?`, silent on a miss. */
async setAnchor(id: number, day_id: number, after_order_index: number): Promise<void> {
await this.nativeUpdate({ id, day: day_id }, { after_order_index });
}
/** RT22 — `UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?`, every row unconditionally (unlike AC17). */
async setSequence(id: number, day_id: number, sequence: number): Promise<void> {
await this.nativeUpdate({ id, day: day_id }, { sequence });
}
/** RT24 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?`. */
async findById(id: number): Promise<RoadtripVia | null> {
const via = await this.findOne({ id });
return via ? (toRow(via) as RoadtripVia) : null;
}
/**
* AS20 (`AssignmentsService.reanchorVias`) — `SELECT id, after_order_index,
* lat, lng FROM roadtrip_vias WHERE day_id = ?`, the `AnchoredVia` shape
* `reanchorByStopOrder` (`@trek/shared/roadtrip`) reads — a different
* projection from {@link listAnchors} (which carries `sequence`, not
* `lat`/`lng`), so it stays its own method rather than reusing one.
*/
async listForReanchor(day_id: number): Promise<AnchoredVia[]> {
return await this.qb('v')
.select(['v.id', 'v.after_order_index', 'v.lat', 'v.lng'])
.where({ day: day_id })
.execute<AnchoredVia[]>('all', false);
}
}
@@ -10,6 +10,7 @@ import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants
import { Days } from '../../db/entities/Days.entity';
import { Places } from '../../db/entities/Places.entity';
import { TripMembers } from '../../db/entities/TripMembers.entity';
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
/**
* The assignments SERVICE, split from the controller/MCP/RPC surfaces (the
@@ -21,13 +22,14 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
* none of the four service imports reaches the days or places domains.
*
* `MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days,
* Places, TripMembers])` registers `DayAssignmentsRepository`/
* Places, TripMembers, RoadtripVias])` registers `DayAssignmentsRepository`/
* `AssignmentParticipantsRepository`/`DaysRepository`/`PlacesRepository`/
* `TripMembersRepository` for `AssignmentsService`'s `@InjectRepository`
* constructor params (Plan 3c Task 3) — the entity classes only, not the
* `DaysModule`/`PlacesModule` modules themselves, so the loop this module's
* docstring already avoids stays avoided (`DaysModule`'s own precedent for
* pulling in `Trips` the same way).
* `TripMembersRepository`/`RoadtripViasRepository` for `AssignmentsService`'s
* `@InjectRepository` constructor params (Plan 3c Task 3; `RoadtripVias`
* added by Plan 3d Task 1 for AS20–AS23) — the entity classes only, not the
* `DaysModule`/`PlacesModule`/`RoadtripModule` modules themselves, so the
* loop this module's docstring already avoids stays avoided (`DaysModule`'s
* own precedent for pulling in `Trips` the same way).
*/
@Module({
imports: [
@@ -35,7 +37,7 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
QueryHelpersModule,
JourneyDomainModule,
RealtimeModule,
MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers]),
MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers, RoadtripVias]),
],
providers: [AssignmentsService],
exports: [AssignmentsService],
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { isEmptyReanchoring, reanchorByStopOrder, type AnchoredVia } from '@trek/shared/roadtrip';
import { isEmptyReanchoring, reanchorByStopOrder } from '@trek/shared/roadtrip';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService, type TripAccess } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -21,6 +21,8 @@ import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { TripMembers } from '../../db/entities/TripMembers.entity';
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
import type { RoadtripViasRepository } from '../../db/repositories/RoadtripVias.repository';
type Trip = TripAccess;
@@ -64,8 +66,12 @@ function sortMinutes(time: string | null): number | null {
* The day-assignment/participant SQL now lives in `DayAssignmentsRepository`/
* `AssignmentParticipantsRepository` (Plan 3c Task 3, consuming Task 2's
* `findWithPlaceAndCategory`/`listForDay` projection unchanged for AS1/AS3);
* AS20–AS23 (`roadtrip_vias`) stay raw on `DatabaseService`, `// ASn — Plan
* 3d` marked — that table belongs to Plan 3d. AS5 (`placeExists`) called
* AS20–AS23 (`roadtrip_vias`) now live in `RoadtripViasRepository` (Plan 3d
* Task 1) — AS20/AS21/AS22 convert inside `updateTime`'s existing
* transaction (legacy order, R7's non-transactional class does not apply
* here since these three already ran inside a transaction), and AS23
* (`listDayVias`) is retired in favour of the repository's own `listForDay`
* (RT2's one copy). AS5 (`placeExists`) called
* `PlacesRepository.existsInTrip` through Task 4's follow-up, once that file
* was free (it moved off an inlined `qb()` read this class carried for one
* session while `Places.repository.ts` was another task's exclusive file).
@@ -100,6 +106,7 @@ export class AssignmentsService {
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
@InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository,
@InjectRepository(RoadtripVias) private readonly roadtripViasRepo: RoadtripViasRepository,
) {}
async verifyTripAccess(tripId: string | number, userId: number) {
@@ -374,7 +381,10 @@ export class AssignmentsService {
return await this.sortDayByTime(stored.day_id);
});
const vias = sorted?.viasMoved ? { dayId: sorted.dayId, vias: await this.listDayVias(sorted.dayId) } : null;
// AS23 — `RoadtripViasRepository.listForDay`, the one copy of this projection
// (RT2/AC16); the "deliberate duplicate" the legacy docstring warned against
// dedupe-ing is gone now that a repository is the one place to inject it from.
const vias = sorted?.viasMoved ? { dayId: sorted.dayId, vias: await this.roadtripViasRepo.listForDay(sorted.dayId) } : null;
return {
assignment: await this.getAssignmentWithPlace(idNum),
@@ -438,38 +448,21 @@ export class AssignmentsService {
// is right for a stop the sort made last and wrong for one that was last already.
const lastAt = previousIds.length - 1;
const seam = previousIds[lastAt] === nextIds[lastAt] ? lastAt : null;
// AS20 — Plan 3d (`roadtrip_vias`)
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId)
// AS20 — `RoadtripViasRepository.listForReanchor`.
const vias = (await this.roadtripViasRepo.listForReanchor(dayId))
.filter(via => via.after_order_index !== seam);
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
for (const viaId of plan.remove) {
// AS21 — Plan 3d
this.dbs.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
// AS21 — `RoadtripViasRepository.deleteInDay` (= RT19).
await this.roadtripViasRepo.deleteInDay(viaId, dayId);
}
for (const via of plan.vias) {
// AS22 — Plan 3d
this.dbs.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
// AS22 — `RoadtripViasRepository.setAnchor` (= RT20).
await this.roadtripViasRepo.setAnchor(via.id, dayId, via.after_order_index);
}
return !isEmptyReanchoring(plan);
}
/**
* The day's vias in the shape the road trip routes broadcast them. RoadtripService
* has this query too, but its module imports this one, so it cannot be injected here.
*
* AS23 — Plan 3d (`roadtrip_vias`): a deliberate duplicate of RoadtripService's
* own query, per the legacy docstring's own note — do not dedupe.
*/
private async listDayVias(dayId: number): Promise<RoadtripVia[]> {
return this.dbs.all<RoadtripVia>(
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
FROM roadtrip_vias
WHERE day_id = ?
ORDER BY after_order_index, sequence, id`,
dayId,
);
}
/** AS24 — `DayAssignmentsRepository.setEndDay`. */
async setEndDay(id: string | number, endDay: boolean) {
const idNum = toRowId(id)!;
+6 -4
View File
@@ -5,25 +5,27 @@ import { noAccess } from '../../mcp/tools/_shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { ChargingService } from './charging.service';
import { answeringRefusals } from './roadtrip-mcp.helpers';
@McpController()
export class ChargingMcp {
constructor(private readonly charging: ChargingService, private readonly db: DatabaseService, readonly addons: AddonsService) {}
constructor(private readonly charging: ChargingService, @InjectRepository(Trips) private readonly tripsRepo: TripsRepository, readonly addons: AddonsService) {}
@Tool({ name: 'get_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a saved charging stop. No API key required. Coverage varies by region and operator. Unknown or stale status is not available capacity. Prices can have conditions and extra fees and are not personalized roaming quotes.',
inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
return answeringRefusals(async () => ok(await this.charging.read(tripId, placeId)));
}
@Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.',
inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) {
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
return ok(await this.charging.lookup(lat, lng, name));
}
}
+6 -3
View File
@@ -1,9 +1,11 @@
import { HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { ChargingInfo } from '@trek/shared';
import { z } from 'zod';
import { DatabaseService } from '../database/database.service';
import { readCappedJson } from '../../utils/cappedFetch';
import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
/** The shape of "we cannot say", exported so nothing has to restate its thirteen fields. */
export const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
@@ -18,9 +20,10 @@ export class ChargingService {
private cached = new Map<string, { expires: number; value: Promise<ChargingInfo> }>();
private sources?: { expires: number; value: Promise<ChargingSource[]> };
private tariffs = new Map<string, { expires: number; value: Promise<ChargingTariff[]> }>();
constructor(private readonly db: DatabaseService) {}
constructor(@InjectRepository(Places) private readonly placesRepo: PlacesRepository) {}
/** CH1 — `PlacesRepository.findChargingProbe` (trip-scoped). */
async read(tripId: number, placeId: number) {
const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
const place = await this.placesRepo.findChargingProbe(placeId, tripId);
if (!place) throw new HttpException({ error: 'Place not found' }, 404);
if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown');
return this.lookup(place.lat, place.lng, place.name);
@@ -2,7 +2,9 @@ import { z } from 'zod';
import { MAX_TRIP_DAYS, roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, ok, type McpContext } from '../../nest-mcp';
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { AuthService } from '../auth/auth.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -18,7 +20,7 @@ const when = addonGate(ADDON_IDS.ROADTRIP);
export class DayBoundariesMcp {
constructor(
private readonly boundaries: DayBoundariesService,
private readonly db: DatabaseService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
private readonly realtime: RealtimeService,
@@ -31,7 +33,7 @@ export class DayBoundariesMcp {
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when,
})
async list({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
return ok({ boundaries: await this.boundaries.list(tripId) });
}
@@ -43,7 +45,7 @@ export class DayBoundariesMcp {
})
async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
// A stop from another trip is refused by the service, with the reason.
return answeringRefusals(async () => {
@@ -1,33 +1,51 @@
import { InjectRepository } from '@mikro-orm/nestjs';
import { HttpException, Injectable } from '@nestjs/common';
import type { RoadtripDayBoundary } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity';
import type { RoadtripDayBoundariesRepository } from '../../db/repositories/RoadtripDayBoundaries.repository';
@Injectable()
export class DayBoundariesService {
constructor(private readonly db: DatabaseService) {}
constructor(
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
@InjectRepository(RoadtripDayBoundaries) private readonly boundariesRepo: RoadtripDayBoundariesRepository,
) {}
/** RB1 — `RoadtripDayBoundariesRepository.listForTrip`. */
async list(tripId: string | number): Promise<RoadtripDayBoundary[]> {
return this.db.all<RoadtripDayBoundary>(
'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number', tripId,
);
return await this.boundariesRepo.listForTrip(Number(tripId));
}
async save(tripId: string | number, boundary: RoadtripDayBoundary): Promise<RoadtripDayBoundary[]> {
const belongs = (id: number) => this.db.get(
'SELECT a.id FROM day_assignments a JOIN days d ON d.id = a.day_id WHERE a.id = ? AND d.trip_id = ?', id, tripId,
);
if (!belongs(boundary.from_assignment_id) || boundary.to_assignment_id !== null && !belongs(boundary.to_assignment_id)) {
const tripIdNum = Number(tripId);
// RB2 — `belongs`, a synchronous closure over `this.db.get` in the legacy code
// (inventory §18.4: converted naively, `!belongs(x)` on a Promise is always
// `false`, accepting a foreign assignment id). Every call site below is
// `await`ed — the async-closure-truthiness-guards ratchet
// (`tests/integration/async-closure-truthiness-guards.test.ts`, RB2-001,
// Task 0) stays green with the `await`, and goes red the moment one is
// dropped (proven for this task's report by hand-dropping one and reverting).
// `DayAssignmentsRepository.findInTrip` (AS12) is the trip-scoped read.
const belongs = async (id: number) => !!(await this.dayAssignmentsRepo.findInTrip(id, tripIdNum));
if (!(await belongs(boundary.from_assignment_id)) || (boundary.to_assignment_id !== null && !(await belongs(boundary.to_assignment_id)))) {
throw new HttpException({ error: 'Stop not found' }, 404);
}
this.db.run(`INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction)
VALUES (?, ?, ?, ?, ?) ON CONFLICT (trip_id, day_number) DO UPDATE SET
from_assignment_id = excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id, fraction = excluded.fraction`,
tripId, boundary.day_number, boundary.from_assignment_id, boundary.to_assignment_id, boundary.to_assignment_id === null ? 1 : boundary.fraction);
return this.list(tripId);
// RB2 → RB3, un-transacted (R7 — pin, don't fix): the ownership check above and
// the upsert below are two statements, not one.
// RB3 — `RoadtripDayBoundariesRepository.upsertBoundary`. `to_assignment_id ===
// null ? 1 : fraction` stays the service's own coercion, unchanged.
await this.boundariesRepo.upsertBoundary(tripIdNum, {
...boundary,
fraction: boundary.to_assignment_id === null ? 1 : boundary.fraction,
});
return await this.list(tripIdNum);
}
/** RB4 — `RoadtripDayBoundariesRepository.deleteForDay`, silent on a miss. */
async remove(tripId: string | number, dayNumber: number): Promise<RoadtripDayBoundary[]> {
this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', tripId, dayNumber);
return this.list(tripId);
await this.boundariesRepo.deleteForDay(Number(tripId), dayNumber);
return await this.list(tripId);
}
}
@@ -1,20 +1,26 @@
import { HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { GoogleRouteImport, GoogleRoutePreview } from '@trek/shared';
import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service';
import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../places/maps-dir.helpers';
import { safeFetchFollow } from '../../utils/ssrfGuard';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { PlacesService } from '../places/places.service';
import { AssignmentsService } from '../assignments/assignments.service';
import { PermissionsService } from '../permissions/permissions.service';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
@Injectable()
export class GoogleRouteService {
constructor(private readonly maps: MapsService, private readonly db: DatabaseService,
constructor(private readonly maps: MapsService,
private readonly places: PlacesService, private readonly assignments: AssignmentsService,
private readonly permissions: PermissionsService,
private readonly uow: UnitOfWork) {}
private readonly uow: UnitOfWork,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
@InjectRepository(Users) private readonly usersRepo: UsersRepository) {}
async preview(raw: string): Promise<GoogleRoutePreview> {
let url = new URL(raw);
@@ -56,9 +62,11 @@ export class GoogleRouteService {
}
async import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) {
const access = await this.db.canAccessTrip(tripId, userId);
// GR0 — `TripsRepository.findAccessible` (keeps the row: `access.user_id` feeds the permission check below).
const access = await this.tripsRepo.findAccessible(tripId, userId);
if (!access) throw new HttpException({ error: 'Trip not found' }, 404);
const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user';
// GR1 — `UsersRepository.getRole`.
const role = (await this.usersRepo.getRole(userId)) ?? 'user';
// `every` cannot await the permission check, so the same all-of test runs as
// an explicit loop — same actions, same order, same short-circuit.
for (const action of ['place_edit', 'day_edit']) {
@@ -4,12 +4,14 @@ import { noAccess } from '../../mcp/tools/_shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { RoadtripHazardsService } from './roadtrip-hazards.service';
@McpController()
export class RoadtripHazardsMcp {
constructor(private readonly hazards: RoadtripHazardsService, private readonly db: DatabaseService, readonly addons: AddonsService) {}
constructor(private readonly hazards: RoadtripHazardsService, @InjectRepository(Trips) private readonly tripsRepo: TripsRepository, readonly addons: AddonsService) {}
@Tool({
name: 'get_roadtrip_hazards',
description: 'Read current DWD and GDACS hazard notices, geometry, source timestamps and feed availability. These are current notices, not forecasts for the trip dates or confirmed road closures. A point means no affected-area polygon is available. Coverage can be incomplete. This does not change routes. The shared roadtrip_show_hazards preference controls map display.',
@@ -17,7 +19,7 @@ export class RoadtripHazardsMcp {
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP),
})
async read({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
return ok(await this.hazards.read());
}
}
@@ -1,4 +1,4 @@
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { SettingsService } from '../settings/settings.service';
import { DayBoundariesService } from './day-boundaries.service';
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
@@ -6,6 +6,12 @@ import { RoadtripRouterService } from './roadtrip-router.service';
import { RoadtripService } from './roadtrip.service';
import { Injectable, HttpException } from '@nestjs/common';
import { type RoadtripPreferences } from '@trek/shared';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { Days } from '../../db/entities/Days.entity';
import type { DaysRepository } from '../../db/repositories/Days.repository';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
import {
assembleRoadtrip,
foldRouteRun,
@@ -25,66 +31,28 @@ import {
standsAsDay,
} from '@trek/shared/roadtrip';
interface StoredDay {
id: number;
day_number: number;
date: string | null;
title: string | null;
default_transport_mode: string | null;
}
interface VisitRow {
/** The booking this stop stands for on its check-in day, when there is one. */
stay_id: number | null;
check_in: string | null;
/** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */
check_out: string | null;
checkout_day: number | null;
id: number;
day_id: number;
place_id: number;
name: string;
lat: number | null;
lng: number | null;
time: string | null;
end_time: string | null;
duration_minutes: number | null;
end_day: number;
leg_transport_mode: string | null;
incoming_leg_transport_mode: string | null;
stop_type: string | null;
fill_percent: number | null;
}
const stopKey = (s: RoadtripStop) =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
@Injectable()
export class RoadtripPlanService {
constructor(
private readonly db: DatabaseService,
private readonly settings: SettingsService,
private readonly preferences: RoadtripPreferencesService,
private readonly router: RoadtripRouterService,
private readonly roadtrip: RoadtripService,
private readonly boundaries: DayBoundariesService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
) {}
async context(tripId: number, userId: number) {
if (!(await this.db.canAccessTrip(tripId, userId))) throw new HttpException({ error: 'Trip not found' }, 404);
const days = this.db.all<StoredDay>(
'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number',
tripId,
);
const visits = this.db.all<VisitRow>(
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
p.duration_minutes, a.end_day,
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id
LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
LEFT JOIN days checkout ON checkout.id = stay.end_day_id
WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`,
tripId,
);
if (!(await this.tripsRepo.findAccessible(tripId, userId))) throw new HttpException({ error: 'Trip not found' }, 404);
// RPL1 — `DaysRepository.listPlanDays`.
const days = await this.daysRepo.listPlanDays(tripId);
// RPL2 — `DayAssignmentsRepository.listRoadtripVisits`.
const visits = await this.dayAssignmentsRepo.listRoadtripVisits(tripId);
return {
days,
visits,
@@ -12,7 +12,9 @@ import {
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { RoadtripPreferencesService } from './roadtrip-preferences.service';
import { answeringRefusals } from './roadtrip-mcp.helpers';
@@ -28,7 +30,7 @@ export class RoadtripPreferencesMcp {
private readonly preferences: RoadtripPreferencesService,
private readonly auth: AuthService,
readonly addons: AddonsService,
private readonly db: DatabaseService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
private readonly guards: McpToolGuardsService,
) {}
@@ -42,7 +44,7 @@ export class RoadtripPreferencesMcp {
when,
})
async read({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
return ok({ tripId, settings: await this.preferences.read(tripId), scope: 'trip' });
}
@@ -57,7 +59,7 @@ export class RoadtripPreferencesMcp {
})
async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
// A day window that ends before it starts is refused by the service, with the reason.
return answeringRefusals(async () => ok({ tripId, settings: await this.preferences.update(tripId, settings), scope: 'trip' }));
@@ -1,4 +1,4 @@
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { RealtimeService } from '../realtime/realtime.service';
import { UnitOfWork } from '../database/unit-of-work';
import { HttpException, Injectable } from '@nestjs/common';
@@ -8,21 +8,21 @@ import {
roadtripPreferencesUpdateSchema,
type RoadtripPreferences,
} from '@trek/shared';
import { RoadtripPreferences as RoadtripPreferencesEntity } from '../../db/entities/RoadtripPreferences.entity';
import type { RoadtripPreferencesRepository } from '../../db/repositories/RoadtripPreferences.repository';
@Injectable()
export class RoadtripPreferencesService {
constructor(
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly uow: UnitOfWork,
@InjectRepository(RoadtripPreferencesEntity) private readonly preferencesRepo: RoadtripPreferencesRepository,
) {}
/** RPF1 — `RoadtripPreferencesRepository.listForTrip`, with the legacy JSON parse (raw-string fallback) unchanged. */
async read(tripId: number): Promise<RoadtripPreferences> {
const settings: Record<string, unknown> = {};
for (const row of this.db.all<{ key: string; value: string }>(
'SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?',
tripId,
)) {
for (const row of await this.preferencesRepo.listForTrip(tripId)) {
try {
settings[row.key] = JSON.parse(row.value);
} catch {
@@ -44,15 +44,14 @@ export class RoadtripPreferencesService {
if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) {
throw new HttpException({ error: 'Day end must be later than day start.' }, 400);
}
// RPF3 — `RoadtripPreferencesRepository.upsertValue`.
for (const [key, value] of Object.entries(validated)) {
this.db.run(
'INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value',
tripId,
key,
JSON.stringify(value),
);
await this.preferencesRepo.upsertValue(tripId, key, JSON.stringify(value));
}
return this.read(tripId);
// RPF1's read-after-write, inside the same transaction (§18.10) — `find`-based,
// so `TrekRepository`'s `disableIdentityMap: true` default already keeps this
// fresh against the upsert just above.
return await this.read(tripId);
});
// The saving tab is left out, like every other trip mutation: it already has
// the answer, and its own echo costs it a second store commit and the route
@@ -108,7 +108,8 @@ export class RoadtripRouterService {
// R1.4: a `flatMap` callback cannot await the now-async profile read, so the
// fan-out runs as an explicit loop — same providers, same order.
for (const id of this.hooks.providersOf('routeProvider')) {
for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`);
// RRT1 — `plugins` is Plan 3j's table; stays a raw hand-off of the connection.
for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`); // Plan 3j
}
}
return ['driving', 'walking', 'cycling', ...plugin];
@@ -175,7 +176,7 @@ export class RoadtripRouterService {
points.length > 30 ||
!pluginsEnabled() ||
!this.hooks.providersOf('routeProvider').includes(id) ||
!(await declaredProfiles(this.db.connection, id)).includes(name)
!(await declaredProfiles(this.db.connection, id)).includes(name) // RRT2 — Plan 3j
)
throw new Error('Routing plugin is unavailable or has too many waypoints');
const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId);
+10 -8
View File
@@ -1,7 +1,9 @@
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, type McpContext } from '../../nest-mcp';
import { z } from 'zod';
import { InjectRepository } from '@mikro-orm/nestjs';
import { RoadtripService } from './roadtrip.service';
import { DatabaseService } from '../database/database.service';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { AuthService } from '../auth/auth.service';
@@ -30,7 +32,7 @@ const roadtripAddonOn = addonGate(ADDON_IDS.ROADTRIP);
export class RoadtripMcp {
constructor(
private readonly roadtrip: RoadtripService,
private readonly db: DatabaseService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
private readonly guards: McpToolGuardsService,
private readonly auth: AuthService,
readonly addons: AddonsService,
@@ -48,7 +50,7 @@ export class RoadtripMcp {
when: roadtripAddonOn,
})
async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) {
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (dayId != null) {
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
return ok({ vias: await this.roadtrip.listForDay(dayId) });
@@ -75,7 +77,7 @@ export class RoadtripMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
const via = await this.roadtrip.create(dayId, { after_order_index, lat, lng });
@@ -117,7 +119,7 @@ export class RoadtripMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
// The same check the REST route makes, in the same change: permission alone would let
@@ -156,7 +158,7 @@ export class RoadtripMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
const next = await this.roadtrip.reanchor(dayId, { vias, remove });
@@ -178,7 +180,7 @@ export class RoadtripMcp {
})
async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
if (!(await this.roadtrip.remove(viaId, dayId))) return noAccess();
@@ -194,7 +196,7 @@ export class RoadtripMcp {
})
async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(input.tripId, ctx.userId))) return noAccess();
if (!(await this.tripsRepo.findAccessible(input.tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(input.dayId, input.tripId))) return noAccess();
const via = await this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index);
@@ -30,14 +30,36 @@ import { RoadtripService } from './roadtrip.service';
import { RoadtripSearchService } from './roadtrip-search.service';
import { RoadtripSearchController } from './roadtrip-search.controller';
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { RoadtripHazardsController } from './roadtrip-hazards.controller';
import { RoadtripHazardsService } from './roadtrip-hazards.service';
import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
import { Trips } from '../../db/entities/Trips.entity';
import { Days } from '../../db/entities/Days.entity';
import { Places } from '../../db/entities/Places.entity';
import { Users } from '../../db/entities/Users.entity';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity';
import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entity';
import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity';
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
@Module({
// McpShared brings the tool guards, Auth the demo check, Permissions the trip guard,
// Addons the enabled-check the MCP tools gate on (the controller has @RequireAddon).
//
// `MikroOrmModule.forFeature(...)` (Plan 3d Task 1) registers every
// repository this module's own providers `@InjectRepository` — a
// `forFeature` registration only reaches providers declared in ITS OWN
// `providers` array (`AssignmentsDomainModule`'s docstring), so the four
// new roadtrip repositories plus `Trips`/`Days`/`Places`/`Users`/
// `DayAssignments` (read through `TripsRepository.findAccessible`,
// `DaysRepository.existsInTrip`/`listPlanDays`, `PlacesRepository
// .isTrackInTrip`/`findChargingProbe`, `UsersRepository.getRole`,
// `DayAssignmentsRepository.findInTrip`/`listRoadtripVisits`) all need
// their own entry here, even though `PlacesModule`/`AssignmentsModule`
// already register some of the same entities for THEIR OWN providers.
imports: [
McpSharedModule,
PermissionsModule,
@@ -47,6 +69,7 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
SettingsModule,
PluginsRuntimeModule,
MapsModule, PlacesModule, AssignmentsModule,
MikroOrmModule.forFeature([Trips, Days, Places, Users, DayAssignments, RoadtripVias, RoadtripDayTracks, RoadtripPreferences, RoadtripDayBoundaries]),
],
controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp,
+99 -123
View File
@@ -1,8 +1,17 @@
import { Injectable } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { RoadtripDayTrack, RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { UnitOfWork } from '../database/unit-of-work';
import { toRowId } from '../common/row-id';
import { Days } from '../../db/entities/Days.entity';
import type { DaysRepository } from '../../db/repositories/Days.repository';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
import type { RoadtripViasRepository } from '../../db/repositories/RoadtripVias.repository';
import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity';
import type { RoadtripDayTracksRepository } from '../../db/repositories/RoadtripDayTracks.repository';
/**
* Via points: the places a day's drive is made to pass through without stopping.
@@ -11,13 +20,22 @@ import { UnitOfWork } from '../database/unit-of-work';
* number in the chain, an arrival time and a line in the itinerary. A via only bends the
* route. Modelling one as a place would put a numbered stop in the middle of the day for
* a spot nobody stops at, and it would show up in the PDF, the map pins and the schedule.
*
* `roadtrip_vias`/`roadtrip_day_tracks` through `RoadtripViasRepository`/
* `RoadtripDayTracksRepository` (Plan 3d Task 1); RT1 (`dayExists`) and RT13
* (`trackExists`) delegate to `DaysRepository.existsInTrip`/
* `PlacesRepository.isTrackInTrip`, the same trip-scoping gates the 3c
* repositories already expose.
*/
@Injectable()
export class RoadtripService {
constructor(
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly uow: UnitOfWork,
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
@InjectRepository(RoadtripVias) private readonly viasRepo: RoadtripViasRepository,
@InjectRepository(RoadtripDayTracks) private readonly tracksRepo: RoadtripDayTracksRepository,
) {}
/**
@@ -35,55 +53,60 @@ export class RoadtripService {
this.realtime.broadcast(tripId, event, payload, socketId);
}
/** The day exists and belongs to this trip. 404 material, checked before every write. */
/**
* The gate every write below parses `dayId` through ONCE (rule 21), before
* it reaches a repository write — never a bare `Number(dayId)`.
*
* Load-bearing, not decorative (Task 0 review carry, RT8): `dayId` feeding
* `RoadtripDayTracksRepository.upsertTrack` is that table's PRIMARY KEY
* column (`roadtrip_day_tracks.day_id`, a single-column INTEGER PK, unlike
* `roadtrip_vias.id`'s own separate autoincrement PK) — SQLite accepts
* `INSERT … VALUES (NULL, …)` on an INTEGER PRIMARY KEY column and
* silently assigns the next free rowid instead of refusing, so a
* `Number(dayId)` that resolved to `NaN`/`undefined` would not throw, it
* would silently write to WHATEVER day that rowid happens to be — possibly
* another trip's. Every caller of `create`/`createMany` reaches them only
* after the controller/MCP's own `dayExists` gate already proved `dayId`
* belongs to this trip (a raw-bind affinity check, R7), so this second
* parse is defence in depth, not the primary guard — but it is what turns
* "cannot happen" into "cannot happen even if the first gate is bypassed
* or wrong", and it is what stops `NaN`/`null` from ever reaching the
* upsert at all.
*/
private requireDayId(dayId: string | number): number {
const parsed = toRowId(dayId);
if (parsed === null) throw new HttpException({ error: 'Day not found' }, 404);
return parsed;
}
/** The day exists and belongs to this trip. 404 material, checked before every write. RT1 → `DaysRepository.existsInTrip` (raw-bind, the same affinity seam the legacy statement used). */
async dayExists(dayId: string | number, tripId: string | number): Promise<boolean> {
return !!this.db.get<{ id: number }>(
'SELECT id FROM days WHERE id = ? AND trip_id = ?',
dayId,
tripId,
);
return await this.daysRepo.existsInTrip(dayId, tripId);
}
async listForDay(dayId: string | number): Promise<RoadtripVia[]> {
return this.db.all<RoadtripVia>(
`SELECT id, day_id, after_order_index, sequence, lat, lng, created_at
FROM roadtrip_vias
WHERE day_id = ?
ORDER BY after_order_index, sequence, id`,
dayId,
);
return await this.viasRepo.listForDay(Number(dayId));
}
/** Every via of a trip, so the client can route all days without one request per day. */
async listForTrip(tripId: string | number): Promise<RoadtripVia[]> {
return this.db.all<RoadtripVia>(
`SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, v.created_at
FROM roadtrip_vias v
JOIN days d ON d.id = v.day_id
WHERE d.trip_id = ?
ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`,
tripId,
);
return await this.viasRepo.listForTrip(Number(tripId));
}
async create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): Promise<RoadtripVia> {
const dayIdNum = this.requireDayId(dayId);
// Appended after whatever already follows that stop, unless the caller says where.
const sequence = input.sequence ?? (
this.db.get<{ next: number }>(
'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
dayId,
input.after_order_index,
)?.next ?? 0
);
const result = this.db.run(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
dayId,
input.after_order_index,
// RT4 (`nextSequence`) then RT5 (`insertVia`), un-transacted (R7 — pin, don't fix):
// two concurrent adds on one leg can read the same next sequence.
const sequence = input.sequence ?? (await this.viasRepo.nextSequence(dayIdNum, input.after_order_index));
const id = await this.viasRepo.insertVia({
day_id: dayIdNum,
after_order_index: input.after_order_index,
sequence,
input.lat,
input.lng,
);
return (await this.byId(Number(result.lastInsertRowid)))!;
lat: input.lat,
lng: input.lng,
});
return (await this.viasRepo.findById(id))!;
}
/**
@@ -104,22 +127,17 @@ export class RoadtripService {
track?: { place_id: number; stray_km?: number | null } | null;
},
): Promise<RoadtripVia[]> {
const dayIdNum = this.requireDayId(dayId);
return await this.uow.transactional(async () => {
// Inside the same transaction as the chain it describes. A day that says it follows
// a road whose vias never landed is worse than a day that says nothing.
if (input.track === null) {
this.db.run('DELETE FROM roadtrip_day_tracks WHERE day_id = ?', dayId);
await this.tracksRepo.deleteForDay(dayIdNum);
} else if (input.track) {
this.db.run(
`INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, ?)
ON CONFLICT(day_id) DO UPDATE SET place_id = excluded.place_id, stray_km = excluded.stray_km`,
dayId,
input.track.place_id,
input.track.stray_km ?? null,
);
await this.tracksRepo.upsertTrack(dayIdNum, input.track.place_id, input.track.stray_km ?? null);
}
for (const leg of input.replace_legs ?? []) {
this.db.run('DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?', dayId, leg);
await this.viasRepo.deleteLeg(dayIdNum, leg);
}
// Per leg, because sequence only orders the vias that follow the same stop. Read
// once up front rather than per insert: the loop is inside the transaction, and a
@@ -128,23 +146,12 @@ export class RoadtripService {
for (const via of input.vias) {
let seq = nextSeq.get(via.after_order_index);
if (seq === undefined) {
seq = this.db.get<{ next: number }>(
'SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?',
dayId,
via.after_order_index,
)?.next ?? 0;
seq = await this.viasRepo.nextSequence(dayIdNum, via.after_order_index);
}
this.db.run(
'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, ?, ?, ?)',
dayId,
via.after_order_index,
seq,
via.lat,
via.lng,
);
await this.viasRepo.insertVia({ day_id: dayIdNum, after_order_index: via.after_order_index, sequence: seq, lat: via.lat, lng: via.lng });
nextSeq.set(via.after_order_index, seq + 1);
}
return this.listForDay(dayId);
return await this.viasRepo.listForDay(dayIdNum);
});
}
@@ -155,23 +162,12 @@ export class RoadtripService {
* second route for a handful of rows would be a second round trip for nothing.
*/
async tracksForTrip(tripId: string | number): Promise<RoadtripDayTrack[]> {
return this.db.all<RoadtripDayTrack>(
`SELECT t.day_id, t.place_id, t.stray_km
FROM roadtrip_day_tracks t
JOIN days d ON d.id = t.day_id
WHERE d.trip_id = ?
ORDER BY t.day_id`,
tripId,
);
return await this.tracksRepo.listForTrip(Number(tripId));
}
/** Whether a place is on this trip, and is a track rather than an ordinary place. */
/** Whether a place is on this trip, and is a track rather than an ordinary place. RT13 → `PlacesRepository.isTrackInTrip`. */
async trackExists(placeId: number, tripId: string | number): Promise<boolean> {
return !!this.db.get<{ id: number }>(
"SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''",
placeId,
tripId,
);
return await this.placesRepo.isTrackInTrip(placeId, Number(tripId));
}
/** Moving a via is the whole edit; where it sits in the chain does not change. */
@@ -182,28 +178,26 @@ export class RoadtripService {
lng: number,
afterOrderIndex?: number,
): Promise<RoadtripVia | null> {
const existing = this.db.get<{ id: number }>(
'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?',
id,
dayId,
);
if (!existing) return null;
const idNum = toRowId(id);
const dayIdNum = toRowId(dayId);
if (idNum === null || dayIdNum === null) return null;
// RT14 → RT15/RT16, un-transacted (R7 — pin, don't fix): the scoping check and the
// write that follows are two statements, not one.
const exists = await this.viasRepo.existsInDay(idNum, dayIdNum);
if (!exists) return null;
// The anchor moves with the point when the caller worked out a new one. It is not a
// property of the via but of where the via sits along the drive, so dragging one past
// a stop changes which leg it belongs to — and leaving it behind is what made the
// route run out to the point and back instead of bending through it.
//
// RT15/RT16 keep the legacy's own scoping (`WHERE id = ?`, no `AND day_id`) — pinned,
// not fixed, per R7: the check above is what guards these writes.
if (afterOrderIndex === undefined) {
this.db.run('UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?', lat, lng, id);
await this.viasRepo.moveCoordinates(idNum, lat, lng);
} else {
this.db.run(
'UPDATE roadtrip_vias SET lat = ?, lng = ?, after_order_index = ? WHERE id = ?',
lat,
lng,
afterOrderIndex,
id,
);
await this.viasRepo.moveCoordinatesAndAnchor(idNum, lat, lng, afterOrderIndex);
}
return this.byId(Number(id));
return await this.viasRepo.findById(idNum);
}
/**
@@ -222,29 +216,18 @@ export class RoadtripService {
dayId: string | number,
input: { vias: { id: number; after_order_index: number }[]; remove?: number[] },
): Promise<RoadtripVia[]> {
const dayIdNum = this.requireDayId(dayId);
return await this.uow.transactional(async () => {
// Read before writing: the OLD anchor is what says which of two merged
// legs came first, and after the updates that information is gone.
const before = new Map(
this.db
.all<{ id: number; after_order_index: number; sequence: number }>(
'SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?',
dayId,
)
.map((v) => [v.id, v]),
);
const before = new Map((await this.viasRepo.listAnchors(dayIdNum)).map((v) => [v.id, v]));
for (const id of input.remove ?? []) {
this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
await this.viasRepo.deleteInDay(id, dayIdNum);
before.delete(id);
}
for (const via of input.vias) {
this.db.run(
'UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?',
via.after_order_index,
via.id,
dayId,
);
await this.viasRepo.setAnchor(via.id, dayIdNum, via.after_order_index);
}
// Renumber, because `sequence` is allocated per leg and starts at 0 in
@@ -259,10 +242,7 @@ export class RoadtripService {
// leg belong ahead of the ones from the leg that merged into it. Old
// sequence orders within a leg, and the id settles the rest so the result
// never depends on row order.
const rows = this.db.all<{ id: number; after_order_index: number }>(
'SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ?',
dayId,
);
const rows = await this.viasRepo.listLegs(dayIdNum);
const byLeg = new Map<number, { id: number }[]>();
for (const row of rows) {
const list = byLeg.get(row.after_order_index) ?? [];
@@ -279,24 +259,20 @@ export class RoadtripService {
a.id - b.id
);
});
list.forEach((row, index) => {
this.db.run('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?', index, row.id, dayId);
});
for (const [index, row] of list.entries()) {
await this.viasRepo.setSequence(row.id, dayIdNum, index);
}
}
return this.listForDay(dayId);
return await this.viasRepo.listForDay(dayIdNum);
});
}
async remove(id: string | number, dayId: string | number): Promise<boolean> {
const result = this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId);
return result.changes > 0;
}
private async byId(id: number): Promise<RoadtripVia | null> {
return this.db.get<RoadtripVia>(
'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?',
id,
) ?? null;
const idNum = toRowId(id);
const dayIdNum = toRowId(dayId);
if (idNum === null || dayIdNum === null) return false;
const deleted = await this.viasRepo.deleteInDayCounted(idNum, dayIdNum);
return deleted > 0;
}
}