refactor(assignments): day assignments through repositories

This commit is contained in:
jubnl
2026-09-23 06:27:21 +02:00
parent d7f5bc64c1
commit f41da98df3
18 changed files with 1478 additions and 249 deletions
@@ -1,4 +1,110 @@
import type { AssignmentParticipants } from '../entities/AssignmentParticipants.entity';
import { coalesce } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class AssignmentParticipantsRepository extends TrekRepository<AssignmentParticipants> {}
/** One assignment's participant, from `QueryHelpersService.loadParticipantsByAssignmentIds` (QH3). */
export interface ParticipantForAssignmentRow {
assignment_id: number;
user_id: number;
username: string;
avatar: string | null;
}
/**
* One assignment's participant, from AS2/AS15/AS31
* (`AssignmentsService.getAssignmentWithPlace`/`getParticipants`/
* `setParticipants`) — narrower than `ParticipantForAssignmentRow` above
* (no `assignment_id` — the legacy statement never selects it) and with
* `COALESCE(display_name, username)` folded into `username`.
*/
export interface ParticipantRow {
user_id: number;
username: string;
avatar: string | null;
}
export class AssignmentParticipantsRepository extends TrekRepository<AssignmentParticipants> {
/**
* `SELECT ap.assignment_id, ap.user_id, u.username, u.avatar
* FROM assignment_participants ap JOIN users u ON ap.user_id = u.id
* WHERE ap.assignment_id IN (${…})` (`query-helpers.service.ts:83-86`, QH3).
*
* Deliberately **no** `COALESCE(display_name, username)` — unlike
* `AssignmentsService.getParticipants` (AS15), which does. The two
* participant projections genuinely disagree on the wire (plan3c inventory
* §1 QH3) and must stay that way; do not "fix" this one to match AS15.
*
* Empty `assignmentIds` short-circuits before any query, matching the
* legacy service method.
*/
async listForAssignments(assignmentIds: number[]): Promise<ParticipantForAssignmentRow[]> {
if (assignmentIds.length === 0) return [];
// `ap.assignment` selects the plain FK scalar as `assignment_id` (never
// separately joined here). `ap.user` IS separately joined (to `u`), so
// its id is pulled explicitly off the join instead (`u.id as user_id`)
// — see `PlaceRatingsRepository.listForPlaces`'s docstring for why
// selecting a relation property that is ALSO an active join's target
// renames the column onto the joined entity's own primary key instead.
return this.qb('ap')
.join('ap.user', 'u')
.select(['ap.assignment', 'u.id as user_id', 'u.username', 'u.avatar'])
.where({ 'ap.assignment': { $in: assignmentIds } })
.execute<ParticipantForAssignmentRow[]>('all', false);
}
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService`) — appended after Task 1's own
// `listForAssignments` above, per this task's coordination note (this file
// may already carry Task 1's uncommitted method; both are kept, and the
// whole file is committed under Task 3's message, noted in its report).
// ---------------------------------------------------------------------------
/**
* AS2/AS15/AS31 (`AssignmentsService.getAssignmentWithPlace`/
* `getParticipants`/`setParticipants`'s post-write re-select) — `SELECT
* ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
* FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE
* ap.assignment_id = ?`. A SEPARATE method from `listForAssignments`
* (QH3) above — the inventory's §18.10 finding: the two participant
* projections genuinely disagree on the wire (this one COALESCEs
* `display_name` over `username`, QH3 does not) and must stay that way,
* never unified into one "shared row shaper". Also a NARROWER column set
* than QH3's own row: the legacy AS2/AS15/AS31 statement never selects
* `assignment_id` at all (unlike QH3, which does) — `ParticipantRow` below
* is deliberately its own type, not `ParticipantForAssignmentRow`, so a
* caller can never accidentally see a key this statement doesn't produce.
*/
async listWithDisplayName(assignment_id: number): Promise<ParticipantRow[]> {
const platform = this.getEntityManager().getPlatform();
return this.qb('ap')
.join('ap.user', 'u')
.select(['u.id as user_id', coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar'])
.where({ 'ap.assignment': assignment_id })
.execute<ParticipantRow[]>('all', false);
}
/** AS29 — `DELETE FROM assignment_participants WHERE assignment_id = ?`. */
async deleteForAssignment(assignment_id: number): Promise<void> {
await this.nativeDelete({ assignment: assignment_id });
}
/**
* AS30 — `INSERT OR IGNORE INTO assignment_participants (assignment_id,
* user_id) VALUES (?, ?)`, run once per id. `upsertMany` with
* `onConflictAction: 'ignore'` on the table's real unique key
* (`UNIQUE(assignment_id, user_id)`,
* `Migration20200101002400_create_assignment_participants` — verified
* against the migration, not assumed), named by entity property
* (`assignment`/`user`, `OauthConsentsRepository.upsertGrant`'s precedent
* for a relation-backed `onConflictFields` list). The caller only ever
* passes a non-empty, already-roster-scoped array; the empty-array guard
* here is defensive, matching the service's own guard.
*/
async insertIgnore(assignment_id: number, user_ids: number[]): Promise<void> {
if (user_ids.length === 0) return;
await this.upsertMany(
user_ids.map((user_id) => ({ assignment: assignment_id, user: user_id })),
{ onConflictFields: ['assignment', 'user'], onConflictAction: 'ignore' },
);
}
}
@@ -1,6 +1,7 @@
import type { Platform } from '@mikro-orm/core';
import type { DayAssignments } from '../entities/DayAssignments.entity';
import { coalesce, columnRef } from '../dialect/sql-functions';
import { coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/**
@@ -93,6 +94,74 @@ interface DayAssignmentsKyselyDB {
};
}
/**
* 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;
}
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;
}
/**
* `day_assignments`/`places`/`day_accommodations`'s shape for AS16
* (`effectiveStart`) and AS18 (`listForTimeSort`), Kysely-typed the same way
* `DayAssignmentsKyselyDB` is above. 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, not just the correlated-subquery shape DY24 hit. Kysely joining the
* physical table name directly is the sanctioned next step down D3's
* escape-hatch order (`find/findOne → nativeUpdate → qb() → em.getKysely()`).
*/
interface AssignmentTimeSortKyselyDB {
day_assignments: {
id: number;
day_id: number;
place_id: number;
order_index: number | null;
assignment_time: string | null;
accommodation_id: number | null;
created_at: string | null;
};
places: {
id: number;
place_time: string | null;
lat: number | null;
lng: number | null;
};
day_accommodations: {
id: number;
check_in: string | null;
};
}
export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
/** The DY1/DY3/AS1/AS3 projection's SELECT list, shared by all three query shapes below. */
private assignmentWithPlaceSelect(platform: Platform) {
@@ -222,4 +291,222 @@ export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {
.where('accommodation_id', '=', accommodation_id)
.execute();
}
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService`) — appended after Task 2's own
// methods above, per this task's file-ownership rule.
// ---------------------------------------------------------------------------
/**
* AS9 (`assignmentExistsInDay`) — `SELECT da.id FROM day_assignments da
* JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND
* d.trip_id = ?`. Raw-bind (`number | string`, D4's T5 escape hatch,
* `TripsRepository.findAccessible`'s precedent): the legacy guard binds
* the route's raw params straight into the statement with no `Number()`/
* `toRowId` conversion of its own, so this must accept and bind exactly
* what it's handed, not coerce it first.
*/
async existsInDay(id: number | string, day_id: number | string, trip_id: number | string): Promise<boolean> {
const row = await this.qb('da')
.join('da.day', 'd')
.select(['da.id'])
.where('da.id = ? AND da.day_id = ? AND d.trip_id = ?', [id, day_id, trip_id])
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/**
* AS12 (`getAssignmentForTrip`) — `SELECT da.* FROM day_assignments da
* JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?`.
* Same raw-bind seam as `existsInDay` above — every real caller
* (`AssignmentOpsController`, `AssignmentsMcp`, `ItineraryRpc`) hands this
* the route/tool id verbatim, and `ItineraryRpc.unassign` reads
* `existing.day_id` off the result, so this is a genuine re-select, not
* only an existence guard.
*/
async findInTrip(id: number | string, trip_id: number | string): Promise<DayAssignmentRow | undefined> {
const row = await this.qb('da')
.join('da.day', 'd')
.select(['da.*'])
.where('da.id = ? AND d.trip_id = ?', [id, trip_id])
.execute<DayAssignmentRow | undefined>('get', false);
return row ?? undefined;
}
/**
* AS6 (`createAssignment`'s append-position read) — `SELECT
* MAX(order_index) as max FROM day_assignments WHERE day_id = ?`, read as
* the highest row by an ordered `limit(1)` (`DaysRepository.maxDayNumber`'s
* precedent — SQLite sorts `NULL` last under `DESC`, so an all-NULL or
* empty table folds the same way `MAX()` ignoring every `NULL` does).
* Returns the raw nullable value, NOT folded to `0`/`-1` here: the
* service's explicit `maxOrder !== null ? maxOrder : -1` check (AS6's
* ruling) needs to tell "no rows" from a stored `0` order_index, which a
* `?? -1` fold would collapse identically — unlike `DaysRepository
* .maxDayNumber`, this repository method must NOT pre-fold the null case.
*/
async maxOrderIndex(day_id: number): Promise<number | null> {
const row = await this.qb('da')
.select('da.order_index')
.where({ day: day_id })
.orderBy({ order_index: 'desc' })
.limit(1)
.execute<{ order_index: number | null } | undefined>('get', false);
return row?.order_index ?? null;
}
/**
* AS7 — `UPDATE day_assignments SET order_index = order_index + 1 WHERE
* day_id = ? AND order_index >= ?`, a column-from-column update
* (`InviteTokensRepository.incrementUsedCount`'s `columnIncrementedBy`
* precedent — `nativeUpdate`'s `EntityData` shape doesn't accept a raw
* fragment value, so this goes through `qb().update()` instead).
*/
async shiftOrderFrom(day_id: number, from: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.qb()
.update({ order_index: columnIncrementedBy(platform, 'order_index', 1) })
.where({ day: day_id, order_index: { $gte: from } })
.execute('run');
}
/**
* AS8 — `INSERT INTO day_assignments (day_id, place_id, order_index,
* notes, accommodation_id) VALUES (?, ?, ?, ?, ?)`. The caller passes
* already-decided values (the `notes || null`/`accommodationId ?? null`
* coercions and the clamped `orderIndex` stay in the service, the same
* split `DaysRepository.createDay`'s docstring describes) — `em.insert()`
* returns the generated PK (R6's `lastInsertRowid` replacement).
*/
async insertAssignment(input: {
day_id: number;
place_id: number;
order_index: number;
notes: string | null;
accommodation_id: number | null;
}): Promise<number> {
return await this.insert({
day: input.day_id,
place: input.place_id,
order_index: input.order_index,
notes: input.notes,
accommodation_id: input.accommodation_id,
});
}
/** AS10 — `DELETE FROM day_assignments WHERE id = ?`. */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/**
* AS11 (`reorderAssignments`, day-scoped: `UPDATE day_assignments SET
* order_index = ? WHERE id = ? AND day_id = ?`) and AS19
* (`sortDayByTime`'s per-row write, NOT day-scoped: `UPDATE
* day_assignments SET order_index = ? WHERE id = ?`) — one method, both
* signatures, per the inventory's ruling ("keep both signatures"):
* `day_id` omitted (`undefined`) reproduces AS19's unscoped statement.
*/
async setOrderIndex(id: number, day_id: number | undefined, order_index: number): Promise<void> {
const where = day_id !== undefined ? { id, day: day_id } : { id };
await this.nativeUpdate(where, { order_index });
}
/** AS13 (`moveAssignment`'s source-day read) — `SELECT day_id FROM day_assignments WHERE id = ?`. */
async getDayId(id: number): Promise<number | undefined> {
const platform = this.getEntityManager().getPlatform();
// `day_id` is `persist(false)` (the physical FK mirror of the `day`
// relation) — a bare `'da.day_id'` select hits the same trap
// `assignmentWithPlaceSelect`'s `category_id` comment documents;
// `columnRef` selects the literal physical column instead.
const row = await this.qb('da')
.select([columnRef(platform, 'da.day_id').as('day_id')])
.where({ id })
.execute<{ day_id: number } | undefined>('get', false);
return row?.day_id;
}
/** AS14 — `UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?`. */
async moveToDay(id: number, day_id: number, order_index: number): Promise<void> {
await this.nativeUpdate({ id }, { day: day_id, order_index });
}
/**
* AS16 (`updateTime`'s pre-write read) — `SELECT da.day_id,
* COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start FROM
* day_assignments da JOIN places p ON da.place_id = p.id LEFT JOIN
* day_accommodations acc ON acc.id = da.accommodation_id WHERE da.id = ?`.
* Kysely (see `AssignmentTimeSortKyselyDB`'s docstring for why: no ORM
* relation to `day_accommodations` exists to `.join()` through).
*/
async effectiveStart(id: number): Promise<{ day_id: number; start: string | null } | undefined> {
const row = await this.kysely<AssignmentTimeSortKyselyDB>()
.selectFrom('day_assignments as da')
.innerJoin('places as p', 'p.id', 'da.place_id')
.leftJoin('day_accommodations as acc', 'acc.id', 'da.accommodation_id')
.select((eb) => ['da.day_id as day_id', eb.fn.coalesce('da.assignment_time', 'p.place_time', 'acc.check_in').as('start')])
.where('da.id', '=', id)
.executeTakeFirst();
return row as { day_id: number; start: string | null } | undefined;
}
/**
* AS17 — `UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?`.
* The falsy-clears-to-null coercion (`placeTime || null`) stays in the
* service (AS17's ruling); this writes the already-decided values.
*/
async setTimes(id: number, assignment_time: string | null, assignment_end_time: string | null): Promise<void> {
await this.nativeUpdate({ id }, { assignment_time, assignment_end_time });
}
/**
* AS18 (`sortDayByTime`'s day-wide read) — `SELECT da.id, da.order_index,
* COALESCE(da.assignment_time, p.place_time, acc.check_in) as
* effective_time, (p.lat IS NOT NULL AND p.lng IS NOT NULL) as located
* FROM day_assignments da JOIN places p ON da.place_id = p.id LEFT JOIN
* day_accommodations acc ON acc.id = da.accommodation_id WHERE da.day_id
* = ? ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC`. Same
* Kysely escape hatch as `effectiveStart`. The computed boolean column is
* expressed with the expression builder's own comparison/`.and()`
* combinators — never the banned `sql` tag — and SQLite returns it as the
* same `0`/`1` integer the legacy raw statement did.
*/
async listForTimeSort(day_id: number): Promise<DayStopRow[]> {
const rows = await this.kysely<AssignmentTimeSortKyselyDB>()
.selectFrom('day_assignments as da')
.innerJoin('places as p', 'p.id', 'da.place_id')
.leftJoin('day_accommodations as acc', 'acc.id', 'da.accommodation_id')
.select((eb) => [
'da.id as id',
'da.order_index as order_index',
eb.fn.coalesce('da.assignment_time', 'p.place_time', 'acc.check_in').as('effective_time'),
eb.and([eb('p.lat', 'is not', null), eb('p.lng', 'is not', null)]).as('located'),
])
.where('da.day_id', '=', day_id)
.orderBy('da.order_index', 'asc')
.orderBy('da.created_at', 'asc')
.orderBy('da.id', 'asc')
.execute();
return rows as unknown as DayStopRow[];
}
/** AS24 — `UPDATE day_assignments SET end_day = ? WHERE id = ?`. */
async setEndDay(id: number, end_day: number): Promise<void> {
await this.nativeUpdate({ id }, { end_day });
}
/** AS25 — `UPDATE day_assignments SET notes = ? WHERE id = ?`. */
async setNotes(id: number, notes: string | null): Promise<void> {
await this.nativeUpdate({ id }, { notes });
}
/** AS26 — `UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?`. */
async setLegMode(id: number, mode: string | null): Promise<void> {
await this.nativeUpdate({ id }, { leg_transport_mode: mode });
}
/** AS27 — `UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?`. */
async setIncomingLegMode(id: number, mode: string | null): Promise<void> {
await this.nativeUpdate({ id }, { incoming_leg_transport_mode: mode });
}
}
@@ -178,4 +178,25 @@ export class DaysRepository extends TrekRepository<Days> {
.execute<DayIdAndNumberRow | undefined>('get', false);
return row ?? undefined;
}
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService`) — appended per the task's own
// file-ownership rule ("additive methods on Days/Places repositories if
// needed").
// ---------------------------------------------------------------------------
/**
* AS4 (`AssignmentsService.dayExists`) — `SELECT id FROM days WHERE id = ?
* AND trip_id = ?`. Raw-bind (`number | string`, D4's T5 escape hatch,
* `TripsRepository.findAccessible`'s precedent): the legacy guard binds
* the route's raw params with no `Number()`/`toRowId` conversion of its
* own, so this must accept and bind exactly what it's handed.
*/
async existsInTrip(id: number | string, trip_id: number | string): Promise<boolean> {
const row = await this.qb('d')
.select(['d.id'])
.where('d.id = ? AND d.trip_id = ?', [id, trip_id])
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
}
@@ -1,9 +1,15 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { AssignmentsService } from './assignments.service';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants.entity';
import { Days } from '../../db/entities/Days.entity';
import { Places } from '../../db/entities/Places.entity';
import { TripMembers } from '../../db/entities/TripMembers.entity';
/**
* The assignments SERVICE, split from the controller/MCP/RPC surfaces (the
@@ -12,10 +18,25 @@ import { AssignmentsService } from './assignments.service';
* that stays off the DaysModule → PlacesModule → AssignmentsModule loop.
* That is what lets PlacesModule import this and places.mcp.ts inject the
* service, which retired assignments.bridge. Everything here is a leaf:
* none of the four imports reaches the days or places domains.
* none of the four service imports reaches the days or places domains.
*
* `MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days,
* Places, TripMembers])` registers `DayAssignmentsRepository`/
* `AssignmentParticipantsRepository`/`DaysRepository`/`PlacesRepository`/
* `TripMembersRepository` for `AssignmentsService`'s `@InjectRepository`
* constructor params (Plan 3c Task 3) — the entity classes only, not the
* `DaysModule`/`PlacesModule` modules themselves, so the loop this module's
* docstring already avoids stays avoided (`DaysModule`'s own precedent for
* pulling in `Trips` the same way).
*/
@Module({
imports: [PermissionsModule, QueryHelpersModule, JourneyDomainModule, RealtimeModule],
imports: [
PermissionsModule,
QueryHelpersModule,
JourneyDomainModule,
RealtimeModule,
MikroOrmModule.forFeature([DayAssignments, AssignmentParticipants, Days, Places, TripMembers]),
],
providers: [AssignmentsService],
exports: [AssignmentsService],
})
+169 -134
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { isEmptyReanchoring, reanchorByStopOrder, type AnchoredVia } from '@trek/shared/roadtrip';
import { RealtimeService } from '../realtime/realtime.service';
@@ -6,20 +7,23 @@ import { DatabaseService, type TripAccess } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { formatAssignmentWithPlace } from '../common/rowShape';
import type { AssignmentRow, DayAssignment, User, Participant } from '../../types';
import type { User } from '../../types';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { UnitOfWork } from '../database/unit-of-work';
import { toRowId } from '../common/row-id';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import type { DayAssignmentsRepository, DayStopRow } from '../../db/repositories/DayAssignments.repository';
import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants.entity';
import type { AssignmentParticipantsRepository } from '../../db/repositories/AssignmentParticipants.repository';
import { Days } from '../../db/entities/Days.entity';
import type { DaysRepository } from '../../db/repositories/Days.repository';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { TripMembers } from '../../db/entities/TripMembers.entity';
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
type Trip = TripAccess;
/** One stop of a day as the time sort reads it. */
interface DayStopRow {
id: number;
order_index: number;
effective_time: string | null;
located: number;
}
/**
* What saving a time changed besides the stop itself, so each caller can tell the
* trip. `reordered` carries the day's whole order and `vias` the day's re-pinned
@@ -56,6 +60,26 @@ function sortMinutes(time: string | null): number | null {
* QueryHelpersService (shared with the day, share and place services).
* Every consumer injects this class (assignments.bridge.ts is deleted —
* PlacesMcp injects it from AssignmentsDomainModule now).
*
* The day-assignment/participant SQL now lives in `DayAssignmentsRepository`/
* `AssignmentParticipantsRepository` (Plan 3c Task 3, consuming Task 2's
* `findWithPlaceAndCategory`/`listForDay` projection unchanged for AS1/AS3);
* AS20–AS23 (`roadtrip_vias`) stay raw on `DatabaseService`, `// ASn — Plan
* 3d` marked — that table belongs to Plan 3d. AS5 (`placeExists`) is the one
* exception to "SQL lives in a repository method": `Places.repository.ts`
* was another task's exclusive, actively-edited file this session (never
* touched here), so the equivalent one-line `qb()` raw-condition read is
* inlined below rather than added as a `PlacesRepository` method — flagged
* in the task report for a follow-up to move it once that file is free.
*
* Assignment ids arriving from a route/tool are an **affinity seam** for the
* mutation methods (`toRowId(id)!`, program rule 15) — every real call site
* only ever reaches a mutation downstream of its own gate check in the same
* request (`assignmentExistsInDay`/`getAssignmentForTrip`, both of which
* stay on the legacy raw-bind seam themselves, matching `TripsRepository
* .findAccessible`'s documented `number | string` pass-through), so the
* non-null assertion is dead-code-safe, the same shape `DaysService` already
* uses for its own single `getDay` gate.
*/
@Injectable()
export class AssignmentsService {
@@ -66,6 +90,11 @@ export class AssignmentsService {
private readonly queryHelpers: QueryHelpersService,
private readonly journey: JourneyDomainService,
private readonly uow: UnitOfWork,
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
@InjectRepository(AssignmentParticipants) private readonly assignmentParticipantsRepo: AssignmentParticipantsRepository,
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
@InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository,
) {}
async verifyTripAccess(tripId: string | number, userId: number) {
@@ -97,19 +126,9 @@ export class AssignmentsService {
* the moved row back in exactly this shape.
*/
async getAssignmentWithPlace(assignmentId: number | bigint) {
const a = this.dbs.get<AssignmentRow>(`
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,
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 categories c ON p.category_id = c.id
WHERE da.id = ?
`, assignmentId);
// AS1 — `DayAssignmentsRepository.findWithPlaceAndCategory`, Task 2's
// projection, consumed unchanged.
const a = await this.dayAssignmentsRepo.findWithPlaceAndCategory(Number(assignmentId));
if (!a) return null;
@@ -117,41 +136,36 @@ export class AssignmentsService {
// one wire shape regardless of which read path produced it.
const tags = (await this.queryHelpers.loadTagsByPlaceIds([a.place_id], { compact: true }))[a.place_id] || [];
const participants = this.dbs.all<Participant>(`
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`, a.id);
// AS2 — `AssignmentParticipantsRepository.listWithDisplayName`
// (COALESCE(display_name, username), unlike QH3's raw username).
const participants = await this.assignmentParticipantsRepo.listWithDisplayName(a.id);
// The same shaper the list path uses. It was spelled out here as a third hand-kept
// copy of the place shape, and the copy silently dropped `stop_type`: the optimistic
// row the client had drawn as a fuel stop was replaced, a beat later, by this answer
// without it — so a petrol station turned into an ordinary numbered place while you
// watched.
//
// `formatAssignmentWithPlace` is typed on `AssignmentWithPlaceRow` directly
// (Plan 3c Task 2 review, "For Task 3" §6.3) — no cast needed here.
return formatAssignmentWithPlace(a, tags, participants);
}
async listDayAssignments(dayId: string | number) {
const assignments = this.dbs.all<AssignmentRow>(`
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,
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 categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`, dayId);
// AS3 — `DayAssignmentsRepository.listForDay`, Task 2's projection (the
// same statement text DY1 uses), consumed unchanged. `toRowId`, not a
// bare `Number()` (Task 2 review point 7): the repository takes a real
// number and the sole caller (`DayAssignmentsController.list`) already
// gates on `dayExists` in the same request.
const assignments = await this.dayAssignmentsRepo.listForDay(toRowId(dayId)!);
const placeIds = [...new Set(assignments.map(a => a.place_id))];
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
const assignmentIds = assignments.map(a => a.id);
// QH3 (`QueryHelpersService.loadParticipantsByAssignmentIds`) — deliberately
// WITHOUT the COALESCE `getAssignmentWithPlace`/`getParticipants` apply
// (inventory §18.10); unchanged from before this conversion.
const participantsByAssignment = await this.queryHelpers.loadParticipantsByAssignmentIds(assignmentIds);
return assignments.map(a => {
@@ -159,12 +173,25 @@ export class AssignmentsService {
});
}
/** AS4 — `DaysRepository.existsInTrip`. Raw-bind pass-through, unchanged. */
async dayExists(dayId: string | number, tripId: string | number) {
return !!this.dbs.get('SELECT id FROM days WHERE id = ? AND trip_id = ?', dayId, tripId);
return await this.daysRepo.existsInTrip(dayId, tripId);
}
/**
* AS5 — `SELECT id FROM places WHERE id = ? AND trip_id = ?`. `Places
* .repository.ts` was another task's exclusive, actively-edited file this
* session (see the class docstring) — inlined here as the one exception to
* "SQL lives in a repository method" rather than adding a method there.
* Same raw-bind seam as `dayExists`/`DayAssignmentsRepository.existsInDay`.
*/
async placeExists(placeId: unknown, tripId: string | number) {
return !!this.dbs.get('SELECT id FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
const row = await this.placesRepo
.qb('p')
.select(['p.id'])
.where('p.id = ? AND p.trip_id = ?', [placeId, tripId])
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/**
@@ -175,73 +202,82 @@ export class AssignmentsService {
* from a place the traveller added, so it draws the hotel a second time.
*/
async createAssignment(dayId: string | number, placeId: unknown, notes?: string | null, opts: { accommodationId?: number; orderIndex?: number } = {}) {
const result = await this.uow.transactional(async () => {
const maxOrder = this.dbs.get<{ max: number | null }>('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?', dayId)!;
const end = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
// Downstream of the caller's own dayExists/placeExists gate in the same
// request (every real call site), so the non-null assertion is
// dead-code-safe — the class docstring's affinity-seam note.
const dayIdNum = toRowId(dayId)!;
const placeIdNum = toRowId(placeId)!;
const insertedId = await this.uow.transactional(async () => {
// AS6 — the explicit null check (not `||`) is load-bearing: a stored 0
// order_index must survive, which `maxOrder || -1` would not.
const maxOrder = await this.dayAssignmentsRepo.maxOrderIndex(dayIdNum);
const end = (maxOrder !== null ? maxOrder : -1) + 1;
// Somewhere in the middle when the caller says so, which means everything from
// there on moves down. The end is still the default and still what every caller
// but one asks for.
const orderIndex = opts.orderIndex !== undefined ? Math.max(0, Math.min(opts.orderIndex, end)) : end;
if (orderIndex < end) {
this.dbs.run('UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ?', dayId, orderIndex);
await this.dayAssignmentsRepo.shiftOrderFrom(dayIdNum, orderIndex); // AS7
}
return this.dbs.run(
'INSERT INTO day_assignments (day_id, place_id, order_index, notes, accommodation_id) VALUES (?, ?, ?, ?, ?)',
dayId, placeId, orderIndex, notes || null, opts.accommodationId ?? null
);
});
return await this.getAssignmentWithPlace(result.lastInsertRowid);
}
async assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) {
return !!this.dbs.get(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?',
id, dayId, tripId
);
}
async deleteAssignment(id: string | number): Promise<void> {
this.dbs.run('DELETE FROM day_assignments WHERE id = ?', id);
}
async reorderAssignments(dayId: string | number, orderedIds: number[]): Promise<void> {
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
await this.uow.transactional(async () => {
orderedIds.forEach((id: number, index: number) => {
update.run(index, id, dayId);
return await this.dayAssignmentsRepo.insertAssignment({ // AS8
day_id: dayIdNum,
place_id: placeIdNum,
order_index: orderIndex,
notes: notes || null,
accommodation_id: opts.accommodationId ?? null,
});
});
return await this.getAssignmentWithPlace(insertedId);
}
/** AS9 — `DayAssignmentsRepository.existsInDay`. Raw-bind pass-through, unchanged. */
async assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) {
return await this.dayAssignmentsRepo.existsInDay(id, dayId, tripId);
}
/** AS10 — `DayAssignmentsRepository.deleteById`. */
async deleteAssignment(id: string | number): Promise<void> {
await this.dayAssignmentsRepo.deleteById(toRowId(id)!);
}
/**
* AS11 — `DayAssignmentsRepository.setOrderIndex`, day-scoped, one row per
* id, sequentially and in the legacy's own order (not `Promise.all` — the
* program's transaction-ordering rule).
*/
async reorderAssignments(dayId: string | number, orderedIds: number[]): Promise<void> {
const dayIdNum = toRowId(dayId)!;
await this.uow.transactional(async () => {
for (const [index, id] of orderedIds.entries()) {
await this.dayAssignmentsRepo.setOrderIndex(id, dayIdNum, index);
}
});
}
/** AS12 — `DayAssignmentsRepository.findInTrip`. Raw-bind pass-through, unchanged. */
async getAssignmentForTrip(id: string | number, tripId: string | number) {
return this.dbs.get<DayAssignment>(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`, id, tripId);
return await this.dayAssignmentsRepo.findInTrip(id, tripId);
}
async moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) {
const idNum = toRowId(id)!;
// The source day comes from the row, not the caller — callers can't lie
// about (or race on) where the assignment was.
const oldDayId = await this.uow.transactional(async () => {
const row = this.dbs.get<{ day_id: number }>('SELECT day_id FROM day_assignments WHERE id = ?', id);
this.dbs.run('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?', newDayId, orderIndex ?? 0, id);
return row?.day_id;
const dayId = await this.dayAssignmentsRepo.getDayId(idNum); // AS13
await this.dayAssignmentsRepo.moveToDay(idNum, toRowId(newDayId)!, orderIndex ?? 0); // AS14
return dayId;
});
const updated = await this.getAssignmentWithPlace(Number(id));
const updated = await this.getAssignmentWithPlace(idNum);
return { assignment: updated, oldDayId };
}
/** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */
async getParticipants(assignmentId: string | number) {
return this.dbs.all(`
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`, assignmentId);
return await this.assignmentParticipantsRepo.listWithDisplayName(toRowId(assignmentId)!);
}
/**
@@ -258,19 +294,15 @@ export class AssignmentsService {
* same.
*/
async updateTime(id: string | number, placeTime: unknown, endTime: unknown): Promise<AssignmentTimeUpdate> {
const idNum = toRowId(id)!;
const sorted = await this.uow.transactional(async () => {
const stored = this.dbs.get<{ day_id: number; start: string | null }>(`
SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN day_accommodations acc ON acc.id = da.accommodation_id
WHERE da.id = ?
`, id);
// AS16 — the three-way COALESCE, via Kysely (no ORM relation to
// `day_accommodations` — see `DayAssignmentsRepository`'s docstring).
const stored = await this.dayAssignmentsRepo.effectiveStart(idNum);
// Falsy times (null, undefined, '') all clear the override — an empty
// string is a clear, not a stored value.
this.dbs.run('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?',
placeTime || null, endTime || null, id);
// string is a clear, not a stored value. AS17.
await this.dayAssignmentsRepo.setTimes(idNum, (placeTime as string | null | undefined) || null, (endTime as string | null | undefined) || null);
// Only a start that moved sorts. An end is a label. A start sent again as it
// stood (the place form saving an End, the stay dialog taking one off, an MCP
@@ -286,7 +318,7 @@ export class AssignmentsService {
const vias = sorted?.viasMoved ? { dayId: sorted.dayId, vias: await this.listDayVias(sorted.dayId) } : null;
return {
assignment: await this.getAssignmentWithPlace(Number(id)),
assignment: await this.getAssignmentWithPlace(idNum),
reordered: sorted ? { dayId: sorted.dayId, orderedIds: sorted.orderedIds } : null,
vias,
};
@@ -302,15 +334,9 @@ export class AssignmentsService {
// counted as untimed and stayed wherever it had been dropped, so pinning an
// afternoon stop sorted that one and left the hotel sitting in front of or
// behind it by accident.
const rows = this.dbs.all<DayStopRow>(`
SELECT da.id, da.order_index, COALESCE(da.assignment_time, p.place_time, acc.check_in) as effective_time,
(p.lat IS NOT NULL AND p.lng IS NOT NULL) as located
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN day_accommodations acc ON acc.id = da.accommodation_id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
`, dayId);
//
// AS18 — `DayAssignmentsRepository.listForTimeSort`, via Kysely.
const rows = await this.dayAssignmentsRepo.listForTimeSort(dayId);
const sorted = chronoOrder(rows, row => sortMinutes(row.effective_time));
if (sorted.every((row, i) => row === rows[i])) return null;
@@ -319,11 +345,12 @@ export class AssignmentsService {
// goes out as a list of ids and every client numbers it by position, so keys kept
// with their gaps would put the day notes and bookings that sort between stops in
// one place for the writer, who reads the day back, and in another for everyone
// else. Only a stop whose key changes is written.
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
sorted.forEach((row, i) => {
if (row.order_index !== i) update.run(i, row.id);
});
// else. Only a stop whose key changes is written. AS19 — no day scoping,
// unlike AS11's `reorderAssignments` (`setOrderIndex`'s `day_id` left
// `undefined`), sequentially and in the legacy's own order.
for (const [i, row] of sorted.entries()) {
if (row.order_index !== i) await this.dayAssignmentsRepo.setOrderIndex(row.id, undefined, i);
}
return { dayId, orderedIds: sorted.map(row => row.id), viasMoved: await this.reanchorVias(dayId, rows, sorted) };
}
@@ -352,13 +379,16 @@ export class AssignmentsService {
// is right for a stop the sort made last and wrong for one that was last already.
const lastAt = previousIds.length - 1;
const seam = previousIds[lastAt] === nextIds[lastAt] ? lastAt : null;
// AS20 — Plan 3d (`roadtrip_vias`)
const vias = this.dbs.all<AnchoredVia>('SELECT id, after_order_index, lat, lng FROM roadtrip_vias WHERE day_id = ?', dayId)
.filter(via => via.after_order_index !== seam);
const plan = reanchorByStopOrder(vias, previousIds, nextIds);
for (const viaId of plan.remove) {
// AS21 — Plan 3d
this.dbs.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
}
for (const via of plan.vias) {
// AS22 — Plan 3d
this.dbs.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
}
return !isEmptyReanchoring(plan);
@@ -367,6 +397,9 @@ export class AssignmentsService {
/**
* The day's vias in the shape the road trip routes broadcast them. RoadtripService
* has this query too, but its module imports this one, so it cannot be injected here.
*
* AS23 — Plan 3d (`roadtrip_vias`): a deliberate duplicate of RoadtripService's
* own query, per the legacy docstring's own note — do not dedupe.
*/
private async listDayVias(dayId: number): Promise<RoadtripVia[]> {
return this.dbs.all<RoadtripVia>(
@@ -378,9 +411,11 @@ export class AssignmentsService {
);
}
/** AS24 — `DayAssignmentsRepository.setEndDay`. */
async setEndDay(id: string | number, endDay: boolean) {
this.dbs.run('UPDATE day_assignments SET end_day = ? WHERE id = ?', endDay ? 1 : 0, id);
return await this.getAssignmentWithPlace(Number(id));
const idNum = toRowId(id)!;
await this.dayAssignmentsRepo.setEndDay(idNum, endDay ? 1 : 0);
return await this.getAssignmentWithPlace(idNum);
}
/**
@@ -389,33 +424,36 @@ export class AssignmentsService {
* invisible in the app. Falsy notes ('' or null) clear the column, the same
* `notes || null` normalisation createAssignment applies. No auto-sort and no
* journey reconcile: the note affects neither the day order nor the skeleton
* mirror (same as the transport-mode writes).
* mirror (same as the transport-mode writes). AS25.
*/
async updateNotes(id: string | number, notes: string | null | undefined) {
this.dbs.run('UPDATE day_assignments SET notes = ? WHERE id = ?', notes || null, id);
return await this.getAssignmentWithPlace(Number(id));
const idNum = toRowId(id)!;
await this.dayAssignmentsRepo.setNotes(idNum, notes || null);
return await this.getAssignmentWithPlace(idNum);
}
/**
* Set the travel mode of the leg leaving this stop (#1281). null clears the
* override so the leg falls back to the day's default_transport_mode. This is
* sticky by design: changing the whole-day default never touches a leg that
* carries its own explicit mode.
* carries its own explicit mode. AS26.
*/
async setLegTransportMode(id: string | number, mode: string | null) {
this.dbs.run('UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?', mode ?? null, id);
return await this.getAssignmentWithPlace(Number(id));
const idNum = toRowId(id)!;
await this.dayAssignmentsRepo.setLegMode(idNum, mode ?? null);
return await this.getAssignmentWithPlace(idNum);
}
/**
* Set the travel mode of the leg arriving at this stop (#1281 boundary legs).
* Mirrors setLegTransportMode but targets incoming_leg_transport_mode; inert
* when the previous timeline element is a place (the column is only read for
* non-place origins like a booking arrival or a morning hotel departure).
* non-place origins like a booking arrival or a morning hotel departure). AS27.
*/
async setIncomingLegTransportMode(id: string | number, mode: string | null) {
this.dbs.run('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?', mode ?? null, id);
return await this.getAssignmentWithPlace(Number(id));
const idNum = toRowId(id)!;
await this.dayAssignmentsRepo.setIncomingLegMode(idNum, mode ?? null);
return await this.getAssignmentWithPlace(idNum);
}
/**
@@ -426,21 +464,18 @@ export class AssignmentsService {
* the request would strand a trip whose membership changed underneath it.
*/
async setParticipants(assignmentId: string | number, userIds: number[], tripId: string | number) {
const roster = await this.dbs.rosterUserIds(tripId);
const idNum = toRowId(assignmentId)!;
// AS28 — `TripMembersRepository.rosterUserIds`; off-roster ids drop silently.
const roster = await this.tripMembersRepo.rosterUserIds(tripId);
const scoped = userIds.filter(id => roster.has(id));
await this.uow.transactional(async () => {
this.dbs.run('DELETE FROM assignment_participants WHERE assignment_id = ?', assignmentId);
await this.assignmentParticipantsRepo.deleteForAssignment(idNum); // AS29
if (scoped.length > 0) {
const insert = this.dbs.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
for (const userId of scoped) insert.run(assignmentId, userId);
await this.assignmentParticipantsRepo.insertIgnore(idNum, scoped); // AS30 — `upsertMany`/`onConflictAction: 'ignore'`
}
});
return this.dbs.all(`
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`, assignmentId);
// AS31
return await this.assignmentParticipantsRepo.listWithDisplayName(idNum);
}
}
+18 -3
View File
@@ -1,4 +1,5 @@
import type { AssignmentRow, Tag, Participant } from '../../types';
import type { Tag, Participant } from '../../types';
import type { AssignmentWithPlaceRow } from '../../db/repositories/DayAssignments.repository';
import type { PlaceRatingRow } from '../query-helpers/query-helpers.service';
/**
@@ -8,8 +9,22 @@ import type { PlaceRatingRow } from '../query-helpers/query-helpers.service';
* free functions too.
*/
/** Reshape a flat assignment+place DB row into the nested API response shape with embedded place, tags, and participants. */
export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], participants: Participant[]) {
/**
* Reshape a flat assignment+place DB row into the nested API response shape with
* embedded place, tags, and participants.
*
* Typed on `AssignmentWithPlaceRow` (`DayAssignmentsRepository`'s own DY1/DY3/
* AS1/AS3 projection row) — Plan 3c Task 2 review, "For Task 3" §6.3: the
* legacy `types.ts#AssignmentRow` narrows several columns to non-null that
* the physical projection genuinely returns nullable (rule 16), and Task 2
* bridged the gap with a documented `as unknown as AssignmentRow` cast at
* its one call site rather than widen the type. Every real call site
* (`DaysService.list`, `AssignmentsService.getAssignmentWithPlace`/
* `listDayAssignments`) passes this same repository row, so typing the
* parameter on it directly removes all three casts in one change instead of
* adding a fourth.
*/
export function formatAssignmentWithPlace(a: AssignmentWithPlaceRow, tags: Partial<Tag>[], participants: Participant[]) {
return {
id: a.id,
day_id: a.day_id,
+7 -6
View File
@@ -6,7 +6,7 @@ import { DatabaseService, type TripAccess } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { formatAssignmentWithPlace } from '../common/rowShape';
import type { AssignmentRow, User } from '../../types';
import type { User } from '../../types';
import { UnitOfWork } from '../database/unit-of-work';
import { toRowId } from '../common/row-id';
import { Days } from '../../db/entities/Days.entity';
@@ -193,11 +193,12 @@ export class DaysService {
const assignmentsByDayId: Record<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
for (const a of allAssignments) {
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
// The repository row is honestly nullable per column (rule 16); `AssignmentRow`
// (types.ts) narrows some of those to non-null the same way the legacy
// `this.db.all<AssignmentRow>(...)` cast already trusted unchecked — same trust
// boundary, moved from the raw-SQL generic to this call site.
assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a as unknown as AssignmentRow, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []));
// Plan 3c Task 2 review, "For Task 3" §6.3: `formatAssignmentWithPlace`
// is now typed on the repository's own `AssignmentWithPlaceRow` (rule
// 16's honestly-nullable columns), so no cast is needed here — the
// `as unknown as AssignmentRow` this line used to carry moved to
// `rowShape.ts`'s parameter type instead, once, for every call site.
assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []));
}
const allNotes = await this.dayNotesRepo.listByDayIds(dayIds);
+56 -61
View File
@@ -1,8 +1,18 @@
/**
* Assignments module e2e — exercises both migrated controllers through the real
* JwtAuthGuard against a temp SQLite db. AssignmentsService runs its real SQL
* (DI-injected, no service mock); journeyService, the permission check,
* canAccessTrip and the WebSocket broadcast stay mocked.
* JwtAuthGuard against a real migrated-and-seeded temp SQLite db
* (createSnapshotTestDb(), Plan 3c Task 3 — this used to hand-roll a dozen
* CREATE TABLEs, a second hand-maintained schema copy that (a) omitted
* several `day_assignments` columns `insertAssignment`'s own `em.insert()`
* RETURNING read-back names (`reservation_status`, `end_day`) and (b), per
* the Task 2 review's warning for this exact file, would hit `no such
* table` the moment any `find()` on an entity in this domain's graph needs
* to resolve a hidden inverse relation this DDL never created — the same
* class of failure `days.e2e.test.ts`'s own conversion (Task 2) fixed for
* that file. AssignmentsService runs its real SQL (DI-injected, no service
* mock); journeyService, the permission check and the WebSocket broadcast
* stay mocked. Every `it(...)` body below is unchanged from before this
* conversion — only the DB bootstrap changed.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
@@ -11,62 +21,17 @@ import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
// Only what the un-mocked DI service actually touches: the auth guard reads
// users; AssignmentsService's real SQL reads/writes the itinerary tables
// (display_name/avatar feed the participants COALESCE projection).
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
display_name TEXT, avatar TEXT);`);
tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL);`);
tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`);
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT,
place_time TEXT, end_time TEXT, duration_minutes INTEGER DEFAULT 60, notes TEXT, image_url TEXT,
transport_mode TEXT DEFAULT 'walking', google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT,
stop_type TEXT, fill_percent INTEGER);`);
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT,
assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT,
accommodation_id INTEGER,
created_at TEXT DEFAULT (datetime('now')));`);
// The auto-sort reads a booked night's hour off its booking, so the table has to be
// here even though nothing in this file books one.
tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER,
place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT,
check_out TEXT, confirmation TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')));`);
tmp.exec(`CREATE TABLE assignment_participants (assignment_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
UNIQUE(assignment_id, user_id));`);
// A start that reorders a day re-pins that day's vias in the same transaction, so
// the sort reads this table whenever it moves a stop.
tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
after_order_index INTEGER NOT NULL, sequence INTEGER NOT NULL DEFAULT 0, lat REAL NOT NULL, lng REAL NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at TEXT);`);
tmp.exec(`CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL);`);
// TripAccessGuard now reads TripsRepository.findAccessible directly
// (Plan 3c Task 0b), a real join against trip_members.
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, currency TEXT);');
tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);');
// StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
return { db: tmp };
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast }));
import { db } from '../../src/db/database';
const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined) }));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
@@ -100,14 +65,21 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
seedUser(db as never, { id: 2, username: 'peer', email: 'peer@example.test' });
// harness.ts's seedUser() omits password_hash, which the real migrated
// schema requires NOT NULL (days.e2e.test.ts's own precedent) — raw
// inserts here instead, matching the SeededUser shape id/role/
// password_version=0 that sessionCookie() needs. `username: 'e2e-user'`
// (user 1) matches the harness default so assertion bodies that spell
// out the owner's username stay unchanged.
db.prepare("INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)").run();
db.prepare("INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'peer', 'peer@example.test', 'x', 'user', 0)").run();
// Plan 3c Task 0b: TripAccessGuard reads TripsRepository.findAccessible
// directly now, a real query — `canAccessTrip.mockReturnValue(...)` no
// longer intercepts it, so trip 5's real row (owned by user 1) is
// seeded once here rather than faked per test.
// directly now, a real query — trip 5's real row (owned by user 1) is
// seeded once here rather than faked per test. `days.day_number` is
// `NOT NULL` on the real schema (the old hand-rolled DDL had no such
// constraint).
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (5, 1, 'Trip')").run();
db.prepare('INSERT INTO days (id, trip_id) VALUES (3, 5), (4, 5)').run();
db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1), (4, 5, 2)').run();
db.prepare('INSERT INTO places (id, trip_id, name) VALUES (2, 5, ?)').run('Louvre');
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
@@ -300,6 +272,28 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
});
});
it('200 set participants replaces the list (AS28-31, the roster-scoped roundtrip)', async () => {
const id = seedAssignment();
// user 1 is trip 5's owner — TripMembersRepository.rosterUserIds includes
// the owner without a trip_members row (AS28), so this exercises the
// replace-all write (AS29 delete + AS30 insertIgnore) without needing a
// seeded membership row.
const res = await request(server)
.put(`/api/trips/5/assignments/${id}/participants`)
.set('Cookie', sessionCookie(1))
.send({ user_ids: [1, 1] }); // duplicate collapses via AS30's INSERT OR IGNORE
expect(res.status).toBe(200);
expect(res.body.participants).toEqual([{ user_id: 1, username: 'e2e-user', avatar: null }]);
expect(db.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(id)).toEqual([{ user_id: 1 }]);
const cleared = await request(server)
.put(`/api/trips/5/assignments/${id}/participants`)
.set('Cookie', sessionCookie(1))
.send({ user_ids: [] });
expect(cleared.status).toBe(200);
expect(cleared.body.participants).toEqual([]);
});
it('200 participants (access-only)', async () => {
const id = seedAssignment();
db.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, 2)').run(id);
@@ -344,7 +338,8 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => {
// TripsRepository.findAccessible genuinely refuses it, the same
// outcome `canAccessTrip.mockImplementation` used to fake.
db.prepare('INSERT OR IGNORE INTO trips (id, user_id, title) VALUES (?, 2, ?)').run(FOREIGN_TRIP, 'Their trip');
db.prepare('INSERT OR IGNORE INTO days (id, trip_id) VALUES (30, ?), (31, ?)').run(FOREIGN_TRIP, FOREIGN_TRIP);
// day_number is NOT NULL on the real schema (the old hand-rolled DDL had no such constraint).
db.prepare('INSERT OR IGNORE INTO days (id, trip_id, day_number) VALUES (30, ?, 1), (31, ?, 2)').run(FOREIGN_TRIP, FOREIGN_TRIP);
db.prepare('INSERT OR IGNORE INTO places (id, trip_id, name) VALUES (20, ?, ?)').run(FOREIGN_TRIP, 'Their hotel');
});
+11 -2
View File
@@ -7,7 +7,11 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from './test-uow';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
createTestTripMembersRepo, sharedTestOrm,
} from './test-uow';
/**
* AccommodationsService over a test connection.
@@ -38,9 +42,14 @@ export async function accommodationsOver(dbs: DatabaseService): Promise<Accommod
const realtime = new RealtimeService();
const assignments = new AssignmentsService(
dbs, permissions, realtime,
new QueryHelpersService(dbs),
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
await createTestUnitOfWork(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestAssignmentParticipantsRepo(dbs.connection),
await createTestDaysRepo(dbs.connection),
await createTestPlacesRepo(dbs.connection),
await createTestTripMembersRepo(dbs.connection),
);
return new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection));
}
+15 -6
View File
@@ -117,6 +117,8 @@ import { AddonsMcp } from '../../src/nest/addons/addons.mcp';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTagsRepo, createTestSettingsRepo,
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
createTestTripMembersRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
} from './test-uow';
import { createTestOrm } from './test-orm';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
@@ -164,7 +166,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService, await createTestUnitOfWork(dbService.connection));
const authService = new AuthService(
permissionsService,
new TripMembershipService(dbService),
new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)),
new WebauthnConfigService(appSettings),
new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo),
new MailerService(dbService),
@@ -172,7 +174,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new AllowedFileTypesService(dbService), await createTestUnitOfWork(dbService.connection),
appSettings, usersRepo, inviteTokensRepo, mcpTokensRepoForAuth, oauthTokensRepo, webauthnCredentialsRepoForAuth, passwordResetTokensRepo,
);
const queryHelpersService = new QueryHelpersService(dbService);
const queryHelpersService = new QueryHelpersService(await createTestTagsRepo(dbService.connection), await createTestPlaceRatingsRepo(dbService.connection), await createTestAssignmentParticipantsRepo(dbService.connection));
const daysService = new DaysService(
dbService,
permissionsService,
@@ -189,7 +191,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbService.connection));
// Exactly one instance, shared by maps, places and share: its stampede guard
// and its on-disk set only work if all three readers see the same maps.
const placePhotoCache = new PlacePhotoCacheService(dbService, makeStorageFixture('photos/google/').storage);
const placePhotoCache = new PlacePhotoCacheService(dbService, makeStorageFixture('photos/google/').storage, await createTestGooglePlacePhotoMetaRepo(dbService.connection), await createTestPlacesRepo(dbService.connection));
const mapsService = new MapsService(dbService, placePhotoCache, appSettings, usersRepo);
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService), await createTestUnitOfWork(dbService.connection));
// The last three were previously omitted, which left them `undefined` at
@@ -199,7 +201,14 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// dependency fails the build — pass them for real regardless of the gate.
// One instance, four consumers: AssignmentsMcp, ReservationsMcp, PlacesMcp and
// AccommodationsService, which writes the day stop a booked night implies.
const assignmentsService = new AssignmentsService(dbService, permissionsService, realtimeService, queryHelpersService, journeyDomain, await createTestUnitOfWork(dbService.connection));
const assignmentsService = new AssignmentsService(
dbService, permissionsService, realtimeService, queryHelpersService, journeyDomain, await createTestUnitOfWork(dbService.connection),
await createTestDayAssignmentsRepo(dbService.connection),
await createTestAssignmentParticipantsRepo(dbService.connection),
await createTestDaysRepo(dbService.connection),
await createTestPlacesRepo(dbService.connection),
await createTestTripMembersRepo(dbService.connection),
);
const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService, assignmentsService, await createTestUnitOfWork(dbService.connection));
// Built after it: deleting a place cancels the nights booked at it through this one.
const placesService = new PlacesService(
@@ -256,7 +265,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new AuthMcp(),
new TodoMcp(todoService, authService, addonsService, guards),
new PackingMcp(packingService, authService, addonsService, guards),
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService, guards, await createTestUnitOfWork(dbService.connection)),
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)), addonsService, guards, await createTestUnitOfWork(dbService.connection)),
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards),
new DaysMcp(daysService, authService, guards),
@@ -271,7 +280,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(await createTestUnitOfWork(dbService.connection), appSettings, await createTestSettingsRepo(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(dbService.connection)), authService, guards),
new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards),
new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(auditLogRepo, usersRepo)),
new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(auditLogRepo, usersRepo)),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(dbService.connection)),
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, await createTestUnitOfWork(dbService.connection)), dbService, authService, addonsService),
+13 -4
View File
@@ -75,6 +75,8 @@ import { makeStorageFixture } from './storage-fixture';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTagsRepo, createTestSettingsRepo, sharedTestOrm,
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
createTestTripMembersRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo,
} from './test-uow';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
@@ -99,7 +101,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
const realtime = new RealtimeService();
const budget = new BudgetService(dbs, permissions, exchangeRates, realtime, await createTestUnitOfWork(dbs.connection));
const addons = await createTestAddonsService(dbs.connection, dbs);
const queryHelpers = new QueryHelpersService(dbs);
const queryHelpers = new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection));
const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection));
const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage, (await sharedTestOrm(dbs.connection)).em);
@@ -116,14 +118,21 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
await createTestDayNotesRepo(dbs.connection),
await createTestTripsRepo(dbs.connection),
);
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage);
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage, await createTestGooglePlacePhotoMetaRepo(dbs.connection), await createTestPlacesRepo(dbs.connection));
const unsplash = new UnsplashService(appSettings, usersRepo, new RuntimeEnvService(), generalStorage);
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection));
const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub(), generalStorage, await createTestUnitOfWork(dbs.connection));
const atlas = new AtlasService(dbs, await createTestUnitOfWork(dbs.connection));
const dayNotes = new DayNotesService(dbs, permissions, realtime);
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey, await createTestUnitOfWork(dbs.connection));
const membership = new TripMembershipService(dbs);
const assignments = new AssignmentsService(
dbs, permissions, realtime, queryHelpers, journey, await createTestUnitOfWork(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestAssignmentParticipantsRepo(dbs.connection),
await createTestDaysRepo(dbs.connection),
await createTestPlacesRepo(dbs.connection),
await createTestTripMembersRepo(dbs.connection),
);
const membership = new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection));
const notifications = await makeNotificationsService(dbs, realtime);
const llmConfig = new LlmConfigResolver(new SettingsService(await createTestUnitOfWork(dbs.connection), appSettings, await createTestSettingsRepo(dbs.connection)), dbs, addons);
const oauth = new PluginOAuthService(dbs);
@@ -0,0 +1,288 @@
/**
* AssignmentParticipantsRepository.listForAssignments (Plan 3c Task 1, QH3):
* the batch participants-by-assignment loader behind
* `QueryHelpersService.loadParticipantsByAssignmentIds`, moved off
* `query-helpers.service.ts`. Deliberately no `COALESCE(display_name,
* username)` — unlike `AssignmentsService.getParticipants` (AS15) — see the
* repository's own docstring.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { AssignmentParticipants } from '../../../../src/db/entities/AssignmentParticipants.entity';
import type { AssignmentParticipantsRepository } from '../../../../src/db/repositories/AssignmentParticipants.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let participants: AssignmentParticipantsRepository;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
participants = t.repo(AssignmentParticipants);
uow = new UnitOfWork(t.em);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function addParticipant(assignmentId: number, userId: number): void {
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignmentId, userId);
}
async function withQueryCount<T>(fn: () => Promise<T>): Promise<{ value: T; queries: number }> {
const connection = t.orm.em.getConnection();
const spy = vi.spyOn(connection, 'execute');
try {
const value = await fn();
return { value, queries: spy.mock.calls.length };
} finally {
spy.mockRestore();
}
}
describe('AssignmentParticipantsRepository.listForAssignments', () => {
it('ASSIGNPARTREPO-001: an empty assignmentIds array short-circuits to [] without querying', async () => {
const { value, queries } = await withQueryCount(() => participants.listForAssignments([]));
expect(value).toEqual([]);
expect(queries).toBe(0);
});
it('ASSIGNPARTREPO-002: joins the user\'s username/avatar, with NO COALESCE(display_name, username) — raw username only', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'raw-username' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('A Display Name', participant.id);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
addParticipant(assignment.id, participant.id);
const rows = await participants.listForAssignments([assignment.id]);
expect(rows).toEqual([{ assignment_id: assignment.id, user_id: participant.id, username: 'raw-username', avatar: null }]);
});
it('ASSIGNPARTREPO-003: batches across several assignments', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const { user: p2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const placeA = createPlace(testDb, trip.id, { name: 'A' });
const placeB = createPlace(testDb, trip.id, { name: 'B' });
const a1 = createDayAssignment(testDb, day.id, placeA.id);
const a2 = createDayAssignment(testDb, day.id, placeB.id);
addParticipant(a1.id, p1.id);
addParticipant(a2.id, p2.id);
const rows = await participants.listForAssignments([a1.id, a2.id]);
expect(rows.map((r) => r.assignment_id).sort()).toEqual([a1.id, a2.id].sort());
});
it('ASSIGNPARTREPO-004: an assignment with no participants contributes no rows', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
expect(await participants.listForAssignments([assignment.id])).toEqual([]);
});
// D-shape (Task 1 brief item): `listForAssignments` is a `qb().execute('all',
// false)` projection, which never hydrates an entity into the identity map
// by construction — proven anyway, the honest "not required, proven
// regardless" shape.
it('ASSIGNPARTREPO-005 (D-shape): a participant added after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'fresh-participant' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// Task 2 review (task-2-review.md, L1, program note): `{
// disableIdentityMap: false }` on the setup read, for consistency with
// every other D-shape test in this file — this particular read is
// genuinely empty (no participant row exists yet), so the flag changes
// nothing here, but the mechanical fix is applied uniformly rather than
// singled out.
await t.repo(AssignmentParticipants).find({}, { disableIdentityMap: false }); // populate the identity map with an unrelated (empty) read
addParticipant(assignment.id, participant.id);
const rows = await participants.listForAssignments([assignment.id]);
expect(rows).toEqual([{ assignment_id: assignment.id, user_id: participant.id, username: 'fresh-participant', avatar: null }]);
});
});
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService`) — appended after Task 1's own
// `listForAssignments` suite above, per this task's file-ownership rule.
// The whole file is committed under this task's message; see its report for
// the coordination note.
// ---------------------------------------------------------------------------
/** The AS2/AS15/AS31 statement, run raw — the parity oracle every assertion below is checked against. */
function legacyParticipantRows(assignmentId: number): unknown {
return testDb.prepare(`
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(assignmentId);
}
describe('AssignmentParticipantsRepository.listWithDisplayName (AS2/AS15/AS31)', () => {
it('ASSIGNPARTREPO-006: no assignment_id key — a narrower row than listForAssignments (QH3)', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'raw-username' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
addParticipant(assignment.id, participant.id);
const rows = await participants.listWithDisplayName(assignment.id);
expect(rows).toStrictEqual(legacyParticipantRows(assignment.id));
expect(rows[0]).not.toHaveProperty('assignment_id');
});
it('ASSIGNPARTREPO-007: username COALESCEs display_name over username, when set', async () => {
const { user: owner } = createUser(testDb);
const { user: withDisplayName } = createUser(testDb, { username: 'raw-username' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Fancy Name', withDisplayName.id);
const { user: withoutDisplayName } = createUser(testDb, { username: 'plain-username' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
addParticipant(assignment.id, withDisplayName.id);
addParticipant(assignment.id, withoutDisplayName.id);
const rows = await participants.listWithDisplayName(assignment.id);
expect(rows).toEqual(expect.arrayContaining([
{ user_id: withDisplayName.id, username: 'Fancy Name', avatar: null },
{ user_id: withoutDisplayName.id, username: 'plain-username', avatar: null },
]));
expect(rows).toStrictEqual(legacyParticipantRows(assignment.id));
});
it('ASSIGNPARTREPO-008: an assignment with no participants returns []', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
expect(await participants.listWithDisplayName(assignment.id)).toEqual([]);
});
// D-shape: a `qb().execute('all', false)` projection, which never hydrates
// an entity into the identity map by construction — proven anyway.
it('ASSIGNPARTREPO-009 (D-shape): a participant added after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
const { user: owner } = createUser(testDb);
const { user: participant } = createUser(testDb, { username: 'fresh-participant-2' });
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await t.repo(AssignmentParticipants).find({}, { disableIdentityMap: false });
addParticipant(assignment.id, participant.id);
const rows = await participants.listWithDisplayName(assignment.id);
expect(rows).toEqual([{ user_id: participant.id, username: 'fresh-participant-2', avatar: null }]);
});
});
describe('AssignmentParticipantsRepository.deleteForAssignment / insertIgnore (AS29/AS30)', () => {
it('ASSIGNPARTREPO-010: deleteForAssignment removes every row for the assignment, none other', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const { user: p2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a1 = createDayAssignment(testDb, day.id, place.id);
const a2 = createDayAssignment(testDb, day.id, place.id);
addParticipant(a1.id, p1.id);
addParticipant(a2.id, p2.id);
await withRequestContext(t.orm, async () => {
await participants.deleteForAssignment(a1.id);
});
expect(await participants.listWithDisplayName(a1.id)).toEqual([]);
expect(await participants.listWithDisplayName(a2.id)).toHaveLength(1);
});
it('ASSIGNPARTREPO-011: insertIgnore writes every id once; a duplicate id in the same call collapses to one row', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, async () => {
await participants.insertIgnore(assignment.id, [p1.id, p1.id]);
});
const rows = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(assignment.id);
expect(rows).toEqual([{ user_id: p1.id }]);
});
it('ASSIGNPARTREPO-012: insertIgnore does not conflict with an existing row for a DIFFERENT assignment', async () => {
const { user: owner } = createUser(testDb);
const { user: shared } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a1 = createDayAssignment(testDb, day.id, place.id);
const a2 = createDayAssignment(testDb, day.id, place.id);
addParticipant(a1.id, shared.id);
await withRequestContext(t.orm, async () => {
await participants.insertIgnore(a2.id, [shared.id]);
});
expect(await participants.listWithDisplayName(a1.id)).toHaveLength(1);
expect(await participants.listWithDisplayName(a2.id)).toHaveLength(1);
});
it('ASSIGNPARTREPO-013: an empty user_ids array writes nothing (0 queries)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
const { queries } = await withQueryCount(() => withRequestContext(t.orm, async () => {
await participants.insertIgnore(assignment.id, []);
}));
expect(queries).toBe(0);
});
it('ASSIGNPARTREPO-014: deleteForAssignment + insertIgnore inside a rolled-back transaction leave the row unchanged (joins the ambient transaction)', async () => {
const { user: owner } = createUser(testDb);
const { user: p1 } = createUser(testDb);
const { user: p2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
addParticipant(assignment.id, p1.id);
let caught: unknown;
try {
await withRequestContext(t.orm, async () => {
await uow.transactional(async () => {
await participants.deleteForAssignment(assignment.id);
await participants.insertIgnore(assignment.id, [p2.id]);
throw new Error('force rollback');
});
});
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
const rows = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(assignment.id);
expect(rows).toEqual([{ user_id: p1.id }]);
});
});
@@ -163,13 +163,21 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
// never hydrates an entity into the identity map by construction — proven
// anyway, the "not required, proven regardless" shape (AssignmentParticipants
// precedent).
//
// Task 2 review (task-2-review.md, L1): the setup read now passes `{
// disableIdentityMap: false }` so it genuinely caches a managed
// `DayAssignments` entity first (the base's own default previously made
// this a no-op read, same class of vacuousness as `DAYREPO-018`) — even
// so, `listForDay` is `qb().execute('all', false)`, never `find`/
// `findOne`, so it cannot return that cached entity regardless; this test
// proves the projection's OWN freshness, not the identity-map guard.
it('ASSIGNPLACEREPO-008 (D-shape): a place rename after an unrelated identity-map read is visible in the FIRST wider projection', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
const assignment = createDayAssignment(testDb, day.id, place.id);
await t.repo(DayAssignments).find({}); // populate the identity map with an unrelated read
await t.repo(DayAssignments).find({}, { disableIdentityMap: false }); // populate the identity map with the managed assignment entity
testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Renamed', place.id);
const [row] = await assignments.listForDay(day.id);
@@ -264,3 +272,326 @@ describe('DayAssignmentsRepository.reanchorToDay (DY24, Kysely)', () => {
expect(row.day_id).toBe(toDay.id);
});
});
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService`) — appended after Task 2's own
// methods/tests above, per this task's file-ownership rule.
// ---------------------------------------------------------------------------
describe('DayAssignmentsRepository — AS4/AS9/AS12 existence + trip-scoped read', () => {
it('ASSIGNREPO-001 (AS9, existsInDay): true only for the matching assignment/day/trip triple', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const otherDay = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await assignments.existsInDay(a.id, day.id, trip.id)).toBe(true);
expect(await assignments.existsInDay(a.id, otherDay.id, trip.id)).toBe(false);
expect(await assignments.existsInDay(a.id, day.id, trip.id + 1)).toBe(false);
});
it('ASSIGNREPO-002 (AS9): raw-bind — a string id binds unconverted, same as a real number', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await assignments.existsInDay(String(a.id), String(day.id), String(trip.id))).toBe(true);
});
it('ASSIGNREPO-003 (AS12, findInTrip): the raw `da.*` row, scoped to the trip via a two-hop join; undefined cross-trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, notes = ? WHERE id = ?').run('09:00', 'a note', a.id);
const row = await assignments.findInTrip(a.id, trip.id);
const legacy = testDb.prepare(`SELECT da.* FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?`).get(a.id, trip.id);
expect(row).toStrictEqual(legacy);
expect(row).toMatchObject({ id: a.id, day_id: day.id, order_index: 3, assignment_time: '09:00', notes: 'a note' });
expect(await assignments.findInTrip(a.id, trip.id + 1)).toBeUndefined();
});
});
describe('DayAssignmentsRepository — AS6/AS7/AS8 (createAssignment)', () => {
it('ASSIGNREPO-004 (AS6, maxOrderIndex): null for a day with no assignments, otherwise the highest order_index — a stored 0 is not confused with "no rows"', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
expect(await assignments.maxOrderIndex(day.id)).toBeNull();
createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
expect(await assignments.maxOrderIndex(day.id)).toBe(0);
createDayAssignment(testDb, day.id, place.id, { order_index: 5 });
expect(await assignments.maxOrderIndex(day.id)).toBe(5);
});
it('ASSIGNREPO-005 (AS7, shiftOrderFrom): shifts order_index for rows at/after the threshold, on that day only', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const otherDay = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const before = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
const at = createDayAssignment(testDb, day.id, place.id, { order_index: 1 });
const after = createDayAssignment(testDb, day.id, place.id, { order_index: 2 });
const foreign = createDayAssignment(testDb, otherDay.id, place.id, { order_index: 1 });
await withRequestContext(t.orm, async () => { await assignments.shiftOrderFrom(day.id, 1); });
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
expect(order(before.id)).toBe(0);
expect(order(at.id)).toBe(2);
expect(order(after.id)).toBe(3);
expect(order(foreign.id)).toBe(1);
});
it('ASSIGNREPO-006 (AS8, insertAssignment): writes every column and returns the inserted id, notes/accommodation_id nullable', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const id = await withRequestContext(t.orm, () => assignments.insertAssignment({
day_id: day.id, place_id: place.id, order_index: 2, notes: null, accommodation_id: null,
}));
expect(typeof id).toBe('number');
const row = testDb.prepare('SELECT day_id, place_id, order_index, notes, accommodation_id FROM day_assignments WHERE id = ?').get(id);
expect(row).toEqual({ day_id: day.id, place_id: place.id, order_index: 2, notes: null, accommodation_id: null });
const id2 = await withRequestContext(t.orm, () => assignments.insertAssignment({
day_id: day.id, place_id: place.id, order_index: 0, notes: 'skip the line', accommodation_id: 42,
}));
const row2 = testDb.prepare('SELECT notes, accommodation_id FROM day_assignments WHERE id = ?').get(id2);
expect(row2).toEqual({ notes: 'skip the line', accommodation_id: 42 });
});
});
describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order / move)', () => {
it('ASSIGNREPO-007 (AS10, deleteById): removes the row', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.deleteById(a.id));
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(a.id)).toBeUndefined();
});
it('ASSIGNREPO-008 (AS11, setOrderIndex day-scoped): writes only the row matching BOTH id and day_id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const otherDay = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
const foreign = createDayAssignment(testDb, otherDay.id, place.id, { order_index: 0 });
await withRequestContext(t.orm, () => assignments.setOrderIndex(a.id, day.id, 7));
// A day_id mismatch (this id does not sit on otherDay) writes nothing.
await withRequestContext(t.orm, () => assignments.setOrderIndex(foreign.id, day.id, 9));
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
expect(order(a.id)).toBe(7);
expect(order(foreign.id)).toBe(0);
});
it('ASSIGNREPO-009 (AS19, setOrderIndex unscoped): day_id undefined writes by id alone', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
await withRequestContext(t.orm, () => assignments.setOrderIndex(a.id, undefined, 3));
expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(3);
});
it('ASSIGNREPO-010 (AS13, getDayId): the assignment\'s day_id, undefined for a missing id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await assignments.getDayId(a.id)).toBe(day.id);
expect(await assignments.getDayId(999999)).toBeUndefined();
});
it('ASSIGNREPO-011 (AS14, moveToDay): writes day_id and order_index together', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const target = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
await withRequestContext(t.orm, () => assignments.moveToDay(a.id, target.id, 4));
const row = testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id);
expect(row).toEqual({ day_id: target.id, order_index: 4 });
});
});
describe('DayAssignmentsRepository.effectiveStart (AS16, Kysely)', () => {
it('ASSIGNREPO-012: byte-identical to the legacy three-way COALESCE, own time wins over the place\'s', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', place.id);
const a = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('14:00', a.id);
const row = await withRequestContext(t.orm, () => assignments.effectiveStart(a.id));
const legacy = testDb.prepare(`
SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start
FROM day_assignments da JOIN places p ON da.place_id = p.id
LEFT JOIN day_accommodations acc ON acc.id = da.accommodation_id
WHERE da.id = ?
`).get(a.id);
expect(row).toEqual(legacy);
expect(row).toEqual({ day_id: day.id, start: '14:00' });
});
it('ASSIGNREPO-013: falls back to the place\'s own time when the assignment has none', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', place.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await withRequestContext(t.orm, () => assignments.effectiveStart(a.id))).toEqual({ day_id: day.id, start: '08:00' });
});
it('ASSIGNREPO-014: a booked night falls back further, to the accommodation\'s check_in (a LEFT JOIN through the plain accommodation_id column, no ORM relation)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const hotel = createPlace(testDb, trip.id);
const night = createDayAssignment(testDb, day.id, hotel.id);
const stay = testDb.prepare(
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (?, ?, ?, ?, '17:00')",
).run(trip.id, hotel.id, day.id, day.id);
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(Number(stay.lastInsertRowid), night.id);
expect(await withRequestContext(t.orm, () => assignments.effectiveStart(night.id))).toEqual({ day_id: day.id, start: '17:00' });
});
it('ASSIGNREPO-015: null start when nothing sets a time anywhere in the chain', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await withRequestContext(t.orm, () => assignments.effectiveStart(a.id))).toEqual({ day_id: day.id, start: null });
});
it('ASSIGNREPO-016: undefined for a missing id', async () => {
expect(await withRequestContext(t.orm, () => assignments.effectiveStart(999999))).toBeUndefined();
});
});
describe('DayAssignmentsRepository.listForTimeSort (AS18, Kysely)', () => {
it('ASSIGNREPO-017: byte-identical to the legacy statement, including the computed `located` boolean (0/1) and the three-key ORDER BY', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const located = createPlace(testDb, trip.id, { name: 'Has coords' });
const unlocated = createPlace(testDb, trip.id, { name: 'No coords' });
testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unlocated.id);
const a1 = createDayAssignment(testDb, day.id, located.id, { order_index: 1 });
const a2 = createDayAssignment(testDb, day.id, unlocated.id, { order_index: 0 });
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('09:00', a1.id);
const rows = await withRequestContext(t.orm, () => assignments.listForTimeSort(day.id));
const legacy = testDb.prepare(`
SELECT da.id, da.order_index, COALESCE(da.assignment_time, p.place_time, acc.check_in) as effective_time,
(p.lat IS NOT NULL AND p.lng IS NOT NULL) as located
FROM day_assignments da JOIN places p ON da.place_id = p.id
LEFT JOIN day_accommodations acc ON acc.id = da.accommodation_id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
`).all(day.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.id)).toEqual([a2.id, a1.id]);
expect(rows.find((r) => r.id === a1.id)?.located).toBe(1);
expect(rows.find((r) => r.id === a2.id)?.located).toBe(0);
});
it('ASSIGNREPO-018: [] for a day with no assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
expect(await withRequestContext(t.orm, () => assignments.listForTimeSort(day.id))).toEqual([]);
});
});
describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27 (single-column writes)', () => {
it('ASSIGNREPO-019 (AS17, setTimes): writes both columns, null-clearable', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setTimes(a.id, '09:00', '10:30'));
expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id))
.toEqual({ assignment_time: '09:00', assignment_end_time: '10:30' });
await withRequestContext(t.orm, () => assignments.setTimes(a.id, null, null));
expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id))
.toEqual({ assignment_time: null, assignment_end_time: null });
});
it('ASSIGNREPO-020 (AS24, setEndDay): writes the 0/1 flag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setEndDay(a.id, 1));
expect((testDb.prepare('SELECT end_day FROM day_assignments WHERE id = ?').get(a.id) as { end_day: number }).end_day).toBe(1);
});
it('ASSIGNREPO-021 (AS25, setNotes): null-clearable', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setNotes(a.id, 'a note'));
expect((testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id) as { notes: string | null }).notes).toBe('a note');
await withRequestContext(t.orm, () => assignments.setNotes(a.id, null));
expect((testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id) as { notes: string | null }).notes).toBeNull();
});
it('ASSIGNREPO-022 (AS26/AS27, setLegMode/setIncomingLegMode): each writes its own column', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setLegMode(a.id, 'cycling'));
await withRequestContext(t.orm, () => assignments.setIncomingLegMode(a.id, 'walking'));
expect(testDb.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(a.id))
.toEqual({ leg_transport_mode: 'cycling', incoming_leg_transport_mode: 'walking' });
await withRequestContext(t.orm, () => assignments.setLegMode(a.id, null));
expect((testDb.prepare('SELECT leg_transport_mode FROM day_assignments WHERE id = ?').get(a.id) as { leg_transport_mode: string | null }).leg_transport_mode).toBeNull();
});
});
// D-shape: a `qb().execute(..., false)` projection or a Kysely read, neither
// of which hydrates an entity into the identity map by construction (the
// ASSIGNPLACEREPO-008 precedent above) — proven anyway for the new reads.
// Task 2 review (task-2-review.md, L1, program note): the setup read passes
// `{ disableIdentityMap: false }` so it genuinely caches the managed
// assignment entity first (a bare `find({})` would be a no-op read under
// the base's own default, the same vacuousness `DAYREPO-018` had).
it('ASSIGNREPO-023 (D-shape): a notes write after an unrelated identity-map read is visible in findInTrip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await t.repo(DayAssignments).find({}, { disableIdentityMap: false }); // populate the identity map with the managed assignment entity
await withRequestContext(t.orm, () => assignments.setNotes(a.id, 'fresh note'));
expect(await assignments.findInTrip(a.id, trip.id)).toMatchObject({ notes: 'fresh note' });
});
@@ -93,4 +93,26 @@ describe('DayNotesRepository.listByDayIds (DY4)', () => {
const day = createDay(testDb, trip.id);
expect(await notes.listByDayIds([day.id])).toEqual([]);
});
// ---------------------------------------------------------------------------
// Plan 3c Task 2 review (task-2-review.md, L2) — appended per the Task 3
// coordinator addendum: `listByDayIds` is `find`-based (unlike Task 3's own
// qb()/Kysely reads), so the identity-map guard is a real, provable
// guarantee here, not "proven regardless". The setup read passes `{
// disableIdentityMap: false }` so it genuinely caches a managed entity
// first — a bare `find({})` would be a no-op under the base's own default
// (`DAYREPO-018`'s vacuousness).
// ---------------------------------------------------------------------------
it('NOTEREPO-008 (D-shape): a text write after an unrelated identity-map read is visible in the FIRST wider listByDayIds', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Old text' });
await t.repo(DayNotes).find({}, { disableIdentityMap: false }); // populate the identity map with the managed note entity
testDb.prepare('UPDATE day_notes SET text = ? WHERE id = ?').run('Fresh text', note.id);
const rows = await notes.listByDayIds([day.id]);
expect(rows).toEqual([expect.objectContaining({ id: note.id, text: 'Fresh text' })]);
});
});
@@ -180,12 +180,50 @@ describe('DaysRepository — Task 2 additions', () => {
// read populated the same entity type — written column (`notes`) is outside
// the narrower read's own projection scope, so a stale write-back would
// revert it if the base class's guarantee ever regressed.
//
// Task 2 review (task-2-review.md, L1): the original setup read
// (`find({})` with no options) went through `TrekRepository`'s OWN
// `disableIdentityMap: true` default too, so it never actually populated
// the identity map with the managed `day` entity — the test passed
// trivially, with nothing stale for `findById` to ever return. `{
// disableIdentityMap: false }` on THIS read is what makes the setup real:
// the managed entity (pre-write `notes`) is cached first, and `findById`'s
// own default must still bypass it. Mutation-proved (Task 3 session): with
// `TrekRepository.findOne`'s `disableIdentityMap` default temporarily
// flipped to `false`, this test fails (`findById` returns the stale
// pre-write `notes`); reverted immediately after.
it('DAYREPO-018 (D-shape): a notes write after an unrelated identity-map read is visible in findById', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await t.repo(Days).find({}); // populate the identity map with an unrelated read
await t.repo(Days).find({}, { disableIdentityMap: false }); // populate the identity map with the managed `day` entity
await days.updateNotesAndTitle(day.id, 'Fresh notes', null);
expect(await days.findById(day.id)).toMatchObject({ notes: 'Fresh notes' });
});
// ---------------------------------------------------------------------------
// Plan 3c Task 3 (`AssignmentsService.dayExists`, AS4) — appended per the
// task's own file-ownership rule.
// ---------------------------------------------------------------------------
describe('existsInTrip (AS4)', () => {
it('DAYREPO-019: true only for the matching day AND trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { user: other } = createUser(testDb);
const otherTrip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
expect(await days.existsInTrip(day.id, trip.id)).toBe(true);
expect(await days.existsInTrip(day.id, otherTrip.id)).toBe(false);
expect(await days.existsInTrip(999999, trip.id)).toBe(false);
});
it('DAYREPO-020: raw-bind — a string id/trip_id binds unconverted, same as a real number', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
expect(await days.existsInTrip(String(day.id), String(trip.id))).toBe(true);
expect(await days.existsInTrip('not-a-number', trip.id)).toBe(false);
});
});
});
@@ -48,38 +48,43 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createTag } from '../../helpers/factories';
import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service';
import { type TripAccess } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../helpers/test-uow';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
createTestTripMembersRepo, createTestDatabaseService,
} from '../../helpers/test-uow';
const dbs = new DatabaseService(testDb);
const realtime = new RealtimeService();
let svc: AssignmentsService;
beforeAll(async () => {
// Plan 3c Task 0b: `dbs` is constructed at module load, before any
// `beforeAll` can resolve a real `EntityManager` — the four
// repository-backed methods are spied directly on this instance instead,
// routed to a real `DatabaseService` built with one.
const real = new DatabaseService(testDb, (await sharedTestOrm(testDb)).em);
vi.spyOn(dbs, 'canAccessTrip').mockImplementation((...a) => real.canAccessTrip(...a));
vi.spyOn(dbs, 'isOwner').mockImplementation((...a) => real.isOwner(...a));
vi.spyOn(dbs, 'rosterUserIds').mockImplementation((...a) => real.rosterUserIds(...a));
vi.spyOn(dbs, 'getPlaceWithTags').mockImplementation((...a) => real.getPlaceWithTags(...a));
// Plan 3c Task 3: `DatabaseService` now needs a real `EntityManager`
// up front (AS0's `canAccessTrip` still delegates to it; `TripAccess`'s
// shape comes off `TripsRepository`) — `createTestDatabaseService`
// (Task 0b's helper) builds one bound to the shared per-file ORM every
// repository below also resolves through.
const dbs = await createTestDatabaseService(testDb);
const realtime = new RealtimeService();
svc = new AssignmentsService(
dbs,
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)),
realtime,
new QueryHelpersService(dbs),
// Real collaborator rather than a stub: reconcile() runs after every mutation
// and needs the same connection to see the rows these cases write.
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
await createTestUnitOfWork(dbs.connection),
);
dbs,
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)),
realtime,
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
// Real collaborator rather than a stub: reconcile() runs after every mutation
// and needs the same connection to see the rows these cases write.
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
await createTestUnitOfWork(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestAssignmentParticipantsRepo(dbs.connection),
await createTestDaysRepo(dbs.connection),
await createTestPlacesRepo(dbs.connection),
await createTestTripMembersRepo(dbs.connection),
);
});
/**
+26 -5
View File
@@ -1,15 +1,32 @@
import { describe, it, expect } from 'vitest';
import { formatAssignmentWithPlace, ratingAggregate } from '../../../../src/nest/common/rowShape';
import type { AssignmentRow, Tag, Participant } from '../../../../src/types';
import type { AssignmentWithPlaceRow } from '../../../../src/db/repositories/DayAssignments.repository';
import type { Tag, Participant } from '../../../../src/types';
function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
/**
* Plan 3c Task 2 review (task-2-review.md, "For Task 3" §6.3): `formatAssignmentWithPlace`
* is typed on `DayAssignmentsRepository`'s own `AssignmentWithPlaceRow` (the
* honestly-nullable projection row, rule 16) rather than the legacy,
* narrower `types.ts#AssignmentRow` — this fixture now builds that shape
* directly, every field required, none defaulted away by a bare `as` cast.
*/
function makeRow(overrides: Partial<AssignmentWithPlaceRow> = {}): AssignmentWithPlaceRow {
return {
id: 1,
day_id: 10,
place_id: 100,
order_index: 0,
notes: 'assignment note',
reservation_status: 'none',
reservation_notes: null,
reservation_datetime: null,
assignment_time: null,
assignment_end_time: null,
end_day: 0,
leg_transport_mode: null,
incoming_leg_transport_mode: null,
accommodation_id: null,
created_at: '2024-01-01T00:00:00Z',
place_name: 'Eiffel Tower',
place_description: 'Famous landmark',
@@ -30,10 +47,14 @@ function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
transport_mode: 'walk',
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
google_ftid: '0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0',
osm_id: null,
amap_poi_id: null,
website: 'https://eiffel-tower.com',
phone: '+33 1 2345 6789',
stop_type: null,
fill_percent: null,
...overrides,
} as AssignmentRow;
};
}
const sampleTags: Partial<Tag>[] = [
@@ -79,12 +100,12 @@ describe('formatAssignmentWithPlace', () => {
});
it('sets place.category to null when category_id is null', () => {
const result = formatAssignmentWithPlace(makeRow({ category_id: null as any }), [], []);
const result = formatAssignmentWithPlace(makeRow({ category_id: null }), [], []);
expect(result.place.category).toBeNull();
});
it('sets place.category to null when category_id is 0 (falsy)', () => {
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []);
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 }), [], []);
expect(result.place.category).toBeNull();
});
@@ -379,6 +379,22 @@ describe('quirk fixes', () => {
expect(day.title).toBe('Arrival');
});
// Plan 3c Task 2 review (task-2-review.md, L3) — appended per the Task 3
// coordinator addendum: DAY-SVC-033/054 pin `notes: ''` → null and
// `title: null` → null, but never the asymmetric half — `title: ''` must
// NOT clear, since the column uses `??` (only null/undefined clear), not
// `||`. A `??` → `||` regression on `title` would otherwise pass every
// existing test.
it('DAY-SVC-055 — an empty-string title does NOT clear (asymmetric coercion: title uses ?? , not ||)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
let day: Day = createDay(testDb, trip.id);
day = await svc.update(day.id, day, { title: 'Arrival' });
day = await svc.update(day.id, day, { title: '' });
expect(day.title).toBe('');
expect(testDb.prepare('SELECT title FROM days WHERE id = ?').get(day.id)).toEqual({ title: '' });
});
// Asserted against AccommodationsService, which owns createAccommodation since
// the days/accommodations split. It read `svc.createAccommodation` until then:
// that is `undefined`, calling it throws, and `.toThrow()` was satisfied by the