Files
TREK/server/src/nest/plugins/contributions/day-schedule.controller.ts
T
Maurice 6680bc7c74 Split PluginsModule into four, and gate coverage per domain
PluginsModule was 21 controllers, 24 domain imports and 10 providers in one
class, which made it the hub most of the Nest graph ran through: AdminModule
imported it for a single cascade-disable call and inherited all of it. It is now
PluginsRuntimeModule (supervisor, capability router, hook contracts),
PluginContributionsModule (the 14 read-only hook controllers, moved to
contributions/), PluginOAuthModule (the credential leaf, moved to oauth/) and a
composition root that keeps the install and delivery controllers. AdminModule
imports the runtime alone.

The three plugin files that reached for the db singleton alongside the injected
DatabaseService no longer do. plugin-host-state and signature-status take the
connection from the caller, and the three per-user settings functions that were
bare exports 'so the RPC host wiring can call them directly' are now
PluginUserSettingsService - the host wiring is providers, so it injects them.

Coverage moves from one repo-wide 80% to a per-domain ratchet. The single number
let a well-covered domain subsidise a thin one: booking-import sits at 50% and
integrations at 20%, and the aggregate still cleared the bar, so either could
have lost another ten points unnoticed. Each domain now carries its own measured
floor, regenerated by scripts/coverage-thresholds.mjs.

Two test-side findings on the way:

expect(undefined).toContain(SomeClass) PASSES in this vitest version - it only
throws for a string needle. Every '<X> is listed in its module providers' guard
was written that way, so all twelve of them silently stopped guarding the moment
their class moved. They go through expectRegisteredProvider now, which asserts
the array shape first.

The atlas region test paid for the one-off decompression of the multi-MB admin1
bundle inside its own 5s budget, which is why it failed under load and passed
alone. The bundle is warmed in beforeAll instead.
2026-08-09 18:44:59 +02:00

114 lines
4.8 KiB
TypeScript

import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../../database/database.service';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
import { stripEmoji } from '../text-sanitize';
/**
* GET /api/day-schedule/:tripId — bounded time contributions plugins attach to the
* day plan via the `dayScheduleProvider` hook: "35 min charging at this stop",
* "45 min security before this flight". The planner renders them as host-drawn
* rows anchored to an itinerary item (or the start/end of a day) and folds the
* minutes into the day's route-footer total — the first hook whose output feeds
* displayed timing, which is why `minutes` is clamped to a day.
*
* Same contract as the other provider hooks: additive + fail-safe, declarative
* data only, strings sanitized + capped, counts budgeted per provider. dayIds
* are checked against the trip so a stale/hand-edited contribution can never
* anchor to another trip's day.
*/
type Tone = 'default' | 'success' | 'warn' | 'danger';
export interface DayScheduleItem {
pluginId: string;
id: string;
dayId: number;
assignmentId?: number;
reservationId?: number;
/** Where the row sits when it is not anchored to an item. */
position?: 'start' | 'end';
minutes?: number;
label: string;
tone: Tone;
}
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
const MAX_ITEMS = 60; // per provider, across the trip's days
const MAX_MINUTES = 1440; // a contribution longer than a day is nonsense data
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
// Bound the work, not just the output: an all-invalid raw array (no item ever
// increments out.length) would otherwise be iterated in full. Slice up front, well
// above any legitimate payload, mirroring the other hooks' length pre-checks.
const MAX_RAW_ITEMS = 1000;
function normalize(pluginId: string, tripDayIds: ReadonlySet<number>, raw: unknown): DayScheduleItem[] {
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>).slice(0, MAX_RAW_ITEMS) : [];
const out: DayScheduleItem[] = [];
for (const it of list) {
if (out.length >= MAX_ITEMS) break;
if (!it || typeof it !== 'object') continue;
const id = cap(it.id, 64);
const label = cap(it.label, 120);
const dayId = Number(it.dayId);
// No id, no label, or a day outside this trip — the row is meaningless, drop it.
if (!id || !label || !Number.isInteger(dayId) || !tripDayIds.has(dayId)) continue;
const assignmentId = Number.isInteger(it.assignmentId) ? (it.assignmentId as number) : undefined;
const reservationId = Number.isInteger(it.reservationId) ? (it.reservationId as number) : undefined;
const minutesRaw = Number(it.minutes);
const minutes = Number.isFinite(minutesRaw) && minutesRaw > 0 ? Math.min(Math.round(minutesRaw), MAX_MINUTES) : undefined;
out.push({
pluginId,
id,
dayId,
...(assignmentId !== undefined ? { assignmentId } : {}),
...(reservationId !== undefined ? { reservationId } : {}),
// Raw-value enum check, same rationale as the marker tone guard.
...(it.position === 'start' || it.position === 'end' ? { position: it.position } : {}),
...(minutes !== undefined ? { minutes } : {}),
label,
tone: TONES.has(it.tone as string) ? (it.tone as Tone) : 'default',
});
}
return out;
}
@Controller('api/day-schedule')
@UseGuards(JwtAuthGuard)
export class DayScheduleController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
) {}
@Get(':tripId')
async get(
@Param('tripId') tripIdRaw: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ items: DayScheduleItem[] }> {
if (!pluginsEnabled()) return { items: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { items: [] };
const ids = this.hooks.providersOf('dayScheduleProvider');
if (ids.length === 0) return { items: [] };
const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>;
const tripDayIds: ReadonlySet<number> = new Set(dayRows.map((d) => d.id));
const perProvider = await Promise.all(
ids.map(async (id): Promise<DayScheduleItem[]> => {
try {
const raw = await this.hooks.daySchedule(id, tripId, userId);
return normalize(id, tripDayIds, raw);
} catch {
return []; // a slow / failing provider contributes nothing
}
}),
);
return { items: perProvider.flat() };
}
}