mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
refactor(assignments): day assignments through repositories
This commit is contained in:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user