diff --git a/server/src/db/repositories/DayAssignments.repository.ts b/server/src/db/repositories/DayAssignments.repository.ts index 629b29944..eac58bd8d 100644 --- a/server/src/db/repositories/DayAssignments.repository.ts +++ b/server/src/db/repositories/DayAssignments.repository.ts @@ -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 - * [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 = 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; - -/** - * 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; + DayAssignmentRow, + DaySeatRow, + DayStopRow, + RoadtripVisitRow, + RoadtripVisitsKyselyDB, + SharePublicAssignmentRow, +} from './day-assignment-rows'; +import type { DB } from '../kysely/db'; export class DayAssignmentsRepository extends TrekRepository { /** 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 { }); } - /** 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>() - .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> { - let query = this.kysely>() - .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>() - .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 { await this.nativeDelete({ id }); diff --git a/server/src/db/repositories/Reservations.repository.ts b/server/src/db/repositories/Reservations.repository.ts index dd7c086d1..260264326 100644 --- a/server/src/db/repositories/Reservations.repository.ts +++ b/server/src/db/repositories/Reservations.repository.ts @@ -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'; diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index f0d5c2ce7..65397f2e1 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -661,6 +661,49 @@ export class ToursRepository extends TrekRepository { })); } + /** + * 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> { + 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 { return await this.db() diff --git a/server/src/db/repositories/day-assignment-rows.ts b/server/src/db/repositories/day-assignment-rows.ts new file mode 100644 index 000000000..5c17e6858 --- /dev/null +++ b/server/src/db/repositories/day-assignment-rows.ts @@ -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 + * [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 = 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; + +/** + * 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; diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 495e47ef6..881260f2d 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -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> { - return await this.dayAssignmentsRepo.deleteTourStagesNotIn(placeId, keys); + return await this.toursRepo.deleteTourStagesNotIn(placeId, keys); } /** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */ diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index ed7498cf2..8a5559262 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -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'; diff --git a/server/tests/helpers/fake-tour-stage-days.ts b/server/tests/helpers/fake-tour-stage-days.ts index 993d4338c..495e2f09b 100644 --- a/server/tests/helpers/fake-tour-stage-days.ts +++ b/server/tests/helpers/fake-tour-stage-days.ts @@ -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; diff --git a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts index d98950411..d443b39a6 100644 --- a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts +++ b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts @@ -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 = { diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index b2fa5fa41..267f93108 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -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', () => { diff --git a/server/tests/unit/nest/common/rowShape.test.ts b/server/tests/unit/nest/common/rowShape.test.ts index 2ca4fe64a..35c2a2bb9 100644 --- a/server/tests/unit/nest/common/rowShape.test.ts +++ b/server/tests/unit/nest/common/rowShape.test.ts @@ -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'; diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts index 6eb6c56c9..6b7642185 100644 --- a/server/tests/unit/nest/tours.service.test.ts +++ b/server/tests/unit/nest/tours.service.test.ts @@ -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)), ); });