diff --git a/.env.example b/.env.example index 2f28c29..8e82b10 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,17 @@ -# MySQL example -DB_TYPE=mysql +# Database Config (Matches mulearnbackend) +DATABASE_ENGINE=django.db.backends.mysql DATABASE_HOST=localhost DATABASE_PORT=3306 DATABASE_USER=root DATABASE_PASSWORD=your_password_here -DATABASE_NAME=scheduler +DATABASE_NAME=mulearn DB_SYNC=false DB_LOGGING=false +# Discord & Security Config +SCHEDULER_SERVICE_API_KEY=your_secure_api_key_here +DISCORD_WEBHOOK_LINK=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN + # SQLite fallback example # DB_TYPE=sqlite # DB_FILE=database.sqlite diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a28ead9..acdc719 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: Prod CI on: push: - branches: [ "main" ] + branches: [ "production" ] workflow_dispatch: jobs: diff --git a/src/app.module.ts b/src/app.module.ts index 7005531..917edbb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { SchedulerModule } from './scheduler/scheduler.module'; import { APP_GUARD } from '@nestjs/core'; -import { ApiKeyGuard } from './guards/api-key.guard'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { ApiKeyGuard } from './common/guards/api-key.guard'; +import { JobsModule } from './modules/jobs/jobs.module'; +import { GritMeterModule } from './modules/grit-meter/grit-meter.module'; @Module({ imports: [ @@ -14,11 +15,12 @@ import { ApiKeyGuard } from './guards/api-key.guard'; imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => { - const dbType = config.get('DB_TYPE'); + const dbEngine = config.get('DATABASE_ENGINE') || config.get('DB_TYPE') || 'mysql'; + const isMysql = dbEngine.includes('mysql') || dbEngine.includes('mariadb'); const entities = [__dirname + '/**/*.entity{.ts,.js}']; - if (dbType !== 'mysql') { - throw new Error('Only MySQL database is supported. Please check DB_TYPE in .env'); + if (!isMysql) { + throw new Error(`Unsupported database engine "${dbEngine}". Please check DATABASE_ENGINE / DB_TYPE in .env`); } return { @@ -27,14 +29,15 @@ import { ApiKeyGuard } from './guards/api-key.guard'; port: parseInt(config.get('DATABASE_PORT', '3306'), 10), username: config.get('DATABASE_USER', 'root'), password: config.get('DATABASE_PASSWORD', ''), - database: config.get('DATABASE_NAME', 'scheduler'), + database: config.get('DATABASE_NAME', 'mulearn'), entities, synchronize: config.get('DB_SYNC', 'false') === 'true', logging: config.get('DB_LOGGING', 'false') === 'true', }; }, }), - SchedulerModule, + JobsModule, + GritMeterModule, ], controllers: [AppController], providers: [ @@ -45,4 +48,4 @@ import { ApiKeyGuard } from './guards/api-key.guard'; }, ], }) -export class AppModule { } +export class AppModule {} diff --git a/src/common/guards/api-key.guard.ts b/src/common/guards/api-key.guard.ts new file mode 100644 index 0000000..58f4b01 --- /dev/null +++ b/src/common/guards/api-key.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor(private readonly configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader) { + throw new UnauthorizedException('Missing Authorization header'); + } + + const [type, token] = authHeader.split(' '); + + if (type !== 'Bearer' || !token) { + throw new UnauthorizedException('Invalid Authorization header format'); + } + + const apiKey = + this.configService.get('SCHEDULER_SERVICE_API_KEY') || + this.configService.get('API_KEY'); + + if (token !== apiKey) { + throw new UnauthorizedException('Invalid API Key'); + } + + return true; + } +} diff --git a/src/guards/api-key.guard.ts b/src/guards/api-key.guard.ts deleted file mode 100644 index ead3842..0000000 --- a/src/guards/api-key.guard.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; - -@Injectable() -export class ApiKeyGuard implements CanActivate { - constructor(private readonly configService: ConfigService) { } - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const authHeader = request.headers.authorization; - - if (!authHeader) { - throw new UnauthorizedException('Missing Authorization header'); - } - - const [type, token] = authHeader.split(' '); - - if (type !== 'Bearer' || !token) { - throw new UnauthorizedException('Invalid Authorization header format'); - } - - const apiKey = this.configService.get('API_KEY'); - - if (token !== apiKey) { - throw new UnauthorizedException('Invalid API Key'); - } - - return true; - } -} diff --git a/src/modules/grit-meter/grit-meter.controller.ts b/src/modules/grit-meter/grit-meter.controller.ts new file mode 100644 index 0000000..16f84bb --- /dev/null +++ b/src/modules/grit-meter/grit-meter.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Post } from '@nestjs/common'; +import { GritMeterService } from './grit-meter.service'; + +@Controller('grit-meter') +export class GritMeterController { + constructor(private readonly gritMeterSvc: GritMeterService) {} + + @Post('process') + async processGritMeter() { + const result = await this.gritMeterSvc.processDailyGritMeter(); + return { success: true, ...result }; + } +} diff --git a/src/modules/grit-meter/grit-meter.module.ts b/src/modules/grit-meter/grit-meter.module.ts new file mode 100644 index 0000000..4b260fd --- /dev/null +++ b/src/modules/grit-meter/grit-meter.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GritMeterService } from './grit-meter.service'; +import { GritMeterController } from './grit-meter.controller'; + +@Module({ + controllers: [GritMeterController], + providers: [GritMeterService], + exports: [GritMeterService], +}) +export class GritMeterModule {} diff --git a/src/modules/grit-meter/grit-meter.service.ts b/src/modules/grit-meter/grit-meter.service.ts new file mode 100644 index 0000000..b4b7304 --- /dev/null +++ b/src/modules/grit-meter/grit-meter.service.ts @@ -0,0 +1,179 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import * as cron from 'node-cron'; + +@Injectable() +export class GritMeterService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(GritMeterService.name); + private cronJob?: cron.ScheduledTask; + + constructor( + private readonly dataSource: DataSource, + private readonly configService: ConfigService, + ) {} + + onModuleInit() { + this.logger.log('Initializing GritMeterService cron job (00:00 UTC daily)'); + this.cronJob = cron.schedule( + '0 0 * * *', + () => { + this.processDailyGritMeter().catch((err) => + this.logger.error('Error processing daily grit meter', err), + ); + }, + { timezone: 'UTC' }, + ); + } + + onModuleDestroy() { + if (this.cronJob) { + this.cronJob.stop(); + } + } + + async processDailyGritMeter(): Promise<{ processed: number; levelDowns: number }> { + this.logger.log('Starting Daily Grit Meter / HP Processing'); + + const isEnabled = await this.checkFeatureFlag(); + if (!isEnabled) { + this.logger.warn('Grit Meter processing skipped: Feature flag "grit_meter_enabled" is OFF'); + return { processed: 0, levelDowns: 0 }; + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + + let processedCount = 0; + let levelDownCount = 0; + + try { + const userLinks: Array<{ + id: string; + user_id: string; + level_id: string; + grit: number; + level_order: number; + }> = await queryRunner.query(` + SELECT + ull.id, + ull.user_id, + ull.level_id, + COALESCE(ull.grit, 50) AS grit, + l.level_order + FROM user_lvl_link ull + JOIN level l ON ull.level_id = l.id + `); + + this.logger.log(`Found ${userLinks.length} user level records to process`); + + const webhookUrl = this.configService.get('DISCORD_WEBHOOK_LINK'); + + for (const link of userLinks) { + const activityResult: Array<{ activity_count: number }> = await queryRunner.query( + ` + SELECT COUNT(*) AS activity_count + FROM karma_activity_log + WHERE user_id = ? + AND created_at >= DATE_SUB(CURDATE(), INTERVAL 1 DAY) + AND created_at < CURDATE() + `, + [link.user_id], + ); + + const hasActivity = (activityResult[0]?.activity_count ?? 0) > 0; + let newGrit = hasActivity ? Math.min(100, link.grit + 1) : link.grit - 1; + + if (newGrit <= 0) { + if (link.level_order >= 5) { + const targetLevelOrder = link.level_order - 1; + const targetLevel: Array<{ id: string }> = await queryRunner.query( + `SELECT id FROM level WHERE level_order = ? LIMIT 1`, + [targetLevelOrder], + ); + + if (targetLevel.length > 0) { + const lowerLevelId = targetLevel[0].id; + newGrit = 100; + + await queryRunner.query( + ` + UPDATE user_lvl_link + SET level_id = ?, + grit = ?, + last_level_down_at = NOW(), + updated_at = NOW() + WHERE id = ? + `, + [lowerLevelId, newGrit, link.id], + ); + + levelDownCount++; + this.logger.log( + `User ${link.user_id} leveled down: Level ${link.level_order} -> Level ${targetLevelOrder}. Grit reset to 100%.`, + ); + + if (webhookUrl) { + await this.sendDiscordWebhook(webhookUrl, link.user_id); + } + } else { + await queryRunner.query( + `UPDATE user_lvl_link SET grit = 0, updated_at = NOW() WHERE id = ?`, + [link.id], + ); + } + } else { + await queryRunner.query( + `UPDATE user_lvl_link SET grit = 0, updated_at = NOW() WHERE id = ?`, + [link.id], + ); + } + } else { + await queryRunner.query( + `UPDATE user_lvl_link SET grit = ?, updated_at = NOW() WHERE id = ?`, + [newGrit, link.id], + ); + } + + processedCount++; + } + + this.logger.log( + `Daily Grit Meter processing complete. Processed: ${processedCount}, Level Downs: ${levelDownCount}`, + ); + } catch (err) { + this.logger.error('Failed processing daily grit meter', err); + throw err; + } finally { + await queryRunner.release(); + } + + return { processed: processedCount, levelDowns: levelDownCount }; + } + + private async checkFeatureFlag(): Promise { + try { + const result: Array<{ value: string }> = await this.dataSource.query( + `SELECT value FROM system_setting WHERE key = 'grit_meter_enabled' LIMIT 1`, + ); + if (!result || result.length === 0) { + return true; + } + return result[0].value.toLowerCase() === 'true'; + } catch (err) { + this.logger.warn('Failed to query feature flag, defaulting to true', err); + return true; + } + } + + private async sendDiscordWebhook(webhookUrl: string, userId: string): Promise { + try { + const content = `user_role<|=|>update<|=|>${userId}`; + await axios.post(webhookUrl, { content }, { timeout: 10000 }); + this.logger.log(`Discord role sync webhook sent for user ${userId}`); + } catch (err) { + this.logger.error(`Failed sending Discord webhook for user ${userId}`, err); + } + } +} diff --git a/src/scheduler/dto/create-job.dto.ts b/src/modules/jobs/dto/create-job.dto.ts similarity index 64% rename from src/scheduler/dto/create-job.dto.ts rename to src/modules/jobs/dto/create-job.dto.ts index 3a630ca..8f0e543 100644 --- a/src/scheduler/dto/create-job.dto.ts +++ b/src/modules/jobs/dto/create-job.dto.ts @@ -1,6 +1,5 @@ import { Type } from 'class-transformer'; import { - IsArray, IsBoolean, IsDateString, IsIn, @@ -9,9 +8,7 @@ import { IsObject, IsOptional, IsString, - ValidateIf, ValidateNested, - ArrayNotEmpty, IsArray as IsArrayValidator, } from 'class-validator'; @@ -25,29 +22,6 @@ class TargetDto { method: string; } -class SchedulingOneOffDto { - @IsString() - @IsNotEmpty() - timezone: string; - - @IsDateString() - execute_at: string; -} - -class SchedulingRecurringDto { - @IsString() - @IsNotEmpty() - cron: string; // basic validation only - - @IsString() - @IsOptional() - timezone?: string; - - @IsDateString() - @IsOptional() - start_time?: string; -} - class RetriesDto { @IsBoolean() @IsOptional() @@ -90,9 +64,6 @@ export class CreateJobDto { @IsOptional() body?: any; - // Scheduling can be either a one-off or recurring config depending on `type`. - // Keep as an optional free-form object to avoid mis-applied nested decorators - // that previously caused validation metadata to attach to the wrong field. @IsOptional() scheduling?: any; diff --git a/src/scheduler/entities/job.entity.ts b/src/modules/jobs/entities/job.entity.ts similarity index 100% rename from src/scheduler/entities/job.entity.ts rename to src/modules/jobs/entities/job.entity.ts diff --git a/src/scheduler/job-runner.service.ts b/src/modules/jobs/job-runner.service.ts similarity index 84% rename from src/scheduler/job-runner.service.ts rename to src/modules/jobs/job-runner.service.ts index 831a362..b0f62bc 100644 --- a/src/scheduler/job-runner.service.ts +++ b/src/modules/jobs/job-runner.service.ts @@ -9,7 +9,6 @@ import * as cron from 'node-cron'; export class JobRunnerService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(JobRunnerService.name); - // track active timers / cron jobs so we can clean up on shutdown private timers = new Map(); private cronJobs = new Map(); @@ -17,13 +16,10 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { private running = 0; private queue: (() => void)[] = []; - // track metadata about scheduled cron tasks so we can detect updates private cronMeta = new Map(); - // periodic DB sync interval private syncInterval?: NodeJS.Timeout; - private readonly SYNC_MS = parseInt(process.env.JOB_SYNC_INTERVAL_MS ?? '300000', 10); // 5 minutes by default - + private readonly SYNC_MS = parseInt(process.env.JOB_SYNC_INTERVAL_MS ?? '300000', 10); constructor( @InjectRepository(JobEntity) @@ -32,7 +28,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { async onModuleInit() { this.logger.log('JobRunner starting — scanning for scheduled jobs'); - // load scheduled jobs and schedule them const jobs = await this.repo.find(); for (const job of jobs) { if (job.type === 'oneoff') { @@ -42,7 +37,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { } } - // start periodic DB sync to pick up changes (additions, updates, deletions) this.syncWithDb().catch((err) => this.logger.error('Initial sync failed', err as any)); this.syncInterval = setInterval(() => { this.syncWithDb().catch((err) => this.logger.error('Periodic sync failed', err as any)); @@ -63,7 +57,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { return; } - // If we already have a timer for this job, skip scheduling if (this.timers.has(job.id)) { this.logger.log(`Oneoff ${job.id} already scheduled`); return; @@ -73,20 +66,16 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { const now = Date.now(); if (runAt <= now && job.status === 'scheduled') { - // run immediately this.enqueue(() => this.execute(job.id)); return; } const delay = Math.max(0, runAt - now); - // Node setTimeout cannot handle delays > 2^31-1 (about 24.8 days) const MAX_TIMEOUT = 2147483647; if (delay > MAX_TIMEOUT) { - // schedule a shorter timer to re-evaluate closer to the run time const t = setTimeout(() => { this.timers.delete(job.id); - // reschedule again (recursive) — this avoids overflow this.scheduleOneOff(job); }, Math.min(delay, MAX_TIMEOUT)); this.timers.set(job.id, t); @@ -99,7 +88,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { this.timers.set(job.id, t); this.logger.log(`Scheduled oneoff ${job.id} to run in ${delay}ms`); } - // already scheduled inside the chosen branch above } catch (err) { this.logger.error('Error scheduling oneoff', err as any); } @@ -116,7 +104,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { const timezone = job.scheduling?.timezone; const options = timezone ? { timezone } : undefined; - // If we already have a cron for this id, remove it first (handles updates) if (this.cronJobs.has(job.id)) { await this.unscheduleJob(job.id); } @@ -136,7 +123,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { private enqueue(fn: () => void) { if (this.running < this.concurrency) { this.running++; - // run immediately Promise.resolve() .then(fn) .catch((err) => this.logger.error('Queue exec error', err)) @@ -176,7 +162,7 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { params: job.params ?? {}, data: job.payload ?? job.body ?? undefined, timeout: 30000, - validateStatus: () => true, // we'll handle statuses ourselves + validateStatus: () => true, }; let responseStatus = 0; @@ -195,28 +181,22 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { const success = responseStatus > 0 && responseStatus < 400; if (success) { - // success handling job.last_error = null; if (job.type === 'oneoff') { - // mark one-off jobs as completed job.status = 'completed'; await this.repo.save(job); - - // ensure any timers/cron are cleaned up await this.unscheduleJob(job.id); } else { job.status = 'scheduled'; - job.attempt_count = 0; // reset attempts for recurring + job.attempt_count = 0; await this.repo.save(job); } - this.logger.log(`Job ${job.id} succeeded with status=${responseStatus}`); return; } - // failure job.last_error = errorMessage ?? `status:${responseStatus}`; await this.repo.save(job); @@ -228,9 +208,8 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { const isRetryable = errorMessage !== null || retryable.includes(responseStatus); if (enabled && job.attempt_count < (maxAttempts || 0) && isRetryable) { - // schedule retry with backoff const attempt = job.attempt_count; - const base = 1000; // ms + const base = 1000; const backoff = Math.pow(2, attempt) * base; const jitter = Math.floor(Math.random() * base); const delay = backoff + jitter; @@ -245,25 +224,21 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { return; } - // no more retries, mark failed job.status = 'failed'; await this.repo.save(job); this.logger.warn(`Job ${job.id} failed permanently after ${job.attempt_count} attempts`); } - // allow external creation to ask runner to schedule a created job async scheduleNewJob(job: JobEntity) { if (job.type === 'oneoff') return this.scheduleOneOff(job); return this.scheduleRecurring(job); } - // sync DB and ensure runner state reflects DB state async syncWithDb() { this.logger.log('Syncing jobs from DB'); const jobs = await this.repo.find(); const dbIds = new Set(jobs.map((j) => j.id)); - // unschedule recurrences that were removed from DB for (const id of Array.from(this.cronJobs.keys())) { if (!dbIds.has(id)) { this.logger.log(`Recurring job ${id} missing in DB — unscheduling`); @@ -271,7 +246,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { } } - // schedule new/changed jobs for (const job of jobs) { if (job.type === 'recurring') { const cronExpr = job.scheduling?.cron; @@ -282,13 +256,11 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { if (!this.cronJobs.has(job.id)) { await this.scheduleRecurring(job); } else if (!meta || meta.cron !== cronExpr || meta.timezone !== tz) { - // updated cron expression or tz await this.unscheduleJob(job.id); await this.scheduleRecurring(job); this.logger.log(`Rescheduled recurring ${job.id} due to change`); } } else if (job.type === 'oneoff') { - // schedule one-off jobs that aren't already scheduled if (job.status === 'scheduled' && !this.timers.has(job.id)) { await this.scheduleOneOff(job); } @@ -296,16 +268,13 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { } } - // allow external callers to unschedule a job (clear timers / cron tasks) async unscheduleJob(id: string) { - // clear main timer const t = this.timers.get(id); if (t) { clearTimeout(t); this.timers.delete(id); } - // clear retry timers like `${id}:retry:${attempt}` for (const key of Array.from(this.timers.keys())) { if (key.startsWith(id + ':retry:')) { const rt = this.timers.get(key); @@ -314,7 +283,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { } } - // stop cron job if present const task = this.cronJobs.get(id); if (task) { try { @@ -328,9 +296,7 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { this.logger.log(`Unscheduled job ${id}`); } - // unschedule everything (clear timers and stop all cron jobs) async unscheduleAll() { - // clear all timers for (const [key, t] of Array.from(this.timers.entries())) { try { clearTimeout(t); @@ -340,7 +306,6 @@ export class JobRunnerService implements OnModuleInit, OnModuleDestroy { this.timers.delete(key); } - // stop all cron jobs for (const [id, task] of Array.from(this.cronJobs.entries())) { try { task.stop(); diff --git a/src/scheduler/scheduler.controller.ts b/src/modules/jobs/jobs.controller.ts similarity index 80% rename from src/scheduler/scheduler.controller.ts rename to src/modules/jobs/jobs.controller.ts index d610f75..647f04c 100644 --- a/src/scheduler/scheduler.controller.ts +++ b/src/modules/jobs/jobs.controller.ts @@ -1,10 +1,10 @@ import { Body, Controller, Get, Post, Delete, Param, HttpCode } from '@nestjs/common'; import { CreateJobDto } from './dto/create-job.dto'; -import { SchedulerService } from './scheduler.service'; +import { JobsService } from './jobs.service'; @Controller('jobs') -export class SchedulerController { - constructor(private readonly svc: SchedulerService) { } +export class JobsController { + constructor(private readonly svc: JobsService) {} @Post() async create(@Body() payload: CreateJobDto) { @@ -17,7 +17,6 @@ export class SchedulerController { return this.svc.findAll(); } - @Delete(':id') @HttpCode(204) async remove(@Param('id') id: string) { diff --git a/src/modules/jobs/jobs.module.ts b/src/modules/jobs/jobs.module.ts new file mode 100644 index 0000000..f4f78a6 --- /dev/null +++ b/src/modules/jobs/jobs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JobEntity } from './entities/job.entity'; +import { JobsController } from './jobs.controller'; +import { JobsService } from './jobs.service'; +import { JobRunnerService } from './job-runner.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([JobEntity])], + controllers: [JobsController], + providers: [JobsService, JobRunnerService], + exports: [JobsService, JobRunnerService, TypeOrmModule], +}) +export class JobsModule {} diff --git a/src/scheduler/scheduler.service.ts b/src/modules/jobs/jobs.service.ts similarity index 89% rename from src/scheduler/scheduler.service.ts rename to src/modules/jobs/jobs.service.ts index f616079..6415d72 100644 --- a/src/scheduler/scheduler.service.ts +++ b/src/modules/jobs/jobs.service.ts @@ -6,8 +6,8 @@ import { CreateJobDto } from './dto/create-job.dto'; import { JobRunnerService } from './job-runner.service'; @Injectable() -export class SchedulerService { - private readonly logger = new Logger(SchedulerService.name); +export class JobsService { + private readonly logger = new Logger(JobsService.name); constructor( @InjectRepository(JobEntity) @@ -31,7 +31,6 @@ export class SchedulerService { }); const saved = await this.repo.save(entity); - // immediately schedule the created job in the background runner try { await this.runner.scheduleNewJob(saved); } catch (err) { @@ -68,14 +67,12 @@ export class SchedulerService { } async removeAll(): Promise { - // ask runner to unschedule all timers/cron tasks first try { await this.runner.unscheduleAll(); } catch (err) { this.logger.warn('Error unscheduling all jobs in runner: ' + String(err)); } - // delete all job rows await this.repo.clear(); this.logger.log('All jobs removed from persistence'); } diff --git a/src/scheduler/scheduler.controller.spec.ts b/src/scheduler/scheduler.controller.spec.ts deleted file mode 100644 index e9b5676..0000000 --- a/src/scheduler/scheduler.controller.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -const request = require('supertest'); -import { TypeOrmModule } from '@nestjs/typeorm'; -import { SchedulerModule } from './scheduler.module'; -import { JobEntity } from './entities/job.entity'; - -describe('SchedulerController (e2e)', () => { - let app: INestApplication; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ - type: 'sqlite', - database: ':memory:', - dropSchema: true, - entities: [JobEntity], - synchronize: true, - logging: false, - }), - SchedulerModule, - ], - }).compile(); - - app = moduleRef.createNestApplication(); - await app.init(); - }, 30000); - - afterAll(async () => { - await app.close(); - }); - - it('/jobs (POST) create oneoff job and list it (GET)', async () => { - const body = { - type: 'oneoff', - target: { url: 'https://example.invalid/ingest', method: 'POST' }, - headers: { 'Content-Type': 'application/json' }, - params: {}, - payload: { foo: 'bar' }, - scheduling: { timezone: 'UTC', execute_at: '2099-01-01T00:00:00Z' }, - retries: { enabled: true, max_attempts: 3, retryable_statuses: [429, 500] }, - }; - - const res = await request(app.getHttpServer()).post('/jobs').send(body).expect(201); - expect(res.body.id).toBeDefined(); - expect(res.body.status).toBe('scheduled'); - - const list = await request(app.getHttpServer()).get('/jobs').expect(200); - expect(Array.isArray(list.body)).toBe(true); - expect(list.body.length).toBeGreaterThanOrEqual(1); - }); - - it('/jobs (POST) create recurring job', async () => { - const body = { - type: 'recurring', - target: { url: 'https://example.invalid/run', method: 'GET' }, - headers: {}, - payload: {}, - body: {}, - scheduling: { cron: '0 0 * * *', timezone: 'UTC', start_time: '2099-01-01T00:00:00Z' }, - retries: { enabled: true, max_attempts: 2 }, - }; - - const res = await request(app.getHttpServer()).post('/jobs').send(body).expect(201); - expect(res.body.id).toBeDefined(); - }); -}); diff --git a/src/scheduler/scheduler.module.ts b/src/scheduler/scheduler.module.ts deleted file mode 100644 index 28c4a67..0000000 --- a/src/scheduler/scheduler.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { JobEntity } from './entities/job.entity'; -import { SchedulerController } from './scheduler.controller'; -import { SchedulerService } from './scheduler.service'; -import { JobRunnerService } from './job-runner.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([JobEntity])], - controllers: [SchedulerController], - providers: [SchedulerService, JobRunnerService], - exports: [SchedulerService], -}) -export class SchedulerModule {} diff --git a/tsconfig.json b/tsconfig.json index aba29b0..9b6d302 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,12 @@ { "compilerOptions": { - "module": "nodenext", - "moduleResolution": "nodenext", - "resolvePackageJsonExports": true, - "esModuleInterop": true, - "isolatedModules": true, + "module": "commonjs", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "ES2023", + "target": "ES2021", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", @@ -20,6 +16,11 @@ "forceConsistentCasingInFileNames": true, "noImplicitAny": false, "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "paths": { + "@common/*": ["src/common/*"], + "@modules/*": ["src/modules/*"], + "@config/*": ["src/config/*"] + } } }