refactor(db): JSON columns are decoded through one declared schema per column

decodeJson/decodeJsonResult/encodeJson read and write a JSON-in-TEXT column against the schema and fallback declared for it in db/json-columns.ts, and log a row that does not decode. reservations.metadata, the OAuth scope and redirect lists, consents, API token scopes, MFA backup codes, journey entry tags and pros/cons and poll options go through it. Readers that used to throw on a broken row (a hotel update, the ICS export, the price mirror, the OAuth token and session reads, a consent, a journey entry, a poll) now take the tolerant fallback the other readers of the same column already used.
This commit is contained in:
Maurice
2026-10-10 12:13:56 +02:00
parent a926990e4a
commit 91deedae8b
26 changed files with 510 additions and 106 deletions
@@ -14,6 +14,8 @@ import { AirtrailRequestError, type AirtrailFlightRaw } from './airtrail.client'
import { AirtrailClient } from './airtrail.client';
import { AirtrailService } from './airtrail.service';
import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from './airtrail.mapper';
import { RESERVATION_METADATA } from '../../db/json-columns';
import { decodeJson } from '../../utils/json-column';
interface ExistingFlightRow {
id: number;
@@ -165,12 +167,8 @@ export class AirtrailImportService {
const linkedRows = await this.reservationsRepo.listAirtrailLinkedForTrip(tripIdNum);
for (const row of linkedRows) {
if (row.external_id) linkedIds.add(row.external_id);
try {
const ids = row.metadata ? JSON.parse(row.metadata).airtrail_ids : null;
if (Array.isArray(ids)) for (const id of ids) linkedIds.add(String(id));
} catch {
/* malformed metadata — ignore */
}
const ids = decodeJson(RESERVATION_METADATA, row.metadata, `airtrail ${row.external_id}`).airtrail_ids;
if (Array.isArray(ids)) for (const id of ids) linkedIds.add(String(id));
}
const existing = await this.reservationsRepo.listFlightReservationsForTrip(tripIdNum);
@@ -194,12 +192,7 @@ export class AirtrailImportService {
const existingSigs = new Set<string>();
for (const row of existing) {
let meta: Record<string, any> = {};
try {
meta = row.metadata ? JSON.parse(row.metadata) : {};
} catch {
/* malformed metadata — ignore */
}
const meta = decodeJson(RESERVATION_METADATA, row.metadata, `reservation ${row.id}`);
const eps = endpointsByReservation.get(row.id) ?? [];
const legs: any[] | null = Array.isArray(meta.legs) ? meta.legs : null;
if (legs && legs.length > 1) {
@@ -218,7 +211,7 @@ export class AirtrailImportService {
} else {
const from = eps[0]?.code ?? null;
const to = eps.length > 1 ? eps[eps.length - 1].code : null;
const sig = softSignature(depDate(row.reservation_time), meta.flight_number ?? null, from, to);
const sig = softSignature(depDate(row.reservation_time), (meta.flight_number as string | null | undefined) ?? null, from, to);
if (sig) existingSigs.add(sig);
}
}
@@ -16,6 +16,8 @@ import { AirtrailClient } from './airtrail.client';
import { AirtrailService } from './airtrail.service';
import { canonicalHash } from './airtrail.mapper';
import { buildSavePayload } from './airtrail-sync.helpers';
import { RESERVATION_METADATA } from '../../db/json-columns';
import { decodeJson } from '../../utils/json-column';
/**
* The AirTrail link lifecycle — the enablement gate, the detach policy, the
@@ -70,12 +72,9 @@ export class AirtrailLinkService {
* AirTrail flight to span the whole route (#1535).
*/
async hasLocalMultiLegShape(reservationId: number, metadataJson: string | null | undefined): Promise<boolean> {
try {
const meta = metadataJson ? JSON.parse(metadataJson) : {};
if (Array.isArray(meta?.legs) && meta.legs.length > 1) return true;
} catch {
/* malformed metadata — fall through to the endpoint count */
}
// Malformed metadata falls through to the endpoint count.
const legs = decodeJson(RESERVATION_METADATA, metadataJson, `reservation ${reservationId}`).legs;
if (Array.isArray(legs) && legs.length > 1) return true;
const n = await this.endpointsRepo.count({ reservation: reservationId });
return n > 2;
}
@@ -1,6 +1,8 @@
import { entityCode } from './airtrail.mapper';
import { flightPassengers, ownPassenger } from './airtrail.client';
import type { AirtrailFlightRaw, AirtrailPassengerWrite, AirtrailSavePayload } from './airtrail.client';
import { RESERVATION_METADATA } from '../../db/json-columns';
import { decodeJson } from '../../utils/json-column';
/**
* The pure half of the AirTrail push: turning a TREK reservation plus the flight
@@ -27,12 +29,7 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
* (#1240).
*/
export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
let meta: Record<string, any>;
try {
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
} catch {
meta = {};
}
const meta = decodeJson(RESERVATION_METADATA, reservation.metadata, `reservation ${reservation.id}`);
const endpoints: any[] = reservation.endpoints || [];
const fromEp = endpoints.find((e) => e.role === 'from');
const toEp = endpoints.find((e) => e.role === 'to');