mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
fix(tours): keep the day assignments repository under the size limit
The stage queries of MDT-02 took DayAssignments.repository.ts past the 1000 lines lint:size allows. Two concerns move out of it instead: - The stages of a multi-day tour that days hold are a tour question, so findTourStage and deleteTourStagesNotIn move to ToursRepository, next to isOnDay and listStagePoints, which AssignmentsService already asks. No constructor changes, and the tests move with them (TOURREPO-019 to 021). - The rows the repository answers with and the Kysely table shapes its statements are typed against move to day-assignment-rows.ts, so the file holds the statements alone and has room again. tours.service.ts stays at its 1000 lines.
This commit is contained in:
@@ -1,189 +1,18 @@
|
||||
import type { Platform } from '@mikro-orm/core';
|
||||
import type { DayAssignments } from '../entities/DayAssignments.entity';
|
||||
import { caseWhenNotNull, coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import type { DB } from '../kysely/db';
|
||||
|
||||
/**
|
||||
* The DY1/DY3/AS1/AS3 assignment-with-place projection row (Plan 3c Task 2's
|
||||
* report is the contract for Task 3, which consumes this unchanged):
|
||||
*
|
||||
* ```sql
|
||||
* SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
* p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
* COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
* COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
* 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]
|
||||
* ```
|
||||
*
|
||||
* `da.*` selects every scalar column of `DayAssignments` (`place_id` among
|
||||
* them, the physical FK column) and then `p.id as place_id` selects it again
|
||||
* under the same output key — the legacy statement did this too, and
|
||||
* better-sqlite3's row object keeps the LAST column with a given name, so
|
||||
* `place_id` on the wire is always `p.id`, not `da.place_id` (they agree by
|
||||
* the JOIN condition regardless). Nullability here is the physical column's,
|
||||
* not `AssignmentRow`'s (types.ts) narrower claims (rule 16) — a place with
|
||||
* no category genuinely nulls `category_name`/`category_color`/
|
||||
* `category_icon` through the LEFT JOIN even though `Categories.name` itself
|
||||
* is NOT NULL.
|
||||
*/
|
||||
export interface AssignmentWithPlaceRow extends DayAssignmentRow {
|
||||
place_name: string;
|
||||
place_description: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
address: string | null;
|
||||
category_id: number | null;
|
||||
price: number | null;
|
||||
place_currency: string | null;
|
||||
place_time: string | null;
|
||||
end_time: string | null;
|
||||
duration_minutes: number | null;
|
||||
place_notes: string | null;
|
||||
image_url: string | null;
|
||||
transport_mode: string | null;
|
||||
google_place_id: string | null;
|
||||
google_ftid: string | null;
|
||||
osm_id: string | null;
|
||||
amap_poi_id: string | null;
|
||||
website: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9) —
|
||||
* {@link AssignmentWithPlaceRow} minus the six columns a public share link
|
||||
* must not leak, and minus the two Tour facet columns the share projection
|
||||
* never selected.
|
||||
*/
|
||||
export type SharePublicAssignmentRow = Omit<
|
||||
import type {
|
||||
AssignmentTimeSortKyselyDB,
|
||||
AssignmentWithPlaceRow,
|
||||
'google_place_id' | 'google_ftid' | 'osm_id' | 'amap_poi_id' | 'stop_type' | 'fill_percent'
|
||||
| 'tour_place_id' | 'tour_route_geometry'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A `day_assignments` row exactly as `AS12` (`AssignmentsService
|
||||
* .getAssignmentForTrip`) re-selects it — every scalar column of the entity
|
||||
* (`da.*`), scoped by a two-hop join to `days` for the trip check (Task 3).
|
||||
*/
|
||||
export interface DayAssignmentRow {
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
order_index: number | null;
|
||||
notes: string | null;
|
||||
reservation_status: string | null;
|
||||
reservation_notes: string | null;
|
||||
reservation_datetime: string | null;
|
||||
created_at: string | null;
|
||||
assignment_time: string | null;
|
||||
assignment_end_time: string | null;
|
||||
leg_transport_mode: string | null;
|
||||
incoming_leg_transport_mode: string | null;
|
||||
end_day: number;
|
||||
accommodation_id: number | null;
|
||||
route_excluded: number;
|
||||
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for anything else. */
|
||||
tour_stage_key: string | null;
|
||||
}
|
||||
|
||||
const _dayAssignmentRowKeys: AssertRowKeys<DayAssignmentRow, DayAssignments> = true;
|
||||
|
||||
/** AS18's stop, exactly as `AssignmentsService.sortDayByTime`/`chronoOrder` read it. */
|
||||
export interface DayStopRow {
|
||||
id: number;
|
||||
order_index: number | null;
|
||||
effective_time: string | null;
|
||||
located: number;
|
||||
}
|
||||
|
||||
/** AC7's row ({@link DayAssignmentsRepository.listSeatRows}), `night-seat.ts`'s `SeatRow`. */
|
||||
export interface DaySeatRow {
|
||||
id: number;
|
||||
order_index: number | null;
|
||||
at: string | null;
|
||||
night_id: number | null;
|
||||
located: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `day_assignments`/`places`/`day_accommodations`'s shape for AS16
|
||||
* (`effectiveStart`) and AS18 (`listForTimeSort`), an explicit `TDB` type
|
||||
* argument for `this.kysely()` (per `WebauthnChallengesRepository
|
||||
* .claimChallenge`'s docstring — entity-metadata inference is not what a
|
||||
* hand-written statement wants). There is no MikroORM relation from
|
||||
* `DayAssignments` to `DayAccommodations` — `accommodation_id` is a plain
|
||||
* `p.integer()` column on this entity, not a `manyToOne` (`day_accommodations`
|
||||
* is Plan 3d's table; adding the relation means touching
|
||||
* `src/db/entities/DayAssignments.entity.ts`, out of this task's scope) — so
|
||||
* the QueryBuilder's relation-path `.join()` cannot express the LEFT JOIN at
|
||||
* all. Kysely joining the physical table name directly is the sanctioned
|
||||
* next step down D3's escape-hatch order (`find/findOne → nativeUpdate →
|
||||
* qb() → em.getKysely()`).
|
||||
*/
|
||||
type AssignmentTimeSortKyselyDB = Pick<DB, 'day_assignments' | 'places' | 'day_accommodations'>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `accommodation_id` is the stay whose booking wrote this stop (null for one
|
||||
* the traveller placed), and `order_index` is the stop's slot in the day plan
|
||||
* the carrier terminals are seated against.
|
||||
*/
|
||||
export interface RoadtripVisitRow {
|
||||
id: number;
|
||||
day_id: number;
|
||||
order_index: 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;
|
||||
accommodation_id: 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.
|
||||
*/
|
||||
type RoadtripVisitsKyselyDB = Pick<DB, 'day_assignments' | 'days' | 'places' | 'day_accommodations'>;
|
||||
DayAssignmentRow,
|
||||
DaySeatRow,
|
||||
DayStopRow,
|
||||
RoadtripVisitRow,
|
||||
RoadtripVisitsKyselyDB,
|
||||
SharePublicAssignmentRow,
|
||||
} from './day-assignment-rows';
|
||||
import type { DB } from '../kysely/db';
|
||||
|
||||
export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
/** The DY1/DY3/AS1/AS3 projection's SELECT list, shared by all three query shapes below. */
|
||||
@@ -448,39 +277,6 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
|
||||
});
|
||||
}
|
||||
|
||||
/** MDT-02: the assignment holding this stage of a tour, with its day; `undefined` while the stage is on no day. */
|
||||
async findTourStage(place_id: number, tour_stage_key: string): Promise<{ id: number; day_id: number } | undefined> {
|
||||
return await this.kysely<Pick<DB, 'day_assignments'>>()
|
||||
.selectFrom('day_assignments')
|
||||
.select(['id', 'day_id'])
|
||||
.where('place_id', '=', place_id)
|
||||
.where('tour_stage_key', '=', tour_stage_key)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* MDT-02: takes off their days the stages of a tour whose starting stop is
|
||||
* not among `keys` (every stage, for none), and answers what it took, so a
|
||||
* stage a changed tour no longer has leaves no day holding it. The caller
|
||||
* owns the transaction: the tour's points are written in the same one.
|
||||
*/
|
||||
async deleteTourStagesNotIn(place_id: number, keys: readonly string[]): Promise<Array<{ id: number; day_id: number }>> {
|
||||
let query = this.kysely<Pick<DB, 'day_assignments'>>()
|
||||
.selectFrom('day_assignments')
|
||||
.select(['id', 'day_id'])
|
||||
.where('place_id', '=', place_id)
|
||||
.where('tour_stage_key', 'is not', null);
|
||||
if (keys.length > 0) query = query.where('tour_stage_key', 'not in', [...keys]);
|
||||
const gone = await query.orderBy('id', 'asc').execute();
|
||||
if (gone.length > 0) {
|
||||
await this.kysely<Pick<DB, 'day_assignments'>>()
|
||||
.deleteFrom('day_assignments')
|
||||
.where('id', 'in', gone.map((row) => row.id))
|
||||
.execute();
|
||||
}
|
||||
return gone;
|
||||
}
|
||||
|
||||
/** AS10 — `DELETE FROM day_assignments WHERE id = ?`. */
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await this.nativeDelete({ id });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Days } from '../entities/Days.entity';
|
||||
import { coalesceOverride, columnRef, dayDistance } from '../dialect/sql-functions';
|
||||
import { castIntegerKysely, concatKysely, startsWithIsoDateKysely, substringKysely } from '../dialect/kysely-functions';
|
||||
import { publicReservationExpr, publicStayExists, type ReservationVisibilityKyselyDB } from './_shared/reservation-visibility';
|
||||
import type { DayAssignmentRow } from './DayAssignments.repository';
|
||||
import type { DayAssignmentRow } from './day-assignment-rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { ROADTRIP_RESERVATION_TYPES } from '@trek/shared';
|
||||
|
||||
@@ -661,6 +661,49 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* MDT-02: the day assignment holding this stage of a tour (by the key of the
|
||||
* stop it starts at), with its day; `undefined` while the stage is on no day.
|
||||
*/
|
||||
async findTourStage(place_id: number, tour_stage_key: string): Promise<{ id: number; day_id: number } | undefined> {
|
||||
return await this.db()
|
||||
.selectFrom('day_assignments')
|
||||
.select(['id', 'day_id'])
|
||||
.where('place_id', '=', place_id)
|
||||
.where('tour_stage_key', '=', tour_stage_key)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* MDT-02: takes off their days the stages of a tour whose starting stop is
|
||||
* not among `keys` (every stage, for none), and answers what it took, so a
|
||||
* stage a changed tour no longer has leaves no day holding it. The caller
|
||||
* owns the transaction: the tour's points are written in the same one.
|
||||
*/
|
||||
async deleteTourStagesNotIn(
|
||||
place_id: number,
|
||||
keys: readonly string[],
|
||||
): Promise<Array<{ id: number; day_id: number }>> {
|
||||
let query = this.db()
|
||||
.selectFrom('day_assignments')
|
||||
.select(['id', 'day_id'])
|
||||
.where('place_id', '=', place_id)
|
||||
.where('tour_stage_key', 'is not', null);
|
||||
if (keys.length > 0) query = query.where('tour_stage_key', 'not in', [...keys]);
|
||||
const gone = await query.orderBy('id', 'asc').execute();
|
||||
if (gone.length > 0) {
|
||||
await this.db()
|
||||
.deleteFrom('day_assignments')
|
||||
.where(
|
||||
'id',
|
||||
'in',
|
||||
gone.map((row) => row.id),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
return gone;
|
||||
}
|
||||
|
||||
/** 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()
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* The rows `DayAssignmentsRepository` answers with, and the Kysely table
|
||||
* shapes its hand-built statements are typed against: what a day assignment
|
||||
* is read as, kept apart from the statements that read it.
|
||||
*/
|
||||
import type { DayAssignments } from '../entities/DayAssignments.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
|
||||
/**
|
||||
* The DY1/DY3/AS1/AS3 assignment-with-place projection row (Plan 3c Task 2's
|
||||
* report is the contract for Task 3, which consumes this unchanged):
|
||||
*
|
||||
* ```sql
|
||||
* SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
* p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
* COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
* COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
* 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]
|
||||
* ```
|
||||
*
|
||||
* `da.*` selects every scalar column of `DayAssignments` (`place_id` among
|
||||
* them, the physical FK column) and then `p.id as place_id` selects it again
|
||||
* under the same output key; the legacy statement did this too, and
|
||||
* better-sqlite3's row object keeps the LAST column with a given name, so
|
||||
* `place_id` on the wire is always `p.id`, not `da.place_id` (they agree by
|
||||
* the JOIN condition regardless). Nullability here is the physical column's,
|
||||
* not `AssignmentRow`'s (types.ts) narrower claims (rule 16): a place with
|
||||
* no category genuinely nulls `category_name`/`category_color`/
|
||||
* `category_icon` through the LEFT JOIN even though `Categories.name` itself
|
||||
* is NOT NULL.
|
||||
*/
|
||||
export interface AssignmentWithPlaceRow extends DayAssignmentRow {
|
||||
place_name: string;
|
||||
place_description: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
address: string | null;
|
||||
category_id: number | null;
|
||||
price: number | null;
|
||||
place_currency: string | null;
|
||||
place_time: string | null;
|
||||
end_time: string | null;
|
||||
duration_minutes: number | null;
|
||||
place_notes: string | null;
|
||||
image_url: string | null;
|
||||
transport_mode: string | null;
|
||||
google_place_id: string | null;
|
||||
google_ftid: string | null;
|
||||
osm_id: string | null;
|
||||
amap_poi_id: string | null;
|
||||
website: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9):
|
||||
* {@link AssignmentWithPlaceRow} minus the six columns a public share link
|
||||
* 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'
|
||||
| 'tour_place_id'
|
||||
| 'tour_route_geometry'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A `day_assignments` row exactly as `AS12` (`AssignmentsService
|
||||
* .getAssignmentForTrip`) re-selects it: every scalar column of the entity
|
||||
* (`da.*`), scoped by a two-hop join to `days` for the trip check (Task 3).
|
||||
*/
|
||||
export interface DayAssignmentRow {
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
order_index: number | null;
|
||||
notes: string | null;
|
||||
reservation_status: string | null;
|
||||
reservation_notes: string | null;
|
||||
reservation_datetime: string | null;
|
||||
created_at: string | null;
|
||||
assignment_time: string | null;
|
||||
assignment_end_time: string | null;
|
||||
leg_transport_mode: string | null;
|
||||
incoming_leg_transport_mode: string | null;
|
||||
end_day: number;
|
||||
accommodation_id: number | null;
|
||||
route_excluded: number;
|
||||
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for anything else. */
|
||||
tour_stage_key: string | null;
|
||||
}
|
||||
|
||||
const _dayAssignmentRowKeys: AssertRowKeys<DayAssignmentRow, DayAssignments> = true;
|
||||
|
||||
/** AS18's stop, exactly as `AssignmentsService.sortDayByTime`/`chronoOrder` read it. */
|
||||
export interface DayStopRow {
|
||||
id: number;
|
||||
order_index: number | null;
|
||||
effective_time: string | null;
|
||||
located: number;
|
||||
}
|
||||
|
||||
/** AC7's row ({@link DayAssignmentsRepository.listSeatRows}), `night-seat.ts`'s `SeatRow`. */
|
||||
export interface DaySeatRow {
|
||||
id: number;
|
||||
order_index: number | null;
|
||||
at: string | null;
|
||||
night_id: number | null;
|
||||
located: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `day_assignments`/`places`/`day_accommodations`'s shape for AS16
|
||||
* (`effectiveStart`) and AS18 (`listForTimeSort`), an explicit `TDB` type
|
||||
* argument for `this.kysely()` (per `WebauthnChallengesRepository
|
||||
* .claimChallenge`'s docstring: entity-metadata inference is not what a
|
||||
* hand-written statement wants). There is no MikroORM relation from
|
||||
* `DayAssignments` to `DayAccommodations`: `accommodation_id` is a plain
|
||||
* `p.integer()` column on this entity, not a `manyToOne` (`day_accommodations`
|
||||
* is Plan 3d's table; adding the relation means touching
|
||||
* `src/db/entities/DayAssignments.entity.ts`, out of this task's scope). So
|
||||
* the QueryBuilder's relation-path `.join()` cannot express the LEFT JOIN at
|
||||
* all. Kysely joining the physical table name directly is the sanctioned
|
||||
* next step down D3's escape-hatch order (`find/findOne → nativeUpdate →
|
||||
* qb() → em.getKysely()`).
|
||||
*/
|
||||
export type AssignmentTimeSortKyselyDB = Pick<DB, 'day_assignments' | 'places' | 'day_accommodations'>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `accommodation_id` is the stay whose booking wrote this stop (null for one
|
||||
* the traveller placed), and `order_index` is the stop's slot in the day plan
|
||||
* the carrier terminals are seated against.
|
||||
*/
|
||||
export interface RoadtripVisitRow {
|
||||
id: number;
|
||||
day_id: number;
|
||||
order_index: 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;
|
||||
accommodation_id: 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.
|
||||
*/
|
||||
export type RoadtripVisitsKyselyDB = Pick<DB, 'day_assignments' | 'days' | 'places' | 'day_accommodations'>;
|
||||
@@ -6,13 +6,14 @@ import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import type { AssignmentParticipantsRepository } from '../../db/repositories/AssignmentParticipants.repository';
|
||||
import type { DayAssignmentsRepository, DayStopRow } from '../../db/repositories/DayAssignments.repository';
|
||||
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
|
||||
import type { DaysRepository } from '../../db/repositories/Days.repository';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { RoadtripViasRepository } from '../../db/repositories/RoadtripVias.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
import type { TripAccess } from '../../db/repositories/Trips.repository';
|
||||
import type { DayStopRow } from '../../db/repositories/day-assignment-rows';
|
||||
import type { User } from '../../types';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { toRowId } from '../common/row-id';
|
||||
@@ -416,7 +417,7 @@ export class AssignmentsService {
|
||||
if (!tourStageKeysOf(await this.toursRepo.listStagePoints(placeId)).includes(key)) {
|
||||
throw new DomainError(400, 'Tour has no such stage', { code: 'tour_stage_unknown' });
|
||||
}
|
||||
if (await this.dayAssignmentsRepo.findTourStage(placeId, key)) {
|
||||
if (await this.toursRepo.findTourStage(placeId, key)) {
|
||||
throw new DomainError(409, 'Stage is already on a day', { code: 'tour_stage_taken' });
|
||||
}
|
||||
}
|
||||
@@ -427,7 +428,7 @@ export class AssignmentsService {
|
||||
* can tell the trip once the change is committed.
|
||||
*/
|
||||
async dropStaleTourStages(placeId: number, keys: readonly string[]): Promise<Array<{ id: number; day_id: number }>> {
|
||||
return await this.dayAssignmentsRepo.deleteTourStagesNotIn(placeId, keys);
|
||||
return await this.toursRepo.deleteTourStagesNotIn(placeId, keys);
|
||||
}
|
||||
|
||||
/** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssignmentWithPlaceRow } from '../../db/repositories/DayAssignments.repository';
|
||||
import type { AssignmentWithPlaceRow } from '../../db/repositories/day-assignment-rows';
|
||||
import type { Tag, Participant } from '../../types';
|
||||
import type { PlaceRatingRow } from '../query-helpers/query-helpers.service';
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* The part of AssignmentsService that ToursService asks when a saved change
|
||||
* takes stages of a multi-day tour off their days (MDT-02), for suites that
|
||||
* build ToursService by hand. Given the day assignments repository it takes
|
||||
* build ToursService by hand. Given the tours repository it takes
|
||||
* the rows off for real, so a suite on the test database sees them go;
|
||||
* without one there is nothing to take off. The journey refresh after it is
|
||||
* left out: the assignments suites cover it.
|
||||
*/
|
||||
import type { DayAssignmentsRepository } from '../../src/db/repositories/DayAssignments.repository';
|
||||
import type { ToursRepository } from '../../src/db/repositories/Tours.repository';
|
||||
import type { AssignmentsService } from '../../src/nest/assignments/assignments.service';
|
||||
|
||||
export function tourStageDays(dayAssignments?: DayAssignmentsRepository): AssignmentsService {
|
||||
export function tourStageDays(tours?: ToursRepository): AssignmentsService {
|
||||
const stages = {
|
||||
dropStaleTourStages: async (placeId: number, keys: readonly string[]) =>
|
||||
dayAssignments ? await dayAssignments.deleteTourStagesNotIn(placeId, keys) : [],
|
||||
tours ? await tours.deleteTourStagesNotIn(placeId, keys) : [],
|
||||
reconcile: async () => undefined,
|
||||
};
|
||||
return stages as unknown as AssignmentsService;
|
||||
|
||||
@@ -1364,40 +1364,6 @@ describe('DayAssignmentsRepository tour stages (MDT-02)', () => {
|
||||
expect(await stageOf(whole)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-044: finds the day a stage is on, and nothing for a stage on none or of another tour', async () => {
|
||||
const { trip, days, place } = tourOnTrip();
|
||||
const held = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
|
||||
expect(await assignments.findTourStage(place.id, 'stage-two')).toEqual({ id: held.id, day_id: days[1].id });
|
||||
expect(await assignments.findTourStage(place.id, 'stage-one')).toBeUndefined();
|
||||
const other = createPlace(testDb, trip.id);
|
||||
expect(await assignments.findTourStage(other.id, 'stage-two')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-045: takes off only the stages not kept, leaving the whole tour and other tours alone', async () => {
|
||||
const { trip, days, place } = tourOnTrip();
|
||||
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
|
||||
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
|
||||
const whole = createDayAssignment(testDb, days[2].id, place.id);
|
||||
const other = createPlace(testDb, trip.id);
|
||||
const otherStage = createDayAssignment(testDb, days[2].id, other.id, { tour_stage_key: 'stage-two' });
|
||||
|
||||
const gone = await uow.transactional(() => assignments.deleteTourStagesNotIn(place.id, ['stage-one']));
|
||||
expect(gone).toEqual([{ id: second.id, day_id: days[1].id }]);
|
||||
const left = (await findRows(t, DayAssignments, {}, { id: 'asc' })).map((row) => row.id);
|
||||
expect(left).toEqual([first.id, whole.id, otherStage.id]);
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-046: takes every stage off with no key kept, and writes nothing when no stage is held', async () => {
|
||||
const { days, place } = tourOnTrip();
|
||||
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
|
||||
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
|
||||
expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([
|
||||
{ id: first.id, day_id: days[0].id },
|
||||
{ id: second.id, day_id: days[1].id },
|
||||
]);
|
||||
expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('ASSIGNREPO-047: a copied assignment keeps the stage it held', async () => {
|
||||
const { days, place } = tourOnTrip();
|
||||
const copy = {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* `ToursRepository`: the list/detail projection (TO1/TO2), the facet probes
|
||||
* places and assignments ask (TO3/TO4/TO5), the trip copy read and write
|
||||
* places and assignments ask (TO3/TO4/TO5), the stages of a multi-day tour
|
||||
* days hold (MDT-02), the trip copy read and write
|
||||
* (TO6/TO8), and the create/edit writes (TO7/TO9).
|
||||
*/
|
||||
import { DayAssignments } from '../../../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../../../src/db/entities/Places.entity';
|
||||
import { TourSources } from '../../../../src/db/entities/TourSources.entity';
|
||||
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../../src/db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
|
||||
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { makePlace } from '../../../helpers/factories/places';
|
||||
import { insertRow, insertRows, updateRows } from '../../../helpers/factories/rows';
|
||||
import { findRows, insertRow, insertRows, updateRows } from '../../../helpers/factories/rows';
|
||||
import { makeDay } from '../../../helpers/factories/trips';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
@@ -299,6 +302,50 @@ describe('ToursRepository stages on days (MDT-02)', () => {
|
||||
[second.id, 'stop-hut'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** A tour on a trip with three days, none holding anything yet. */
|
||||
function tourOnDays() {
|
||||
const { trip } = fixture();
|
||||
const days = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)];
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createTour(testDb, place.id);
|
||||
return { trip, days, place };
|
||||
}
|
||||
|
||||
it('TOURREPO-019: findTourStage finds the day a stage is on, and nothing for a stage on none or of another tour', async () => {
|
||||
const { trip, days, place } = tourOnDays();
|
||||
const held = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
|
||||
expect(await repo.findTourStage(place.id, 'stage-two')).toEqual({ id: held.id, day_id: days[1].id });
|
||||
expect(await repo.findTourStage(place.id, 'stage-one')).toBeUndefined();
|
||||
const other = createPlace(testDb, trip.id);
|
||||
expect(await repo.findTourStage(other.id, 'stage-two')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TOURREPO-020: deleteTourStagesNotIn takes off only the stages not kept, leaving the whole tour and other tours alone', async () => {
|
||||
const { trip, days, place } = tourOnDays();
|
||||
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
|
||||
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
|
||||
const whole = createDayAssignment(testDb, days[2].id, place.id);
|
||||
const other = createPlace(testDb, trip.id);
|
||||
createTour(testDb, other.id);
|
||||
const otherStage = createDayAssignment(testDb, days[2].id, other.id, { tour_stage_key: 'stage-two' });
|
||||
|
||||
const gone = await new UnitOfWork(t.em).transactional(() => repo.deleteTourStagesNotIn(place.id, ['stage-one']));
|
||||
expect(gone).toEqual([{ id: second.id, day_id: days[1].id }]);
|
||||
const left = (await findRows(t, DayAssignments, {}, { id: 'asc' })).map((row) => row.id);
|
||||
expect(left).toEqual([first.id, whole.id, otherStage.id]);
|
||||
});
|
||||
|
||||
it('TOURREPO-021: deleteTourStagesNotIn takes every stage off with no key kept, and writes nothing when no stage is held', async () => {
|
||||
const { days, place } = tourOnDays();
|
||||
const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' });
|
||||
const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' });
|
||||
expect(await repo.deleteTourStagesNotIn(place.id, [])).toEqual([
|
||||
{ id: first.id, day_id: days[0].id },
|
||||
{ id: second.id, day_id: days[1].id },
|
||||
]);
|
||||
expect(await repo.deleteTourStagesNotIn(place.id, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToursRepository writes', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssignmentWithPlaceRow } from '../../../../src/db/repositories/DayAssignments.repository';
|
||||
import type { AssignmentWithPlaceRow } from '../../../../src/db/repositories/day-assignment-rows';
|
||||
import { formatAssignmentWithPlace, ratingAggregate } from '../../../../src/nest/common/rowShape';
|
||||
import type { Tag, Participant } from '../../../../src/types';
|
||||
|
||||
|
||||
@@ -21,12 +21,7 @@ import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
||||
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
|
||||
import { buildPlaceImportService } from '../../helpers/place-import';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import {
|
||||
createTestDayAssignmentsRepo,
|
||||
createTestPlacesRepo,
|
||||
createTestUnitOfWork,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { keyed } from '../../helpers/tour-keys';
|
||||
import {
|
||||
createTestTourActivitiesRepo,
|
||||
@@ -97,7 +92,7 @@ beforeAll(async () => {
|
||||
await createTestTourActivitiesRepo(testDb),
|
||||
buildPlaceImportService(),
|
||||
noPaddleChecks(),
|
||||
tourStageDays(await createTestDayAssignmentsRepo(testDb)),
|
||||
tourStageDays(await createTestToursRepo(testDb)),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user