mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
refactor(integrations,booking-import,airports,maps,route-usage): airtrail, booking import, airport backfill, maps cache and route-usage counters through repositories
This commit is contained in:
@@ -1126,6 +1126,30 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
const result = await this.kysely<PlacesNarrowInsertKyselyDB>().insertInto('places').values(row).executeTakeFirstOrThrow();
|
||||
return Number(result.insertId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3h Task 4 (`maps.service.ts`'s photo-fetch method, MAP9) — additive,
|
||||
// per this task's own file-ownership rule ("additive methods on
|
||||
// Reservations/ReservationEndpoints/Days/Places/Users repositories").
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* MAP9 — `UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
* WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')`.
|
||||
* **SECURITY/PARITY**: the `$or` guard is what stops this from ever
|
||||
* clobbering a place's already-set/custom image — preserved exactly.
|
||||
* Unscoped by `trip_id`/`id` (matching the legacy statement): a
|
||||
* `google_place_id` can appear on more than one place row across
|
||||
* different trips, and every empty-image row sharing it is filled in one
|
||||
* statement.
|
||||
*/
|
||||
async setImageUrlIfUnset(google_place_id: string, image_url: string): Promise<number> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.nativeUpdate(
|
||||
{ google_place_id, $or: [{ image_url: null }, { image_url: '' }] },
|
||||
{ image_url, updated_at: currentTimestamp(platform) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link PlacesRepository.listImportable}'s row shape (CL45). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReservationEndpoints } from '../entities/ReservationEndpoints.entity';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { columnRef } from '../dialect/sql-functions';
|
||||
import { travelerOwnsExpr, type ReservationTravelersOwnsKyselyDB } from './_shared/reservation-travelers-owns';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
@@ -100,6 +101,31 @@ export class ReservationEndpointsRepository extends TrekRepository<ReservationEn
|
||||
await this.nativeUpdate({ id }, { local_date });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3h Task 4 (`airtrail-import.service.ts`) — additive, per this task's
|
||||
// own file-ownership rule.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* ATI3 (`airtrail-import.service.ts#importAirtrailFlights`) — `SELECT
|
||||
* e.reservation_id, e.code, e.local_date, e.sequence FROM
|
||||
* reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
|
||||
* WHERE r.trip_id = ? AND r.type = 'flight' ORDER BY e.sequence`. Distinct
|
||||
* from {@link listForTrip} above (RS3): a narrower column list, a
|
||||
* `r.type = 'flight'` filter and a single-key `ORDER BY e.sequence` (not
|
||||
* `e.reservation_id, e.sequence`) — a genuinely different statement, per
|
||||
* D4.
|
||||
*/
|
||||
async listFlightEndpointsForTrip(trip_id: number): Promise<{ reservation_id: number; code: string | null; local_date: string | null; sequence: number }[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.qb('e')
|
||||
.join('e.reservation', 'r')
|
||||
.select([columnRef(platform, 'e.reservation_id').as('reservation_id'), 'e.code', 'e.local_date', 'e.sequence'])
|
||||
.where({ 'r.trip': trip_id, 'r.type': 'flight' })
|
||||
.orderBy({ sequence: 'asc' })
|
||||
.execute<{ reservation_id: number; code: string | null; local_date: string | null; sequence: number }[]>('all', false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3f Task 1 (`AtlasService`) — additive, append-only per that task's
|
||||
// own file-ownership rule. AT6/AT45/AT46 all consume Task 0's shared
|
||||
|
||||
@@ -578,6 +578,16 @@ interface PublicApiUnplannedPlaceKyselyDB {
|
||||
day_accommodations: { id: number; place_id: number | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 3h Task 4 (`AirportsService.backfillFlightEndpoints`, AIR1) —
|
||||
* additive. The narrow `reservations`/`reservation_endpoints` shape the
|
||||
* `NOT EXISTS` guard needs.
|
||||
*/
|
||||
interface FlightsMissingEndpointsKyselyDB {
|
||||
reservations: { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null; type: string | null };
|
||||
reservation_endpoints: { id: number; reservation_id: number };
|
||||
}
|
||||
|
||||
export class ReservationsRepository extends TrekRepository<Reservations> {
|
||||
private joinedQuery() {
|
||||
return this.kysely<ReservationJoinKyselyDB>()
|
||||
@@ -1499,6 +1509,141 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
|
||||
.where('id', '=', id as number)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3h Task 4 (AirTrail's bookkeeping, `nest/integrations/airtrail*.ts` —
|
||||
// owns zero tables of its own, R7) + airports' flight-endpoint backfill
|
||||
// (`nest/airports/airports.service.ts`) — additive, per this task's own
|
||||
// file-ownership rule ("additive methods on Reservations/ReservationEndpoints/
|
||||
// Days/Places/Users repositories"). `trip_id` is a `persist(false)` FK
|
||||
// mirror — every trip-scoped read below filters by the `trip` ref property
|
||||
// (the `findInTrip`/`listResyncCandidates` precedent above), never a raw
|
||||
// column name.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** ATI1 (`airtrail-import.service.ts#importAirtrailFlights`, dedup: already-linked ids) — `SELECT external_id, metadata FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'`. */
|
||||
async listAirtrailLinkedForTrip(trip_id: number): Promise<{ external_id: string | null; metadata: string | null }[]> {
|
||||
return this.qb('r')
|
||||
.select(['r.external_id', 'r.metadata'])
|
||||
.where({ trip: trip_id, external_source: 'airtrail' })
|
||||
.execute<{ external_id: string | null; metadata: string | null }[]>('all', false);
|
||||
}
|
||||
|
||||
/** ATI2 (`airtrail-import.service.ts#importAirtrailFlights`, existing-flight signature dedup) — `SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`. */
|
||||
async listFlightReservationsForTrip(trip_id: number): Promise<{ id: number; reservation_time: string | null; metadata: string | null }[]> {
|
||||
return this.qb('r')
|
||||
.select(['r.id', 'r.reservation_time', 'r.metadata'])
|
||||
.where({ trip: trip_id, type: 'flight' })
|
||||
.execute<{ id: number; reservation_time: string | null; metadata: string | null }[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* ATI5 (`airtrail-import.service.ts#importAirtrailFlights`, multi-leg
|
||||
* branch) — `UPDATE reservations SET external_source = 'airtrail',
|
||||
* external_id = ?, external_owner_user_id = ?, sync_enabled = 0,
|
||||
* external_synced_at = ? WHERE id = ?`. **CORRECTNESS GAP, pre-existing,
|
||||
* report don't fix** (inventory §3b): neither this nor {@link
|
||||
* linkAirtrailSingleFlight} is transactional with the preceding
|
||||
* `ReservationsService.create` call — a failure between the two leaves an
|
||||
* untracked reservation. Preserved exactly, not "fixed" with an added
|
||||
* `uow.transactional` wrap.
|
||||
*/
|
||||
async linkAirtrailMultiLeg(id: number, external_id: string, external_owner_user_id: number, external_synced_at: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 0, external_synced_at });
|
||||
}
|
||||
|
||||
/**
|
||||
* ATI6 (`airtrail-import.service.ts#importAirtrailFlights`, single-flight
|
||||
* branch) — `UPDATE reservations SET external_source = 'airtrail',
|
||||
* external_id = ?, external_owner_user_id = ?, sync_enabled = 1,
|
||||
* external_hash = ?, external_synced_at = ? WHERE id = ?`. Same
|
||||
* pre-existing tx gap as {@link linkAirtrailMultiLeg} — report don't fix.
|
||||
*/
|
||||
async linkAirtrailSingleFlight(id: number, external_id: string, external_owner_user_id: number, external_hash: string, external_synced_at: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 1, external_hash, external_synced_at });
|
||||
}
|
||||
|
||||
/** ATL2 (`airtrail-link.service.ts#detach`) — `UPDATE reservations SET sync_enabled = 0 WHERE id = ?`. */
|
||||
async setAirtrailSyncDisabled(id: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { sync_enabled: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* ATL4 (`airtrail-link.service.ts#pushReservationToAirtrail`) — `SELECT
|
||||
* id, trip_id, external_id, external_owner_user_id, sync_enabled FROM
|
||||
* reservations WHERE id = ? AND external_source = 'airtrail'`. `trip_id`
|
||||
* via `columnRef` (the `listResyncCandidates` precedent above) — the
|
||||
* mirror column, not a bare select.
|
||||
*/
|
||||
async findAirtrailLinked(id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.qb('r')
|
||||
.select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_owner_user_id', 'r.sync_enabled'])
|
||||
.where({ id, external_source: 'airtrail' })
|
||||
.execute<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined>('get', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* ATL5 (`airtrail-link.service.ts#pushReservationToAirtrail`, self-write
|
||||
* suppression) / ATS2 (`airtrail-sync.service.ts#syncOwner`, byte-identical
|
||||
* 2nd call site) — `UPDATE reservations SET external_hash = ?,
|
||||
* external_synced_at = ? WHERE id = ?`. One method, two call sites (D4).
|
||||
*/
|
||||
async setAirtrailSyncStamp(id: number, external_hash: string, external_synced_at: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { external_hash, external_synced_at });
|
||||
}
|
||||
|
||||
/**
|
||||
* ATS1 (`airtrail-sync.service.ts#syncOwner`) — `SELECT id, trip_id,
|
||||
* external_id, external_hash FROM reservations WHERE external_source =
|
||||
* 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?`.
|
||||
*/
|
||||
async listAirtrailSyncCandidatesForOwner(owner_user_id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.qb('r')
|
||||
.select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_hash'])
|
||||
.where({ external_source: 'airtrail', sync_enabled: 1, external_owner_user_id: owner_user_id })
|
||||
.execute<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* ATS3 (`airtrail-sync.service.ts#runAirtrailSync`) — `SELECT DISTINCT
|
||||
* external_owner_user_id AS uid FROM reservations WHERE external_source =
|
||||
* 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL`.
|
||||
* **NON-HTTP ENTRYPOINT SOURCE** — the cron poll's owner list.
|
||||
*/
|
||||
async listAirtrailSyncOwners(): Promise<number[]> {
|
||||
const rows = await this.qb('r')
|
||||
.select(['r.external_owner_user_id'])
|
||||
.distinct()
|
||||
.where({ external_source: 'airtrail', sync_enabled: 1, external_owner_user_id: { $ne: null } })
|
||||
.execute<{ external_owner_user_id: number }[]>('all', false);
|
||||
return rows.map((row) => row.external_owner_user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* AIR1 (`airports.service.ts#backfillFlightEndpoints`) — `SELECT r.id,
|
||||
* r.metadata, r.reservation_time, r.reservation_end_time FROM reservations
|
||||
* r WHERE r.type = 'flight' AND NOT EXISTS (SELECT 1 FROM
|
||||
* reservation_endpoints e WHERE e.reservation_id = r.id)`. **NON-HTTP
|
||||
* ENTRYPOINT SOURCE** — the boot-sweep backfill; idempotent via this NOT
|
||||
* EXISTS guard, same shape as `Reservations.repository.ts`'s own
|
||||
* `listUnplannedPlacesForPublicApi` NOT EXISTS precedent (`eb.not(eb.exists(...))`).
|
||||
*/
|
||||
async listFlightsMissingEndpoints(): Promise<{ id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[]> {
|
||||
const rows = await this.kysely<FlightsMissingEndpointsKyselyDB>()
|
||||
.selectFrom('reservations as r')
|
||||
.select(['r.id', 'r.metadata', 'r.reservation_time', 'r.reservation_end_time'])
|
||||
.where('r.type', '=', 'flight')
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('reservation_endpoints as e').select('e.id').whereRef('e.reservation_id', '=', 'r.id'))))
|
||||
.execute();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** AIR3 (`airports.service.ts#backfillFlightEndpoints`, looped) — `UPDATE reservations SET needs_review = 1 WHERE id = ?`. */
|
||||
async markNeedsReview(id: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { needs_review: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */
|
||||
|
||||
@@ -1,4 +1,135 @@
|
||||
import type { RouteUsageDaily } from '../entities/RouteUsageDaily.entity';
|
||||
import { nowDateOffset } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class RouteUsageDailyRepository extends TrekRepository<RouteUsageDaily> {}
|
||||
/**
|
||||
* A `route_usage_daily` row exactly as `SELECT *` reads it (RU3's shape,
|
||||
* snake_case). Named `RouteUsageDailyRow`, not `RouteUsageDayRow` —
|
||||
* `@trek/shared` already exports a `RouteUsageDayRow` (the camelCase API
|
||||
* shape `RouteUsageService.rows()` maps this into), and `route-usage.service.ts`
|
||||
* imports both; a same-named repository export would collide.
|
||||
*/
|
||||
export interface RouteUsageDailyRow {
|
||||
day: string;
|
||||
profile: string;
|
||||
surface: string;
|
||||
self_hosted: number;
|
||||
requests: number;
|
||||
waypoints: number;
|
||||
km: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface RouteUsageDailyKyselyDB {
|
||||
route_usage_daily: {
|
||||
day: string;
|
||||
profile: string;
|
||||
surface: string;
|
||||
self_hosted: number;
|
||||
requests: number;
|
||||
waypoints: number;
|
||||
km: number;
|
||||
failed: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `route_usage_daily` — a composite-PK `(day, profile, surface, self_hosted)`
|
||||
* table (`RouteUsageDaily.entity.ts`'s `[PrimaryKeyProp]`, matching R4).
|
||||
* Kysely throughout: RU2's `ON CONFLICT ... DO UPDATE SET count = count +
|
||||
* excluded.count` shape (an ADDITIVE upsert) is genuinely beyond `em.upsert`
|
||||
* (`onConflictAction: 'merge'` always OVERWRITES — see {@link record}'s own
|
||||
* docstring), and once RU2 needs the escape hatch, the rest of the class
|
||||
* stays there too rather than mixing QB and Kysely for the same table.
|
||||
*/
|
||||
export class RouteUsageDailyRepository extends TrekRepository<RouteUsageDaily> {
|
||||
/**
|
||||
* RU2 (`RouteUsageService.record`, inside the caller's own `uow.transactional`,
|
||||
* once per batch entry) — `INSERT INTO route_usage_daily (day, profile,
|
||||
* surface, self_hosted, requests, waypoints, km, failed) VALUES
|
||||
* (date('now'), ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(day, profile, surface,
|
||||
* self_hosted) DO UPDATE SET requests = requests + excluded.requests,
|
||||
* waypoints = waypoints + excluded.waypoints, km = km + excluded.km,
|
||||
* failed = failed + excluded.failed`.
|
||||
*
|
||||
* **R4**: an ADDITIVE upsert, not a blind overwrite — `em.upsert`'s
|
||||
* `onConflictAction: 'merge'` (the shape `PlaceDetailsCacheRepository
|
||||
* .upsertEntry` already uses successfully for a composite-key upsert, R8)
|
||||
* has no way to express `col = col + excluded.col`: merge always REPLACES
|
||||
* the value with
|
||||
* the new one, it cannot reference the pre-existing row's own value in the
|
||||
* SET expression. Kysely's `onConflict(...).doUpdateSet(...)`, each column
|
||||
* an `eb(col, '+', eb.ref('excluded.col'))` binary-operator expression
|
||||
* (the `DocumentSyncItemsRepository`-precedent `eb.ref('excluded.x')` shape,
|
||||
* widened here from a blind `eb.ref(...)` re-assignment to a real `+`),
|
||||
* is the only shape that renders `col = col + excluded.col`.
|
||||
*
|
||||
* `day` is a plain bound parameter, not `date('now')` spelled into the
|
||||
* statement: the caller resolves "today" ONCE per batch (`todayUtc()`,
|
||||
* the 3f/`TripsRepository.lastStartedTrip` precedent for a BARE
|
||||
* `date('now')` with no offset — Task 0's report) rather than this
|
||||
* repository re-deriving a SQLite date function inline for every entry.
|
||||
* The legacy statement re-evaluated `date('now')` on every loop
|
||||
* iteration; a batch's entries import in the same tick, so this cannot
|
||||
* observably diverge except across a real UTC-midnight boundary mid-loop,
|
||||
* which the legacy code was equally exposed to (both resolve "today" once
|
||||
* per entry's own execution instant — the JS resolution just does it a
|
||||
* few microseconds earlier, at the loop's start rather than inside each
|
||||
* statement).
|
||||
*/
|
||||
async record(entry: {
|
||||
day: string;
|
||||
profile: string;
|
||||
surface: string;
|
||||
self_hosted: number;
|
||||
requests: number;
|
||||
waypoints: number;
|
||||
km: number;
|
||||
failed: number;
|
||||
}): Promise<void> {
|
||||
await this.kysely<RouteUsageDailyKyselyDB>()
|
||||
.insertInto('route_usage_daily')
|
||||
.values(entry)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['day', 'profile', 'surface', 'self_hosted']).doUpdateSet({
|
||||
requests: (eb) => eb('requests', '+', eb.ref('excluded.requests')),
|
||||
waypoints: (eb) => eb('waypoints', '+', eb.ref('excluded.waypoints')),
|
||||
km: (eb) => eb('km', '+', eb.ref('excluded.km')),
|
||||
failed: (eb) => eb('failed', '+', eb.ref('excluded.failed')),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** RU3 (`RouteUsageService.rows`) — `SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface`. */
|
||||
async rows(): Promise<RouteUsageDailyRow[]> {
|
||||
const result = await this.kysely<RouteUsageDailyKyselyDB>()
|
||||
.selectFrom('route_usage_daily')
|
||||
.selectAll()
|
||||
.orderBy('day', 'desc')
|
||||
.orderBy('profile', 'asc')
|
||||
.orderBy('surface', 'asc')
|
||||
.execute();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* RU4 (`RouteUsageService.purgeExpired`) — `DELETE FROM route_usage_daily
|
||||
* WHERE day < date('now', ?)`, `?` bound to `` `-${RETENTION_DAYS} days` ``.
|
||||
* `nowDateOffset` (Task 0's groundwork helper): `RETENTION_DAYS`
|
||||
* (`route-usage.service.ts`) is a compile-time module constant, so the
|
||||
* legacy statement's bound-modifier form and a literal day-count offset
|
||||
* are the same shape at this call site. `PlaceShadowPicksRepository
|
||||
* .expireStale`'s `nativeDelete({ created_at: { $lt: nowMinusDays(...) }
|
||||
* })` is the closest working precedent for the filter shape.
|
||||
*/
|
||||
async purgeExpired(retentionDays: number): Promise<number> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
return this.nativeDelete({ day: { $lt: nowDateOffset(platform, -retentionDays) } });
|
||||
}
|
||||
|
||||
/** RU5 (`RouteUsageService.clear`) — `DELETE FROM route_usage_daily`, unscoped (the admin's "start over"). */
|
||||
async clear(): Promise<number> {
|
||||
return this.nativeDelete({});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AirportsController } from './airports.controller';
|
||||
import { AirportsService } from './airports.service';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { AirportsMcp } from './airports.mcp';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
|
||||
/** Airports domain (L2 leaf module). Registered in AppModule.
|
||||
* DatabaseModule is imported explicitly: the flight-endpoint backfill needs the
|
||||
* connection, and @Global only reaches modules that are in the graph — which
|
||||
* the airports e2e TestingModule is not. SchedulingModule (task-6-rereview.md
|
||||
* M1) is imported for the same reason — it is deliberately NOT @Global — so
|
||||
* the boot backfill can route through CronRegistrarService.runOnBoot instead
|
||||
* of running outside a request context. */
|
||||
* MikroOrmModule.forFeature([Reservations, ReservationEndpoints]) (Plan 3h
|
||||
* Task 4, AIR1-3): the flight-endpoint backfill's raw SQL is repository-backed
|
||||
* now — registered explicitly here, not left to a transitive export, since
|
||||
* the airports e2e TestingModule builds this module standalone. SchedulingModule
|
||||
* (task-6-rereview.md M1) is imported for the same reason — it is deliberately
|
||||
* NOT @Global — so the boot backfill can route through
|
||||
* CronRegistrarService.runOnBoot instead of running outside a request context. */
|
||||
@Module({
|
||||
imports: [DatabaseModule, SchedulingModule],
|
||||
imports: [MikroOrmModule.forFeature([Reservations, ReservationEndpoints]), SchedulingModule],
|
||||
controllers: [AirportsController],
|
||||
providers: [AirportsService, AirportsMcp],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { Airport } from '@trek/shared';
|
||||
import { searchAirports, findByIata, load } from './airports.data';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
|
||||
/**
|
||||
@@ -35,7 +39,8 @@ import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
@Injectable()
|
||||
export class AirportsService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
@InjectRepository(ReservationEndpoints) private readonly endpointsRepo: ReservationEndpointsRepository,
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
@@ -62,33 +67,23 @@ export class AirportsService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async backfillFlightEndpoints(): Promise<void> {
|
||||
const pending = this.db.prepare(`
|
||||
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
|
||||
FROM reservations r
|
||||
WHERE r.type = 'flight'
|
||||
AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id)
|
||||
`).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[];
|
||||
const pending = await this.reservationsRepo.listFlightsMissingEndpoints();
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
load();
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const markReview = this.db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?');
|
||||
|
||||
let filled = 0;
|
||||
let flagged = 0;
|
||||
for (const r of pending) {
|
||||
if (!r.metadata) { markReview.run(r.id); flagged++; continue; }
|
||||
if (!r.metadata) { await this.reservationsRepo.markNeedsReview(r.id); flagged++; continue; }
|
||||
let meta: any;
|
||||
try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; }
|
||||
try { meta = JSON.parse(r.metadata); } catch { await this.reservationsRepo.markNeedsReview(r.id); flagged++; continue; }
|
||||
|
||||
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
|
||||
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
|
||||
|
||||
if (!dep || !arr) { markReview.run(r.id); flagged++; continue; }
|
||||
if (!dep || !arr) { await this.reservationsRepo.markNeedsReview(r.id); flagged++; continue; }
|
||||
|
||||
const split = (iso: string | null) => {
|
||||
if (!iso) return { date: null as string | null, time: null as string | null };
|
||||
@@ -98,8 +93,16 @@ export class AirportsService implements OnApplicationBootstrap {
|
||||
const depParts = split(r.reservation_time);
|
||||
const arrParts = split(r.reservation_end_time);
|
||||
|
||||
insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date);
|
||||
insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date);
|
||||
await this.endpointsRepo.insertEndpoint({
|
||||
reservation_id: r.id, role: 'from', sequence: 0,
|
||||
name: dep.city ? `${dep.city} (${dep.iata})` : dep.name, code: dep.iata,
|
||||
lat: dep.lat, lng: dep.lng, timezone: dep.tz, local_time: depParts.time, local_date: depParts.date,
|
||||
});
|
||||
await this.endpointsRepo.insertEndpoint({
|
||||
reservation_id: r.id, role: 'to', sequence: 1,
|
||||
name: arr.city ? `${arr.city} (${arr.iata})` : arr.name, code: arr.iata,
|
||||
lat: arr.lat, lng: arr.lng, timezone: arr.tz, local_time: arrParts.time, local_date: arrParts.date,
|
||||
});
|
||||
filled++;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import { ImportJobsService } from './import-jobs.service';
|
||||
import { KitineraryExtractorModule } from './kitinerary-extractor.module';
|
||||
@@ -9,9 +10,26 @@ import { BudgetModule } from '../budget/budget.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
|
||||
/**
|
||||
* `MikroOrmModule.forFeature([Days, Reservations])` (Plan 3h Task 4, BI1/BI2):
|
||||
* `BookingImportService.resolveDayId` reaches `DaysRepository.findByTripAndDate`
|
||||
* and `ReservationsRepository.findNearestDayId`.
|
||||
*/
|
||||
@Module({
|
||||
imports: [KitineraryExtractorModule, LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule, MapsModule, PlacesModule],
|
||||
imports: [
|
||||
KitineraryExtractorModule,
|
||||
LlmParseModule,
|
||||
ReservationsModule,
|
||||
PermissionsModule,
|
||||
BudgetModule,
|
||||
AddonsModule,
|
||||
MapsModule,
|
||||
PlacesModule,
|
||||
MikroOrmModule.forFeature([Days, Reservations]),
|
||||
],
|
||||
providers: [BookingImportService, ImportJobsService],
|
||||
// The HTTP surface lives in reservation-import/, which shares the prefix with
|
||||
// the AirTrail import; these are what it needs.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, HttpException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
@@ -7,7 +8,10 @@ import { BudgetService } from '../budget/budget.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { DaysRepository } from '../../db/repositories/Days.repository';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import type { User } from '../../types';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
@@ -21,7 +25,8 @@ export class BookingImportService {
|
||||
constructor(
|
||||
private readonly extractor: KitineraryExtractorService,
|
||||
private readonly llmParse: LlmParseService,
|
||||
private readonly dbs: DatabaseService,
|
||||
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
private readonly reservations: ReservationsService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly budget: BudgetService,
|
||||
@@ -31,20 +36,34 @@ export class BookingImportService {
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* BI1/BI2 — `SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1`
|
||||
* then, on a miss, `SELECT id FROM days WHERE trip_id = ? ORDER BY
|
||||
* ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1`. The SAME
|
||||
* two-statement shape `ReservationsService#resolveDayIdFromTime` already
|
||||
* converted (RS10/RS11) — `DaysRepository.findByTripAndDate` for the exact
|
||||
* match, `ReservationsRepository.findNearestDayId` (which already wraps
|
||||
* `dayDistance`, R4/BI2's own "not a new SQL helper" resolution) for the
|
||||
* nearest-day fallback — reused here rather than re-derived, per the
|
||||
* project's single-source-of-truth rule.
|
||||
*/
|
||||
private async resolveDayId(tripId: string, iso: string | null | undefined): Promise<number | null> {
|
||||
if (!iso) return null;
|
||||
const date = iso.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
const exact = this.db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
const tripIdNum = this.rowIdNum(tripId);
|
||||
const exact = await this.daysRepo.findByTripAndDate(tripIdNum, date);
|
||||
if (exact) return exact.id;
|
||||
// Clamp to the nearest trip day so an out-of-range / unmatched check-in still
|
||||
// resolves and the accommodation row is inserted.
|
||||
const nearest = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
return nearest?.id ?? null;
|
||||
const nearestId = await this.reservationsRepo.findNearestDayId(tripIdNum, date);
|
||||
return nearestId ?? null;
|
||||
}
|
||||
|
||||
/** `tripId` arrives as a route-param string; both repository calls above need a genuine `number` (rule 23) — same coercion shape as `ReservationsService.rowIdNum`. */
|
||||
private rowIdNum(value: string): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : -1;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import type { BookingImportMode, BookingImportPreviewResponse, TrekWsPayload } from '@trek/shared';
|
||||
@@ -36,7 +38,11 @@ export class ImportJobsService {
|
||||
/** Tail of each user's job chain — parses run one at a time per user, not all at once. */
|
||||
private readonly chains = new Map<number, Promise<void>>();
|
||||
|
||||
constructor(private readonly bookingImport: BookingImportService, private readonly realtime: RealtimeService) {}
|
||||
constructor(
|
||||
private readonly bookingImport: BookingImportService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly orm: MikroORM,
|
||||
) {}
|
||||
|
||||
/** Create a job and queue it behind the user's other parses; returns the job id at once. */
|
||||
start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string {
|
||||
@@ -45,8 +51,20 @@ export class ImportJobsService {
|
||||
this.jobs.set(id, job);
|
||||
// Chain onto the user's previous parse so they run sequentially (one CPU-heavy
|
||||
// inference at a time), while the request returns immediately.
|
||||
//
|
||||
// R9 (Plan 3h Task 4, per 3f's own `StorageHealthNotifierService`
|
||||
// precedent): `run()` executes fully detached from the original HTTP
|
||||
// request — the controller has already returned by the time this
|
||||
// `.then()` continuation even starts — so it gets its own fresh
|
||||
// `withRequestContext` fork here, independent of whatever context (if
|
||||
// any) was live at `start()`'s own call site. Insurance, not a fix for
|
||||
// an observed failure: `BookingImportService.preview` has no
|
||||
// `EntityManager` to lose before this task's own conversion (raw
|
||||
// `better-sqlite3` calls have no request-scoping concept at all) — the
|
||||
// risk only exists AFTER BI1/BI2 convert, which is why the wrap lands
|
||||
// in this SAME commit.
|
||||
const prev = this.chains.get(userId) ?? Promise.resolve();
|
||||
const next = prev.then(() => this.run(job, files, mode)).catch(() => {});
|
||||
const next = prev.then(() => withRequestContext(this.orm, () => this.run(job, files, mode))).catch(() => {});
|
||||
this.chains.set(userId, next);
|
||||
void next.finally(() => {
|
||||
if (this.chains.get(userId) === next) this.chains.delete(userId);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { ReservationsReadModule } from '../reservations/reservations-read.module';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { AirtrailClient } from './airtrail.client';
|
||||
import { AirtrailLinkService } from './airtrail-link.service';
|
||||
import { AirtrailService } from './airtrail.service';
|
||||
@@ -15,9 +20,23 @@ import { AirtrailService } from './airtrail.service';
|
||||
* ReservationsReadModule — so ReservationsModule → AirtrailCoreModule carries
|
||||
* no cycle. The pull (AirtrailSyncService), which does need
|
||||
* ReservationsService, lives one level up in AirtrailModule.
|
||||
*
|
||||
* `MikroOrmModule.forFeature([Users, Reservations, ReservationEndpoints,
|
||||
* AppSettings])` (Plan 3h Task 4): `AirtrailService`'s entire credential
|
||||
* surface lives on `Users` (R7 — AirTrail owns zero tables of its own);
|
||||
* `AirtrailLinkService` reaches `Reservations`/`ReservationEndpoints` for its
|
||||
* bookkeeping and `AppSettings` for the sync-enabled kill-switch. Only
|
||||
* entity classes are imported here, not their owning Nest modules — no new
|
||||
* module edge, so no cycle risk.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AuditModule, AddonsModule, RealtimeModule, ReservationsReadModule],
|
||||
imports: [
|
||||
AuditModule,
|
||||
AddonsModule,
|
||||
RealtimeModule,
|
||||
ReservationsReadModule,
|
||||
MikroOrmModule.forFeature([Users, Reservations, ReservationEndpoints, AppSettings]),
|
||||
],
|
||||
providers: [AirtrailClient, AirtrailService, AirtrailLinkService],
|
||||
exports: [AirtrailClient, AirtrailService, AirtrailLinkService],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { AirtrailImportResult } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { DaysRepository } from '../../db/repositories/Days.repository';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { AirtrailRequestError, type AirtrailFlightRaw } from './airtrail.client';
|
||||
@@ -108,13 +114,27 @@ function orderConnectionChain(group: AirtrailFlightRaw[]): AirtrailFlightRaw[] |
|
||||
@Injectable()
|
||||
export class AirtrailImportService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
@InjectRepository(ReservationEndpoints) private readonly endpointsRepo: ReservationEndpointsRepository,
|
||||
@InjectRepository(Days) private readonly daysRepo: DaysRepository,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly reservations: ReservationsService,
|
||||
private readonly client: AirtrailClient,
|
||||
private readonly airtrail: AirtrailService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `tripId` arrives as `string | number` (route param vs. internal caller);
|
||||
* `DaysRepository.listByTrip`'s typed filter needs a genuine `number`
|
||||
* (rule 23) — same coercion shape as `ReservationsService.rowIdNum`.
|
||||
* `-1` never matches a real trip id, so an already-impossible `tripId`
|
||||
* degrades to "no days", never a `NaN` reaching the query.
|
||||
*/
|
||||
private rowIdNum(value: string | number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : -1;
|
||||
}
|
||||
|
||||
async importAirtrailFlights(
|
||||
tripId: string | number,
|
||||
userId: number,
|
||||
@@ -130,13 +150,12 @@ export class AirtrailImportService {
|
||||
const byId = new Map(selected.map(f => [String(f.id), f]));
|
||||
|
||||
const result: AirtrailImportResult = { imported: [], skipped: [] };
|
||||
const tripIdNum = this.rowIdNum(tripId);
|
||||
|
||||
// Every AirTrail id already linked to this trip: the external_id column plus
|
||||
// the metadata.airtrail_ids of joined multi-leg imports.
|
||||
const linkedIds = new Set<string>();
|
||||
const linkedRows = this.db.connection
|
||||
.prepare("SELECT external_id, metadata FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'")
|
||||
.all(tripId) as { external_id: string | null; metadata: string | null }[];
|
||||
const linkedRows = await this.reservationsRepo.listAirtrailLinkedForTrip(tripIdNum);
|
||||
for (const row of linkedRows) {
|
||||
if (row.external_id) linkedIds.add(row.external_id);
|
||||
try {
|
||||
@@ -147,24 +166,16 @@ export class AirtrailImportService {
|
||||
}
|
||||
}
|
||||
|
||||
const existing = this.db.connection
|
||||
.prepare("SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'")
|
||||
.all(tripId) as ExistingFlightRow[];
|
||||
const existing = await this.reservationsRepo.listFlightReservationsForTrip(tripIdNum);
|
||||
const endpointsByReservation = new Map<number, EndpointRow[]>();
|
||||
const endpointRows = this.db.connection
|
||||
.prepare(
|
||||
`SELECT e.reservation_id, e.code, e.local_date, e.sequence
|
||||
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
|
||||
WHERE r.trip_id = ? AND r.type = 'flight' ORDER BY e.sequence`,
|
||||
)
|
||||
.all(tripId) as EndpointRow[];
|
||||
const endpointRows = await this.endpointsRepo.listFlightEndpointsForTrip(tripIdNum);
|
||||
for (const ep of endpointRows) {
|
||||
const list = endpointsByReservation.get(ep.reservation_id);
|
||||
if (list) list.push(ep);
|
||||
else endpointsByReservation.set(ep.reservation_id, [ep]);
|
||||
}
|
||||
|
||||
const days = this.db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[];
|
||||
const days = await this.daysRepo.listByTrip(tripIdNum);
|
||||
const dayIdByDate = new Map<string, number>();
|
||||
const dayDateById = new Map<number, string>();
|
||||
for (const day of days) {
|
||||
@@ -236,10 +247,7 @@ export class AirtrailImportService {
|
||||
const mapped = mapFlightsToMultiLegReservation(chain, resolveDayId);
|
||||
const { reservation } = await this.reservations.create(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
sync_enabled = 0, external_synced_at = ? WHERE id = ?`,
|
||||
).run(ids[0], userId, now, reservation.id);
|
||||
await this.reservationsRepo.linkAirtrailMultiLeg(Number(reservation.id), ids[0], userId, now);
|
||||
|
||||
reservation.external_source = 'airtrail';
|
||||
reservation.external_id = ids[0];
|
||||
@@ -280,10 +288,7 @@ export class AirtrailImportService {
|
||||
try {
|
||||
const { reservation } = await this.reservations.create(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
sync_enabled = 1, external_hash = ?, external_synced_at = ? WHERE id = ?`,
|
||||
).run(fid, userId, canonicalHash(flight), now, reservation.id);
|
||||
await this.reservationsRepo.linkAirtrailSingleFlight(Number(reservation.id), fid, userId, canonicalHash(flight), now);
|
||||
|
||||
// Carry the linkage on the broadcast payload so members see the badge live.
|
||||
reservation.external_source = 'airtrail';
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ReservationsReadService } from '../reservations/reservations-read.service';
|
||||
@@ -25,7 +31,9 @@ import { buildSavePayload } from './airtrail-sync.helpers';
|
||||
@Injectable()
|
||||
export class AirtrailLinkService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
@InjectRepository(ReservationEndpoints) private readonly endpointsRepo: ReservationEndpointsRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly addons: AddonsService,
|
||||
private readonly reads: ReservationsReadService,
|
||||
@@ -36,8 +44,8 @@ export class AirtrailLinkService {
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
async syncGloballyEnabled(): Promise<boolean> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL))) return false;
|
||||
const row = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'");
|
||||
return row?.value !== 'false';
|
||||
const value = await this.appSettings.getValue('airtrail_sync_enabled');
|
||||
return value !== 'false';
|
||||
}
|
||||
|
||||
async broadcastUpdated(tripId: number, reservationId: number): Promise<void> {
|
||||
@@ -50,7 +58,7 @@ export class AirtrailLinkService {
|
||||
}
|
||||
|
||||
async detach(tripId: number, reservationId: number): Promise<void> {
|
||||
this.db.run('UPDATE reservations SET sync_enabled = 0 WHERE id = ?', reservationId);
|
||||
await this.reservationsRepo.setAirtrailSyncDisabled(reservationId);
|
||||
await this.broadcastUpdated(tripId, reservationId);
|
||||
}
|
||||
|
||||
@@ -68,11 +76,8 @@ export class AirtrailLinkService {
|
||||
} catch {
|
||||
/* malformed metadata — fall through to the endpoint count */
|
||||
}
|
||||
const row = this.db.get<{ n: number }>(
|
||||
'SELECT COUNT(*) AS n FROM reservation_endpoints WHERE reservation_id = ?',
|
||||
reservationId,
|
||||
) as { n: number };
|
||||
return row.n > 2;
|
||||
const n = await this.endpointsRepo.count({ reservation: reservationId });
|
||||
return n > 2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,12 +89,7 @@ export class AirtrailLinkService {
|
||||
async pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
|
||||
if (!(await this.syncGloballyEnabled())) return;
|
||||
|
||||
const row = this.db.get<{
|
||||
id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number;
|
||||
}>(
|
||||
"SELECT id, trip_id, external_id, external_owner_user_id, sync_enabled FROM reservations WHERE id = ? AND external_source = 'airtrail'",
|
||||
reservationId,
|
||||
);
|
||||
const row = await this.reservationsRepo.findAirtrailLinked(reservationId);
|
||||
if (!row || !row.sync_enabled) return;
|
||||
|
||||
// An edit that turned this linked flight into a multi-leg booking severs the
|
||||
@@ -136,12 +136,7 @@ export class AirtrailLinkService {
|
||||
// next poll doesn't treat our own write as an inbound change.
|
||||
const saved = await this.client.getFlight(creds, Number(row.external_id));
|
||||
if (saved) {
|
||||
this.db.run(
|
||||
'UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?',
|
||||
canonicalHash(saved),
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
await this.reservationsRepo.setAirtrailSyncStamp(row.id, canonicalHash(saved), new Date().toISOString());
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`AirTrail push failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { logInfo, logError } from '../audit/audit-log.logger';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { AirtrailSyncService } from './airtrail-sync.service';
|
||||
|
||||
@@ -14,22 +16,23 @@ import { AirtrailSyncService } from './airtrail-sync.service';
|
||||
@Injectable()
|
||||
export class AirtrailSyncJob implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
private readonly airtrail: AirtrailSyncService,
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
// Through runOnBoot (task-6-review-parity.md C1: raw SQL today, but the
|
||||
// wrap belongs at the entrypoint so it stays safe if this dependency
|
||||
// graph goes repository-backed later). minutes defaults to the same
|
||||
// fallback the interval read itself falls back to, so an absent MikroORM
|
||||
// (logged distinctly by runOnBoot, never silently) still registers the
|
||||
// job at its default cadence rather than not registering at all.
|
||||
// Through runOnBoot (task-6-review-parity.md C1 — the same shape whether
|
||||
// the read below is raw SQL or, as of Plan 3h Task 4 (ATJ1), repository-
|
||||
// backed: the wrap belongs at the entrypoint either way). minutes
|
||||
// defaults to the same fallback the interval read itself falls back to,
|
||||
// so an absent MikroORM (logged distinctly by runOnBoot, never silently)
|
||||
// still registers the job at its default cadence rather than not
|
||||
// registering at all.
|
||||
let minutes = 5;
|
||||
await this.registrar.runOnBoot('airtrail-sync-boot', async () => {
|
||||
const value = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'airtrail_poll_interval_minutes')?.value;
|
||||
const value = await this.appSettings.getValue('airtrail_poll_interval_minutes');
|
||||
const raw = Number.parseInt(value || '5', 10);
|
||||
minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5;
|
||||
logInfo(`AirTrail sync: scheduled every ${minutes}m`);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { ReservationsService } from '../reservations/reservations.service';
|
||||
import { logError, logInfo } from '../audit/audit-log.logger';
|
||||
import { AirtrailAuthError, type AirtrailFlightRaw } from './airtrail.client';
|
||||
@@ -27,7 +29,7 @@ export { buildSavePayload } from './airtrail-sync.helpers';
|
||||
@Injectable()
|
||||
export class AirtrailSyncService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
|
||||
private readonly link: AirtrailLinkService,
|
||||
private readonly reservations: ReservationsService,
|
||||
private readonly client: AirtrailClient,
|
||||
@@ -64,10 +66,7 @@ export class AirtrailSyncService {
|
||||
}
|
||||
const byId = new Map(flights.map((f) => [String(f.id), f]));
|
||||
|
||||
const linked = this.db.all<{ id: number; trip_id: number; external_id: string; external_hash: string | null }>(
|
||||
"SELECT id, trip_id, external_id, external_hash FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?",
|
||||
uid,
|
||||
);
|
||||
const linked = await this.reservationsRepo.listAirtrailSyncCandidatesForOwner(uid);
|
||||
|
||||
let changed = 0;
|
||||
for (const row of linked) {
|
||||
@@ -92,12 +91,7 @@ export class AirtrailSyncService {
|
||||
}
|
||||
try {
|
||||
await this.reservations.update(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
|
||||
this.db.run(
|
||||
'UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?',
|
||||
hash,
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
await this.reservationsRepo.setAirtrailSyncStamp(row.id, hash, new Date().toISOString());
|
||||
await this.link.broadcastUpdated(row.trip_id, row.id);
|
||||
changed++;
|
||||
} catch (err) {
|
||||
@@ -114,10 +108,8 @@ export class AirtrailSyncService {
|
||||
this.running = true;
|
||||
let changed = 0;
|
||||
try {
|
||||
const owners = this.db.all<{ uid: number }>(
|
||||
"SELECT DISTINCT external_owner_user_id AS uid FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL",
|
||||
);
|
||||
for (const { uid } of owners) changed += await this.syncOwner(uid);
|
||||
const owners = await this.reservationsRepo.listAirtrailSyncOwners();
|
||||
for (const uid of owners) changed += await this.syncOwner(uid);
|
||||
if (changed > 0) logInfo(`AirTrail sync: applied ${changed} change(s)`);
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync failed: ${err instanceof Error ? err.message : err}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AirtrailController } from './airtrail.controller';
|
||||
import { AirtrailCoreModule } from './airtrail-core.module';
|
||||
import { AirtrailSyncService } from './airtrail-sync.service';
|
||||
@@ -10,6 +11,10 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
|
||||
import { Days } from '../../db/entities/Days.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
|
||||
/**
|
||||
* AirTrail integration domain. The connection lives under
|
||||
@@ -23,9 +28,25 @@ import { ReservationsModule } from '../reservations/reservations.module';
|
||||
* this module holds what genuinely needs ReservationsService — the pull
|
||||
* (remote changes apply through the real reservation update path), its cron
|
||||
* and the importer. That split is what retired airtrail.bridge.
|
||||
*
|
||||
* `MikroOrmModule.forFeature([Reservations, ReservationEndpoints, Days,
|
||||
* AppSettings])` (Plan 3h Task 4): `AirtrailImportService` needs
|
||||
* `Reservations`/`ReservationEndpoints`/`Days`, `AirtrailSyncService` needs
|
||||
* `Reservations`, `AirtrailSyncJob` needs `AppSettings` — registered here
|
||||
* directly (not left to `AirtrailCoreModule`'s own transitive export) so
|
||||
* every module that CONSTRUCTS one of these services carries its own
|
||||
* `forFeature` registration, per the program's BOOT GATE rule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AirtrailCoreModule, PermissionsModule, AddonsModule, AuditModule, ReservationsModule, SchedulingModule],
|
||||
imports: [
|
||||
AirtrailCoreModule,
|
||||
PermissionsModule,
|
||||
AddonsModule,
|
||||
AuditModule,
|
||||
ReservationsModule,
|
||||
SchedulingModule,
|
||||
MikroOrmModule.forFeature([Reservations, ReservationEndpoints, Days, AppSettings]),
|
||||
],
|
||||
controllers: [AirtrailController],
|
||||
providers: [AirtrailSyncService, AirtrailSyncJob, AirtrailImportService, AirtrailMcp],
|
||||
exports: [AirtrailSyncService, AirtrailImportService],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { AirtrailFlight } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
@@ -29,29 +31,33 @@ interface UserConnRow {
|
||||
* airtrail_allow_insecure_tls, airtrail_write_enabled). That was a deliberate
|
||||
* decision, not an omission: an integrations table would have needed a migration
|
||||
* and bought nothing while AirTrail is the only integration of this shape.
|
||||
*
|
||||
* Plan 3h Task 4 (ATC1-5): the raw `users` reads/writes now go through
|
||||
* `UsersRepository`'s additive `getAirtrailConnRow`/`getAirtrailWriteEnabled`/
|
||||
* `setAirtrailSettingsWithKey`/`setAirtrailSettings`/`clearAirtrailApiKey`
|
||||
* methods (R7 — the repository sees only opaque, already-encrypted TEXT;
|
||||
* `maybe_encrypt_api_key`/`decrypt_api_key` stay here). `saveSettings` keeps
|
||||
* its pre-existing asymmetry with `DawarichService#saveSettings`
|
||||
* (Task 3): Dawarich wraps its writes in `uow.transactional`, this one does
|
||||
* not — preserved exactly, not "fixed" to match.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AirtrailService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
private readonly audit: AuditService,
|
||||
private readonly client: AirtrailClient,
|
||||
) {}
|
||||
|
||||
private async readRow(userId: number): Promise<UserConnRow | undefined> {
|
||||
return this.db.get<UserConnRow>(
|
||||
'SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?',
|
||||
userId,
|
||||
);
|
||||
const row = await this.usersRepo.getAirtrailConnRow(userId);
|
||||
return row ?? undefined;
|
||||
}
|
||||
|
||||
/** Has this user opted in to TREK writing their flight edits back to AirTrail? (#1240) */
|
||||
async isAirtrailWriteEnabled(userId: number): Promise<boolean> {
|
||||
const row = this.db.get<{ airtrail_write_enabled?: number | null }>(
|
||||
'SELECT airtrail_write_enabled FROM users WHERE id = ?',
|
||||
userId,
|
||||
);
|
||||
return !!row?.airtrail_write_enabled;
|
||||
const value = await this.usersRepo.getAirtrailWriteEnabled(userId);
|
||||
return !!value;
|
||||
}
|
||||
|
||||
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
||||
@@ -116,18 +122,12 @@ export class AirtrailService {
|
||||
const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
|
||||
|
||||
if (newKey !== undefined) {
|
||||
this.db.run(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||
trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId,
|
||||
);
|
||||
await this.usersRepo.setAirtrailSettingsWithKey(userId, trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0);
|
||||
} else {
|
||||
this.db.run(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||
trimmedUrl || null, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId,
|
||||
);
|
||||
await this.usersRepo.setAirtrailSettings(userId, trimmedUrl || null, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0);
|
||||
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
|
||||
if (!trimmedUrl) {
|
||||
this.db.run('UPDATE users SET airtrail_api_key = NULL WHERE id = ?', userId);
|
||||
await this.usersRepo.clearAirtrailApiKey(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PlacePhotosModule } from '../place-photos/place-photos.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
|
||||
/**
|
||||
* Maps / geo domain (L3 leaf module). Registered in AppModule. Exports
|
||||
@@ -14,13 +16,16 @@ import { Users } from '../../db/entities/Users.entity';
|
||||
* geocoding, PlacesModule's search_place tool and list-import enrichment).
|
||||
* Nothing outside the container consumes this domain, so there is no bridge.
|
||||
*
|
||||
* MikroOrmModule.forFeature([AppSettings, Users]): MapsService passes its own
|
||||
* AppSettingsRepository/UsersRepository to instance-api-keys.ts's
|
||||
* resolveApiKey now (Plan 3a Task 5) — every other read here is still raw SQL
|
||||
* through DatabaseService (maps is outside Plan 3a).
|
||||
* MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places]):
|
||||
* MapsService passes its own AppSettingsRepository/UsersRepository to
|
||||
* instance-api-keys.ts's resolveApiKey (Plan 3a Task 5). Plan 3h Task 4 (R8)
|
||||
* adds `PlaceDetailsCache` (MAP3-8 reuse `PlaceDetailsCacheRepository`, built
|
||||
* for `place-enrichment.service.ts` — only the entity class is imported
|
||||
* here, not that domain's own Nest module, so there is no new module edge/
|
||||
* cycle risk) and `Places` (MAP9's additive `setImageUrlIfUnset`).
|
||||
*/
|
||||
@Module({
|
||||
imports: [PlacePhotosModule, StorageModule, MikroOrmModule.forFeature([AppSettings, Users])],
|
||||
imports: [PlacePhotosModule, StorageModule, MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places])],
|
||||
controllers: [MapsController],
|
||||
providers: [MapsService, MapsMcp],
|
||||
exports: [MapsService],
|
||||
|
||||
@@ -18,6 +18,10 @@ import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
|
||||
import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDetailsCache.repository';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
|
||||
import {
|
||||
AMAP_SHORT_HOSTS,
|
||||
@@ -28,7 +32,6 @@ import {
|
||||
} from './providers/amap.provider';
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
|
||||
import {
|
||||
trekPlacesSearch,
|
||||
@@ -683,20 +686,29 @@ type KeyedProvider =
|
||||
* (Nominatim/Overpass/Google), the place-details/photo caches and the SSRF
|
||||
* guard on every outbound URL. DI-native since the maps fold: the legacy
|
||||
* services/mapsService.ts functions live here as methods over the injected
|
||||
* DatabaseService (byte-identical SQL and behaviour). Every consumer injects
|
||||
* repositories (byte-identical SQL and behaviour). Every consumer injects
|
||||
* this class; pure helpers live in maps.helpers.ts.
|
||||
*
|
||||
* The per-endpoint kill-switches are settings reads the legacy route does
|
||||
* inline; they're encapsulated here as `*Disabled()` helpers over the same
|
||||
* `app_settings` rows.
|
||||
* `app_settings` rows (`AppSettingsRepository`, already injected pre-Plan-3h
|
||||
* for the API-key resolution logic).
|
||||
*
|
||||
* Plan 3h Task 4 (R8): the file's last 9 raw statements are converted —
|
||||
* MAP1/MAP2 reuse the already-injected `AppSettingsRepository`; MAP3-8
|
||||
* (`place_details_cache`) reuse `PlaceDetailsCacheRepository` — built for
|
||||
* `place-enrichment.service.ts`, an unrelated domain reading the SAME
|
||||
* table — no new repository; MAP9 (`places.image_url`) is one additive
|
||||
* `PlacesRepository` method.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MapsService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly photoCache: PlacePhotoCacheService,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
@InjectRepository(PlaceDetailsCache) private readonly placeDetailsCache: PlaceDetailsCacheRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
) {}
|
||||
|
||||
/** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the
|
||||
@@ -704,11 +716,8 @@ export class MapsService {
|
||||
private readonly brandLogoCache = new Map<string, { at: number; logo: BrandLogo | null }>();
|
||||
|
||||
private async isSettingDisabled(key: string): Promise<boolean> {
|
||||
const row = this.database.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
key,
|
||||
);
|
||||
return row?.value === 'false';
|
||||
const value = await this.appSettings.getValue(key);
|
||||
return value === 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1031,11 +1040,8 @@ export class MapsService {
|
||||
* not take place search down.
|
||||
*/
|
||||
async placesProviderChoice(): Promise<PlacesProviderChoice> {
|
||||
const row = this.database.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
PLACES_PROVIDER_SETTING,
|
||||
);
|
||||
return isPlacesProviderChoice(row?.value) ? row.value : 'auto';
|
||||
const value = await this.appSettings.getValue(PLACES_PROVIDER_SETTING);
|
||||
return isPlacesProviderChoice(value) ? value : 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2486,11 +2492,7 @@ export class MapsService {
|
||||
|
||||
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const cached = this.database.get<{ payload_json: string; fetched_at: number }>(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0',
|
||||
placeId,
|
||||
langKey,
|
||||
);
|
||||
const cached = await this.placeDetailsCache.findEntry(placeId, langKey, 0);
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
||||
|
||||
// Closes the autocomplete session this lookup belongs to, so Google bills
|
||||
@@ -2546,13 +2548,7 @@ export class MapsService {
|
||||
};
|
||||
|
||||
try {
|
||||
this.database.run(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)',
|
||||
placeId,
|
||||
langKey,
|
||||
JSON.stringify(place),
|
||||
Date.now(),
|
||||
);
|
||||
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 0, payload_json: JSON.stringify(place), fetched_at: Date.now() });
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache place details:', dbErr);
|
||||
}
|
||||
@@ -2579,24 +2575,14 @@ export class MapsService {
|
||||
|
||||
const langKey = toApiLang(lang);
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const cached = this.database.get<{ payload_json: string; fetched_at: number }>(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0',
|
||||
placeId,
|
||||
langKey,
|
||||
);
|
||||
const cached = await this.placeDetailsCache.findEntry(placeId, langKey, 0);
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
||||
|
||||
const place = await provider.placeDetails(placeId, lang);
|
||||
if (!place) return { place: null };
|
||||
|
||||
try {
|
||||
this.database.run(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)',
|
||||
placeId,
|
||||
langKey,
|
||||
JSON.stringify(place),
|
||||
Date.now(),
|
||||
);
|
||||
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 0, payload_json: JSON.stringify(place), fetched_at: Date.now() });
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache place details:', dbErr);
|
||||
}
|
||||
@@ -2635,11 +2621,7 @@ export class MapsService {
|
||||
|
||||
// Check DB cache for expanded result
|
||||
if (!refresh) {
|
||||
const cached = this.database.get<{ payload_json: string }>(
|
||||
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1',
|
||||
placeId,
|
||||
langKey,
|
||||
);
|
||||
const cached = await this.placeDetailsCache.findEntry(placeId, langKey, 1);
|
||||
if (cached) return { place: JSON.parse(cached.payload_json) };
|
||||
}
|
||||
|
||||
@@ -2694,13 +2676,7 @@ export class MapsService {
|
||||
};
|
||||
|
||||
try {
|
||||
this.database.run(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)',
|
||||
placeId,
|
||||
langKey,
|
||||
JSON.stringify(place),
|
||||
Date.now(),
|
||||
);
|
||||
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 1, payload_json: JSON.stringify(place), fetched_at: Date.now() });
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache expanded place details:', dbErr);
|
||||
}
|
||||
@@ -2844,11 +2820,7 @@ export class MapsService {
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
this.database.run(
|
||||
"UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')",
|
||||
cached.photoUrl,
|
||||
placeId,
|
||||
);
|
||||
await this.placesRepo.setImageUrlIfUnset(placeId, cached.photoUrl);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { RouteUsageController } from './route-usage.controller';
|
||||
import { RouteUsageRetentionJob } from './route-usage.job';
|
||||
import { RouteUsageService } from './route-usage.service';
|
||||
import { RouteUsageDaily } from '../../db/entities/RouteUsageDaily.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
|
||||
/**
|
||||
* Routing usage counters. Registered in AppModule; exports the service so an admin
|
||||
* surface can read the summary without going through HTTP.
|
||||
*
|
||||
* MikroOrmModule.forFeature([RouteUsageDaily, AppSettings]) (Plan 3h Task 4):
|
||||
* `RouteUsageService` reaches its own table's repository (R4's additive
|
||||
* upsert) plus `AppSettingsRepository` for the enabled/kill-switch read.
|
||||
*/
|
||||
@Module({
|
||||
imports: [SchedulingModule],
|
||||
imports: [SchedulingModule, MikroOrmModule.forFeature([RouteUsageDaily, AppSettings])],
|
||||
controllers: [RouteUsageController],
|
||||
providers: [RouteUsageService, RouteUsageRetentionJob],
|
||||
exports: [RouteUsageService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type {
|
||||
RouteUsageDayRow,
|
||||
RouteUsageProfile,
|
||||
@@ -6,7 +7,11 @@ import type {
|
||||
RouteUsageSummaryResult,
|
||||
RouteUsageSurface,
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { todayUtc } from '@trek/shared';
|
||||
import { RouteUsageDaily } from '../../db/entities/RouteUsageDaily.entity';
|
||||
import { RouteUsageDailyRepository } from '../../db/repositories/RouteUsageDaily.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
|
||||
/** Days a counted day survives. Aggregates are tiny, so this is a year and a bit. */
|
||||
@@ -15,17 +20,6 @@ export const RETENTION_DAYS = 400;
|
||||
/** Days returned in the summary's own series, newest first. */
|
||||
export const SERIES_DAYS = 90;
|
||||
|
||||
interface DbRow {
|
||||
day: string;
|
||||
profile: string;
|
||||
surface: string;
|
||||
self_hosted: number;
|
||||
requests: number;
|
||||
waypoints: number;
|
||||
km: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route usage counters.
|
||||
*
|
||||
@@ -42,55 +36,53 @@ interface DbRow {
|
||||
@Injectable()
|
||||
export class RouteUsageService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(RouteUsageDaily) private readonly routeUsageRepo: RouteUsageDailyRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
async enabled(): Promise<boolean> {
|
||||
const row = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM app_settings WHERE key = 'route_usage_enabled'",
|
||||
);
|
||||
const value = await this.appSettings.getValue('route_usage_enabled');
|
||||
// Absent means on: the counters have to be collecting before anyone thinks to
|
||||
// look for them, and there is nothing here to protect.
|
||||
return row?.value !== 'false';
|
||||
return value !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a batch onto today's rows. Returns whether anything was written, so a
|
||||
* switched-off instance answers 200 rather than an error the client would log on
|
||||
* every flush.
|
||||
*
|
||||
* RU2/R4: `RouteUsageDailyRepository.record`'s Kysely `onConflict(...)
|
||||
* .doUpdateSet(...)` upsert is ADDITIVE (`requests = requests +
|
||||
* excluded.requests`, …), not a blind overwrite — see that method's own
|
||||
* docstring. `today` is resolved ONCE per batch (`todayUtc()`, the 3f
|
||||
* bare-`date('now')` precedent), not re-derived per entry.
|
||||
*/
|
||||
async record(report: RouteUsageReportRequest): Promise<boolean> {
|
||||
if (!(await this.enabled())) return false;
|
||||
const today = todayUtc();
|
||||
// One transaction for the batch: a flush is a handful of rows, and a partial
|
||||
// one would leave a day counted twice on the client's next retry.
|
||||
await this.uow.transactional(async () => {
|
||||
for (const entry of report.entries) {
|
||||
this.db.run(
|
||||
`INSERT INTO route_usage_daily (day, profile, surface, self_hosted, requests, waypoints, km, failed)
|
||||
VALUES (date('now'), ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(day, profile, surface, self_hosted) DO UPDATE SET
|
||||
requests = requests + excluded.requests,
|
||||
waypoints = waypoints + excluded.waypoints,
|
||||
km = km + excluded.km,
|
||||
failed = failed + excluded.failed`,
|
||||
entry.profile,
|
||||
entry.surface,
|
||||
entry.selfHosted ? 1 : 0,
|
||||
entry.requests,
|
||||
entry.waypoints,
|
||||
Math.round(entry.km),
|
||||
entry.failed,
|
||||
);
|
||||
await this.routeUsageRepo.record({
|
||||
day: today,
|
||||
profile: entry.profile,
|
||||
surface: entry.surface,
|
||||
self_hosted: entry.selfHosted ? 1 : 0,
|
||||
requests: entry.requests,
|
||||
waypoints: entry.waypoints,
|
||||
km: Math.round(entry.km),
|
||||
failed: entry.failed,
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async rows(): Promise<RouteUsageDayRow[]> {
|
||||
const rows = this.db.all<DbRow>(
|
||||
'SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface',
|
||||
);
|
||||
const rows = await this.routeUsageRepo.rows();
|
||||
return rows.map((r) => ({
|
||||
day: r.day,
|
||||
profile: r.profile as RouteUsageProfile,
|
||||
@@ -174,16 +166,11 @@ export class RouteUsageService {
|
||||
|
||||
/** Removes days past the retention window. Returns how many rows went. */
|
||||
async purgeExpired(): Promise<number> {
|
||||
const result = this.db.run(
|
||||
`DELETE FROM route_usage_daily WHERE day < date('now', ?)`,
|
||||
`-${RETENTION_DAYS} days`,
|
||||
);
|
||||
return result.changes ?? 0;
|
||||
return this.routeUsageRepo.purgeExpired(RETENTION_DAYS);
|
||||
}
|
||||
|
||||
/** Wipes every counter. The admin's own "start over". */
|
||||
async clear(): Promise<number> {
|
||||
const result = this.db.run('DELETE FROM route_usage_daily');
|
||||
return result.changes ?? 0;
|
||||
return this.routeUsageRepo.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,20 +15,26 @@ const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logErr
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
import { AirportsService } from '../../../../src/nest/airports/airports.service';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
||||
import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createTestReservationsRepo, createTestReservationEndpointsRepo } from '../../../helpers/test-uow';
|
||||
import type { ReservationsRepository } from '../../../../src/db/repositories/Reservations.repository';
|
||||
import type { ReservationEndpointsRepository } from '../../../../src/db/repositories/ReservationEndpoints.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let reservationsRepo: ReservationsRepository;
|
||||
let endpointsRepo: ReservationEndpointsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Global context disallowed — the production setting — so this genuinely
|
||||
// proves the boot backfill runs (or doesn't) inside a request context,
|
||||
// rather than happening to work because the test default allows it.
|
||||
t = await createTestOrm(testDb, { allowGlobalContext: false });
|
||||
reservationsRepo = await createTestReservationsRepo(testDb);
|
||||
endpointsRepo = await createTestReservationEndpointsRepo(testDb);
|
||||
});
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
@@ -42,7 +48,7 @@ describe('AirportsService boot backfill', () => {
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async (name: string, fn: () => void | Promise<void>) => { registered.push([name, fn]); await fn(); }),
|
||||
};
|
||||
const svc = new AirportsService(new DatabaseService(testDb), registrar as unknown as CronRegistrarService);
|
||||
const svc = new AirportsService(reservationsRepo, endpointsRepo, registrar as unknown as CronRegistrarService);
|
||||
const backfillSpy = vi.spyOn(svc, 'backfillFlightEndpoints').mockResolvedValue(undefined);
|
||||
await svc.onApplicationBootstrap();
|
||||
expect(registrar.runOnBoot).toHaveBeenCalledWith('airports-flight-endpoints-boot', expect.any(Function));
|
||||
@@ -51,7 +57,7 @@ describe('AirportsService boot backfill', () => {
|
||||
|
||||
it('AIRPORTS-SVC-002: with MikroORM wired into the registrar, the boot-time backfill actually runs — no cannotUseGlobalContext, nothing swallowed', async () => {
|
||||
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm);
|
||||
const svc = new AirportsService(new DatabaseService(testDb), registrar);
|
||||
const svc = new AirportsService(reservationsRepo, endpointsRepo, registrar);
|
||||
const backfillSpy = vi.spyOn(svc, 'backfillFlightEndpoints');
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
@@ -66,7 +72,7 @@ describe('AirportsService boot backfill', () => {
|
||||
|
||||
it('AIRPORTS-SVC-003: without MikroORM, the boot-time backfill never runs at all — logged with runOnBoot\'s own distinct message, never swallowed into "[DB] Flight endpoint backfill failed"', async () => {
|
||||
const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService); // no orm
|
||||
const svc = new AirportsService(new DatabaseService(testDb), registrar);
|
||||
const svc = new AirportsService(reservationsRepo, endpointsRepo, registrar);
|
||||
const backfillSpy = vi.spyOn(svc, 'backfillFlightEndpoints');
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
@@ -86,7 +92,7 @@ describe('AirportsService boot backfill', () => {
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async () => { throw new Error('RequestContext.create blew up'); }),
|
||||
};
|
||||
const svc = new AirportsService(new DatabaseService(testDb), registrar as unknown as CronRegistrarService);
|
||||
const svc = new AirportsService(reservationsRepo, endpointsRepo, registrar as unknown as CronRegistrarService);
|
||||
const backfillSpy = vi.spyOn(svc, 'backfillFlightEndpoints');
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
import { AirtrailSyncJob } from '../../../src/nest/integrations/airtrail-sync.job';
|
||||
import type { AirtrailSyncService } from '../../../src/nest/integrations/airtrail-sync.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
|
||||
const SETTING_KEY = 'airtrail_poll_interval_minutes';
|
||||
@@ -32,18 +32,18 @@ function makeJob(intervalSetting?: string, enabled = true) {
|
||||
unregister: vi.fn(),
|
||||
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
|
||||
};
|
||||
const db = {
|
||||
get: vi.fn((_sql: string, ..._params: unknown[]) => (intervalSetting === undefined ? undefined : { value: intervalSetting })),
|
||||
const appSettings = {
|
||||
getValue: vi.fn((_key: string) => Promise.resolve(intervalSetting === undefined ? null : intervalSetting)),
|
||||
};
|
||||
const airtrail = {
|
||||
runAirtrailSync: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
};
|
||||
const job = new AirtrailSyncJob(
|
||||
db as unknown as DatabaseService,
|
||||
appSettings as unknown as AppSettingsRepository,
|
||||
airtrail as unknown as AirtrailSyncService,
|
||||
registrar as unknown as CronRegistrarService,
|
||||
);
|
||||
return { job, registrar, db, airtrail, takeTick: () => onTick };
|
||||
return { job, registrar, appSettings, airtrail, takeTick: () => onTick };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
@@ -57,9 +57,9 @@ describe('AirtrailSyncJob bootstrap', () => {
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-002: reads its own setting key, not the Dawarich one', async () => {
|
||||
const { job, db } = makeJob('10');
|
||||
const { job, appSettings } = makeJob('10');
|
||||
await job.onApplicationBootstrap();
|
||||
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY);
|
||||
expect(appSettings.getValue).toHaveBeenCalledWith(SETTING_KEY);
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-003: clamps the interval to 1-59 minutes and falls back to 5 on anything else', async () => {
|
||||
@@ -80,10 +80,10 @@ describe('AirtrailSyncJob bootstrap', () => {
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-004: the test gate stops the job before it registers, reads or logs anything', async () => {
|
||||
const { job, registrar, db } = makeJob('5', false);
|
||||
const { job, registrar, appSettings } = makeJob('5', false);
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
expect(appSettings.getValue).not.toHaveBeenCalled();
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
expect(registrar.runOnBoot).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -104,10 +104,10 @@ describe('AirtrailSyncJob bootstrap', () => {
|
||||
});
|
||||
|
||||
it('AIRTRAIL-JOB-010: registers at the default 5m cadence if runOnBoot declines to run fn (no ORM available)', async () => {
|
||||
const { job, registrar, db } = makeJob('30');
|
||||
const { job, registrar, appSettings } = makeJob('30');
|
||||
registrar.runOnBoot.mockImplementationOnce(async () => { /* simulates no ORM available — fn never runs */ });
|
||||
await job.onApplicationBootstrap();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
expect(appSettings.getValue).not.toHaveBeenCalled();
|
||||
expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', '*/5 * * * *', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@ vi.mock('../../../../src/db/database', () => ({
|
||||
// them, but the module-level import has to resolve.
|
||||
canAccessTrip: vi.fn(), isOwner: () => false, getPlaceWithTags: () => null,
|
||||
}));
|
||||
import { db as dbConn } from '../../../../src/db/database';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
const permissionsStub = { checkPermission: vi.fn(() => true) };
|
||||
|
||||
@@ -32,7 +30,13 @@ function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any })
|
||||
// Places became a constructor dep with the place DI fold (was a path mock of
|
||||
// services/placeService); only confirm() reaches it, so a bare create stub does.
|
||||
const places = { create: vi.fn() };
|
||||
return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never, permissionsStub as never, undefined as never, undefined as never, undefined as never, maps as never, places as never), extractor, llmParse, reservations, maps, places };
|
||||
return {
|
||||
svc: new BookingImportService(
|
||||
extractor as any, llmParse as any, undefined as never, undefined as never,
|
||||
reservations as never, permissionsStub as never, undefined as never, undefined as never, undefined as never, maps as never, places as never,
|
||||
),
|
||||
extractor, llmParse, reservations, maps, places,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
|
||||
const { broadcastToUser } = vi.hoisted(() => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../../../src/websocket', () => ({ broadcastToUser }));
|
||||
|
||||
import { ImportJobsService } from '../../../../src/nest/booking-import/import-jobs.service';
|
||||
import { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
// R9 (Plan 3h Task 4): `run()` now forks its own `withRequestContext`, so the
|
||||
// service needs a real `MikroORM` — the `StorageHealthNotifierService`
|
||||
// precedent (`storage-health-notifier.service.test.ts`) for a hand-built
|
||||
// double that still needs one.
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
beforeAll(async () => {
|
||||
// Global context disallowed — the production setting — so a passing suite
|
||||
// genuinely proves `run()`'s own `withRequestContext` wrap is load-bearing,
|
||||
// not merely harmless (the `airports.service.test.ts` precedent).
|
||||
t = await createTestOrm(testDb, { allowGlobalContext: false });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
type Preview = ReturnType<typeof vi.fn>;
|
||||
function makeService(preview: Preview) {
|
||||
return new ImportJobsService({ preview } as never, new RealtimeService());
|
||||
return new ImportJobsService({ preview } as never, new RealtimeService(), t.orm);
|
||||
}
|
||||
const files = (n: number) => Array.from({ length: n }, (_, i) => ({ originalname: `f${i}.pdf` })) as never;
|
||||
const eventsFor = (jobId: string) => broadcastToUser.mock.calls.map((c) => c[1]).filter((p) => p.jobId === jobId);
|
||||
@@ -38,6 +57,17 @@ describe('ImportJobsService', () => {
|
||||
expect(eventsFor(id).every((p) => p.tripId === '7')).toBe(true);
|
||||
});
|
||||
|
||||
it('IMPORTJOBS-CTX-001 (R9 ratchet): start() is called from a bare, non-request context (like every test above) and the detached run() still resolves cleanly — the withRequestContext wrap around it means the missing ambient context never reaches BookingImportService.preview as cannotUseGlobalContext', async () => {
|
||||
const preview = vi.fn(async () => ({ items: [{ id: 'ctx' }] }));
|
||||
const svc = makeService(preview);
|
||||
|
||||
const id = svc.start('7', files(1), 'no-ai', 42);
|
||||
await vi.waitFor(() => expect(svc.get(id, 42)?.status).toBe('done'));
|
||||
const job = svc.get(id, 42)!;
|
||||
expect(job.error).toBeUndefined();
|
||||
expect(job.result).toEqual({ items: [{ id: 'ctx' }] });
|
||||
});
|
||||
|
||||
it('records an error and pushes import:error when the parse throws', async () => {
|
||||
const preview = vi.fn(async () => { throw new Error('parse boom'); });
|
||||
const svc = makeService(preview);
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { TrekPhotoCacheService } from '../../../src/nest/memories/trek-phot
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { ThumbnailService } from '../../../src/nest/memories/thumbnail.service';
|
||||
import type { AirtrailSyncService } from '../../../src/nest/integrations/airtrail-sync.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
|
||||
function registrarStub(enabled = true) {
|
||||
@@ -144,12 +144,12 @@ describe('JourneyThumbsJob', () => {
|
||||
describe('AirtrailSyncJob', () => {
|
||||
function make(intervalSetting: string | undefined, enabled = true) {
|
||||
const registrar = registrarStub(enabled);
|
||||
const db = {
|
||||
get: vi.fn(() => (intervalSetting === undefined ? undefined : { value: intervalSetting })),
|
||||
const appSettings = {
|
||||
getValue: vi.fn(async () => (intervalSetting === undefined ? null : intervalSetting)),
|
||||
};
|
||||
const airtrail = { runAirtrailSync: vi.fn().mockResolvedValue(undefined) };
|
||||
const job = new AirtrailSyncJob(
|
||||
db as unknown as DatabaseService,
|
||||
appSettings as unknown as AppSettingsRepository,
|
||||
airtrail as unknown as AirtrailSyncService,
|
||||
registrar as unknown as CronRegistrarService,
|
||||
);
|
||||
|
||||
@@ -60,8 +60,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
trekPlacesSearch: vi.fn(async (): Promise<unknown[]> => []),
|
||||
}));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import {
|
||||
AmapPlacesProvider,
|
||||
@@ -74,6 +72,8 @@ import { isGooglePlaceId } from '../../../src/nest/maps/maps.helpers';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
|
||||
// resolveMapsKey/resolveAmapKey (maps.service.ts) now read AppSettingsRepository/
|
||||
// UsersRepository directly (Plan 3a Task 5's instance-api-keys.ts conversion)
|
||||
@@ -81,8 +81,12 @@ import type { UsersRepository } from '../../../src/db/repositories/Users.reposit
|
||||
// the SAME mockInstanceGet/mockDbGet seams the rest of this file already
|
||||
// controls into the new repository methods, so every existing keys()/
|
||||
// mockInstanceGet/mockProviderGet call below keeps its meaning unchanged.
|
||||
// `places_provider` keeps its own dedicated mockProviderGet seam, per the
|
||||
// pre-conversion raw-SQL mock's own `args[0] === 'places_provider'` branch —
|
||||
// MAP2 (`placesProviderChoice`) reads that key through this stub now.
|
||||
const appSettingsStub = {
|
||||
getValue: async (key: string) => (mockInstanceGet(key) as { value: string | null } | undefined)?.value ?? null,
|
||||
getValue: async (key: string) =>
|
||||
(key === 'places_provider' ? mockProviderGet(key) : (mockInstanceGet(key) as { value: string | null } | undefined))?.value ?? null,
|
||||
} as unknown as AppSettingsRepository;
|
||||
const usersStub = {
|
||||
getApiKeyColumn: async (userId: number, name: 'maps_api_key' | 'amap_api_key') => {
|
||||
@@ -91,6 +95,24 @@ const usersStub = {
|
||||
},
|
||||
} as unknown as UsersRepository;
|
||||
|
||||
// Plan 3h Task 4 (R8/MAP9): the SAME mockDbGet/mockDbRun-preserving shape
|
||||
// maps.service.test.ts's own stubs use for `place_details_cache`/`places`.
|
||||
const placeDetailsCacheStub = {
|
||||
findEntry: async (placeId: string, lang: string, _kind: number) => {
|
||||
const row = mockDbGet(placeId, lang) as { payload_json: string; fetched_at: number } | undefined;
|
||||
return row ? { payload_json: row.payload_json, fetched_at: row.fetched_at } : null;
|
||||
},
|
||||
upsertEntry: async (row: { place_id: string; lang: string; expanded: number; payload_json: string; fetched_at: number }) => {
|
||||
mockDbRun(row.place_id, row.lang, row.payload_json, row.fetched_at);
|
||||
},
|
||||
} as unknown as PlaceDetailsCacheRepository;
|
||||
const placesStub = {
|
||||
setImageUrlIfUnset: async (google_place_id: string, image_url: string) => {
|
||||
mockDbRun(image_url, google_place_id);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as PlacesRepository;
|
||||
|
||||
const photoCacheStub = {
|
||||
get: vi.fn(() => null),
|
||||
getErrored: vi.fn(() => false),
|
||||
@@ -101,7 +123,7 @@ const photoCacheStub = {
|
||||
serveKey: vi.fn(() => null),
|
||||
} as unknown as PlacePhotoCacheService;
|
||||
|
||||
const svc = new MapsService(new DatabaseService(db as never), photoCacheStub, appSettingsStub, usersStub);
|
||||
const svc = new MapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub);
|
||||
|
||||
/** A provider over a fixed key, which is all these cases need. */
|
||||
function provider(): AmapPlacesProvider {
|
||||
|
||||
@@ -18,7 +18,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
@@ -59,8 +58,7 @@ afterEach(() => {
|
||||
function make(enabled = true) {
|
||||
if (enabled) delete process.env.TREK_PLACES_ENABLED;
|
||||
else process.env.TREK_PLACES_ENABLED = 'false';
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
|
||||
return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -24,7 +24,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
@@ -90,8 +89,7 @@ const osmHit = (over: Record<string, unknown> = {}) => ({
|
||||
*/
|
||||
function make(enabled = true) {
|
||||
trekPlaces.on = enabled;
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
|
||||
return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -23,11 +23,9 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
SsrfBlockedError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
|
||||
const svcOf = () => new MapsService(new DatabaseService(db as never), {} as never, {} as never, {} as never);
|
||||
const svcOf = () => new MapsService({} as never, {} as never, {} as never, {} as never, {} as never);
|
||||
|
||||
const filePage = (over: Record<string, unknown> = {}) => ({
|
||||
pageid: 4711,
|
||||
|
||||
@@ -31,7 +31,6 @@ vi.mock('../../../src/nest/geo/nominatim.client', async (importOriginal) => ({
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
@@ -72,8 +71,7 @@ afterEach(() => {
|
||||
function make(enabled = true) {
|
||||
if (enabled) delete process.env.TREK_PLACES_ENABLED;
|
||||
else process.env.TREK_PLACES_ENABLED = 'false';
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
|
||||
return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -18,7 +18,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
@@ -45,8 +44,7 @@ const PLACE = {
|
||||
};
|
||||
|
||||
function make(osmTags: Record<string, string> | null) {
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
const svc = new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
|
||||
const svc = new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never);
|
||||
vi.spyOn(svc, 'resolveOsmIdentity').mockResolvedValue(
|
||||
osmTags ? { tags: osmTags, osmUrl: 'https://www.openstreetmap.org/node/1', matchedName: "L'Osteria" } : null,
|
||||
);
|
||||
@@ -111,8 +109,7 @@ describe('MapsService.getPlaceDetails for a gers: id', () => {
|
||||
// only adds what OSM knows about the same building. Letting its failure
|
||||
// through would turn a working answer into an error for the one user whose
|
||||
// details request happened to land while Overpass was unreachable.
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
const svc = new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
|
||||
const svc = new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never);
|
||||
vi.spyOn(svc, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down'));
|
||||
mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } });
|
||||
|
||||
|
||||
@@ -19,12 +19,10 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
SsrfBlockedError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { toWikiLang, haversineMetres, namesOverlap } from '../../../src/nest/maps/maps.helpers';
|
||||
|
||||
const svcOf = () => new MapsService(new DatabaseService(db as never), {} as never, {} as never, {} as never);
|
||||
const svcOf = () => new MapsService({} as never, {} as never, {} as never, {} as never, {} as never);
|
||||
|
||||
// The Brandenburg Gate and the underground station named after it, 250m apart.
|
||||
const GATE = { lat: 52.5163, lng: 13.3777 };
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => (
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
@@ -116,8 +115,7 @@ afterEach(() => {
|
||||
function make(enabled = true) {
|
||||
if (enabled) delete process.env.TREK_PLACES_ENABLED;
|
||||
else process.env.TREK_PLACES_ENABLED = 'false';
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
|
||||
return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never);
|
||||
}
|
||||
|
||||
// Overpass is the one network call this file must never make; stubbing it is
|
||||
|
||||
@@ -31,7 +31,6 @@ vi.mock('../../../src/nest/geo/nominatim.client', async (importOriginal) => ({
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
@@ -87,8 +86,7 @@ const osmAnswer = (rows: unknown[]) => ({ ok: true, json: async () => rows });
|
||||
*/
|
||||
function make(enabled = true) {
|
||||
trekPlaces.on = enabled;
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
|
||||
return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -169,12 +169,12 @@ const photoCacheStub = {
|
||||
serveKey: (placeId: string) => mockServeFilePath(placeId),
|
||||
} as unknown as PlacePhotoCacheService;
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { MapsService, withPhotoFetchSlot, readWikiIdentity } from '../../../src/nest/maps/maps.service';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
// Type-only, so the module stays mocked: this import is erased at runtime.
|
||||
import type { SsrfResult } from '../../../src/utils/ssrfGuard';
|
||||
|
||||
@@ -183,9 +183,14 @@ import type { SsrfResult } from '../../../src/utils/ssrfGuard';
|
||||
// mocked db module above — these two stubs wire the SAME mockInstanceGet/
|
||||
// mockDbGet seams the rest of this file already controls into the new
|
||||
// repository methods, so every existing mockInstanceGet/mockDbGet call below
|
||||
// keeps its meaning unchanged.
|
||||
// keeps its meaning unchanged. `places_provider` keeps its own dedicated
|
||||
// mockProviderGet seam (mirroring the pre-conversion raw-SQL mock's own
|
||||
// `args[0] === 'places_provider'` branch) — MAP2 (`placesProviderChoice`)
|
||||
// reads that key through this SAME stub now, and the amap-provider-choice
|
||||
// suite (`mockProviderGet.mockReturnValue(...)`) still drives it.
|
||||
const appSettingsStub = {
|
||||
getValue: async (key: string) => (mockInstanceGet(key) as { value: string | null } | undefined)?.value ?? null,
|
||||
getValue: async (key: string) =>
|
||||
(key === 'places_provider' ? mockProviderGet(key) : (mockInstanceGet(key) as { value: string | null } | undefined))?.value ?? null,
|
||||
} as unknown as AppSettingsRepository;
|
||||
const usersStub = {
|
||||
getApiKeyColumn: async (userId: number, name: 'maps_api_key' | 'amap_api_key') => {
|
||||
@@ -194,10 +199,35 @@ const usersStub = {
|
||||
},
|
||||
} as unknown as UsersRepository;
|
||||
|
||||
// The service under test, constructed over the mocked db stub — DatabaseService
|
||||
// routes get/run through the stubbed prepare(), so mockDbGet/mockDbRun keep
|
||||
// flowing exactly as they did for the legacy module.
|
||||
const svc = new MapsService(new DatabaseService(db as never), photoCacheStub, appSettingsStub, usersStub);
|
||||
// Plan 3h Task 4 (R8/MAP9): MAP3-8 (`place_details_cache`) and MAP9
|
||||
// (`places.image_url`) used to be raw `this.database.get`/`.run` calls,
|
||||
// intercepted by the SAME mockDbGet/mockDbRun seams every other bare `db.get`/
|
||||
// `db.run` call in this file already flows through. These stubs preserve that
|
||||
// exact positional-argument shape (the SQL text itself was never bound, so
|
||||
// dropping it costs nothing) so every existing mockDbGet/mockDbRun
|
||||
// configuration and assertion below keeps its meaning unchanged.
|
||||
const placeDetailsCacheStub = {
|
||||
findEntry: async (placeId: string, lang: string, _kind: number) => {
|
||||
const row = mockDbGet(placeId, lang) as { payload_json: string; fetched_at: number } | undefined;
|
||||
return row ? { payload_json: row.payload_json, fetched_at: row.fetched_at } : null;
|
||||
},
|
||||
upsertEntry: async (row: { place_id: string; lang: string; expanded: number; payload_json: string; fetched_at: number }) => {
|
||||
mockDbRun(row.place_id, row.lang, row.payload_json, row.fetched_at);
|
||||
},
|
||||
} as unknown as PlaceDetailsCacheRepository;
|
||||
const placesStub = {
|
||||
setImageUrlIfUnset: async (google_place_id: string, image_url: string) => {
|
||||
mockDbRun(image_url, google_place_id);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as PlacesRepository;
|
||||
|
||||
// The service under test, constructed over the mocked seams above — every
|
||||
// collaborator that used to reach the mocked db module directly now routes
|
||||
// through a repository stub that flows into the SAME mockDbGet/mockDbRun/
|
||||
// mockInstanceGet/mockProviderGet functions, so they keep firing exactly as
|
||||
// they did for the legacy module.
|
||||
const svc = new MapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub);
|
||||
|
||||
/**
|
||||
* Switch the TREK Places index off for one case.
|
||||
@@ -2672,14 +2702,14 @@ describe('searchOverpassPois all-endpoints-down', () => {
|
||||
|
||||
// ── Wrapper surface (kept from the pre-fold wrapper suite) ────────────────────
|
||||
|
||||
/** A DatabaseService stub whose get() returns the row the test wants. */
|
||||
function makeSettingsDb(row?: { value: string }) {
|
||||
const get = vi.fn(() => row);
|
||||
return { db: { get } as unknown as DatabaseService, get };
|
||||
/** An AppSettingsRepository stub whose getValue() returns the row's value the test wants. */
|
||||
function makeSettingsRepo(row?: { value: string }) {
|
||||
const getValue = vi.fn(async (_key: string) => row?.value ?? null);
|
||||
return { repo: { getValue } as unknown as AppSettingsRepository, getValue };
|
||||
}
|
||||
|
||||
function settingsSvc(row?: { value: string }) {
|
||||
return new MapsService(makeSettingsDb(row).db, photoCacheStub, appSettingsStub, usersStub);
|
||||
return new MapsService(photoCacheStub, makeSettingsRepo(row).repo, usersStub, placeDetailsCacheStub, placesStub);
|
||||
}
|
||||
|
||||
describe('kill-switch settings reads', () => {
|
||||
@@ -2702,14 +2732,14 @@ describe('kill-switch settings reads', () => {
|
||||
});
|
||||
|
||||
it('queries the matching app_settings key', async () => {
|
||||
const { db: settingsDb, get } = makeSettingsDb({ value: 'true' });
|
||||
const s = new MapsService(settingsDb, photoCacheStub, appSettingsStub, usersStub);
|
||||
const { repo: settingsRepo, getValue } = makeSettingsRepo({ value: 'true' });
|
||||
const s = new MapsService(photoCacheStub, settingsRepo, usersStub, placeDetailsCacheStub, placesStub);
|
||||
await s.autocompleteDisabled();
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('app_settings'), 'places_autocomplete_enabled');
|
||||
expect(getValue).toHaveBeenCalledWith('places_autocomplete_enabled');
|
||||
await s.detailsDisabled();
|
||||
expect(get).toHaveBeenCalledWith(expect.any(String), 'places_details_enabled');
|
||||
expect(getValue).toHaveBeenCalledWith('places_details_enabled');
|
||||
await s.photosDisabled();
|
||||
expect(get).toHaveBeenCalledWith(expect.any(String), 'places_photos_enabled');
|
||||
expect(getValue).toHaveBeenCalledWith('places_photos_enabled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3401,7 +3431,7 @@ describe('readWikiIdentity', () => {
|
||||
describe('brandLogo', () => {
|
||||
// A fresh service per case: the logo cache lives on the instance, and a hit from
|
||||
// one case would answer the next one's question before its fetch stub ran.
|
||||
const service = (): MapsService => new MapsService(new DatabaseService(db as never), photoCacheStub, appSettingsStub, usersStub);
|
||||
const service = (): MapsService => new MapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub);
|
||||
|
||||
const claimResponse = (file: string | null) => ({
|
||||
ok: true,
|
||||
|
||||
@@ -128,7 +128,7 @@ const dbs = new DatabaseService(testDb);
|
||||
const placesStorageFx = makeStorageFixture('');
|
||||
|
||||
async function makePlacesService(
|
||||
maps: MapsService = new MapsService(dbs, photoCacheStub, noAppSettings, noUsers),
|
||||
maps: MapsService = new MapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never),
|
||||
): Promise<PlacesService> {
|
||||
return new PlacesService(
|
||||
dbs,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { RouteUsageService, RETENTION_DAYS } from '../../../src/nest/route-usage/route-usage.service';
|
||||
import type { RouteUsageEntry } from '@trek/shared';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { RouteUsageDaily } from '../../../src/db/entities/RouteUsageDaily.entity';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
|
||||
/**
|
||||
* SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router".
|
||||
@@ -27,16 +29,15 @@ function makeDb(): Database.Database {
|
||||
return db;
|
||||
}
|
||||
|
||||
/** The slice of DatabaseService this domain uses. */
|
||||
/**
|
||||
* Plan 3h Task 4 (R4/RU2): the real `RouteUsageDailyRepository`/
|
||||
* `AppSettingsRepository`, over the SAME MikroORM `createTestUnitOfWork`
|
||||
* shares (`sharedTestOrm`, one ORM per handle) — the additive-counter Kysely
|
||||
* upsert (RU2) needs a genuine MikroORM/Kysely connection, not a raw bridge.
|
||||
*/
|
||||
async function serviceOver(db: Database.Database): Promise<RouteUsageService> {
|
||||
const bridge = {
|
||||
get: <T>(sql: string, ...p: unknown[]) => db.prepare(sql).get(...p) as T | undefined,
|
||||
all: <T>(sql: string, ...p: unknown[]) => db.prepare(sql).all(...p) as T[],
|
||||
run: (sql: string, ...p: unknown[]) => db.prepare(sql).run(...p),
|
||||
};
|
||||
// The batch transaction runs through MikroORM on this very handle now, so the
|
||||
// raw statements inside the callback still land inside it.
|
||||
return new RouteUsageService(bridge as never, await createTestUnitOfWork(db));
|
||||
const t = await sharedTestOrm(db);
|
||||
return new RouteUsageService(t.repo(RouteUsageDaily), t.repo(AppSettings), await createTestUnitOfWork(db));
|
||||
}
|
||||
|
||||
const entry = (over: Partial<RouteUsageEntry> = {}): RouteUsageEntry => ({
|
||||
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTripMembersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo, createTestPlaceDetailsCacheRepo,
|
||||
createTestReservationsRepo,
|
||||
createTestReservationEndpointsRepo,
|
||||
createTestReservationTravelersRepo,
|
||||
@@ -153,7 +153,7 @@ beforeAll(async () => {
|
||||
);
|
||||
placesSvc = new PlacesService(
|
||||
dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(),
|
||||
new MapsService(dbs(), photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection)), new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
|
||||
new MapsService(photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestPlaceDetailsCacheRepo(dbs().connection), await createTestPlacesRepo(dbs().connection)), new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache,
|
||||
new JourneyDomainService(
|
||||
dbs(), new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), await createTestJourneyPhotosRepo(dbs().connection), dbs()), await createTestUnitOfWork(dbs().connection),
|
||||
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm,
|
||||
createTestDaysRepo, createTestDayAssignmentsRepo, createTestDayNotesRepo, createTestTripsRepo,
|
||||
createTestTripMembersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo,
|
||||
createTestGooglePlacePhotoMetaRepo, createTestPlacesRepo, createTestCategoriesRepo, createTestPlaceDetailsCacheRepo,
|
||||
createTestReservationsRepo,
|
||||
createTestReservationEndpointsRepo,
|
||||
createTestReservationTravelersRepo,
|
||||
@@ -196,7 +196,7 @@ beforeAll(async () => {
|
||||
dbs(),
|
||||
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
|
||||
new RealtimeService(),
|
||||
new MapsService(dbs(), photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection)),
|
||||
new MapsService(photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestPlaceDetailsCacheRepo(dbs().connection), await createTestPlacesRepo(dbs().connection)),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), new RuntimeEnvService(), coversFx.storage),
|
||||
photoCache,
|
||||
|
||||
@@ -38,7 +38,9 @@ async function makeImportService(): Promise<AirtrailImportService> {
|
||||
const permissions = new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection));
|
||||
const realtime = { broadcast } as unknown as RealtimeService;
|
||||
return new AirtrailImportService(
|
||||
dbs(),
|
||||
await createTestReservationsRepo(dbs().connection),
|
||||
await createTestReservationEndpointsRepo(dbs().connection),
|
||||
await createTestDaysRepo(dbs().connection),
|
||||
realtime,
|
||||
new ReservationsService(
|
||||
dbs(),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { db } from '../../../src/db/database';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
|
||||
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
@@ -25,7 +24,7 @@ let t: TestOrm;
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(db);
|
||||
svc = new AirtrailService(
|
||||
new DatabaseService(db),
|
||||
t.repo(Users),
|
||||
new AuditService(t.repo(AuditLog), t.repo(Users)),
|
||||
{ listFlights } as unknown as AirtrailClient,
|
||||
);
|
||||
|
||||
@@ -18,7 +18,9 @@ vi.mock('../../../src/nest/integrations/airtrail.mapper', () => ({
|
||||
|
||||
import { AirtrailLinkService } from '../../../src/nest/integrations/airtrail-link.service';
|
||||
import { AirtrailAuthError } from '../../../src/nest/integrations/airtrail.client';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { ReservationsRepository } from '../../../src/db/repositories/Reservations.repository';
|
||||
import type { ReservationEndpointsRepository } from '../../../src/db/repositories/ReservationEndpoints.repository';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { AirtrailSyncService } from '../../../src/nest/integrations/airtrail-sync.service';
|
||||
@@ -29,7 +31,6 @@ import type { AirtrailService } from '../../../src/nest/integrations/airtrail.se
|
||||
|
||||
const linkedRow = { id: 5, trip_id: 9, external_id: '42', external_owner_user_id: 7, sync_enabled: 1 };
|
||||
|
||||
const runSpy = vi.fn();
|
||||
const getFlight = vi.fn();
|
||||
const listFlights = vi.fn();
|
||||
const saveFlight = vi.fn();
|
||||
@@ -39,26 +40,39 @@ const updateReservation = vi.fn();
|
||||
const isAirtrailWriteEnabled = vi.fn();
|
||||
const getAirtrailCredentials = vi.fn();
|
||||
|
||||
/** Routes reads by SQL, exactly as the old db.prepare stub did. */
|
||||
let dbGet: (sql: string) => unknown;
|
||||
let dbAll: (sql: string) => unknown[];
|
||||
// The Plan 3h Task 4 conversion's repository-shaped seams, replacing the old
|
||||
// "route reads by SQL text" db.prepare stub: same fixtures, same test intent
|
||||
// (the write gate + the #1535 multi-leg guard), asserted against the
|
||||
// repository METHOD now instead of a literal SQL fragment.
|
||||
const getValueMock = vi.fn<(key: string) => string | null>();
|
||||
const countEndpointsMock = vi.fn<(filter: unknown) => number>();
|
||||
const findAirtrailLinkedMock = vi.fn<(id: number) => typeof linkedRow | undefined>();
|
||||
const detachSpy = vi.fn<(id: number) => void>();
|
||||
const setAirtrailSyncStampSpy = vi.fn();
|
||||
const listSyncCandidatesMock = vi.fn<(ownerId: number) => unknown[]>();
|
||||
|
||||
function makeServices(): { link: AirtrailLinkService; sync: AirtrailSyncService } {
|
||||
const db = {
|
||||
get: (sql: string) => dbGet(sql),
|
||||
all: (sql: string) => dbAll(sql),
|
||||
run: (sql: string, ...args: unknown[]) => {
|
||||
runSpy(sql, args);
|
||||
return {};
|
||||
},
|
||||
} as unknown as DatabaseService;
|
||||
const reservationsRepo = {
|
||||
findAirtrailLinked: (id: number) => Promise.resolve(findAirtrailLinkedMock(id)),
|
||||
setAirtrailSyncDisabled: (id: number) => { detachSpy(id); return Promise.resolve(); },
|
||||
setAirtrailSyncStamp: (...args: unknown[]) => { setAirtrailSyncStampSpy(...args); return Promise.resolve(); },
|
||||
listAirtrailSyncCandidatesForOwner: (ownerId: number) => Promise.resolve(listSyncCandidatesMock(ownerId)),
|
||||
} as unknown as ReservationsRepository;
|
||||
const endpointsRepo = {
|
||||
count: (filter: unknown) => Promise.resolve(countEndpointsMock(filter)),
|
||||
} as unknown as ReservationEndpointsRepository;
|
||||
const appSettings = {
|
||||
getValue: (key: string) => Promise.resolve(getValueMock(key)),
|
||||
} as unknown as AppSettingsRepository;
|
||||
|
||||
const client = { getFlight, listFlights, saveFlight } as unknown as AirtrailClient;
|
||||
const airtrail = { isAirtrailWriteEnabled, getAirtrailCredentials } as unknown as AirtrailService;
|
||||
// The push and the shared link lifecycle live on AirtrailLinkService since the
|
||||
// core/pull split (which retired airtrail.bridge); the pull delegates to it.
|
||||
const link = new AirtrailLinkService(
|
||||
db,
|
||||
reservationsRepo,
|
||||
endpointsRepo,
|
||||
appSettings,
|
||||
{ broadcast: vi.fn() } as unknown as RealtimeService,
|
||||
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
|
||||
{ getReservationWithJoins } as unknown as ReservationsReadService,
|
||||
@@ -66,7 +80,7 @@ function makeServices(): { link: AirtrailLinkService; sync: AirtrailSyncService
|
||||
airtrail,
|
||||
);
|
||||
const sync = new AirtrailSyncService(
|
||||
db,
|
||||
reservationsRepo,
|
||||
link,
|
||||
{ getReservation, update: updateReservation } as unknown as ReservationsService,
|
||||
client,
|
||||
@@ -81,13 +95,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Global sync setting, the linked reservation row and the endpoint count the
|
||||
// multi-leg guard checks (#1535) — two = plain from/to.
|
||||
dbGet = (sql: string) => {
|
||||
if (sql.includes('app_settings')) return { value: 'true' };
|
||||
if (sql.includes('FROM reservation_endpoints')) return { n: 2 };
|
||||
if (sql.includes('FROM reservations')) return { ...linkedRow };
|
||||
return undefined;
|
||||
};
|
||||
dbAll = () => [];
|
||||
getValueMock.mockReturnValue('true');
|
||||
countEndpointsMock.mockReturnValue(2);
|
||||
findAirtrailLinkedMock.mockReturnValue({ ...linkedRow });
|
||||
listSyncCandidatesMock.mockReturnValue([]);
|
||||
svc = makeServices();
|
||||
|
||||
getAirtrailCredentials.mockReturnValue({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false });
|
||||
@@ -113,7 +124,8 @@ describe('pushReservationToAirtrail write gate (#1240)', () => {
|
||||
await svc.link.pushReservationToAirtrail(5, 9);
|
||||
expect(getFlight).not.toHaveBeenCalled();
|
||||
expect(saveFlight).not.toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled(); // no detach, no hash write — pure no-op
|
||||
expect(detachSpy).not.toHaveBeenCalled(); // no detach, no hash write — pure no-op
|
||||
expect(setAirtrailSyncStampSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes back, preserving AirTrail-owned fields, when the owner has opted in', async () => {
|
||||
@@ -127,16 +139,11 @@ describe('pushReservationToAirtrail write gate (#1240)', () => {
|
||||
|
||||
it('#1535 detaches instead of pushing when the reservation grew extra stops', async () => {
|
||||
isAirtrailWriteEnabled.mockReturnValue(true);
|
||||
dbGet = (sql: string) => {
|
||||
if (sql.includes('app_settings')) return { value: 'true' };
|
||||
if (sql.includes('FROM reservation_endpoints')) return { n: 3 }; // from + stop + to
|
||||
if (sql.includes('FROM reservations')) return { ...linkedRow };
|
||||
return undefined;
|
||||
};
|
||||
countEndpointsMock.mockReturnValue(3); // from + stop + to
|
||||
await svc.link.pushReservationToAirtrail(5, 9);
|
||||
// Pushing would rewrite the single AirTrail flight to span the whole route.
|
||||
expect(saveFlight).not.toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [5]);
|
||||
expect(detachSpy).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('#1535 detaches on metadata.legs even when the endpoint count is not available', async () => {
|
||||
@@ -149,7 +156,7 @@ describe('pushReservationToAirtrail write gate (#1240)', () => {
|
||||
});
|
||||
await svc.link.pushReservationToAirtrail(5, 9);
|
||||
expect(saveFlight).not.toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [5]);
|
||||
expect(detachSpy).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('detaches when the owner key stopped working, rather than retrying forever', async () => {
|
||||
@@ -157,7 +164,7 @@ describe('pushReservationToAirtrail write gate (#1240)', () => {
|
||||
getFlight.mockRejectedValue(new AirtrailAuthError('invalid key'));
|
||||
await svc.link.pushReservationToAirtrail(5, 9);
|
||||
expect(saveFlight).not.toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [5]);
|
||||
expect(detachSpy).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('detaches when the flight is gone from AirTrail — the same as a remote delete', async () => {
|
||||
@@ -165,19 +172,15 @@ describe('pushReservationToAirtrail write gate (#1240)', () => {
|
||||
getFlight.mockResolvedValue(null);
|
||||
await svc.link.pushReservationToAirtrail(5, 9);
|
||||
expect(saveFlight).not.toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [5]);
|
||||
expect(detachSpy).toHaveBeenCalledWith(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inbound sync multi-leg guard (#1535)', () => {
|
||||
function withLinkedRow(endpointCount: number) {
|
||||
dbGet = (sql: string) => {
|
||||
if (sql.includes('app_settings')) return { value: 'true' };
|
||||
if (sql.includes('FROM reservation_endpoints')) return { n: endpointCount };
|
||||
return undefined;
|
||||
};
|
||||
dbAll = (sql: string) =>
|
||||
sql.includes('sync_enabled = 1') ? [{ id: 5, trip_id: 9, external_id: '42', external_hash: 'stale' }] : [];
|
||||
getValueMock.mockReturnValue('true');
|
||||
countEndpointsMock.mockReturnValue(endpointCount);
|
||||
listSyncCandidatesMock.mockReturnValue([{ id: 5, trip_id: 9, external_id: '42', external_hash: 'stale' }]);
|
||||
svc = makeServices();
|
||||
}
|
||||
|
||||
@@ -191,7 +194,7 @@ describe('inbound sync multi-leg guard (#1535)', () => {
|
||||
|
||||
const { changed } = await svc.sync.runAirtrailSyncForUser(7);
|
||||
expect(updateReservation).not.toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [5]);
|
||||
expect(detachSpy).toHaveBeenCalledWith(5);
|
||||
expect(changed).toBe(1);
|
||||
});
|
||||
|
||||
@@ -202,7 +205,7 @@ describe('inbound sync multi-leg guard (#1535)', () => {
|
||||
|
||||
await svc.sync.runAirtrailSyncForUser(7);
|
||||
expect(updateReservation).toHaveBeenCalledTimes(1);
|
||||
expect(runSpy).not.toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), expect.anything());
|
||||
expect(detachSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detaches a flight that vanished from AirTrail, keeping the TREK row', async () => {
|
||||
@@ -210,7 +213,7 @@ describe('inbound sync multi-leg guard (#1535)', () => {
|
||||
listFlights.mockResolvedValue([]); // the linked id is no longer there
|
||||
const { changed } = await svc.sync.runAirtrailSyncForUser(7);
|
||||
expect(updateReservation).not.toHaveBeenCalled();
|
||||
expect(runSpy).toHaveBeenCalledWith(expect.stringContaining('sync_enabled = 0'), [5]);
|
||||
expect(detachSpy).toHaveBeenCalledWith(5);
|
||||
expect(changed).toBe(1);
|
||||
});
|
||||
|
||||
@@ -219,7 +222,7 @@ describe('inbound sync multi-leg guard (#1535)', () => {
|
||||
getAirtrailCredentials.mockReturnValue(null);
|
||||
const { changed } = await svc.sync.runAirtrailSyncForUser(7);
|
||||
expect(listFlights).not.toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
expect(detachSpy).not.toHaveBeenCalled();
|
||||
expect(changed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import { db } from '../../../src/db/database';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
|
||||
import { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AuditService } from '../../../src/nest/database/../audit/audit.service';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
@@ -27,7 +26,7 @@ let t: TestOrm;
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(db);
|
||||
svc = new AirtrailService(
|
||||
new DatabaseService(db),
|
||||
t.repo(Users),
|
||||
new AuditService(t.repo(AuditLog), t.repo(Users)),
|
||||
new AirtrailClient(),
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
createTestAssignmentParticipantsRepo,
|
||||
createTestGooglePlacePhotoMetaRepo,
|
||||
createTestPlacesRepo,
|
||||
createTestPlaceDetailsCacheRepo,
|
||||
createTestTripMembersRepo,
|
||||
createTestDayAssignmentsRepo,
|
||||
createTestCategoriesRepo,
|
||||
@@ -133,7 +134,7 @@ beforeAll(async () => {
|
||||
dbs,
|
||||
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)),
|
||||
realtime,
|
||||
new MapsService(dbs, photoCache, await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection)),
|
||||
new MapsService(photoCache, await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestPlaceDetailsCacheRepo(dbs.connection), await createTestPlacesRepo(dbs.connection)),
|
||||
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
|
||||
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), runtimeEnv, makeStorageFixture('').storage),
|
||||
photoCache,
|
||||
|
||||
Reference in New Issue
Block a user