refactor(trips): trips through repositories

This commit is contained in:
jubnl
2026-09-23 09:33:48 +02:00
parent f19face405
commit 18a4784ebd
19 changed files with 1244 additions and 218 deletions
+11 -4
View File
@@ -279,10 +279,17 @@ export function maxOf(platform: Platform, ref: string, aliasName: string): RawQu
* for this one, unlike `countAll`/`minOf`/`maxOf`, because `.as()` already
* covers it and a `value`-bearing fragment cannot reuse `alias()`'s bare
* identifier validation the same way an aggregate's literal alias does).
* `value`/`whenTrue`/`whenFalse` are all bound as parameters, never
* interpolated — even though today's only caller (TM2's owner/member role
* label) passes fixed literal strings, parity is by VALUE, not by whether
* the string happens to be a source-code literal at the call site.
* `value`/`whenTrue`/`whenFalse` are all passed through `raw()`'s own
* parameter escaping, never spliced into the SQL text by this function —
* even though today's only caller (TM2's owner/member role label) passes
* fixed literal strings, parity is by VALUE, not by whether the string
* happens to be a source-code literal at the call site. (Task 6 review's
* I1: downstream, MikroORM still formats the FINAL query with every
* parameter inlined as an escaped literal in the SQL text it hands
* better-sqlite3 — not as a separate bound-params array — so a non-finite
* `value` renders as the bare token `NaN`/`Infinity` and fails at PREPARE
* time regardless of this function's own escaping; callers must guard a
* non-finite `value` before calling.)
*/
export function caseWhenEquals(platform: Platform, ref: string, value: number, whenTrue: string, whenFalse: string): RawQueryFragment {
if (platform instanceof SqlitePlatform) return raw(`CASE WHEN ${column(ref)} = ? THEN ? ELSE ? END`, [value, whenTrue, whenFalse]);
@@ -1,4 +1,7 @@
import type { Days } from '../entities/Days.entity';
import { DayAssignments } from '../entities/DayAssignments.entity';
import { DayNotes } from '../entities/DayNotes.entity';
import { DayAccommodations } from '../entities/DayAccommodations.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -199,4 +202,66 @@ export class DaysRepository extends TrekRepository<Days> {
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
// ---------------------------------------------------------------------------
// Plan 3c Task 7 (`TripsService.generateDays`) — appended per the task's own
// file-ownership rule ("additive methods on Days/Places/Users repositories").
// ---------------------------------------------------------------------------
/** TP3 — `UPDATE days SET date = NULL WHERE id = ?` (nullifies rather than deletes: assignments/notes/accommodations survive). */
async clearDate(id: number): Promise<void> {
await this.nativeUpdate({ id }, { date: null });
}
/**
* TP12 (`trips.service.ts::generateDays`'s `isEmptyDay`) — the legacy
* statement is `SELECT NOT EXISTS(...) AND NOT EXISTS(...) AND NOT
* EXISTS(...) AS empty` with no `FROM` clause and `@id` named params, the
* single most dialect-specific statement in the cluster (inventory §17b).
* Expressed here as three `count()`s (R6's own "three counts" alternative
* to a Kysely `selectNoFrom`), one per table the legacy statement's three
* `NOT EXISTS` correlate against — `day_assignments` and `day_notes` are
* this plan's own tables; `day_accommodations` is Plan 3d's, read (never
* written) here through the entity's already-generated `TrekRepository`
* (`this.getEntityManager().getRepository(DayAccommodations)`, the same
* cross-repository-read shape `TripMembersRepository.rosterUserIds`
* documents for `Trips`) — no domain method of `days`' own is added to
* that repository, so ownership of `day_accommodations`' write logic
* stays with Plan 3d.
*/
async isEmptyDay(day_id: number): Promise<boolean> {
const em = this.getEntityManager();
const [assignments, notes, accommodations] = await Promise.all([
em.getRepository(DayAssignments).count({ day: day_id }),
em.getRepository(DayNotes).count({ day: day_id }),
em.getRepository(DayAccommodations).count({ $or: [{ startDay: day_id }, { endDay: day_id }] }),
]);
return assignments === 0 && notes === 0 && accommodations === 0;
}
/**
* TP6 (`trips.service.ts::generateDays`'s trailing-empty trim) — the
* legacy statement orders candidate days `ORDER BY d.day_number DESC` and
* takes the first `limit` that pass all three `NOT EXISTS` checks (§17b).
* Reproduced here by reading every day of the trip in the same
* `day_number DESC` order and filtering with {@link isEmptyDay} per row,
* stopping once `limit` matches are collected — same result set as the
* single correlated statement (both examine days in day_number-descending
* order and take the first N that are empty), documented per R6 as the
* "three counts, comment which" alternative to a Kysely `selectNoFrom`.
*/
async listTrailingEmptyIds(trip_id: number, limit: number): Promise<number[]> {
if (limit <= 0) return [];
const candidates = await this.qb('d')
.select(['d.id'])
.where({ trip: trip_id })
.orderBy({ day_number: 'desc' })
.execute<{ id: number }[]>('all', false);
const empty: number[] = [];
for (const c of candidates) {
if (empty.length >= limit) break;
if (await this.isEmptyDay(c.id)) empty.push(c.id);
}
return empty;
}
}
@@ -176,14 +176,18 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
* `Number.isFinite` short-circuit guard — the same reason `remove()` below
* has both: `renameGuest`/`deleteGuest`'s callers still do a bare
* `Number.parseInt(userId)` on the route's `:userId`, unvalidated — a
* non-numeric id parses to `NaN`, and MikroORM/Kysely inline a non-finite
* `number` as the literal SQL token `NaN` even through a raw `?`
* placeholder (`no such column: NaN`, a 500 at PREPARE time — verified
* directly against a compiled boot, not assumed; a typed filter has the
* identical failure). The legacy statement bound it as a genuine
* better-sqlite3 parameter, matching zero rows (404 `'Guest not found'`,
* not a crash) — only skipping the query for a non-finite `user_id`
* reproduces that byte for byte.
* non-numeric id parses to `NaN`. **Mechanism (Task 6 review's I1,
* captured by wrapping `better-sqlite3.prepare`, not assumed): MikroORM
* formats every query with ALL parameters inlined as escaped literals —
* the params array that reaches better-sqlite3 is always empty — so a
* non-finite `NaN`/`Infinity` renders as a bare, unquoted SQL token and
* SQLite fails at PREPARE time (`no such column: NaN`, a 500), before any
* statement runs. A raw `?` placeholder does not avoid this (a typed
* filter has the identical failure) because the inlining happens during
* formatting, not at the bind step.** The legacy statement bound it as a
* genuine better-sqlite3 parameter, matching zero rows (404 `'Guest not
* found'`, not a crash) — only skipping the query for a non-finite
* `user_id` reproduces that byte for byte.
*/
async isGuestOfTrip(trip_id: number | string, user_id: number): Promise<boolean> {
if (!Number.isFinite(user_id)) return false;
@@ -239,10 +243,14 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
* a literal, unquoted SQL token (`WHERE … AND user_id = NaN`), which
* SQLite's parser reads as a column reference and rejects at PREPARE time
* (`no such column: NaN`, a 500). Switching to a raw `m.user_id = ?`
* placeholder does NOT fix this — MikroORM/Kysely inline a non-finite
* `number` as that same literal token even through a `?` placeholder's
* bound-params array (reproduced directly: still `no such column: NaN` at
* prepare time, before any bind happens). The legacy statement bound
* placeholder does NOT fix this (Task 6 review's I1, captured by wrapping
* `better-sqlite3.prepare`): MikroORM formats every query with ALL
* parameters inlined as escaped literals — the params array that reaches
* better-sqlite3 is always empty — so `NaN` renders as that same bare
* token regardless of whether the predicate was written as a typed filter
* or a raw `?` placeholder (reproduced directly: still `no such column:
* NaN` at prepare time, before any bind happens — the inlining happens
* during formatting, not at the bind step). The legacy statement bound
* `targetUserId` (`NaN`) straight into a real better-sqlite3 parameter,
* which silently matches zero rows and returns 200 `{ success: true }` —
* only skipping the query entirely for a non-finite `user_id` reproduces
@@ -257,4 +265,29 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
.andWhere('m.user_id = ?', [user_id])
.execute('run');
}
// ---------------------------------------------------------------------------
// Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository
// is rooted at `trip_members` joined to `users`, and RP3's shape is
// deliberately NOT `listWithUserAndInviter`'s (TM2's COALESCE'd
// `username`), so it gets its own method here rather than a variant
// parameter on TM2's.
// ---------------------------------------------------------------------------
/**
* RP3 (`trips.rpc.ts::members`) — `SELECT u.id, u.username, u.display_name,
* u.avatar FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE
* tm.trip_id = ?`. Emits raw `username` AND `display_name` as SEPARATE
* fields — deliberately NOT `listWithUserAndInviter`'s (TM2) COALESCE'd
* single `username` (the ruling: "do not harmonise" — a plugin reading
* `trips.members` and the REST/MCP roster are two different wire shapes on
* purpose, inventory §18.10).
*/
async listRawUsernameAndDisplayName(trip_id: number | string): Promise<{ id: number; username: string; display_name: string | null; avatar: string | null }[]> {
return await this.qb('m')
.join('m.user', 'u')
.select(['u.id', 'u.username', 'u.display_name', 'u.avatar'])
.where('m.trip_id = ?', [trip_id])
.execute<{ id: number; username: string; display_name: string | null; avatar: string | null }[]>('all', false);
}
}
@@ -1,4 +1,5 @@
import type { Trips } from '../entities/Trips.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -35,6 +36,66 @@ export interface TripRawRow {
const _tripRawRowKeys: AssertRowKeys<TripRawRow, Trips> = true;
/**
* `TRIP_SELECT`'s output row (inventory §11a) — every `TripRawRow` column
* plus the four computed/joined columns the projection adds. `feed_token`
* is always `null` here (SQL-side blanking via `NULL AS feed_token`/its
* Kysely equivalent, never a JS strip) — see `TripsRepository
* .tripSelectQuery`'s docstring.
*/
export interface TripSelectRow extends Omit<TripRawRow, 'feed_token'> {
feed_token: null;
day_count: number;
place_count: number;
is_owner: number;
owner_username: string;
shared_count: number;
}
/** The narrow `trips`/`users`/`days`/`places`/`trip_members` shape `tripSelectQuery` needs (`this.kysely()`'s typed `DB` argument). */
interface TripSelectKyselyDB {
trips: {
// `number | string`, not a bare `number`: the raw-bind seam (D4's T5
// escape hatch) — `findForViewer`'s `WHERE t.id = ?` accepts the route's
// unconverted id with no coercion, the same seam every QB-based method
// in this file documents. Widening the TYPE (not spelling `sql\`...\``,
// which `no-restricted-syntax` bans in `src/db/repositories/**`) is what
// lets `.where('t.id', '=', trip_id)` accept a `string` here.
id: number | string;
user_id: number;
title: string;
description: string | null;
start_date: string | null;
end_date: string | null;
currency: string | null;
cover_image: string | null;
is_archived: number | null;
reminder_days: number | null;
feed_token: string | null;
created_at: string | null;
updated_at: string | null;
};
users: { id: number; username: string };
trip_members: { id: number; trip_id: number; user_id: number };
days: { id: number; trip_id: number };
places: { id: number; trip_id: number };
}
/** TP21's output row (inventory §11c) — `relevance` is the computed sort key, never sent to the client (the controller destructures `{id, title, start_date, end_date}` only). */
export interface ActiveTripRow {
id: number;
title: string;
start_date: string | null;
end_date: string | null;
relevance: number;
}
/** The narrow `trips`/`trip_members` shape `activeTrip` needs. */
interface ActiveTripKyselyDB {
trips: { id: number; title: string; start_date: string | null; end_date: string | null; user_id: number; is_archived: number | null };
trip_members: { id: number; trip_id: number; user_id: number };
}
export class TripsRepository extends TrekRepository<Trips> {
/**
* The `LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
@@ -224,4 +285,215 @@ export class TripsRepository extends TrekRepository<Trips> {
.execute<TripRawRow | undefined>('get', false);
return row ?? null;
}
// ---------------------------------------------------------------------------
// Plan 3c Task 7 (`TripsService`) — `TRIP_SELECT` (inventory §11a) becomes
// ONE builder here (`tripSelectQuery`), expressed through `this.kysely()`
// (a typed one-table-plus-joins DB interface, the same escape hatch
// `DayAssignmentsRepository.reanchorToDay`/`DayAssignmentsRepository
// .listForTimeSort` use): three correlated scalar `COUNT(*)` subqueries a
// QueryBuilder select list cannot express, a `CASE WHEN` projected column,
// and `NULL AS feed_token` placed AFTER `t.*` (via `.selectAll('t')` then
// a later `.select()` of the literal) so the duplicate key wins in the row
// object exactly the way the legacy statement's column order does — not by
// hand-listing every `trips` column (§18.5: that loses the "blanked once,
// survives a schema change" guarantee the legacy comment describes).
// Verified directly with `.compile()` against a real in-memory DB before
// wiring in (task report, TRIP_SELECT SQL captures) — a fully seeded trip
// (owner, member, stranger; day/place counts; `feed_token` set in the DB)
// produces `feed_token: null`, `is_owner`, `owner_username`, all four
// counts, byte-identical in shape to a raw run of the legacy statement.
// ---------------------------------------------------------------------------
/**
* `this.kysely()`'s typed `DB` argument for every `TRIP_SELECT`-shaped
* query below — narrowed to the columns those queries read or join
* through, per `WebauthnChallengesRepository.claimChallenge`'s docstring
* (entity-metadata inference is not what a hand-written statement wants).
*/
private tripSelectQuery(user_id: number) {
return this.kysely<TripSelectKyselyDB>()
.selectFrom('trips as t')
.innerJoin('users as u', 'u.id', 't.user_id')
.selectAll('t')
.select((eb) => [
eb.val<string | null>(null).as('feed_token'),
eb.selectFrom('days as d').select((eb2) => eb2.fn.countAll<number>().as('c')).whereRef('d.trip_id', '=', 't.id').as('day_count'),
eb.selectFrom('places as p').select((eb2) => eb2.fn.countAll<number>().as('c')).whereRef('p.trip_id', '=', 't.id').as('place_count'),
eb.case().when('t.user_id', '=', user_id).then(1).else(0).end().as('is_owner'),
'u.username as owner_username',
eb.selectFrom('trip_members as tm').select((eb2) => eb2.fn.countAll<number>().as('c')).whereRef('tm.trip_id', '=', 't.id').as('shared_count'),
]);
}
/**
* TP20 (`trips.service.ts::get`) — `TRIP_SELECT` scoped to one trip AND
* the access predicate (`t.user_id = :userId OR m.user_id IS NOT NULL`),
* i.e. "the trip if this viewer may see it, else nothing". Also serves
* TP19 (`create`'s post-insert re-select) and TP29 (`updateTrip`'s
* post-write re-select): both call sites are only ever reached after the
* acting user has already passed an access check for this exact trip
* (creator = owner; `updateTrip`'s callers all gate on `canAccessTrip`/
* `requireTripEdit` first), so the access predicate here is always
* trivially satisfied in those two contexts and the result is identical
* to the legacy's unscoped `WHERE t.id = :tripId` re-select. Also serves
* `TripMembersService.getTripForViewer` (TM1, Task 6 review's "for Task
* 7" note) — same reasoning: every caller already holds access.
*
* Raw-bind (`number | string`, D4's T5 escape hatch — `TripSelectKyselyDB`'s
* `trips.id` is typed `number | string` for exactly this): the legacy
* statement bound the route's unconverted `tripId` with no `Number()`/
* `toRowId` conversion, matching `findAccessible`'s documented seam.
*/
async findForViewer(trip_id: number | string, user_id: number): Promise<TripSelectRow | undefined> {
const row = await this.tripSelectQuery(user_id)
.leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id))
.where('t.id', '=', trip_id)
.where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('m.user_id', 'is not', null)]))
.executeTakeFirst();
return row as TripSelectRow | undefined;
}
/**
* TP16/TP17 (`trips.service.ts::list`) — `TRIP_SELECT` + the access join,
* optionally filtered by `is_archived`, `ORDER BY t.created_at DESC`.
* `archived === null` reproduces TP16 (no filter); any other value (0 or
* 1, the controller's own coercion) reproduces TP17.
*/
async listForUser(user_id: number, archived: number | null): Promise<TripSelectRow[]> {
let query = this.tripSelectQuery(user_id)
.leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id))
.where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('m.user_id', 'is not', null)]));
if (archived !== null) query = query.where('t.is_archived', '=', archived);
const rows = await query.orderBy('t.created_at', 'desc').execute();
return rows as unknown as TripSelectRow[];
}
/**
* TP21 (`trips.service.ts::activeTrip`) — the triple `CASE WHEN …
* relevance` projection and the double-`CASE WHEN` `ORDER BY` (one ASC,
* one DESC). The legacy statement's `ORDER BY` items reference the
* `relevance` SELECT-list alias (`CASE WHEN relevance < 2 …`, `CASE WHEN
* relevance = 2 …`); reproduced here by recomputing the equivalent
* boolean expression inline at each of the four sites (the `.select()`
* and three `.orderBy()` calls below) rather than referencing the alias,
* since Kysely's typed `ORDER BY` can't name a computed SELECT alias and
* `sql`-tagged templates are banned in `src/db/repositories/**` — and a
* shared private helper can't be typed across these four callbacks either
* (`leftJoin`'s alias widens each callback's own `ExpressionBuilder`
* table-set in a way a separate generic method signature can't match
* structurally). Semantically identical to the legacy statement — SQLite
* evaluates both forms to the same three-way ordering, verified directly
* against `.compile()`/real rows before wiring in (task report,
* TRIP_SELECT SQL captures): `running` = `relevance = 0`, `upcoming` =
* `relevance = 1`, `running OR upcoming` = `relevance < 2`.
*/
async activeTrip(user_id: number, today: string): Promise<ActiveTripRow | undefined> {
const row = await this.kysely<ActiveTripKyselyDB>()
.selectFrom('trips as t')
.leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id))
.select(['t.id', 't.title', 't.start_date', 't.end_date'])
.select((eb) => [
eb.case()
.when(eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]))
.then(0)
.when(eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]))
.then(1)
.else(2)
.end()
.as('relevance'),
])
.where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('m.user_id', 'is not', null)]))
.where('t.is_archived', '=', 0)
.orderBy((eb) =>
eb.case()
.when(eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]))
.then(0)
.when(eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]))
.then(1)
.else(2)
.end(), 'asc')
.orderBy((eb) =>
eb.case()
.when(eb.or([
eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]),
eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]),
]))
.then(eb.ref('t.start_date'))
.end(), 'asc')
.orderBy((eb) =>
eb.case()
.when(eb.not(eb.or([
eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]),
eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]),
])))
.then(eb.ref('t.start_date'))
.end(), 'desc')
.limit(1)
.executeTakeFirst();
return row as ActiveTripRow | undefined;
}
/**
* TP18 (`trips.service.ts::create`'s INSERT) — the column set of the
* legacy statement, written verbatim (the `||`/clamp defaults are the
* service's own, decided before this call, per the program's D2 split).
* Returns `em.insert()`'s PK (R6's `lastInsertRowid` replacement).
*/
async insertTrip(input: {
user_id: number;
title: string;
description: string | null;
start_date: string | null;
end_date: string | null;
currency: string;
reminder_days: number;
}): Promise<number> {
return await this.insert({
user: input.user_id,
title: input.title,
description: input.description,
start_date: input.start_date,
end_date: input.end_date,
currency: input.currency,
reminder_days: input.reminder_days,
});
}
/**
* TP25 (`trips.service.ts::updateTrip`) — `UPDATE trips SET title=?,
* description=?, start_date=?, end_date=?, currency=?, is_archived=?,
* cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP WHERE
* id=?`. The `||`/`!== undefined` pre-image fallbacks are the SERVICE's
* own (R5/§18.6 — this write stays OUTSIDE the days-regeneration
* transaction, unchanged); this takes the final, already-decided values
* and writes them verbatim, `updated_at` stamped unconditionally like the
* legacy statement. Named `updateTripRow` (not `updateTrip`, the
* deliverable list's own name) to keep it unambiguous next to
* `TripsService.updateTrip`, which calls it.
*/
async updateTripRow(id: number, data: {
title: string;
description: string | null;
start_date: string | null;
end_date: string | null;
currency: string;
is_archived: number;
cover_image: string | null;
reminder_days: number;
}): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { ...data, updated_at: currentTimestamp(platform) });
}
/** TP35 (`trips.service.ts::updateCoverImage`) — `UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */
async setCoverImage(id: number, cover_image: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { cover_image, updated_at: currentTimestamp(platform) });
}
/** TP34 (`trips.service.ts::remove`) — `DELETE FROM trips WHERE id = ?` (security-sensitive: cascades the whole trip). */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
}
@@ -9,7 +9,6 @@ import { UnitOfWork } from '../database/unit-of-work';
import { BudgetService } from '../budget/budget.service';
import { PermissionsService } from '../permissions/permissions.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TRIP_SELECT } from '../trips/trips.service';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { NotFoundError, ValidationError } from '../common/domain-errors';
@@ -80,18 +79,6 @@ export class TripMembersService {
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
) {}
/**
* Task 7: `TripsService` still owns `TRIP_SELECT` and its own raw
* `this.dbs.connection` handle (`trips.service.ts:166-168`) — `getTripForViewer`
* below is this service's one documented raw-SQL survivor, importing the
* projection rather than copying it, unchanged until Task 7 converts
* `TripsService` itself and this call points at a repository method
* instead (`TripsRepository.findForViewer`, per the inventory's proposal).
*/
private get db() {
return this.dbs.connection;
}
async canAccessTrip(tripId: string | number, userId: number) {
const access = await this.dbs.canAccessTrip(tripId, userId);
return access as { user_id: number } | null | undefined;
@@ -107,9 +94,13 @@ export class TripMembersService {
/** The trip in list shape, for the re-read a handover broadcasts. Same query the
* trip routes use, imported rather than copied so the two cannot drift.
* // Task 7: converts once TripsService itself does — see the class docstring above. */
* Plan 3c Task 7: repointed to `TripsRepository.findForViewer` — the
* SQL-side `NULL AS feed_token` blanking survives the move (Task 6
* review's "for Task 7" note): `findForViewer`'s row always carries
* `feed_token: null`, which is load-bearing for the `trip:updated`
* broadcast a transfer sends. */
async getTripForViewer(tripId: string | number, userId: number) {
return this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
return this.tripsRepo.findForViewer(tripId, userId);
}
/** Fire-and-forget trip-invite notification (mirrors the route's dynamic import). */
+34 -18
View File
@@ -1,3 +1,4 @@
import { EntityManager } from '@mikro-orm/core';
import { tripCreateRequestSchema, tripUpdateRequestSchema } from '@trek/shared';
import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators';
import { PluginGuards } from '../plugins/host/plugin-guards.service';
@@ -5,12 +6,15 @@ import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { ReservationsService } from '../reservations/reservations.service';
import { DaysService } from '../days/days.service';
import { AccommodationsService } from '../accommodations/accommodations.service';
import { TripMembersService } from '../trip-members/trip-members.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { Trips } from '../../db/entities/Trips.entity';
import { Places } from '../../db/entities/Places.entity';
import { Users } from '../../db/entities/Users.entity';
import { TripMembers } from '../../db/entities/TripMembers.entity';
import { TripsService, NotFoundError, ValidationError, withoutFeedToken } from './trips.service';
const TRIP_EDIT_ACTION = 'trip_edit';
@@ -36,20 +40,30 @@ export class TripsRpc {
private readonly reservations: ReservationsService,
private readonly days: DaysService,
private readonly membership: TripMembershipService,
private readonly db: DatabaseService,
// Plan 3c Task 7: `DatabaseService` DROPPED — RP1–RP6 were its only uses
// in this class, and every one now resolves through `em.getRepository(...)`
// below (same precedent as `TripReadModelService`, Task 6: "DatabaseService
// is dropped from its constructor entirely" once a service goes fully
// SQL-free). Every call site that hand-constructs `TripsRpc` positionally
// drops its `db` fake/instance argument in the same slot.
private readonly realtime: RealtimeService,
private readonly guards: PluginGuards,
// Appended: the hand-wired plugin-host harnesses build this positionally.
private readonly accommodations: AccommodationsService,
private readonly roster: TripMembersService,
// Plan 3c Task 7: RP1–RP6's repository calls all resolve through this,
// the same `this.em.getRepository(...)` shape `TripsService.canAccessTrip`/
// `.isOwner` used since Task 0b — `EntityManager` is `@Global()`, so no
// module needs new wiring. Appended last, per this class's own convention.
private readonly em: EntityManager,
) {}
@PluginMethod('trips.getById', { permission: 'db:read:trips' })
async getById(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
return this.guards.tripRead(params, ctx, () =>
return this.guards.tripRead(params, ctx, async () =>
// db:read:trips is a read grant on the trip, not on the credential that
// publishes it anonymously — see withoutFeedToken.
withoutFeedToken(this.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(params.tripId, 'tripId'))),
// publishes it anonymously — see withoutFeedToken. RP1 — TripsRepository.findRaw.
withoutFeedToken(await this.em.getRepository(Trips).findRaw(num(params.tripId, 'tripId'))),
);
}
@@ -58,10 +72,9 @@ export class TripsRpc {
// The trip's place POOL. Places carry no itinerary position of their own
// (day_id/order_index live on day_assignments), so order by created_at like the
// REST list does. trips.getDays is the day-ordered itinerary.
return this.guards.tripRead(params, ctx, () =>
this.db
.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY created_at DESC')
.all(num(params.tripId, 'tripId')),
// RP2 — PlacesRepository.listForTripOrdered (Task 4/5's repository, Task 7's own additive method).
return this.guards.tripRead(params, ctx, async () =>
await this.em.getRepository(Places).listForTripOrdered(num(params.tripId, 'tripId')),
);
}
@@ -106,10 +119,12 @@ export class TripsRpc {
@PluginMethod('trips.members', { permission: 'db:read:trips' })
async members(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
return this.guards.tripRead(params, ctx, () =>
this.db
.prepare('SELECT u.id, u.username, u.display_name, u.avatar FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE tm.trip_id = ?')
.all(num(params.tripId, 'tripId')) as unknown[],
// RP3 — TripMembersRepository.listRawUsernameAndDisplayName: raw `username`
// AND `display_name` as separate fields, deliberately NOT TM2's COALESCE
// (inventory §18.10 — a plugin's roster shape and the REST/MCP roster
// shape are two different wire shapes on purpose; do not harmonise).
return this.guards.tripRead(params, ctx, async () =>
await this.em.getRepository(TripMembers).listRawUsernameAndDisplayName(num(params.tripId, 'tripId')),
);
}
@@ -130,11 +145,11 @@ export class TripsRpc {
if ('cover_image' in input && !(await this.guards.canEditAs('trip_cover_upload', tripId, actor))) {
throw new ForbiddenResource(`no permission to change the cover of trip ${tripId}`);
}
const user = this.db.prepare('SELECT role FROM users WHERE id = ?').get(actor) as { role?: string } | undefined;
const role = await this.em.getRepository(Users).getRole(actor); // RP4 — UsersRepository.getRole
try {
// The no-rebase core, parity with the legacy host path, which never
// re-anchored the budget currency.
const result = await this.trips.updateTrip(tripId, actor, input as Parameters<TripsService['updateTrip']>[2], user?.role ?? 'user');
const result = await this.trips.updateTrip(tripId, actor, input as Parameters<TripsService['updateTrip']>[2], role ?? 'user');
this.realtime.broadcast(tripId, 'trip:updated', { trip: result.updatedTrip });
return result.updatedTrip;
} catch (e) {
@@ -172,7 +187,8 @@ export class TripsRpc {
const targetUserId = num(params.userId, 'userId');
const actor = this.guards.requireActor(ctx, 'trip member');
await this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION);
const target = this.db.prepare('SELECT id FROM users WHERE id = ?').get(targetUserId) as { id: number } | undefined;
// RP5 — UsersRepository.findIdAndEmail (existence check only; the `email` field is unused here).
const target = await this.em.getRepository(Users).findIdAndEmail(targetUserId);
if (!target) throw new ForbiddenResource(`no user ${targetUserId}`);
// The acting user is recorded as the inviter.
return this.membership.joinTripAsMember(tripId, targetUserId, actor);
@@ -186,8 +202,8 @@ export class TripsRpc {
await this.guards.requireTripEdit(tripId, actor, MEMBER_MANAGE_ACTION);
// Never remove the OWNER through this path: that would orphan the trip.
// Ownership transfer is a separate, deliberate action.
const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner');
const ownerId = await this.em.getRepository(Trips).getOwnerId(tripId); // RP6 — TripsRepository.getOwnerId
if (ownerId !== null && ownerId === targetUserId) throw new ForbiddenResource('cannot remove the trip owner');
await this.roster.removeMember(tripId, targetUserId);
return { removed: true };
}
+121 -111
View File
@@ -3,6 +3,8 @@ import path from 'path';
import { EntityManager } from '@mikro-orm/core';
import { DatabaseService } from '../database/database.service';
import { Trips } from '../../db/entities/Trips.entity';
import { Days } from '../../db/entities/Days.entity';
import { Users } from '../../db/entities/Users.entity';
import { MAX_TRIP_DAYS, tripSpanDays, type ActiveTrip, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -172,10 +174,26 @@ export class TripsService {
private readonly em: EntityManager,
) {}
// Plan 3c Task 7: the raw better-sqlite3 handle now backs only the three
// documented survivors — `remove`'s TP32/TP33 (journey_entries, Plan 3g)
// and `copy`/`getCopiedTrip` (Task 8's own file). Every other method below
// reads/writes through `tripsRepo`/`daysRepo` (or a sibling repository
// reached the same way `TripsService.canAccessTrip`/`.isOwner` already
// did since Task 0b: `this.em.getRepository(...)`, not a new constructor
// parameter — the shared per-request `EntityManager` caches repositories,
// so this is the same instance a hand-constructed test spies on).
private get db() {
return this.dbs.connection;
}
private get tripsRepo() {
return this.em.getRepository(Trips);
}
private get daysRepo() {
return this.em.getRepository(Days);
}
async canAccessTrip(tripId: string | number, userId: number) {
const access = await this.em.getRepository(Trips).findAccessible(tripId, userId);
return access as { user_id: number } | null | undefined;
@@ -195,46 +213,49 @@ export class TripsService {
// ── Day generation ────────────────────────────────────────────────────────
/**
* TP1–TP13 (inventory §11b), all through `DaysRepository` — statement order
* and the two-phase renumber (negative pass then positive pass, sequential
* `for…of`, never `Promise.all`: order is load-bearing against
* `UNIQUE(trip_id, day_number)`, the same discipline Task 2's own
* `DaysService` conversion kept) preserved byte-for-byte. Inherits the
* caller's transaction — `updateTrip`'s `:423` `uow.transactional` block —
* or runs with none at all when called from `create` (R5/§18.6, unchanged
* by this conversion; no transaction is opened HERE either way).
*
* TP1/TP4/TP8/TP13 (four legacy sites, one shared repository method):
* `listOrderedForReorder` orders `ASC` by `day_number` where TP1's own
* statement has no `ORDER BY` at all — harmless: TP1's result only ever
* feeds `.filter()`/re-`.sort()` calls below, never relies on read order.
*/
async generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, dayCount?: number) {
const existing = this.db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
const setDayNumber = this.db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const trip_id = Number(tripId);
const existing = await this.daysRepo.listOrderedForReorder(trip_id); // TP1
// Helper: two-phase renumber to avoid UNIQUE(trip_id, day_number) collisions
function renumber(days: { id: number }[]) {
days.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
days.forEach((d, i) => setDayNumber.run(i + 1, d.id));
}
// Two-phase renumber to avoid UNIQUE(trip_id, day_number) collisions.
const renumber = async (days: { id: number }[]) => {
for (let i = 0; i < days.length; i++) await this.daysRepo.setDayNumber(days[i].id, -(i + 1)); // TP2
for (let i = 0; i < days.length; i++) await this.daysRepo.setDayNumber(days[i].id, i + 1); // TP2
};
if (!startDate || !endDate) {
// Nullify all dated days instead of deleting them — preserves assignments/notes/accommodations
const withDates = existing.filter(d => d.date);
if (withDates.length > 0) {
const nullify = this.db.prepare('UPDATE days SET date = NULL WHERE id = ?');
for (const d of withDates) nullify.run(d.id);
}
for (const d of withDates) await this.daysRepo.clearDate(d.id); // TP3
// Now all days are dateless — adjust count toward dayCount target
const allDays = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
const allDays = await this.daysRepo.listOrderedForReorder(trip_id); // TP4
const targetCount = Math.min(Math.max(dayCount ?? (allDays.length || 7), 1), MAX_TRIP_DAYS);
const needed = targetCount - allDays.length;
if (needed > 0) {
const insert = this.db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 0; i < needed; i++) insert.run(tripId, allDays.length + i + 1);
for (let i = 0; i < needed; i++) await this.daysRepo.insertDay({ trip_id, day_number: allDays.length + i + 1, date: null }); // TP5
} else if (needed < 0) {
// Only trim trailing empty days to avoid destroying content
const candidates = this.db.prepare(
`SELECT d.id FROM days d
WHERE d.trip_id = ?
AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id)
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = d.id)
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id)
ORDER BY d.day_number DESC
LIMIT ?`
).all(tripId, -needed) as { id: number }[];
const del = this.db.prepare('DELETE FROM days WHERE id = ?');
for (const d of candidates) del.run(d.id);
const candidates = await this.daysRepo.listTrailingEmptyIds(trip_id, -needed); // TP6
for (const id of candidates) await this.daysRepo.deleteById(id); // TP7
}
const remaining = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
renumber(remaining);
const remaining = await this.daysRepo.listOrderedForReorder(trip_id); // TP8
await renumber(remaining);
return;
}
@@ -257,10 +278,7 @@ export class TripsService {
// Phase 1: stamp all existing days with negative day_numbers to free up slots
const allExisting = [...dated, ...dateless];
allExisting.forEach((d, i) => setDayNumber.run(-(i + 1), d.id));
const assignDay = this.db.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?');
const insert = this.db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
for (let i = 0; i < allExisting.length; i++) await this.daysRepo.setDayNumber(allExisting[i].id, -(i + 1)); // TP2
let datelessIdx = 0;
@@ -268,21 +286,20 @@ export class TripsService {
const date = targetDates[i];
if (i < dated.length) {
// Positional remap: existing dated day i gets new date — keeps all children
assignDay.run(date, i + 1, dated[i].id);
await this.daysRepo.setDayNumberAndDate(dated[i].id, i + 1, date); // TP9
} else if (datelessIdx < dateless.length) {
// Reuse a dateless day — keeps its assignments, notes, etc.
assignDay.run(date, i + 1, dateless[datelessIdx].id);
await this.daysRepo.setDayNumberAndDate(dateless[datelessIdx].id, i + 1, date); // TP9
datelessIdx++;
} else {
insert.run(tripId, i + 1, date);
await this.daysRepo.insertDay({ trip_id, day_number: i + 1, date }); // TP10
}
}
// Overflow dated days (trip shrunk): delete them (issue #909).
// Cascade removes their assignments, notes, and accommodations.
const del = this.db.prepare('DELETE FROM days WHERE id = ?');
for (let i = targetDates.length; i < dated.length; i++) {
del.run(dated[i].id);
await this.daysRepo.deleteById(dated[i].id); // TP11
}
// Any remaining unused dateless days: drop the empty placeholders so day_count
@@ -290,45 +307,28 @@ export class TripsService {
// notes, accommodations) — mirrors the dateless-path trimming above (#1083).
// Base must be max(targetDates.length, dated.length) to avoid colliding with
// positives already assigned by the main loop or the overflow loop above.
const isEmptyDay = this.db.prepare(
`SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id)
AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id)
AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = @id OR dac.end_day_id = @id) AS empty`
);
const maxAssigned = Math.max(targetDates.length, dated.length);
let keptDateless = 0;
for (let i = datelessIdx; i < dateless.length; i++) {
const empty = (isEmptyDay.get({ id: dateless[i].id }) as { empty: number }).empty;
const empty = await this.daysRepo.isEmptyDay(dateless[i].id); // TP12
if (empty) {
del.run(dateless[i].id);
await this.daysRepo.deleteById(dateless[i].id); // TP11
} else {
setDayNumber.run(maxAssigned + keptDateless + 1, dateless[i].id);
await this.daysRepo.setDayNumber(dateless[i].id, maxAssigned + keptDateless + 1); // TP2
keptDateless++;
}
}
// Final renumber to compact and eliminate any gaps/negatives
const remaining = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
renumber(remaining);
const remaining = await this.daysRepo.listOrderedForReorder(trip_id); // TP13
await renumber(remaining);
}
// ── Trip CRUD ─────────────────────────────────────────────────────────────
/** TP16/TP17 — `TripsRepository.listForUser` (`TRIP_SELECT` + the access join, ONE builder, inventory §11a/§11c). */
async list(userId: number, archived: number | null) {
if (archived === null) {
return this.db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
ORDER BY t.created_at DESC
`).all({ userId });
}
return this.db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
ORDER BY t.created_at DESC
`).all({ userId, archived });
return this.tripsRepo.listForUser(userId, archived);
}
async create(userId: number, data: CreateTripData) {
@@ -337,24 +337,25 @@ export class TripsService {
? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3)
: 3;
const result = this.db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
const tripId = await this.tripsRepo.insertTrip({ // TP18
user_id: userId,
title: data.title,
description: data.description || null,
start_date: data.start_date || null,
end_date: data.end_date || null,
currency: data.currency || 'EUR',
reminder_days: rd,
});
const tripId = result.lastInsertRowid;
await this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count);
const trip = this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
return { trip, tripId: Number(tripId), reminderDays: rd };
const trip = await this.tripsRepo.findForViewer(tripId, userId); // TP19 — the creator always owns it, so the access predicate is trivially satisfied
return { trip, tripId, reminderDays: rd };
}
/** TP20 — `TripsRepository.findForViewer`: `TRIP_SELECT` scoped to one trip AND the access predicate. */
async get(tripId: string | number, userId: number) {
return this.db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId }) as Trip | undefined;
return await this.tripsRepo.findForViewer(tripId, userId) as Trip | undefined;
}
/**
@@ -368,34 +369,25 @@ export class TripsService {
* a startup redirect, so it reads four columns of one row instead of every
* trip with its per-trip day/place counts.
*/
/** TP21 — `TripsRepository.activeTrip`: the triple `CASE WHEN … relevance` projection and the double-`CASE WHEN` `ORDER BY`. */
async activeTrip(userId: number, today = new Date().toISOString().slice(0, 10)) {
return this.db.prepare(`
SELECT t.id, t.title, t.start_date, t.end_date,
CASE
WHEN t.start_date IS NOT NULL AND t.end_date IS NOT NULL AND t.start_date <= :today AND t.end_date >= :today THEN 0
WHEN t.start_date IS NOT NULL AND t.start_date >= :today THEN 1
ELSE 2
END AS relevance
FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0
ORDER BY relevance ASC,
CASE WHEN relevance < 2 THEN t.start_date END ASC,
CASE WHEN relevance = 2 THEN t.start_date END DESC
LIMIT 1
`).get({ userId, today }) as ActiveTrip & { relevance: number } | undefined;
return await this.tripsRepo.activeTrip(userId, today) as ActiveTrip & { relevance: number } | undefined;
}
/** TP22 — `TripsRepository.findRaw` (shared with `TripReadModelService.getTripSummary`, TR-B, per the inventory's own note that they are the identical statement). */
async getRaw(tripId: string | number): Promise<Trip | undefined> {
return this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined;
const row = await this.tripsRepo.findRaw(tripId);
return (row ?? undefined) as Trip | undefined;
}
async searchCoverImages(query: string, userId: number) {
return this.unsplash.searchUnsplashPhotos(query, 9, await this.unsplash.getUnsplashKey(userId));
}
/** TP23 — `TripsRepository.getOwnerId` (already existed, TB1), reshaped to the legacy `{user_id}` row shape (matches `TripReadModelService`'s own TR-A reshape). */
async getOwner(tripId: string | number): Promise<{ user_id: number } | undefined> {
return this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
const ownerId = await this.tripsRepo.getOwnerId(tripId);
return ownerId === null ? undefined : { user_id: ownerId };
}
/**
@@ -403,8 +395,14 @@ export class TripsService {
* through update() below; the plugin RPC host calls this directly (parity:
* the legacy host path never rebased).
*/
/**
* TP24–TP29 (inventory §11c) — TP25's `UPDATE trips` write stays BEFORE
* the `:423` days-regeneration transaction (R5/§18.6, unchanged): if
* `generateDays` throws, the transaction rolls back the day rows but the
* trip keeps its new dates. Flagged, not fixed, per the ruling.
*/
async updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): Promise<UpdateTripResult> {
const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined;
const trip = await this.tripsRepo.findRaw(tripId) as (Trip & { reminder_days?: number }) | null; // TP24
if (!trip) throw new NotFoundError('Trip not found');
const { title, description, currency, is_archived, cover_image, reminder_days } = data;
@@ -420,11 +418,17 @@ export class TripsService {
? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder)
: oldReminder;
this.db.prepare(`
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
const tripIdNum = Number(tripId); // safe: `trip` above only resolved through the raw-bind seam on a real row (Task 6 review's own ruling on this exact conversion)
await this.tripsRepo.updateTripRow(tripIdNum, { // TP25
title: newTitle,
description: newDesc ?? null,
start_date: newStart || null,
end_date: newEnd || null,
currency: newCurrency,
is_archived: newArchived ?? 0,
cover_image: newCover ?? null,
reminder_days: newReminder,
});
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
@@ -433,18 +437,14 @@ export class TripsService {
await this.uow.transactional(async () => {
// Accommodations have no absolute date columns, so their pre-change dates must be
// snapshotted before generateDays re-dates the day rows in place.
const prevDateByDayId = new Map(
(this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[])
.map(d => [d.id, d.date]),
);
const prevDays = await this.daysRepo.listOrderedForReorder(tripIdNum); // TP26
const prevDateByDayId = new Map(prevDays.map(d => [d.id, d.date]));
await this.generateDays(tripId, newStart || null, newEnd || null, dayCount);
if (data.date_shift_mode === 'shift_all') {
// Explicit "shift everything": bookings stay glued to their (re-dated) day rows,
// so re-stamp reservation_time to follow — same rules as reorderDays/insertDay.
const newDateByDayId = new Map(
(this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[])
.map(d => [d.id, d.date]),
);
const newDays = await this.daysRepo.listOrderedForReorder(tripIdNum); // TP27
const newDateByDayId = new Map(newDays.map(d => [d.id, d.date]));
await this.days.restampReservationDates(tripId, prevDateByDayId, newDateByDayId);
} else {
// Default: generateDays re-dates day rows positionally; re-anchor dated bookings to
@@ -466,10 +466,14 @@ export class TripsService {
const isAdminEdit = userRole === 'admin' && trip.user_id !== userId;
let ownerEmail: string | undefined;
if (Object.keys(changes).length > 0 && isAdminEdit) {
ownerEmail = (this.db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
ownerEmail = (await this.em.getRepository(Users).getEmail(trip.user_id)) ?? undefined; // TP28
}
const updatedTrip = this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
// TP29 — every caller of updateTrip has already passed an access check for
// this exact trip (canAccessTrip / requireTripEdit), so the access
// predicate `findForViewer` applies is always trivially satisfied here —
// same reasoning as `create`'s TP19 re-select above.
const updatedTrip = await this.tripsRepo.findForViewer(tripId, userId);
return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder };
}
@@ -508,14 +512,19 @@ export class TripsService {
// ── Delete ─────────────────────────────────────────────────────────────────
/**
* TP30–TP34 (inventory §11c) — TP32/TP33 (`journey_entries`) stay raw
* inside the transaction (`// Plan 3g`, that table's owning plan); every
* other statement moved through `TripsRepository`.
*/
async remove(tripId: string | number, userId: number, userRole: string): Promise<DeleteTripInfo> {
const trip = this.db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(tripId) as { title: string; user_id: number } | undefined;
const trip = await this.tripsRepo.findIdTitleOwner(tripId); // TP30 (the `id` field this method also selects is unused here)
if (!trip) throw new NotFoundError('Trip not found');
const isAdminDelete = userRole === 'admin' && trip.user_id !== userId;
let ownerEmail: string | undefined;
if (isAdminDelete) {
ownerEmail = (this.db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
ownerEmail = (await this.em.getRepository(Users).getEmail(trip.user_id)) ?? undefined; // TP31
}
// Quirk fix on top of the 1:1 move: the three-statement delete runs in a
@@ -527,14 +536,14 @@ export class TripsService {
this.db.prepare(`
DELETE FROM journey_entries
WHERE source_trip_id = ? AND type = 'skeleton'
`).run(tripId);
`).run(tripId); // TP32 — Plan 3g
// Detach filled entries (keep user's written content, just remove trip link)
this.db.prepare(`
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL, source_assignment_id = NULL
WHERE source_trip_id = ?
`).run(tripId);
`).run(tripId); // TP33 — Plan 3g
this.db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
await this.tripsRepo.deleteById(Number(tripId)); // TP34 — safe: `trip` above only resolved through the raw-bind seam on a real row
});
return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail };
@@ -553,8 +562,9 @@ export class TripsService {
});
}
/** TP35 — `TripsRepository.setCoverImage`. */
async updateCoverImage(tripId: string | number, coverUrl: string): Promise<void> {
this.db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, tripId);
await this.tripsRepo.setCoverImage(Number(tripId), coverUrl); // safe: only called after `getRaw`/`getOwner` matched the same id through the raw-bind seam
}
// ── Copy / duplicate ─────────────────────────────────────────────────────