mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user