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:
Maurice
2026-10-20 15:04:04 +02:00
parent 8fa75b3c4e
commit 3748bdb815
11 changed files with 309 additions and 267 deletions
@@ -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 -1
View File
@@ -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';
+4 -4
View File
@@ -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';
+2 -7
View File
@@ -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)),
);
});