fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations

This commit is contained in:
jubnl
2026-09-24 02:39:30 +02:00
parent 915ab84faf
commit f337f116d9
14 changed files with 379 additions and 103 deletions
@@ -1,5 +1,5 @@
import type { DayAccommodations } from '../entities/DayAccommodations.entity';
import { coalesceParam, columnRef } from '../dialect/sql-functions';
import { coalesceOverride, columnRef } from '../dialect/sql-functions';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -127,18 +127,19 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
/**
* RS29/RS42 — `UPDATE day_accommodations SET check_in = COALESCE(?,
* check_in), check_in_end = COALESCE(?, check_in_end), check_out =
* COALESCE(?, check_out) WHERE id = ?`. `coalesceParam` (value-side, a
* bound `?`) through `qb().update()` — `nativeUpdate`'s `EntityData`
* shape doesn't accept a raw fragment value (`DayAssignmentsRepository
* .shiftOrderFrom`'s precedent).
* COALESCE(?, check_out) WHERE id = ?`. `coalesceOverride` (value-side, a
* bound `?` FIRST, the column the fallback — the new value wins) through
* `qb().update()` — `nativeUpdate`'s `EntityData` shape doesn't accept a
* raw fragment value (`DayAssignmentsRepository.shiftOrderFrom`'s
* precedent).
*/
async patchTimes(id: number, check_in: string | null, check_in_end: string | null, check_out: string | null): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.qb()
.update({
check_in: coalesceParam(platform, 'check_in', check_in),
check_in_end: coalesceParam(platform, 'check_in_end', check_in_end),
check_out: coalesceParam(platform, 'check_out', check_out),
check_in: coalesceOverride(platform, check_in, 'check_in'),
check_in_end: coalesceOverride(platform, check_in_end, 'check_in_end'),
check_out: coalesceOverride(platform, check_out, 'check_out'),
})
.where({ id })
.execute('run');
@@ -148,7 +149,7 @@ export class DayAccommodationsRepository extends TrekRepository<DayAccommodation
async patchConfirmation(id: number, confirmation: string | null): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.qb()
.update({ confirmation: coalesceParam(platform, 'confirmation', confirmation) })
.update({ confirmation: coalesceOverride(platform, confirmation, 'confirmation') })
.where({ id })
.execute('run');
}
@@ -1,7 +1,7 @@
import type { Reservations } from '../entities/Reservations.entity';
import { DayAssignments } from '../entities/DayAssignments.entity';
import { Days } from '../entities/Days.entity';
import { castIntegerKysely, coalesceParam, columnRef, concatKysely, dayDistance, startsWithIsoDateKysely, substringKysely } from '../dialect/sql-functions';
import { castIntegerKysely, coalesceOverride, columnRef, concatKysely, dayDistance, startsWithIsoDateKysely, substringKysely } from '../dialect/sql-functions';
import { publicReservationExpr, publicStayExists, type ReservationVisibilityKyselyDB } from './_shared/reservation-visibility';
import type { DayAssignmentRow } from './DayAssignments.repository';
import { TrekRepository } from './_shared/trek-repository';
@@ -960,15 +960,16 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
* reservations SET metadata = ?, confirmation_number = COALESCE(?,
* confirmation_number) WHERE id = ?`. Runs AFTER `updateAccommodation`'s
* own transaction commits (§18.6 — R5 class, flagged not fixed).
* `coalesceParam` for the value-side COALESCE (`DayAccommodationsRepository
* .patchConfirmation`'s precedent).
* `coalesceOverride` for the value-side COALESCE — the bound `?` FIRST,
* the column the fallback, so the new value wins
* (`DayAccommodationsRepository.patchConfirmation`'s precedent).
*/
async setMetadataAndConfirmation(id: number, metadata: string, confirmation: string | null): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.qb()
.update({
metadata,
confirmation_number: coalesceParam(platform, 'confirmation_number', confirmation),
confirmation_number: coalesceOverride(platform, confirmation, 'confirmation_number'),
})
.where({ id })
.execute('run');
@@ -1,4 +1,3 @@
import type { ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely';
import type { CollectionMembers } from '../../entities/CollectionMembers.entity';
import type { Collections } from '../../entities/Collections.entity';
import type { TrekRepository } from './trek-repository';
@@ -42,17 +41,14 @@ import type { TrekRepository } from './trek-repository';
* copy-to-trip/labels/invites surface go through the SAME service methods,
* never re-deriving the predicate.
*
* **Kysely-callable form** ({@link collectionVisibleExpr}): an inline
* boolean `EXISTS`/`OR` predicate for a caller already building a raw,
* multi-join statement through `this.kysely()` (the shape Task 2's dynamic
* OR-condition methods — `matchingCollectionPlaces`/`findMembership`,
* CL58/CL69 — are the most likely future consumers of), modelled on
* `packing-visibility.ts`'s `packingVisibleToActorExpr`: an owner-id equality
* plus a correlated `EXISTS` against `collection_members`, mirroring CL2's
* own UNION shape as a boolean OR instead of a value-returning UNION SELECT.
* This form answers "is this collection visible to this user", not "what is
* their role" — a caller needing the actual role (for an edit/delete gate)
* always uses {@link resolveCollectionRole}, never this boolean form.
* A Kysely-callable boolean-`EXISTS`/`OR` twin of this predicate (for a
* caller already building a raw, multi-join statement through
* `this.kysely()`, modelled on `packing-visibility.ts`'s
* `packingVisibleToActorExpr`) was drafted alongside this QB form but had no
* consumer by Plan 3h's whole-plan review — Task 2's own dynamic OR-condition
* methods (`matchingCollectionPlaces`/`findMembership`, CL58/CL69) never
* needed it — and was deleted there (L5) rather than shipped untested. Add it
* back, with a consumer or its own parity test, if a future task needs it.
*
* Proven against five actor cases (owner, admin member, editor member,
* viewer member, an invited-but-still-pending user, a complete stranger) by
@@ -89,40 +85,3 @@ export async function resolveCollectionRole(
if (!member) return null;
return member.role === 'admin' || member.role === 'viewer' ? (member.role as 'admin' | 'viewer') : 'editor';
}
/**
* `collections`/`collection_members`'s REAL table names, the shape
* {@link collectionVisibleExpr} needs. A consumer's own local Kysely `DB`
* interface satisfies this by structural extension, the
* `ReservationVisibilityKyselyDB`/`PackingVisibilityKyselyDB` precedent.
*/
export interface CollectionVisibilityKyselyDB {
collections: { id: number; owner_id: number };
collection_members: { collection_id: number; user_id: number; status: string };
}
/**
* The Kysely-expression twin of CL2's UNION, as a boolean OR: owner-id
* equality, or a correlated `EXISTS` against an accepted `collection_members`
* row. `collections` is a genuine `p.manyToOne(Users)` FK
* (`Collections.entity.ts`), but this form takes the raw `owner_id` column
* directly rather than joining through the relation — matching CL2's own
* `owner_id = ?` text, and avoiding a second join the caller's own outer
* query may not want.
*/
export function collectionVisibleExpr(
eb: ExpressionBuilder<CollectionVisibilityKyselyDB, 'collections'>,
userId: number,
): ExpressionWrapper<CollectionVisibilityKyselyDB, 'collections', SqlBool> {
return eb.or([
eb('collections.owner_id', '=', userId),
eb.exists(
eb
.selectFrom('collection_members as cm')
.select('cm.collection_id')
.whereRef('cm.collection_id', '=', 'collections.id')
.where('cm.user_id', '=', userId)
.where('cm.status', '=', 'accepted'),
),
]);
}
@@ -14,6 +14,7 @@ import {
Req,
UseGuards,
} from '@nestjs/common';
import { MikroORM } from '@mikro-orm/core';
import type { Request } from 'express';
import type { User } from '../../types';
import { ADDON_IDS } from '../../addons';
@@ -23,6 +24,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { RealtimeService } from '../realtime/realtime.service';
import { withRequestContext } from '../database/request-context';
import { docFailed } from './document-provider';
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
@@ -70,6 +72,7 @@ export class DocSyncController {
private readonly sync: DocSyncService,
private readonly registry: DocumentProviderRegistry,
private readonly realtime: RealtimeService,
private readonly orm: MikroORM,
) {}
/**
@@ -316,7 +319,24 @@ export class DocSyncController {
// A first run right away, so the user sees something happen instead of
// waiting out a poll interval and wondering whether it worked.
void this.sync.syncLink(res.data, { full: true });
//
// L1 (Plan 3h Task 7 review) — a third R9-class detached chain the
// inventory missed: `syncLink` runs ~36 ORM/Kysely statements, detached
// after this response goes out, on the request's own EntityManager fork.
// Wrapped in its own `withRequestContext` fork for the same reason the
// webhook nudge timer (`DocSyncWebhookController#schedule`) and the
// import-job runner (`ImportJobsService#start`) are: insurance against
// the request-scoped fork being gone by the time this runs, per 3f's own
// measurement that `AsyncLocalStorage` survives a detached chain intact
// in this codebase today, not a fix for an observed failure. A failure
// here is not a failure of the binding (the next poll carries it), so it
// is logged rather than thrown — same posture as the webhook
// subscription failure just above.
void withRequestContext(this.orm, () => this.sync.syncLink(res.data, { full: true })).catch((err: unknown) => {
this.logger.error(
`link ${res.data.id}: the first run after creation failed (${err instanceof Error ? err.message : String(err)}), the next poll carries it`,
);
});
return await this.config.publicLink((await this.config.getLink(res.data.id)) ?? res.data, publicOrigin(req));
}
+6 -1
View File
@@ -979,9 +979,14 @@ export class DocSyncService {
* queries (a 3rd near-duplicate listing shape alongside {@link issues}'s
* own `LIMIT 200` fixed-state-list) stay two repository methods, per R2's
* own instruction not to unify near-duplicate reads with different shapes.
* `state` is checked for TRUTHINESS, not `undefined` (Plan 3h Task 7
* review, M2): legacy's own gate (`doc-sync.controller.ts`, pre-R2) read
* `state ? <filtered> : <unfiltered>`, so `?state=` (an empty string)
* returns every item — an `=== undefined` check would instead filter on
* `state = ''`, which matches nothing.
*/
async itemsForTrip(tripId: number, state?: string) {
return state === undefined ? this.items.listForTrip(tripId) : this.items.listForTripByState(tripId, state);
return state ? this.items.listForTripByState(tripId, state) : this.items.listForTrip(tripId);
}
/** Per-trip view for the UI: what is synced, what needs attention. */