feat(server): add the schedule wiring

This commit is contained in:
jubnl
2026-08-10 21:22:13 +02:00
parent ba7d55f999
commit 399bbda0fa
8 changed files with 407 additions and 58 deletions
@@ -21,6 +21,11 @@ export class RuntimeEnvService {
return readEnv().demo.enabled;
}
/** True only under exactly NODE_ENV='test' (case-sensitive, like the :memory: DB pick). */
isTest(): boolean {
return readEnv().app.isTest;
}
nodeEnv(): string | undefined {
return readEnv().app.nodeEnv;
}
+2 -1
View File
@@ -54,6 +54,7 @@ import { AddonsModule } from './addons/addons.module';
import { AuditModule } from './audit/audit.module';
import { PermissionsModule } from './permissions/permissions.module';
import { PluginsModule } from './plugins/plugins.module';
import { SchedulingModule } from './scheduling/scheduling.module';
import { McpModule } from '@trek/nest-mcp';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-policy';
import { TrekExceptionFilter } from './common/trek-exception.filter';
@@ -66,7 +67,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -0,0 +1,80 @@
import { Injectable, type OnApplicationShutdown } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { readEnv } from '../../app-config';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
/**
* The one way TREK code schedules a cron. Job providers register here from
* their onApplicationBootstrap instead of touching cron/@nestjs/schedule
* directly, because this is where two process-wide invariants live:
*
* - Tests never tick. buildApp() is shared with the integration/e2e harness,
* so onApplicationBootstrap runs on every suite boot; register() refuses to
* schedule when NODE_ENV is 'test' (readEnv().app.isTest, read at register
* time — tests/setup.ts pins NODE_ENV='test' before any app boots).
* Providers gate their boot sweeps and banner logs behind isEnabled() for
* the same reason.
* - Shutdown stops everything. @nestjs/schedule's orchestrator only closes
* jobs declared via @Cron decorators, never registry-added ones, so this
* service stops its own in onApplicationShutdown — fired by nestApp.close()
* in prod and by the suites' afterAll close() alike.
*
* The timezone is resolved per register() call (readEnv().app.tz || 'UTC'),
* keeping the retired scheduler's read-at-schedule-time behavior: a dynamic
* job that re-registers picks up a changed TZ. timezone: 'none' schedules in
* the server-local zone (the demo reset and the trek-photo sweep always ran
* that way).
*/
@Injectable()
export class CronRegistrarService implements OnApplicationShutdown {
/** Names this service registered — the registry may hold other owners' jobs. */
private readonly names = new Set<string>();
constructor(
private readonly registry: SchedulerRegistry,
private readonly runtimeEnv: RuntimeEnvService,
) {}
/** False under NODE_ENV=test — the single gate keeping the suites timer-free. */
isEnabled(): boolean {
return !this.runtimeEnv.isTest();
}
/**
* Schedule a cron. An existing job under the same name is stopped and
* replaced, so a dynamic job restarts by calling register() again.
* Returns false without scheduling anything when isEnabled() is false.
*/
register(
name: string,
expression: string,
onTick: () => void | Promise<void>,
opts?: { timezone?: 'app' | 'none' },
): boolean {
this.unregister(name);
if (!this.isEnabled()) return false;
const timeZone = opts?.timezone === 'none' ? undefined : readEnv().app.tz || 'UTC';
const job = CronJob.from({ cronTime: expression, onTick, start: true, timeZone });
this.registry.addCronJob(name, job);
this.names.add(name);
return true;
}
/** Stop and drop a job by name. A name that was never registered is a no-op. */
unregister(name: string): void {
if (!this.names.has(name)) return;
this.registry.deleteCronJob(name); // deleteCronJob stops the job before dropping it
this.names.delete(name);
}
get jobCount(): number {
return this.names.size;
}
onApplicationShutdown(): void {
for (const name of [...this.names]) {
this.unregister(name);
}
}
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { AppConfigModule } from '../app-config/app-config.module';
import { CronRegistrarService } from './cron-registrar.service';
/**
* Shared cron infrastructure — deliberately NOT @Global. The e2e suites boot
* partial module graphs (DatabaseModule + one domain module), where a global
* module absent from the compiled tree resolves nothing: every module that
* owns a job imports SchedulingModule explicitly, so its TestingModule keeps
* compiling. ScheduleModule.forRoot() lives here (not app.module.ts) for the
* same reason, and AppConfigModule is imported so RuntimeEnvService resolves
* in those partial graphs too.
*
* No @Cron decorators exist anywhere in the tree — jobs go through
* CronRegistrarService so the test gate and shutdown semantics apply. Keep it
* that way: a decorator-declared job would be mounted by the schedule
* explorer on every harness boot, gate or no gate.
*/
@Module({
imports: [ScheduleModule.forRoot(), AppConfigModule],
providers: [CronRegistrarService],
exports: [CronRegistrarService],
})
export class SchedulingModule {}