refactor(days): days and day notes through repositories

This commit is contained in:
jubnl
2026-09-23 05:31:36 +02:00
parent d2e0d7e320
commit d7f5bc64c1
18 changed files with 1104 additions and 207 deletions
@@ -1,4 +1,225 @@
import type { Platform } from '@mikro-orm/core';
import type { DayAssignments } from '../entities/DayAssignments.entity';
import { coalesce, columnRef } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class DayAssignmentsRepository extends TrekRepository<DayAssignments> {}
/**
* The DY1/DY3/AS1/AS3 assignment-with-place projection row (Plan 3c Task 2's
* report is the contract for Task 3, which consumes this unchanged):
*
* ```sql
* SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
* p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
* COALESCE(da.assignment_time, p.place_time) as place_time,
* COALESCE(da.assignment_end_time, p.end_time) as end_time,
* p.duration_minutes, p.notes as place_notes,
* p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id,
* p.website, p.phone, p.stop_type, p.fill_percent,
* 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 = ? | da.day_id = ? | da.day_id IN (...)>
* [ORDER BY da.order_index ASC, da.created_at ASC]
* ```
*
* `da.*` selects every scalar column of `DayAssignments` (`place_id` among
* them, the physical FK column) and then `p.id as place_id` selects it again
* under the same output key — the legacy statement did this too, and
* better-sqlite3's row object keeps the LAST column with a given name, so
* `place_id` on the wire is always `p.id`, not `da.place_id` (they agree by
* the JOIN condition regardless). Nullability here is the physical column's,
* not `AssignmentRow`'s (types.ts) narrower claims (rule 16) — a place with
* no category genuinely nulls `category_name`/`category_color`/
* `category_icon` through the LEFT JOIN even though `Categories.name` itself
* is NOT NULL.
*/
export interface AssignmentWithPlaceRow {
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;
assignment_time: string | null;
assignment_end_time: string | null;
end_day: number;
leg_transport_mode: string | null;
incoming_leg_transport_mode: string | null;
accommodation_id: number | null;
created_at: string | null;
place_name: string;
place_description: string | null;
lat: number | null;
lng: number | null;
address: string | null;
category_id: number | null;
price: number | null;
place_currency: string | null;
place_time: string | null;
end_time: string | null;
duration_minutes: number | null;
place_notes: string | null;
image_url: string | null;
transport_mode: string | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
amap_poi_id: string | null;
website: string | null;
phone: string | null;
stop_type: string | null;
fill_percent: number | null;
category_name: string | null;
category_color: string | null;
category_icon: string | null;
}
/**
* `day_assignments`'s shape as DY24's `em.getKysely()` call needs it (an
* explicit `TDB` type argument, per `WebauthnChallengesRepository
* .claimChallenge`'s docstring — entity-metadata inference is not what a
* hand-written statement wants), narrowed to the columns `reanchorToDay`
* reads or writes.
*/
interface DayAssignmentsKyselyDB {
day_assignments: {
id: number;
day_id: number;
order_index: number | null;
accommodation_id: number | 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) {
return [
'da.*',
'p.id as place_id',
'p.name as place_name',
'p.description as place_description',
'p.lat',
'p.lng',
'p.address',
// `category_id` is `persist(false)` on `Places` — a bare `'p.category_id'`
// string selects nothing (not a real property name), and `'p.category'`
// (the owning relation) is ALSO the joined alias here (`c`), so MikroORM
// absorbs the select into the join and returns `c__id`, not `category_id`
// (`McpTokensRepository.listAllWithUsername`'s `t.user`/`user_id`
// docstring is the same trap, one join away from this one). `columnRef`
// selects the literal physical column, keyed `category_id`.
columnRef(platform, 'p.category_id'),
'p.price',
'p.currency as place_currency',
coalesce(platform, 'da.assignment_time', 'p.place_time').as('place_time'),
coalesce(platform, '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',
] as const;
}
/**
* AS1 (`assignments.service.ts::getAssignmentWithPlace`, Task 3): the
* projection filtered to one assignment, no ORDER BY (a single row).
*/
async findWithPlaceAndCategory(id: number): Promise<AssignmentWithPlaceRow | null> {
const platform = this.getEntityManager().getPlatform();
const row = await this.qb('da')
.join('da.place', 'p')
.leftJoin('p.category', 'c')
.select(this.assignmentWithPlaceSelect(platform))
.where({ 'da.id': id })
.execute<AssignmentWithPlaceRow | undefined>('get', false);
return row ?? null;
}
/**
* DY1 (`days.service.ts::getAssignmentsForDay`) and AS3
* (`assignments.service.ts::listDayAssignments`, Task 3): the projection
* for one day, `ORDER BY da.order_index ASC, da.created_at ASC`.
*/
async listForDay(day_id: number): Promise<AssignmentWithPlaceRow[]> {
const platform = this.getEntityManager().getPlatform();
return await this.qb('da')
.join('da.place', 'p')
.leftJoin('p.category', 'c')
.select(this.assignmentWithPlaceSelect(platform))
.where({ 'da.day': day_id })
.orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' })
.execute<AssignmentWithPlaceRow[]>('all', false);
}
/**
* DY3 (`days.service.ts::list`): the same projection for several days at
* once (`WHERE da.day_id IN (...)`), same ORDER BY. Empty-array
* short-circuit before any query, as the legacy dynamic-`IN` builder did.
*/
async listWithPlaceAndCategory(day_ids: number[]): Promise<AssignmentWithPlaceRow[]> {
if (day_ids.length === 0) return [];
const platform = this.getEntityManager().getPlatform();
return await this.qb('da')
.join('da.place', 'p')
.leftJoin('p.category', 'c')
.select(this.assignmentWithPlaceSelect(platform))
.where({ 'da.day': { $in: day_ids } })
.orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' })
.execute<AssignmentWithPlaceRow[]>('all', false);
}
/**
* DY24 (`days.service.ts::resyncAccommodationDays`'s `moveStayStop`,
* inventory §18.2) — a correlated scalar subquery in an UPDATE SET clause
* the QueryBuilder cannot express:
*
* ```sql
* UPDATE day_assignments
* SET day_id = :dayId,
* order_index = COALESCE((SELECT MAX(order_index) FROM day_assignments WHERE day_id = :dayId), -1) + 1
* WHERE accommodation_id = :accId
* ```
*
* `this.kysely()` (`_shared/trek-repository.ts`), not
* `this.getEntityManager().getKysely()` directly — see
* `WebauthnChallengesRepository.claimChallenge`'s docstring for why.
* Proven to join the caller's ambient `uow.transactional` block by
* rollback, not by doc comment (ROLLBACK proof in the repository test and
* the task report): a call inside a transaction that then throws leaves
* the row un-moved; inside one that commits, the row is moved.
*/
async reanchorToDay(accommodation_id: number, day_id: number): Promise<void> {
await this.kysely<DayAssignmentsKyselyDB>()
.updateTable('day_assignments')
.set((eb) => ({
day_id,
order_index: eb(
eb.fn.coalesce(
eb
.selectFrom('day_assignments as da2')
.select((eb2) => eb2.fn.max('da2.order_index').as('m'))
.where('da2.day_id', '=', day_id),
eb.val(-1),
),
'+',
eb.val(1),
),
}))
.where('accommodation_id', '=', accommodation_id)
.execute();
}
}
@@ -62,4 +62,16 @@ export class DayNotesRepository extends TrekRepository<DayNotes> {
}
return toRow(inserted) as DayNoteRow;
}
/**
* DY4 (`days.service.ts::list`) — `` SELECT * FROM day_notes WHERE day_id
* IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC ``.
* Empty-array short-circuit before any query, as the legacy dynamic-`IN`
* builder did.
*/
async listByDayIds(day_ids: number[]): Promise<DayNoteRow[]> {
if (day_ids.length === 0) return [];
const notes = await this.find({ day: { $in: day_ids } }, { orderBy: { sort_order: 'asc', created_at: 'asc' } });
return notes.map((n) => toRow(n) as DayNoteRow);
}
}
@@ -15,6 +15,19 @@ export interface DayRow {
const _dayRowKeys: AssertRowKeys<DayRow, Days> = true;
/** The DY26/DY29 reorder/insert projection: `SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number`. */
export interface DayOrderRow {
id: number;
day_number: number;
date: string | null;
}
/** The DY21 re-anchor lookup: `SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1`. */
export interface DayIdAndNumberRow {
id: number;
day_number: number;
}
export class DaysRepository extends TrekRepository<Days> {
/** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */
async listByTrip(trip_id: number): Promise<DayRow[]> {
@@ -84,4 +97,85 @@ export class DaysRepository extends TrekRepository<Days> {
}
return toRow(inserted) as DayRow;
}
/**
* `SELECT * FROM days WHERE id = ?` — DY7/DY10/DY12/DY32/DY36's shared
* re-select, one method for all five sites (DY32/DY36 read after their
* transaction resolves, so this is a fresh statement against committed
* rows, no `refresh` needed).
*/
async findById(id: number): Promise<DayRow | undefined> {
const day = await this.findOne({ id });
return day ? (toRow(day) as DayRow) : undefined;
}
/**
* DY9 — `UPDATE days SET notes = ?, title = ? WHERE id = ?`. The presence
* sentinel that decides `notes`/`title` (the legacy's asymmetric `||`
* vs. `??` coercion) stays in `DaysService.update`; this takes the final,
* already-decided values and writes them verbatim.
*/
async updateNotesAndTitle(id: number, notes: string | null, title: string | null): Promise<void> {
await this.nativeUpdate({ id }, { notes, title });
}
/** DY11 — `UPDATE days SET default_transport_mode = ? WHERE id = ?` (#1281). */
async setDefaultTransportMode(id: number, mode: string | null): Promise<void> {
await this.nativeUpdate({ id }, { default_transport_mode: mode });
}
/** DY13 — `DELETE FROM days WHERE id = ?`, unscoped: the caller proved trip access. */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/**
* DY26/DY29 — `SELECT id, day_number, date FROM days WHERE trip_id = ?
* ORDER BY day_number`, the read `reorder`/`insert` both take before
* opening their `uow.transactional` block (same text, two legacy methods,
* one repository method here).
*/
async listOrderedForReorder(trip_id: number): Promise<DayOrderRow[]> {
return await this.qb('d')
.select(['d.id', 'd.day_number', 'd.date'])
.where({ trip: trip_id })
.orderBy({ day_number: 'asc' })
.execute<DayOrderRow[]>('all', false);
}
/** DY27/DY30 — `UPDATE days SET day_number = ? WHERE id = ?` (the two-phase renumber's per-row write). */
async setDayNumber(id: number, day_number: number): Promise<void> {
await this.nativeUpdate({ id }, { day_number });
}
/** DY28/DY33 — `UPDATE days SET day_number = ?, date = ? WHERE id = ?`. */
async setDayNumberAndDate(id: number, day_number: number, date: string | null): Promise<void> {
await this.nativeUpdate({ id }, { day_number, date });
}
/**
* DY31/DY34 — `INSERT INTO days (trip_id, day_number, date) VALUES (?, ?,
* ?)` (no `notes` column named, unlike `createDay`'s DY6 — the row's
* `notes` is left to the column's own NULL default). Returns the inserted
* id (`em.insert()`'s returned PK, R6's `lastInsertRowid` replacement);
* the caller re-selects separately (DY32/DY36), after its transaction
* resolves, so no read-back happens here.
*/
async insertDay(input: { trip_id: number; day_number: number; date: string | null }): Promise<number> {
return await this.insert({
trip: input.trip_id,
day_number: input.day_number,
date: input.date,
});
}
/** DY21 — `SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1`. */
async findByTripAndDate(trip_id: number, date: string): Promise<DayIdAndNumberRow | undefined> {
const row = await this.qb('d')
.select(['d.id', 'd.day_number'])
.where({ trip: trip_id, date })
.limit(1)
.execute<DayIdAndNumberRow | undefined>('get', false);
return row ?? undefined;
}
}
@@ -50,6 +50,16 @@ export class TripsRepository extends TrekRepository<Trips> {
return { id: row.id, user_id: row.user_id, currency: row.currency ?? null };
}
/**
* DY35 (`days.service.ts::insert`'s dated path) — `UPDATE trips SET
* end_date = ? WHERE id = ?`: a dated insert extends the trip by one day.
* Plan 3c Task 7 (`TripsService`) reuses this for its own `end_date`
* writes rather than duplicating the statement.
*/
async setEndDate(id: number, end_date: string | null): Promise<void> {
await this.nativeUpdate({ id }, { end_date });
}
/** `SELECT id FROM trips WHERE id = ? AND user_id = ?` */
async isOwner(trip_id: number | string, user_id: number): Promise<boolean> {
const row = await this.qb('t')
+22 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { DaysController } from './days.controller';
import { DaysService } from './days.service';
import { DaysMcp } from './days.mcp';
@@ -10,6 +11,10 @@ import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
import { PlacesModule } from '../places/places.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Days } from '../../db/entities/Days.entity';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import { DayNotes } from '../../db/entities/DayNotes.entity';
import { Trips } from '../../db/entities/Trips.entity';
/**
* Days (S6 — Phase 2 trip sub-domain), mounted at /api/trips/:tripId/days.
@@ -19,9 +24,25 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
*
* Day notes used to live here too, with their own full file set; they are their
* own domain now (day-notes/).
*
* `MikroOrmModule.forFeature([Days, DayAssignments, DayNotes, Trips])`
* registers `DaysRepository`/`DayAssignmentsRepository`/`DayNotesRepository`/
* `TripsRepository` for `DaysService`'s `@InjectRepository` constructor
* params (Plan 3c Task 2) — the same forFeature + `@InjectRepository`
* wiring every converted domain copies (`trip-membership.module.ts`'s
* precedent for pulling in a repository this module doesn't otherwise own).
*/
@Module({
imports: [McpSharedModule, PermissionsModule, QueryHelpersModule, PlacesModule, AuthModule, RealtimeModule, PluginGuardsModule],
imports: [
McpSharedModule,
PermissionsModule,
QueryHelpersModule,
PlacesModule,
AuthModule,
RealtimeModule,
PluginGuardsModule,
MikroOrmModule.forFeature([Days, DayAssignments, DayNotes, Trips]),
],
controllers: [DaysController],
providers: [DaysService, DaysMcp, DaysRpc],
exports: [DaysService],
+151 -125
View File
@@ -1,12 +1,22 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
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, Day, DayNote, User } from '../../types';
import type { AssignmentRow, User } from '../../types';
import { UnitOfWork } from '../database/unit-of-work';
import { toRowId } from '../common/row-id';
import { Days } from '../../db/entities/Days.entity';
import type { DaysRepository, DayOrderRow } from '../../db/repositories/Days.repository';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
import type { DayAssignmentsRepository } from '../../db/repositories/DayAssignments.repository';
import { DayNotes } from '../../db/entities/DayNotes.entity';
import type { DayNotesRepository } from '../../db/repositories/DayNotes.repository';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
type Trip = TripAccess;
@@ -44,21 +54,30 @@ function withDatePart(timestamp: string, date: string): string {
export class DayReorderError extends Error {}
/**
* Day domain service — owns the day + accommodation SQL (moved 1:1 from the
* legacy services/dayService.ts: identical statements, the `||` falsy-coercion
* defaults, the post-write re-selects, the two-phase negative-day_number
* renumber and the reservation re-stamping). The legacy hand-rolled
* BEGIN/COMMIT blocks in reorder/insert became uow.transactional() (same
* rollback-on-throw semantics, savepoint-safe when nested). Verified defects
* were fixed after the port (2026-07): update() now presence-sentinels BOTH
* columns (the legacy always-write wiped notes when only a title was sent),
* createAccommodation/deleteAccommodation run their multi-statement writes in
* a transaction, and getAssignmentsForDay batch-loads tags instead of one
* query per assignment. Trip access
* rides DatabaseService.canAccessTrip; mutations use the
* 'day_edit' permission; the WebSocket broadcast keeps its legacy call path.
* There are no non-Nest consumers left. days.bridge.ts served them and was
* deleted once the last one folded into the container.
* Day domain service — the day + day-assignment-projection SQL now lives in
* `DaysRepository`/`DayAssignmentsRepository`/`DayNotesRepository` (Plan 3c
* Task 2); `restampReservationDates`/`assertNoInvertedAccommodation` and the
* `day_accommodations`/`reservations`/`reservation_endpoints` halves of
* `resyncAccommodationDays` stay on the raw `DatabaseService` connection —
* those tables belong to Plan 3d (`// DYn — Plan 3d` marks each site). Trip
* access still rides `DatabaseService.canAccessTrip` (Task 0b's 110
* unconverted callers, this among them); mutations use the 'day_edit'
* permission; the WebSocket broadcast keeps its legacy call path.
*
* Day ids arriving from a route (`:id`) are an **affinity seam**: the legacy
* statement bound the route string straight into `WHERE id = ?` with no
* `Number()`/validation of its own, relying on SQLite's own text/integer
* affinity. `getDay` is the one gate that converts and answers the legacy
* not-found (`toRowId` → `undefined`, program rule 15); every mutation below
* is only ever reached downstream of a successful `getDay` call in the same
* request (REST/MCP/RPC all check-then-act), so `toRowId(id)!` there is not
* a live 404 path, just the same non-null-assert style this file already
* used before conversion (`this.db.get<Day>(...)!`). `tripId` is the DY0
* seam: `TripAccessGuard` already validated it with the SAME raw-bind
* escape hatch `TripsRepository.findAccessible` uses, so every id-shape a
* plain `Number(tripId)` would mis-parse relative to SQLite's affinity was
* already refused before this service's handler ever runs — safe to convert
* with a bare `Number()` downstream of that guard.
*/
@Injectable()
export class DaysService {
@@ -68,6 +87,10 @@ export class DaysService {
private readonly realtime: RealtimeService,
private readonly queryHelpers: QueryHelpersService,
private readonly uow: UnitOfWork,
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
@InjectRepository(DayAssignments) private readonly dayAssignmentsRepo: DayAssignmentsRepository,
@InjectRepository(DayNotes) private readonly dayNotesRepo: DayNotesRepository,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
) {}
async verifyTripAccess(tripId: string | number, userId: number) {
@@ -86,21 +109,9 @@ export class DaysService {
// Day assignment helpers
// -------------------------------------------------------------------------
/** DY1 — `DayAssignmentsRepository.listForDay`, the single-day ordered projection. */
async getAssignmentsForDay(dayId: number | string) {
const assignments = this.db.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);
const assignments = await this.dayAssignmentsRepo.listForDay(Number(dayId));
// One batched tag load instead of the legacy per-assignment query; the
// non-compact loader returns the same full tag rows (t.* minus the join
@@ -163,29 +174,15 @@ export class DaysService {
// -------------------------------------------------------------------------
async list(tripId: string | number) {
const days = this.db.all<Day>('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId);
const days = await this.daysRepo.listByTrip(Number(tripId));
if (days.length === 0) {
return { days: [] };
}
const dayIds = days.map(d => d.id);
const dayPlaceholders = dayIds.map(() => '?').join(',');
const allAssignments = this.db.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 IN (${dayPlaceholders})
ORDER BY da.order_index ASC, da.created_at ASC
`, ...dayIds);
const allAssignments = await this.dayAssignmentsRepo.listWithPlaceAndCategory(dayIds);
const placeIds = [...new Set(allAssignments.map(a => a.place_id))];
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
@@ -196,14 +193,15 @@ export class DaysService {
const assignmentsByDayId: Record<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
for (const a of allAssignments) {
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.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] || []));
}
const allNotes = this.db.all<DayNote>(
`SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC`,
...dayIds
);
const notesByDayId: Record<number, DayNote[]> = {};
const allNotes = await this.dayNotesRepo.listByDayIds(dayIds);
const notesByDayId: Record<number, typeof allNotes[number][]> = {};
for (const note of allNotes) {
if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = [];
notesByDayId[note.day_id].push(note);
@@ -219,34 +217,42 @@ export class DaysService {
}
async create(tripId: string | number, date?: string, notes?: string) {
const maxDay = this.db.get<{ max: number | null }>('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?', tripId)!;
const dayNumber = (maxDay.max || 0) + 1;
const tripIdNum = Number(tripId);
// DY5: `DaysRepository.maxDayNumber` already folds the no-rows case to 0
// (`?? 0`); the `|| 0` here is the legacy expression kept verbatim so a
// stored 0 still becomes 1 — the same outcome either fold produces, but
// the ruling is to keep the literal formula in the service.
const maxDayNumber = await this.daysRepo.maxDayNumber(tripIdNum);
const dayNumber = (maxDayNumber || 0) + 1;
const result = this.db.run(
'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)',
tripId, dayNumber, date || null, notes || null
);
const day = this.db.get<Day>('SELECT * FROM days WHERE id = ?', result.lastInsertRowid)!;
const day = await this.daysRepo.createDay({
trip_id: tripIdNum,
day_number: dayNumber,
date: date || null,
notes: notes || null,
});
return { ...day, assignments: [] };
}
async getDay(id: string | number, tripId: string | number) {
return this.db.get<Day>('SELECT * FROM days WHERE id = ? AND trip_id = ?', id, tripId);
const dayId = toRowId(id);
if (dayId === null) return undefined;
return await this.daysRepo.findInTrip(dayId, Number(tripId));
}
async update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
async update(id: string | number, current: { notes?: string | null; title?: string | null }, fields: { notes?: string; title?: string | null }) {
const dayId = toRowId(id)!;
// Both columns use the presence sentinel: an absent key preserves the
// current value (the legacy version always wrote notes, so setting a title
// silently wiped the day's notes — the client sends the two fields in
// separate requests).
this.db.run('UPDATE days SET notes = ?, title = ? WHERE id = ?',
'notes' in fields ? (fields.notes || null) : (current.notes ?? null),
'title' in fields ? (fields.title ?? null) : (current.title ?? null),
id
);
const updatedDay = this.db.get<Day>('SELECT * FROM days WHERE id = ?', id)!;
return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) };
// separate requests). Note the asymmetry: `notes` falls back through `||`
// (an empty string clears too), `title` through `??` (only null/undefined
// clear) — both byte-identical to the legacy statement's own coercions.
const notes = 'notes' in fields ? (fields.notes || null) : (current.notes ?? null);
const title = 'title' in fields ? (fields.title ?? null) : (current.title ?? null);
await this.daysRepo.updateNotesAndTitle(dayId, notes, title);
const updatedDay = (await this.daysRepo.findById(dayId))!;
return { ...updatedDay, assignments: await this.getAssignmentsForDay(dayId) };
}
/**
@@ -255,13 +261,16 @@ export class DaysService {
* per-leg assignment transport setter. Per-segment leg modes still override it.
*/
async setDefaultTransportMode(id: string | number, mode: string | null) {
this.db.run('UPDATE days SET default_transport_mode = ? WHERE id = ?', mode ?? null, id);
const updatedDay = this.db.get<Day>('SELECT * FROM days WHERE id = ?', id)!;
return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) };
const dayId = toRowId(id)!;
await this.daysRepo.setDefaultTransportMode(dayId, mode ?? null);
const updatedDay = (await this.daysRepo.findById(dayId))!;
return { ...updatedDay, assignments: await this.getAssignmentsForDay(dayId) };
}
/** DY13 — unscoped by trip: the caller (every route) already proved trip access via `getDay`. */
async remove(id: string | number): Promise<void> {
this.db.run('DELETE FROM days WHERE id = ?', id);
const dayId = toRowId(id)!;
await this.daysRepo.deleteById(dayId);
}
// -------------------------------------------------------------------------
@@ -282,12 +291,17 @@ export class DaysService {
* moved day so reservation_time/reservation_end_time follow their day's new
* date (time-of-day preserved). Transport endpoints (flight legs) shift by the
* same per-booking day delta so multi-leg timing stays internally consistent.
*
* DY14–DY18 — Plan 3d (`reservations`/`reservation_endpoints`): stays raw on
* `DatabaseService` inside the caller's `uow.transactional` block, per the
* Plan 3b `UserCleanupService` precedent for cross-domain writes.
*/
async restampReservationDates(
tripId: string | number,
oldDateById: Map<number, string | null>,
newDateById: Map<number, string | null>,
): Promise<void> {
// DY14 — Plan 3d
const reservations = this.db.all<{
id: number; day_id: number | null; end_day_id: number | null;
reservation_time: string | null; reservation_end_time: string | null;
@@ -296,9 +310,13 @@ export class DaysService {
tripId
);
// DY15 — Plan 3d
const setTime = this.db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
// DY16 — Plan 3d
const setEndTime = this.db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?');
// DY17 — Plan 3d
const endpoints = this.db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?');
// DY18 — Plan 3d
const setEndpointDate = this.db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?');
for (const r of reservations) {
@@ -326,7 +344,14 @@ export class DaysService {
}
}
/** A stay must not end before it begins after a reorder/insert. */
/**
* A stay must not end before it begins after a reorder/insert.
*
* DY19 — Plan 3d: the statement's root table is `day_accommodations`
* (joined to `days` only to read `day_number`), so it stays raw on
* `DatabaseService` per the inventory's ruling, even though `days` itself
* is owned here.
*/
private async assertNoInvertedAccommodation(tripId: string | number): Promise<void> {
const spans = this.db.all<{ id: number; start_no: number; end_no: number }>(`
SELECT a.id, s.day_number AS start_no, e.day_number AS end_no
@@ -351,54 +376,61 @@ export class DaysService {
* its day rows, mirroring resyncReservationDays' out-of-range semantics, so moving a
* whole trip still shifts everything together. The linked hotel reservation follows
* its accommodation's start day in both branches.
*
* DY20/DY22/DY23 — Plan 3d (`day_accommodations`/`reservations`) stay raw;
* DY21 (`DaysRepository.findByTripAndDate`) and DY25
* (`DaysRepository.findById`) convert — both root on `days`, which this
* plan owns. DY24 (the `day_assignments` stop that follows its booking)
* converts via `DayAssignmentsRepository.reanchorToDay`, a Kysely
* statement proven to join this method's ambient transaction by rollback
* (see the task report).
*/
async resyncAccommodationDays(
tripId: string | number,
prevDateByDayId: Map<number, string | null>,
): Promise<void> {
const tripIdNum = Number(tripId);
// DY20 — Plan 3d
const stays = this.db.all<{ id: number; start_day_id: number; end_day_id: number }>(
'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?',
tripId
);
if (stays.length === 0) return;
const dayByDate = this.db.prepare('SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1');
// DY22 — Plan 3d
const updateStay = this.db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ? WHERE id = ?');
// DY23 — Plan 3d
const restampLinkedRes = this.db.prepare(`
UPDATE reservations SET day_id = :dayId,
reservation_time = CASE WHEN reservation_time IS NULL THEN :date
ELSE :date || SUBSTR(reservation_time, 11) END
WHERE accommodation_id = :accId AND type = 'hotel'
`);
// The day stop a booking wrote moves with it, the way its linked booking does.
// Left behind it would sit on a day the traveller no longer sleeps there, with
// nothing on screen to say why. Re-indexed to the end of the target day, because
// its old position belonged to a day it is leaving.
const moveStayStop = this.db.prepare(`
UPDATE day_assignments
SET day_id = :dayId,
order_index = COALESCE((SELECT MAX(order_index) FROM day_assignments WHERE day_id = :dayId), -1) + 1
WHERE accommodation_id = :accId
`);
for (const stay of stays) {
const oldStartDate = prevDateByDayId.get(stay.start_day_id);
const oldEndDate = prevDateByDayId.get(stay.end_day_id);
if (oldStartDate && oldEndDate) {
const newStart = dayByDate.get(tripId, oldStartDate) as { id: number; day_number: number } | undefined;
const newEnd = dayByDate.get(tripId, oldEndDate) as { id: number; day_number: number } | undefined;
// DY21
const newStart = await this.daysRepo.findByTripAndDate(tripIdNum, oldStartDate);
const newEnd = await this.daysRepo.findByTripAndDate(tripIdNum, oldEndDate);
if (newStart && newEnd && newStart.day_number <= newEnd.day_number
&& (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) {
updateStay.run(newStart.id, newEnd.id, stay.id);
moveStayStop.run({ dayId: newStart.id, accId: stay.id });
// The day stop a booking wrote moves with it, the way its linked booking does.
// Left behind it would sit on a day the traveller no longer sleeps there, with
// nothing on screen to say why. Re-indexed to the end of the target day, because
// its old position belonged to a day it is leaving. DY24, via Kysely.
await this.dayAssignmentsRepo.reanchorToDay(stay.id, newStart.id);
stay.start_day_id = newStart.id;
}
}
// Keep the linked reservation on the stay's (possibly re-dated) start day — its
// reservation_time is a snapshot of that day's date, stale after any range change.
const startDayDate = this.db.get<{ date: string | null }>('SELECT date FROM days WHERE id = ?', stay.start_day_id)?.date;
if (startDayDate) {
restampLinkedRes.run({ dayId: stay.start_day_id, date: startDayDate, accId: stay.id });
// DY25
const startDay = await this.daysRepo.findById(stay.start_day_id);
if (startDay?.date) {
restampLinkedRes.run({ dayId: stay.start_day_id, date: startDay.date, accId: stay.id });
}
}
}
@@ -408,10 +440,8 @@ export class DaysService {
* day ids (a permutation of the current ids).
*/
async reorder(tripId: string | number, orderedIds: number[]) {
const rows = this.db.all<{ id: number; day_number: number; date: string | null }>(
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number',
tripId
);
const tripIdNum = Number(tripId);
const rows: DayOrderRow[] = await this.daysRepo.listOrderedForReorder(tripIdNum);
const existingIds = new Set(rows.map(r => r.id));
if (orderedIds.length !== rows.length || !orderedIds.every(id => existingIds.has(id))) {
@@ -423,18 +453,19 @@ export class DaysService {
const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
const isDated = sortedDates.length > 0;
const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const setDayNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
await this.uow.transactional(async () => {
// Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions.
orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id));
// Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions —
// one `nativeUpdate` per row, in the legacy order, both phases inside
// this same transaction.
for (const [i, id] of orderedIds.entries()) {
await this.daysRepo.setDayNumber(id, -(i + 1));
}
const newDateById = new Map<number, string | null>();
orderedIds.forEach((id, i) => {
for (const [i, id] of orderedIds.entries()) {
const date = isDated ? (sortedDates[i] ?? null) : null;
setDayNumberAndDate.run(i + 1, date, id);
await this.daysRepo.setDayNumberAndDate(id, i + 1, date);
newDateById.set(id, date);
});
}
if (isDated) await this.restampReservationDates(tripId, oldDateById, newDateById);
await this.assertNoInvertedAccommodation(tripId);
@@ -450,26 +481,22 @@ export class DaysService {
* shifted days have their dates re-stamped (same rules as reorder).
*/
async insert(tripId: string | number, position?: number) {
const rows = this.db.all<{ id: number; day_number: number; date: string | null }>(
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number',
tripId
);
const tripIdNum = Number(tripId);
const rows: DayOrderRow[] = await this.daysRepo.listOrderedForReorder(tripIdNum);
const n = rows.length;
const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1);
const datedRows = rows.filter(r => r.date) as { id: number; day_number: number; date: string }[];
const isDated = datedRows.length > 0;
const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
if (!isDated) {
const newRowid = await this.uow.transactional(async () => {
const newId = await this.uow.transactional(async () => {
const toShift = rows.filter(r => r.day_number >= pos);
toShift.forEach(r => setDayNumber.run(-r.day_number, r.id));
const result = this.db.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)', tripId, pos);
toShift.forEach(r => setDayNumber.run(r.day_number + 1, r.id));
return result.lastInsertRowid;
for (const r of toShift) await this.daysRepo.setDayNumber(r.id, -r.day_number);
const insertedId = await this.daysRepo.insertDay({ trip_id: tripIdNum, day_number: pos, date: null });
for (const r of toShift) await this.daysRepo.setDayNumber(r.id, r.day_number + 1);
return insertedId;
});
const day = this.db.get<Day>('SELECT * FROM days WHERE id = ?', newRowid)!;
const day = (await this.daysRepo.findById(newId))!;
return { ...day, assignments: [], notes_items: [] };
}
@@ -477,28 +504,27 @@ export class DaysService {
const start = datedRows.map(r => r.date).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))[0];
const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i));
const oldDateById = new Map(rows.map(r => [r.id, r.date]));
const setDayNumberAndDate = this.db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
const newId = await this.uow.transactional(async () => {
rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id));
const result = this.db.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)', tripId, pos, dates[pos - 1]);
const insertedId = Number(result.lastInsertRowid);
for (const [i, r] of rows.entries()) await this.daysRepo.setDayNumber(r.id, -(i + 1));
const insertedId = await this.daysRepo.insertDay({ trip_id: tripIdNum, day_number: pos, date: dates[pos - 1] });
const orderedIds = rows.map(r => r.id);
orderedIds.splice(pos - 1, 0, insertedId);
const newDateById = new Map<number, string | null>();
orderedIds.forEach((id, i) => {
setDayNumberAndDate.run(i + 1, dates[i], id);
for (const [i, id] of orderedIds.entries()) {
await this.daysRepo.setDayNumberAndDate(id, i + 1, dates[i]);
newDateById.set(id, dates[i]);
});
}
await this.restampReservationDates(tripId, oldDateById, newDateById);
await this.assertNoInvertedAccommodation(tripId);
this.db.run('UPDATE trips SET end_date = ? WHERE id = ?', dates[dates.length - 1], tripId);
// DY35 — `TripsRepository.setEndDate`, added this task for Task 7 to reuse.
await this.tripsRepo.setEndDate(tripIdNum, dates[dates.length - 1]);
return insertedId;
});
const day = this.db.get<Day>('SELECT * FROM days WHERE id = ?', newId)!;
const day = (await this.daysRepo.findById(newId))!;
return { ...day, assignments: [], notes_items: [] };
}