From 49e76c92b17a3510da50ae1deaf8002c5c67d010 Mon Sep 17 00:00:00 2001 From: debian Date: Sun, 8 Mar 2026 05:49:00 -0400 Subject: [PATCH] fase(19): scheduling module refactor --- .ralph/.loop_start_sha | 2 +- .ralph/fix_plan.md | 38 +-- .ralph/progress.json | 2 +- dist/api/router.js | 2 + dist/main.js | 20 +- .../application/SchedulingService.js | 114 +++++++++ .../commands/CreateScheduleCommand.js | 32 +++ .../commands/DeleteScheduleCommand.js | 21 ++ .../commands/ToggleScheduleCommand.js | 24 ++ .../application/queries/ListSchedulesQuery.js | 24 ++ .../scheduling/domain/entities/Schedule.js | 96 ++++++++ .../domain/events/ScheduleCreated.js | 14 ++ .../scheduling/domain/events/ScheduleFired.js | 14 ++ .../domain/events/ScheduleToggled.js | 14 ++ .../domain/ports/IScheduleRepository.js | 2 + .../domain/value-objects/CronExpression.js | 54 +++++ dist/modules/scheduling/index.js | 22 ++ .../http/SchedulingController.js | 76 ++++++ .../repositories/KyselyScheduleRepository.js | 74 ++++++ frontend/src/App.tsx | 2 + .../src/pages/settings/SchedulesSection.tsx | 228 ++++++++++++++++++ .../src/pages/settings/SettingsLayout.tsx | 3 +- src/api/router.ts | 2 + src/api/server.ts | 2 + src/main.ts | 23 +- .../application/SchedulingService.ts | 100 ++++++++ .../commands/CreateScheduleCommand.ts | 47 ++++ .../commands/DeleteScheduleCommand.ts | 25 ++ .../commands/ToggleScheduleCommand.ts | 37 +++ .../application/queries/ListSchedulesQuery.ts | 38 +++ .../scheduling/domain/entities/Schedule.ts | 136 +++++++++++ .../domain/events/ScheduleCreated.ts | 13 + .../scheduling/domain/events/ScheduleFired.ts | 13 + .../domain/events/ScheduleToggled.ts | 13 + .../domain/ports/IScheduleRepository.ts | 10 + .../domain/value-objects/CronExpression.ts | 23 ++ src/modules/scheduling/index.ts | 11 + .../http/SchedulingController.ts | 110 +++++++++ .../repositories/KyselyScheduleRepository.ts | 89 +++++++ 39 files changed, 1546 insertions(+), 24 deletions(-) create mode 100644 dist/modules/scheduling/application/SchedulingService.js create mode 100644 dist/modules/scheduling/application/commands/CreateScheduleCommand.js create mode 100644 dist/modules/scheduling/application/commands/DeleteScheduleCommand.js create mode 100644 dist/modules/scheduling/application/commands/ToggleScheduleCommand.js create mode 100644 dist/modules/scheduling/application/queries/ListSchedulesQuery.js create mode 100644 dist/modules/scheduling/domain/entities/Schedule.js create mode 100644 dist/modules/scheduling/domain/events/ScheduleCreated.js create mode 100644 dist/modules/scheduling/domain/events/ScheduleFired.js create mode 100644 dist/modules/scheduling/domain/events/ScheduleToggled.js create mode 100644 dist/modules/scheduling/domain/ports/IScheduleRepository.js create mode 100644 dist/modules/scheduling/domain/value-objects/CronExpression.js create mode 100644 dist/modules/scheduling/index.js create mode 100644 dist/modules/scheduling/infrastructure/http/SchedulingController.js create mode 100644 dist/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.js create mode 100644 frontend/src/pages/settings/SchedulesSection.tsx create mode 100644 src/modules/scheduling/application/SchedulingService.ts create mode 100644 src/modules/scheduling/application/commands/CreateScheduleCommand.ts create mode 100644 src/modules/scheduling/application/commands/DeleteScheduleCommand.ts create mode 100644 src/modules/scheduling/application/commands/ToggleScheduleCommand.ts create mode 100644 src/modules/scheduling/application/queries/ListSchedulesQuery.ts create mode 100644 src/modules/scheduling/domain/entities/Schedule.ts create mode 100644 src/modules/scheduling/domain/events/ScheduleCreated.ts create mode 100644 src/modules/scheduling/domain/events/ScheduleFired.ts create mode 100644 src/modules/scheduling/domain/events/ScheduleToggled.ts create mode 100644 src/modules/scheduling/domain/ports/IScheduleRepository.ts create mode 100644 src/modules/scheduling/domain/value-objects/CronExpression.ts create mode 100644 src/modules/scheduling/index.ts create mode 100644 src/modules/scheduling/infrastructure/http/SchedulingController.ts create mode 100644 src/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.ts diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index 512ed60..13f2495 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -5a28270dc9f8480d705811b8558f2662bab460f5 +1cf597fee15fa5299f89d65d6bc8613dfc5af240 diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 55fa697..c61a39b 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -322,31 +322,31 @@ Spec: `.ralph/specs/phase-17-licensing.md` --- -## Phase 18: CLI + CI/CD [PENDIENTE] +## Phase 18: CLI + CI/CD [COMPLETO] Spec: `.ralph/specs/phase-18-cli-cicd.md` -- [ ] 18.1: Instalar: `npm i commander` -- [ ] 18.2: Refactorizar `src/cli/abe.ts` con commander: comando `explore` con flags --url, --config (json file), --output (json|junit|markdown), --fail-on-severity, --api-key -- [ ] 18.3: Comando `abe report` — genera report de una sesión por id -- [ ] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas -- [ ] 18.5: Output JUnit XML: cada finding = failing test, cada state sin findings = passing test -- [ ] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error -- [ ] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite -- [ ] 18.8: Crear `Dockerfile.ci` — imagen con Chromium para CI (basada en mcr.microsoft.com/playwright) -- [ ] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo -- [ ] 18.10: Actualizar README.md con sección CLI -- [ ] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration` +- [x] 18.1: Instalar: `npm i commander` +- [x] 18.2: Refactorizar `src/cli/abe.ts` con commander: comando `explore` con flags --url, --config (json file), --output (json|junit|markdown), --fail-on-severity, --api-key +- [x] 18.3: Comando `abe report` — genera report de una sesión por id +- [x] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas +- [x] 18.5: Output JUnit XML: cada finding = failing test, cada state sin findings = passing test +- [x] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error +- [x] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite +- [x] 18.8: Crear `Dockerfile.ci` — imagen con Chromium para CI (basada en mcr.microsoft.com/playwright) +- [x] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo +- [x] 18.10: Actualizar README.md con sección CLI +- [x] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration` --- -## Phase 19: Scheduling Module Refactor [PENDIENTE] +## Phase 19: Scheduling Module Refactor [COMPLETO] -- [ ] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure) -- [ ] 19.2: Crear Schedule aggregate con cron validation (Zod) -- [ ] 19.3: Integrar con job queue -- [ ] 19.4: Crear SchedulingController con CRUD + toggle -- [ ] 19.5: Frontend: Schedules section en Settings -- [ ] 19.6: Verificar build + commit: `fase(19): scheduling module refactor` +- [x] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure) +- [x] 19.2: Crear Schedule aggregate con cron validation (Zod) +- [x] 19.3: Integrar con job queue +- [x] 19.4: Crear SchedulingController con CRUD + toggle +- [x] 19.5: Frontend: Schedules section en Settings +- [x] 19.6: Verificar build + commit: `fase(19): scheduling module refactor` --- diff --git a/.ralph/progress.json b/.ralph/progress.json index 69777d4..6dcda21 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1 +1 @@ -{"status": "completed", "timestamp": "2026-03-08 05:21:06"} +{"status": "failed", "timestamp": "2026-03-08 05:42:52"} diff --git a/dist/api/router.js b/dist/api/router.js index f122bf4..05821df 100644 --- a/dist/api/router.js +++ b/dist/api/router.js @@ -10,6 +10,7 @@ const FindingsController_1 = require("../modules/findings/infrastructure/http/Fi const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController"); const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController"); const IntegrationsController_1 = require("../modules/integrations/infrastructure/http/IntegrationsController"); +const SchedulingController_1 = require("../modules/scheduling/infrastructure/http/SchedulingController"); const LicensingController_1 = require("../modules/licensing/infrastructure/http/LicensingController"); const FeatureGateMiddleware_1 = require("../modules/licensing/infrastructure/middleware/FeatureGateMiddleware"); const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController"); @@ -27,6 +28,7 @@ function createRouter(deps) { router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps)); router.use('/reports', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'reports:basic'), (0, ReportingController_1.createReportingRouter)(deps.reportingDeps)); router.use('/integrations', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'integrations:webhook'), (0, IntegrationsController_1.createIntegrationsRouter)(deps.integrationsDeps)); + router.use('/schedules', (0, SchedulingController_1.createSchedulingRouter)(deps.schedulingDeps)); // Licensing routes (public-ish — only status and activate, no sensitive data) const licensingController = new LicensingController_1.LicensingController(licenseService); router.use('/license', licensingController.router); diff --git a/dist/main.js b/dist/main.js index 4510101..f2592e0 100644 --- a/dist/main.js +++ b/dist/main.js @@ -60,6 +60,13 @@ const OnFindingCreated_1 = require("./modules/integrations/application/event-han // Licensing module const RSALicenseValidator_1 = require("./modules/licensing/infrastructure/validators/RSALicenseValidator"); const LicenseService_1 = require("./modules/licensing/application/LicenseService"); +// Scheduling module +const KyselyScheduleRepository_1 = require("./modules/scheduling/infrastructure/repositories/KyselyScheduleRepository"); +const CreateScheduleCommand_1 = require("./modules/scheduling/application/commands/CreateScheduleCommand"); +const ToggleScheduleCommand_1 = require("./modules/scheduling/application/commands/ToggleScheduleCommand"); +const DeleteScheduleCommand_1 = require("./modules/scheduling/application/commands/DeleteScheduleCommand"); +const ListSchedulesQuery_1 = require("./modules/scheduling/application/queries/ListSchedulesQuery"); +const SchedulingService_1 = require("./modules/scheduling/application/SchedulingService"); // Job queue const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue"); const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker"); @@ -125,7 +132,7 @@ async function bootstrap() { // 11b. Licensing const licenseValidator = new RSALicenseValidator_1.RSALicenseValidator(); const licenseService = new LicenseService_1.LicenseService(licenseValidator); - // 11c. Integrations + // 11c. Integrations (moved from 11d) const integrationRepo = new KyselyIntegrationRepository_1.KyselyIntegrationRepository(db); const webhookRepo = new KyselyWebhookEndpointRepository_1.KyselyWebhookEndpointRepository(db); const webhookDispatcher = new WebhookDispatcher_1.WebhookDispatcher(webhookRepo, logger); @@ -136,6 +143,14 @@ async function bootstrap() { jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger })); jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo })); jobQueue.start(); + // 12b. Scheduling module (after job queue, since it enqueues jobs) + const scheduleRepo = new KyselyScheduleRepository_1.KyselyScheduleRepository(db); + const createSchedule = new CreateScheduleCommand_1.CreateScheduleCommand(scheduleRepo, eventBus); + const toggleSchedule = new ToggleScheduleCommand_1.ToggleScheduleCommand(scheduleRepo, eventBus); + const deleteSchedule = new DeleteScheduleCommand_1.DeleteScheduleCommand(scheduleRepo, eventBus); + const listSchedules = new ListSchedulesQuery_1.ListSchedulesQuery(scheduleRepo); + const schedulingService = new SchedulingService_1.SchedulingService(scheduleRepo, jobQueue, eventBus, logger); + await schedulingService.start(); // 13. HTTP server const app = (0, server_1.createServer)({ config, @@ -146,6 +161,7 @@ async function bootstrap() { fuzzingDeps: { runFuzz, repository: fuzzRepo }, reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue }, integrationsDeps: { integrationRepo, webhookRepo }, + schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo }, licenseService, authDeps: { registerCommand, @@ -183,6 +199,8 @@ async function bootstrap() { httpServer.close(); // Close socket.io io.close(); + // Stop scheduling service + schedulingService.stop(); // Stop job queue and wait for active jobs jobQueue.pause(); await jobQueue.waitForActive(30000); diff --git a/dist/modules/scheduling/application/SchedulingService.js b/dist/modules/scheduling/application/SchedulingService.js new file mode 100644 index 0000000..5eb621f --- /dev/null +++ b/dist/modules/scheduling/application/SchedulingService.js @@ -0,0 +1,114 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SchedulingService = void 0; +/** + * SchedulingService — manages cron jobs for scheduled explorations. + * On startup, loads all enabled schedules and registers cron tasks. + * When a schedule fires, it enqueues an exploration job via IJobQueue. + */ +const cron = __importStar(require("node-cron")); +const ExplorationWorker_1 = require("../../../jobs/workers/ExplorationWorker"); +const ScheduleFired_1 = require("../domain/events/ScheduleFired"); +const UniqueId_1 = require("../../../shared/domain/UniqueId"); +class SchedulingService { + constructor(scheduleRepo, jobQueue, eventBus, logger) { + this.scheduleRepo = scheduleRepo; + this.jobQueue = jobQueue; + this.eventBus = eventBus; + this.logger = logger; + this.jobs = new Map(); + } + async start() { + const schedules = await this.scheduleRepo.findAll(true); + for (const schedule of schedules) { + this.registerCron(schedule); + } + this.logger.info({ count: schedules.length }, 'SchedulingService started'); + } + stop() { + for (const [id, task] of this.jobs) { + task.stop(); + this.logger.debug({ scheduleId: id }, 'Cron job stopped'); + } + this.jobs.clear(); + this.logger.info('SchedulingService stopped'); + } + registerCron(schedule) { + this.unregisterCron(schedule.id.toString()); + if (!schedule.enabled) + return; + if (!cron.validate(schedule.cronExpression.value)) { + this.logger.warn({ scheduleId: schedule.id.toString(), cron: schedule.cronExpression.value }, 'Invalid cron, skipping'); + return; + } + const task = cron.schedule(schedule.cronExpression.value, () => { + void this.fire(schedule.id.toString()); + }); + this.jobs.set(schedule.id.toString(), task); + this.logger.info({ scheduleId: schedule.id.toString(), cron: schedule.cronExpression.value }, 'Cron job registered'); + } + unregisterCron(scheduleId) { + const existing = this.jobs.get(scheduleId); + if (existing) { + existing.stop(); + this.jobs.delete(scheduleId); + } + } + async fire(scheduleId) { + const schedule = await this.scheduleRepo.findById(UniqueId_1.UniqueId.from(scheduleId)); + if (!schedule || !schedule.enabled) + return; + this.logger.info({ scheduleId, url: schedule.url }, 'Firing scheduled exploration'); + const payload = { + sessionId: UniqueId_1.UniqueId.create().toString(), + url: schedule.url, + seed: Math.floor(Math.random() * 0x7fffffff), + maxStates: schedule.config['maxStates'] ?? 50, + config: schedule.config, + }; + try { + const jobId = await this.jobQueue.enqueue(ExplorationWorker_1.EXPLORATION_JOB_TYPE, payload); + schedule.markFired(Date.now()); + await this.scheduleRepo.update(schedule); + await this.eventBus.publish(new ScheduleFired_1.ScheduleFired(scheduleId, { scheduleId, url: schedule.url, jobId })); + this.logger.info({ scheduleId, jobId, url: schedule.url }, 'Scheduled exploration enqueued'); + } + catch (err) { + this.logger.error({ scheduleId, err: err instanceof Error ? err.message : String(err) }, 'Failed to enqueue scheduled exploration'); + } + } +} +exports.SchedulingService = SchedulingService; diff --git a/dist/modules/scheduling/application/commands/CreateScheduleCommand.js b/dist/modules/scheduling/application/commands/CreateScheduleCommand.js new file mode 100644 index 0000000..c820d07 --- /dev/null +++ b/dist/modules/scheduling/application/commands/CreateScheduleCommand.js @@ -0,0 +1,32 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CreateScheduleCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const Schedule_1 = require("../../domain/entities/Schedule"); +class CreateScheduleCommand { + constructor(scheduleRepo, eventBus) { + this.scheduleRepo = scheduleRepo; + this.eventBus = eventBus; + } + async execute(req) { + const result = Schedule_1.Schedule.create(req); + if (!result.ok) + return (0, Result_1.Err)(result.error); + const schedule = result.value; + await this.scheduleRepo.save(schedule); + for (const event of schedule.domainEvents) { + await this.eventBus.publish(event); + } + schedule.clearEvents(); + return (0, Result_1.Ok)({ + id: schedule.id.toString(), + name: schedule.name, + url: schedule.url, + cronExpression: schedule.cronExpression.value, + enabled: schedule.enabled, + nextRunAt: schedule.nextRunAt, + createdAt: schedule.createdAt, + }); + } +} +exports.CreateScheduleCommand = CreateScheduleCommand; diff --git a/dist/modules/scheduling/application/commands/DeleteScheduleCommand.js b/dist/modules/scheduling/application/commands/DeleteScheduleCommand.js new file mode 100644 index 0000000..08a9f17 --- /dev/null +++ b/dist/modules/scheduling/application/commands/DeleteScheduleCommand.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DeleteScheduleCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +class DeleteScheduleCommand { + constructor(scheduleRepo, eventBus) { + this.scheduleRepo = scheduleRepo; + this.eventBus = eventBus; + } + async execute(req) { + const id = UniqueId_1.UniqueId.from(req.id); + const schedule = await this.scheduleRepo.findById(id); + if (!schedule) + return (0, Result_1.Err)('Schedule not found'); + await this.scheduleRepo.delete(id); + void this.eventBus; + return (0, Result_1.Ok)(undefined); + } +} +exports.DeleteScheduleCommand = DeleteScheduleCommand; diff --git a/dist/modules/scheduling/application/commands/ToggleScheduleCommand.js b/dist/modules/scheduling/application/commands/ToggleScheduleCommand.js new file mode 100644 index 0000000..79f5ff9 --- /dev/null +++ b/dist/modules/scheduling/application/commands/ToggleScheduleCommand.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ToggleScheduleCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +class ToggleScheduleCommand { + constructor(scheduleRepo, eventBus) { + this.scheduleRepo = scheduleRepo; + this.eventBus = eventBus; + } + async execute(req) { + const schedule = await this.scheduleRepo.findById(UniqueId_1.UniqueId.from(req.id)); + if (!schedule) + return (0, Result_1.Err)('Schedule not found'); + schedule.toggle(req.enabled); + await this.scheduleRepo.update(schedule); + for (const event of schedule.domainEvents) { + await this.eventBus.publish(event); + } + schedule.clearEvents(); + return (0, Result_1.Ok)({ id: req.id, enabled: req.enabled }); + } +} +exports.ToggleScheduleCommand = ToggleScheduleCommand; diff --git a/dist/modules/scheduling/application/queries/ListSchedulesQuery.js b/dist/modules/scheduling/application/queries/ListSchedulesQuery.js new file mode 100644 index 0000000..4389a62 --- /dev/null +++ b/dist/modules/scheduling/application/queries/ListSchedulesQuery.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ListSchedulesQuery = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class ListSchedulesQuery { + constructor(scheduleRepo) { + this.scheduleRepo = scheduleRepo; + } + async execute(req) { + const schedules = await this.scheduleRepo.findAll(req.enabledOnly); + return (0, Result_1.Ok)(schedules.map((s) => ({ + id: s.id.toString(), + name: s.name, + url: s.url, + cronExpression: s.cronExpression.value, + config: s.config, + enabled: s.enabled, + lastRunAt: s.lastRunAt, + nextRunAt: s.nextRunAt, + createdAt: s.createdAt, + }))); + } +} +exports.ListSchedulesQuery = ListSchedulesQuery; diff --git a/dist/modules/scheduling/domain/entities/Schedule.js b/dist/modules/scheduling/domain/entities/Schedule.js new file mode 100644 index 0000000..c7c71b1 --- /dev/null +++ b/dist/modules/scheduling/domain/entities/Schedule.js @@ -0,0 +1,96 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Schedule = exports.CreateScheduleSchema = void 0; +const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const Result_1 = require("../../../../shared/domain/Result"); +const CronExpression_1 = require("../value-objects/CronExpression"); +const ScheduleCreated_1 = require("../events/ScheduleCreated"); +const ScheduleToggled_1 = require("../events/ScheduleToggled"); +const zod_1 = require("zod"); +exports.CreateScheduleSchema = zod_1.z.object({ + name: zod_1.z.string().min(1).max(100), + url: zod_1.z.string().url(), + cronExpression: zod_1.z.string().min(1), + config: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional().default({}), + enabled: zod_1.z.boolean().optional().default(true), +}); +class Schedule extends AggregateRoot_1.AggregateRoot { + get name() { return this.props.name; } + get url() { return this.props.url; } + get cronExpression() { return this.props.cronExpression; } + get config() { return this.props.config; } + get enabled() { return this.props.enabled; } + get lastRunAt() { return this.props.lastRunAt; } + get nextRunAt() { return this.props.nextRunAt; } + get createdAt() { return this.props.createdAt; } + static create(input) { + const parsed = exports.CreateScheduleSchema.safeParse(input); + if (!parsed.success) { + return (0, Result_1.Err)(parsed.error.issues.map((e) => e.message).join(', ')); + } + const cronResult = CronExpression_1.CronExpression.create(parsed.data.cronExpression); + if (!cronResult.ok) { + return (0, Result_1.Err)(cronResult.error); + } + const id = UniqueId_1.UniqueId.create(); + const now = Date.now(); + const schedule = new Schedule({ + name: parsed.data.name, + url: parsed.data.url, + cronExpression: cronResult.value, + config: parsed.data.config, + enabled: parsed.data.enabled, + lastRunAt: null, + nextRunAt: now + 60000, // approximate next run + createdAt: now, + }, id); + schedule.addDomainEvent(new ScheduleCreated_1.ScheduleCreated(id.toString(), { + name: parsed.data.name, + url: parsed.data.url, + cronExpression: parsed.data.cronExpression, + })); + return (0, Result_1.Ok)(schedule); + } + static reconstitute(id, props) { + const cronResult = CronExpression_1.CronExpression.create(props.cronExpression); + // If stored cron is invalid, store raw value — shouldn't happen in practice + const cronExpr = (0, Result_1.isOk)(cronResult) + ? cronResult.value + : { props: { value: props.cronExpression }, value: props.cronExpression }; + return new Schedule({ + name: props.name, + url: props.url, + cronExpression: cronExpr, + config: props.config, + enabled: props.enabled, + lastRunAt: props.lastRunAt, + nextRunAt: props.nextRunAt, + createdAt: props.createdAt, + }, UniqueId_1.UniqueId.from(id)); + } + toggle(enabled) { + this.props.enabled = enabled; + this.addDomainEvent(new ScheduleToggled_1.ScheduleToggled(this.id.toString(), { enabled })); + } + markFired(now) { + this.props.lastRunAt = now; + this.props.nextRunAt = now + 60000; // approximate + } + update(fields) { + if (fields.cronExpression !== undefined) { + const cronResult = CronExpression_1.CronExpression.create(fields.cronExpression); + if (!cronResult.ok) + return (0, Result_1.Err)(cronResult.error); + this.props.cronExpression = cronResult.value; + } + if (fields.name !== undefined) + this.props.name = fields.name; + if (fields.url !== undefined) + this.props.url = fields.url; + if (fields.config !== undefined) + this.props.config = fields.config; + return (0, Result_1.Ok)(undefined); + } +} +exports.Schedule = Schedule; diff --git a/dist/modules/scheduling/domain/events/ScheduleCreated.js b/dist/modules/scheduling/domain/events/ScheduleCreated.js new file mode 100644 index 0000000..12be30b --- /dev/null +++ b/dist/modules/scheduling/domain/events/ScheduleCreated.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ScheduleCreated = void 0; +const crypto_1 = require("crypto"); +class ScheduleCreated { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'scheduling.schedule_created'; + this.occurredOn = new Date(); + } +} +exports.ScheduleCreated = ScheduleCreated; diff --git a/dist/modules/scheduling/domain/events/ScheduleFired.js b/dist/modules/scheduling/domain/events/ScheduleFired.js new file mode 100644 index 0000000..99cf1ae --- /dev/null +++ b/dist/modules/scheduling/domain/events/ScheduleFired.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ScheduleFired = void 0; +const crypto_1 = require("crypto"); +class ScheduleFired { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'scheduling.schedule_fired'; + this.occurredOn = new Date(); + } +} +exports.ScheduleFired = ScheduleFired; diff --git a/dist/modules/scheduling/domain/events/ScheduleToggled.js b/dist/modules/scheduling/domain/events/ScheduleToggled.js new file mode 100644 index 0000000..3a5718d --- /dev/null +++ b/dist/modules/scheduling/domain/events/ScheduleToggled.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ScheduleToggled = void 0; +const crypto_1 = require("crypto"); +class ScheduleToggled { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'scheduling.schedule_toggled'; + this.occurredOn = new Date(); + } +} +exports.ScheduleToggled = ScheduleToggled; diff --git a/dist/modules/scheduling/domain/ports/IScheduleRepository.js b/dist/modules/scheduling/domain/ports/IScheduleRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/scheduling/domain/ports/IScheduleRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/scheduling/domain/value-objects/CronExpression.js b/dist/modules/scheduling/domain/value-objects/CronExpression.js new file mode 100644 index 0000000..dc2e114 --- /dev/null +++ b/dist/modules/scheduling/domain/value-objects/CronExpression.js @@ -0,0 +1,54 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CronExpression = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +const Result_1 = require("../../../../shared/domain/Result"); +const cron = __importStar(require("node-cron")); +class CronExpression extends ValueObject_1.ValueObject { + get value() { + return this.props.value; + } + static create(expression) { + if (!expression || expression.trim().length === 0) { + return (0, Result_1.Err)('Cron expression cannot be empty'); + } + if (!cron.validate(expression)) { + return (0, Result_1.Err)(`Invalid cron expression: "${expression}"`); + } + return (0, Result_1.Ok)(new CronExpression({ value: expression })); + } +} +exports.CronExpression = CronExpression; diff --git a/dist/modules/scheduling/index.js b/dist/modules/scheduling/index.js new file mode 100644 index 0000000..048787f --- /dev/null +++ b/dist/modules/scheduling/index.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createSchedulingRouter = exports.KyselyScheduleRepository = exports.SchedulingService = exports.ListSchedulesQuery = exports.DeleteScheduleCommand = exports.ToggleScheduleCommand = exports.CreateScheduleCommand = exports.CronExpression = exports.Schedule = void 0; +// Scheduling module public API +var Schedule_1 = require("./domain/entities/Schedule"); +Object.defineProperty(exports, "Schedule", { enumerable: true, get: function () { return Schedule_1.Schedule; } }); +var CronExpression_1 = require("./domain/value-objects/CronExpression"); +Object.defineProperty(exports, "CronExpression", { enumerable: true, get: function () { return CronExpression_1.CronExpression; } }); +var CreateScheduleCommand_1 = require("./application/commands/CreateScheduleCommand"); +Object.defineProperty(exports, "CreateScheduleCommand", { enumerable: true, get: function () { return CreateScheduleCommand_1.CreateScheduleCommand; } }); +var ToggleScheduleCommand_1 = require("./application/commands/ToggleScheduleCommand"); +Object.defineProperty(exports, "ToggleScheduleCommand", { enumerable: true, get: function () { return ToggleScheduleCommand_1.ToggleScheduleCommand; } }); +var DeleteScheduleCommand_1 = require("./application/commands/DeleteScheduleCommand"); +Object.defineProperty(exports, "DeleteScheduleCommand", { enumerable: true, get: function () { return DeleteScheduleCommand_1.DeleteScheduleCommand; } }); +var ListSchedulesQuery_1 = require("./application/queries/ListSchedulesQuery"); +Object.defineProperty(exports, "ListSchedulesQuery", { enumerable: true, get: function () { return ListSchedulesQuery_1.ListSchedulesQuery; } }); +var SchedulingService_1 = require("./application/SchedulingService"); +Object.defineProperty(exports, "SchedulingService", { enumerable: true, get: function () { return SchedulingService_1.SchedulingService; } }); +var KyselyScheduleRepository_1 = require("./infrastructure/repositories/KyselyScheduleRepository"); +Object.defineProperty(exports, "KyselyScheduleRepository", { enumerable: true, get: function () { return KyselyScheduleRepository_1.KyselyScheduleRepository; } }); +var SchedulingController_1 = require("./infrastructure/http/SchedulingController"); +Object.defineProperty(exports, "createSchedulingRouter", { enumerable: true, get: function () { return SchedulingController_1.createSchedulingRouter; } }); diff --git a/dist/modules/scheduling/infrastructure/http/SchedulingController.js b/dist/modules/scheduling/infrastructure/http/SchedulingController.js new file mode 100644 index 0000000..289e0aa --- /dev/null +++ b/dist/modules/scheduling/infrastructure/http/SchedulingController.js @@ -0,0 +1,76 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createSchedulingRouter = createSchedulingRouter; +const express_1 = require("express"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +function createSchedulingRouter(deps) { + const router = (0, express_1.Router)(); + const { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo } = deps; + // GET /api/schedules + router.get('/', async (_req, res) => { + const result = await listSchedules.execute({}); + if (!result.ok) { + res.status(500).json({ error: result.error }); + return; + } + res.json(result.value); + }); + // POST /api/schedules + router.post('/', async (req, res) => { + const body = req.body; + const result = await createSchedule.execute({ + name: body.name ?? '', + url: body.url ?? '', + cronExpression: body.cronExpression ?? '', + config: body.config ?? {}, + enabled: body.enabled !== false, + }); + if (!result.ok) { + res.status(400).json({ error: result.error }); + return; + } + // Register cron after creation + const schedule = await scheduleRepo.findById(UniqueId_1.UniqueId.from(result.value.id)); + if (schedule) { + schedulingService.registerCron(schedule); + } + res.status(201).json(result.value); + }); + // PATCH /api/schedules/:id/toggle + router.patch('/:id/toggle', async (req, res) => { + const id = String(req.params['id']); + const { enabled } = req.body; + if (enabled === undefined) { + res.status(400).json({ error: 'enabled is required' }); + return; + } + const result = await toggleSchedule.execute({ id, enabled }); + if (!result.ok) { + res.status(result.error === 'Schedule not found' ? 404 : 400).json({ error: result.error }); + return; + } + // Update cron registration + const schedule = await scheduleRepo.findById(UniqueId_1.UniqueId.from(id)); + if (schedule) { + if (enabled) { + schedulingService.registerCron(schedule); + } + else { + schedulingService.unregisterCron(id); + } + } + res.json(result.value); + }); + // DELETE /api/schedules/:id + router.delete('/:id', async (req, res) => { + const id = String(req.params['id']); + schedulingService.unregisterCron(id); + const result = await deleteSchedule.execute({ id }); + if (!result.ok) { + res.status(result.error === 'Schedule not found' ? 404 : 400).json({ error: result.error }); + return; + } + res.status(204).send(); + }); + return router; +} diff --git a/dist/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.js b/dist/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.js new file mode 100644 index 0000000..8f6ddbb --- /dev/null +++ b/dist/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.js @@ -0,0 +1,74 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KyselyScheduleRepository = void 0; +const Schedule_1 = require("../../domain/entities/Schedule"); +class KyselyScheduleRepository { + constructor(db) { + this.db = db; + } + async save(schedule) { + await this.db + .insertInto('schedules') + .values({ + id: schedule.id.toString(), + name: schedule.name, + url: schedule.url, + config_json: JSON.stringify(schedule.config), + cron_expression: schedule.cronExpression.value, + enabled: schedule.enabled ? 1 : 0, + last_run_at: schedule.lastRunAt, + next_run_at: schedule.nextRunAt, + created_at: schedule.createdAt, + }) + .execute(); + } + async findById(id) { + const row = await this.db + .selectFrom('schedules') + .selectAll() + .where('id', '=', id.toString()) + .executeTakeFirst(); + if (!row) + return null; + return this.toEntity(row); + } + async findAll(enabledOnly = false) { + let query = this.db.selectFrom('schedules').selectAll().orderBy('created_at', 'desc'); + if (enabledOnly) { + query = query.where('enabled', '=', 1); + } + const rows = await query.execute(); + return rows.map((r) => this.toEntity(r)); + } + async update(schedule) { + await this.db + .updateTable('schedules') + .set({ + name: schedule.name, + url: schedule.url, + config_json: JSON.stringify(schedule.config), + cron_expression: schedule.cronExpression.value, + enabled: schedule.enabled ? 1 : 0, + last_run_at: schedule.lastRunAt, + next_run_at: schedule.nextRunAt, + }) + .where('id', '=', schedule.id.toString()) + .execute(); + } + async delete(id) { + await this.db.deleteFrom('schedules').where('id', '=', id.toString()).execute(); + } + toEntity(row) { + return Schedule_1.Schedule.reconstitute(row.id, { + name: row.name, + url: row.url, + cronExpression: row.cron_expression, + config: JSON.parse(row.config_json), + enabled: row.enabled === 1, + lastRunAt: row.last_run_at, + nextRunAt: row.next_run_at, + createdAt: row.created_at, + }); + } +} +exports.KyselyScheduleRepository = KyselyScheduleRepository; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67378d4..7c2287a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { NotificationsSection } from '@/pages/settings/NotificationsSection' import { IntegrationsSection } from '@/pages/settings/IntegrationsSection' import { AppearanceSection } from '@/pages/settings/AppearanceSection' import { LicenseSection } from '@/pages/settings/LicenseSection' +import { SchedulesSection } from '@/pages/settings/SchedulesSection' import { Reports } from '@/pages/Reports' function VisualReview() { @@ -57,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/settings/SchedulesSection.tsx b/frontend/src/pages/settings/SchedulesSection.tsx new file mode 100644 index 0000000..682e5b4 --- /dev/null +++ b/frontend/src/pages/settings/SchedulesSection.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { Plus, Trash2, Power, PowerOff, Clock } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { apiFetch } from '@/lib/api' + +interface Schedule { + id: string + name: string + url: string + cronExpression: string + enabled: boolean + lastRunAt: number | null + nextRunAt: number | null + createdAt: number +} + +interface CreateScheduleForm { + name: string + url: string + cronExpression: string +} + +function useSchedules() { + return useQuery({ + queryKey: ['schedules'], + queryFn: () => apiFetch('/api/schedules'), + }) +} + +export function SchedulesSection() { + const qc = useQueryClient() + const { data: schedules, isLoading } = useSchedules() + const [open, setOpen] = useState(false) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + defaultValues: { name: '', url: '', cronExpression: '0 * * * *' }, + }) + + const createMutation = useMutation({ + mutationFn: (data: CreateScheduleForm) => + apiFetch('/api/schedules', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['schedules'] }) + toast.success('Schedule created') + setOpen(false) + reset() + }, + onError: (e: Error) => toast.error(e.message), + }) + + const toggleMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + apiFetch(`/api/schedules/${id}/toggle`, { + method: 'PATCH', + body: JSON.stringify({ enabled }), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['schedules'] }), + onError: (e: Error) => toast.error(e.message), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/schedules/${id}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['schedules'] }) + toast.success('Schedule deleted') + }, + onError: (e: Error) => toast.error(e.message), + }) + + return ( +
+
+
+

Schedules

+

+ Automatically run explorations on a cron schedule. +

+
+ + + + + + + + Create Schedule + +
createMutation.mutate(data))} + className="space-y-4" + > +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + + {errors.url && ( +

{errors.url.message}

+ )} +
+ +
+ + + {errors.cronExpression && ( +

{errors.cronExpression.message}

+ )} +

+ Examples: 0 2 * * * (daily at 2am),{' '} + 0 * * * * (hourly),{' '} + 0 9 * * 1 (Monday 9am) +

+
+ +
+ + +
+
+
+
+
+ + {isLoading ? ( +
Loading schedules...
+ ) : !schedules || schedules.length === 0 ? ( +
+ +

No schedules yet.

+

Create a schedule to automatically run explorations.

+
+ ) : ( +
+ {schedules.map((schedule) => ( +
+
+
+ {schedule.name} + + {schedule.enabled ? 'Active' : 'Paused'} + +
+

{schedule.url}

+
+ + {schedule.cronExpression} + + {schedule.lastRunAt && ( + + Last run: {new Date(schedule.lastRunAt).toLocaleString()} + + )} +
+
+ +
+ + toggleMutation.mutate({ id: schedule.id, enabled }) + } + disabled={toggleMutation.isPending} + /> + {schedule.enabled ? ( + + ) : ( + + )} + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/settings/SettingsLayout.tsx b/frontend/src/pages/settings/SettingsLayout.tsx index 0151638..5c90b49 100644 --- a/frontend/src/pages/settings/SettingsLayout.tsx +++ b/frontend/src/pages/settings/SettingsLayout.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet } from 'react-router-dom' -import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug } from 'lucide-react' +import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug, Clock } from 'lucide-react' import { cn } from '@/lib/utils' const navItems = [ @@ -7,6 +7,7 @@ const navItems = [ { label: 'Organization', href: '/settings/organization', icon: Building }, { label: 'API Keys', href: '/settings/api-keys', icon: Key }, { label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders }, + { label: 'Schedules', href: '/settings/schedules', icon: Clock }, { label: 'Notifications', href: '/settings/notifications', icon: Bell }, { label: 'Integrations', href: '/settings/integrations', icon: Plug }, { label: 'Appearance', href: '/settings/appearance', icon: Palette }, diff --git a/src/api/router.ts b/src/api/router.ts index a0e677e..7185bcb 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -7,6 +7,7 @@ import { createFindingsRouter } from '../modules/findings/infrastructure/http/Fi import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController'; import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController'; import { createIntegrationsRouter } from '../modules/integrations/infrastructure/http/IntegrationsController'; +import { createSchedulingRouter } from '../modules/scheduling/infrastructure/http/SchedulingController'; import { LicensingController } from '../modules/licensing/infrastructure/http/LicensingController'; import { LicenseService } from '../modules/licensing/application/LicenseService'; import { requireFeature } from '../modules/licensing/infrastructure/middleware/FeatureGateMiddleware'; @@ -71,6 +72,7 @@ export function createRouter(deps: ServerDependencies): Router { router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps)); router.use('/reports', requireFeature(licenseService, 'reports:basic'), createReportingRouter(deps.reportingDeps)); router.use('/integrations', requireFeature(licenseService, 'integrations:webhook'), createIntegrationsRouter(deps.integrationsDeps)); + router.use('/schedules', createSchedulingRouter(deps.schedulingDeps)); // Licensing routes (public-ish — only status and activate, no sensitive data) const licensingController = new LicensingController(licenseService); diff --git a/src/api/server.ts b/src/api/server.ts index d8d07ae..cc6cfac 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -22,6 +22,7 @@ import { ReportingControllerDeps } from '../modules/reporting/infrastructure/htt import { IntegrationsDeps } from '../modules/integrations/infrastructure/http/IntegrationsController'; import { AuthControllerDeps } from './router'; import { LicenseService } from '../modules/licensing/application/LicenseService'; +import { SchedulingControllerDeps } from '../modules/scheduling/infrastructure/http/SchedulingController'; export interface ServerDependencies { config: AppConfig; @@ -32,6 +33,7 @@ export interface ServerDependencies { fuzzingDeps: FuzzingControllerDeps; reportingDeps: ReportingControllerDeps; integrationsDeps: IntegrationsDeps; + schedulingDeps: SchedulingControllerDeps; authDeps: AuthControllerDeps; licenseService: LicenseService; } diff --git a/src/main.ts b/src/main.ts index e5f96b5..357d978 100644 --- a/src/main.ts +++ b/src/main.ts @@ -65,6 +65,14 @@ import { OnFindingCreated } from './modules/integrations/application/event-handl import { RSALicenseValidator } from './modules/licensing/infrastructure/validators/RSALicenseValidator'; import { LicenseService } from './modules/licensing/application/LicenseService'; +// Scheduling module +import { KyselyScheduleRepository } from './modules/scheduling/infrastructure/repositories/KyselyScheduleRepository'; +import { CreateScheduleCommand } from './modules/scheduling/application/commands/CreateScheduleCommand'; +import { ToggleScheduleCommand } from './modules/scheduling/application/commands/ToggleScheduleCommand'; +import { DeleteScheduleCommand } from './modules/scheduling/application/commands/DeleteScheduleCommand'; +import { ListSchedulesQuery } from './modules/scheduling/application/queries/ListSchedulesQuery'; +import { SchedulingService } from './modules/scheduling/application/SchedulingService'; + // Job queue import { SQLiteJobQueue } from './jobs/SQLiteJobQueue'; import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker'; @@ -147,7 +155,7 @@ async function bootstrap(): Promise { const licenseValidator = new RSALicenseValidator(); const licenseService = new LicenseService(licenseValidator); - // 11c. Integrations + // 11c. Integrations (moved from 11d) const integrationRepo = new KyselyIntegrationRepository(db); const webhookRepo = new KyselyWebhookEndpointRepository(db); const webhookDispatcher = new WebhookDispatcher(webhookRepo, logger); @@ -163,6 +171,15 @@ async function bootstrap(): Promise { jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger, reportRepository: reportRepo, findingRepository: findingRepo })); jobQueue.start(); + // 12b. Scheduling module (after job queue, since it enqueues jobs) + const scheduleRepo = new KyselyScheduleRepository(db); + const createSchedule = new CreateScheduleCommand(scheduleRepo, eventBus); + const toggleSchedule = new ToggleScheduleCommand(scheduleRepo, eventBus); + const deleteSchedule = new DeleteScheduleCommand(scheduleRepo, eventBus); + const listSchedules = new ListSchedulesQuery(scheduleRepo); + const schedulingService = new SchedulingService(scheduleRepo, jobQueue, eventBus, logger); + await schedulingService.start(); + // 13. HTTP server const app = createServer({ config, @@ -173,6 +190,7 @@ async function bootstrap(): Promise { fuzzingDeps: { runFuzz, repository: fuzzRepo }, reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue }, integrationsDeps: { integrationRepo, webhookRepo }, + schedulingDeps: { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo }, licenseService, authDeps: { registerCommand, @@ -218,6 +236,9 @@ async function bootstrap(): Promise { // Close socket.io io.close(); + // Stop scheduling service + schedulingService.stop(); + // Stop job queue and wait for active jobs jobQueue.pause(); await jobQueue.waitForActive(30_000); diff --git a/src/modules/scheduling/application/SchedulingService.ts b/src/modules/scheduling/application/SchedulingService.ts new file mode 100644 index 0000000..587aec1 --- /dev/null +++ b/src/modules/scheduling/application/SchedulingService.ts @@ -0,0 +1,100 @@ +/** + * SchedulingService — manages cron jobs for scheduled explorations. + * On startup, loads all enabled schedules and registers cron tasks. + * When a schedule fires, it enqueues an exploration job via IJobQueue. + */ +import * as cron from 'node-cron'; +import { IScheduleRepository } from '../domain/ports/IScheduleRepository'; +import { IJobQueue } from '../../../jobs/JobQueue'; +import { EventBus } from '../../../shared/application/EventBus'; +import { Logger } from '../../../shared/infrastructure/Logger'; +import { EXPLORATION_JOB_TYPE, ExplorationJobPayload } from '../../../jobs/workers/ExplorationWorker'; +import { ScheduleFired } from '../domain/events/ScheduleFired'; +import { UniqueId } from '../../../shared/domain/UniqueId'; +import { Schedule } from '../domain/entities/Schedule'; + +export class SchedulingService { + private readonly jobs = new Map(); + + constructor( + private readonly scheduleRepo: IScheduleRepository, + private readonly jobQueue: IJobQueue, + private readonly eventBus: EventBus, + private readonly logger: Logger + ) {} + + async start(): Promise { + const schedules = await this.scheduleRepo.findAll(true); + for (const schedule of schedules) { + this.registerCron(schedule); + } + this.logger.info({ count: schedules.length }, 'SchedulingService started'); + } + + stop(): void { + for (const [id, task] of this.jobs) { + task.stop(); + this.logger.debug({ scheduleId: id }, 'Cron job stopped'); + } + this.jobs.clear(); + this.logger.info('SchedulingService stopped'); + } + + registerCron(schedule: Schedule): void { + this.unregisterCron(schedule.id.toString()); + + if (!schedule.enabled) return; + if (!cron.validate(schedule.cronExpression.value)) { + this.logger.warn({ scheduleId: schedule.id.toString(), cron: schedule.cronExpression.value }, 'Invalid cron, skipping'); + return; + } + + const task = cron.schedule(schedule.cronExpression.value, () => { + void this.fire(schedule.id.toString()); + }); + + this.jobs.set(schedule.id.toString(), task); + this.logger.info({ scheduleId: schedule.id.toString(), cron: schedule.cronExpression.value }, 'Cron job registered'); + } + + unregisterCron(scheduleId: string): void { + const existing = this.jobs.get(scheduleId); + if (existing) { + existing.stop(); + this.jobs.delete(scheduleId); + } + } + + private async fire(scheduleId: string): Promise { + const schedule = await this.scheduleRepo.findById(UniqueId.from(scheduleId)); + if (!schedule || !schedule.enabled) return; + + this.logger.info({ scheduleId, url: schedule.url }, 'Firing scheduled exploration'); + + const payload: ExplorationJobPayload = { + sessionId: UniqueId.create().toString(), + url: schedule.url, + seed: Math.floor(Math.random() * 0x7fffffff), + maxStates: (schedule.config['maxStates'] as number | undefined) ?? 50, + config: schedule.config, + }; + + try { + const jobId = await this.jobQueue.enqueue(EXPLORATION_JOB_TYPE, payload); + + schedule.markFired(Date.now()); + await this.scheduleRepo.update(schedule); + + await this.eventBus.publish( + new ScheduleFired(scheduleId, { scheduleId, url: schedule.url, jobId }) + ); + + this.logger.info({ scheduleId, jobId, url: schedule.url }, 'Scheduled exploration enqueued'); + } catch (err: unknown) { + this.logger.error( + { scheduleId, err: err instanceof Error ? err.message : String(err) }, + 'Failed to enqueue scheduled exploration' + ); + } + } +} diff --git a/src/modules/scheduling/application/commands/CreateScheduleCommand.ts b/src/modules/scheduling/application/commands/CreateScheduleCommand.ts new file mode 100644 index 0000000..e3033d5 --- /dev/null +++ b/src/modules/scheduling/application/commands/CreateScheduleCommand.ts @@ -0,0 +1,47 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { IScheduleRepository } from '../../domain/ports/IScheduleRepository'; +import { Schedule, CreateScheduleProps } from '../../domain/entities/Schedule'; + +export type CreateScheduleRequest = CreateScheduleProps; + +export interface CreateScheduleResponse { + id: string; + name: string; + url: string; + cronExpression: string; + enabled: boolean; + nextRunAt: number | null; + createdAt: number; +} + +export class CreateScheduleCommand implements UseCase { + constructor( + private readonly scheduleRepo: IScheduleRepository, + private readonly eventBus: EventBus + ) {} + + async execute(req: CreateScheduleRequest): Promise> { + const result = Schedule.create(req); + if (!result.ok) return Err(result.error); + + const schedule = result.value; + await this.scheduleRepo.save(schedule); + + for (const event of schedule.domainEvents) { + await this.eventBus.publish(event); + } + schedule.clearEvents(); + + return Ok({ + id: schedule.id.toString(), + name: schedule.name, + url: schedule.url, + cronExpression: schedule.cronExpression.value, + enabled: schedule.enabled, + nextRunAt: schedule.nextRunAt, + createdAt: schedule.createdAt, + }); + } +} diff --git a/src/modules/scheduling/application/commands/DeleteScheduleCommand.ts b/src/modules/scheduling/application/commands/DeleteScheduleCommand.ts new file mode 100644 index 0000000..f7d5f10 --- /dev/null +++ b/src/modules/scheduling/application/commands/DeleteScheduleCommand.ts @@ -0,0 +1,25 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { IScheduleRepository } from '../../domain/ports/IScheduleRepository'; + +export interface DeleteScheduleRequest { id: string } +export type DeleteScheduleResponse = void; + +export class DeleteScheduleCommand implements UseCase { + constructor( + private readonly scheduleRepo: IScheduleRepository, + private readonly eventBus: EventBus + ) {} + + async execute(req: DeleteScheduleRequest): Promise> { + const id = UniqueId.from(req.id); + const schedule = await this.scheduleRepo.findById(id); + if (!schedule) return Err('Schedule not found'); + + await this.scheduleRepo.delete(id); + void this.eventBus; + return Ok(undefined); + } +} diff --git a/src/modules/scheduling/application/commands/ToggleScheduleCommand.ts b/src/modules/scheduling/application/commands/ToggleScheduleCommand.ts new file mode 100644 index 0000000..9a0db7d --- /dev/null +++ b/src/modules/scheduling/application/commands/ToggleScheduleCommand.ts @@ -0,0 +1,37 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { IScheduleRepository } from '../../domain/ports/IScheduleRepository'; + +export interface ToggleScheduleRequest { + id: string; + enabled: boolean; +} + +export interface ToggleScheduleResponse { + id: string; + enabled: boolean; +} + +export class ToggleScheduleCommand implements UseCase { + constructor( + private readonly scheduleRepo: IScheduleRepository, + private readonly eventBus: EventBus + ) {} + + async execute(req: ToggleScheduleRequest): Promise> { + const schedule = await this.scheduleRepo.findById(UniqueId.from(req.id)); + if (!schedule) return Err('Schedule not found'); + + schedule.toggle(req.enabled); + await this.scheduleRepo.update(schedule); + + for (const event of schedule.domainEvents) { + await this.eventBus.publish(event); + } + schedule.clearEvents(); + + return Ok({ id: req.id, enabled: req.enabled }); + } +} diff --git a/src/modules/scheduling/application/queries/ListSchedulesQuery.ts b/src/modules/scheduling/application/queries/ListSchedulesQuery.ts new file mode 100644 index 0000000..7646c65 --- /dev/null +++ b/src/modules/scheduling/application/queries/ListSchedulesQuery.ts @@ -0,0 +1,38 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok } from '../../../../shared/domain/Result'; +import { IScheduleRepository } from '../../domain/ports/IScheduleRepository'; + +export interface ListSchedulesRequest { enabledOnly?: boolean } + +export interface ScheduleDTO { + id: string; + name: string; + url: string; + cronExpression: string; + config: Record; + enabled: boolean; + lastRunAt: number | null; + nextRunAt: number | null; + createdAt: number; +} + +export class ListSchedulesQuery implements UseCase { + constructor(private readonly scheduleRepo: IScheduleRepository) {} + + async execute(req: ListSchedulesRequest): Promise> { + const schedules = await this.scheduleRepo.findAll(req.enabledOnly); + return Ok( + schedules.map((s) => ({ + id: s.id.toString(), + name: s.name, + url: s.url, + cronExpression: s.cronExpression.value, + config: s.config, + enabled: s.enabled, + lastRunAt: s.lastRunAt, + nextRunAt: s.nextRunAt, + createdAt: s.createdAt, + })) + ); + } +} diff --git a/src/modules/scheduling/domain/entities/Schedule.ts b/src/modules/scheduling/domain/entities/Schedule.ts new file mode 100644 index 0000000..5893bb3 --- /dev/null +++ b/src/modules/scheduling/domain/entities/Schedule.ts @@ -0,0 +1,136 @@ +import { AggregateRoot } from '../../../../shared/domain/AggregateRoot'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { Result, Ok, Err, isOk } from '../../../../shared/domain/Result'; +import { CronExpression } from '../value-objects/CronExpression'; +import { ScheduleCreated } from '../events/ScheduleCreated'; +import { ScheduleToggled } from '../events/ScheduleToggled'; +import { z } from 'zod'; + +export const CreateScheduleSchema = z.object({ + name: z.string().min(1).max(100), + url: z.string().url(), + cronExpression: z.string().min(1), + config: z.record(z.string(), z.unknown()).optional().default({}), + enabled: z.boolean().optional().default(true), +}); + +export type CreateScheduleProps = z.infer; + +interface ScheduleProps { + name: string; + url: string; + cronExpression: CronExpression; + config: Record; + enabled: boolean; + lastRunAt: number | null; + nextRunAt: number | null; + createdAt: number; +} + +export class Schedule extends AggregateRoot { + get name(): string { return this.props.name; } + get url(): string { return this.props.url; } + get cronExpression(): CronExpression { return this.props.cronExpression; } + get config(): Record { return this.props.config; } + get enabled(): boolean { return this.props.enabled; } + get lastRunAt(): number | null { return this.props.lastRunAt; } + get nextRunAt(): number | null { return this.props.nextRunAt; } + get createdAt(): number { return this.props.createdAt; } + + static create(input: CreateScheduleProps): Result { + const parsed = CreateScheduleSchema.safeParse(input); + if (!parsed.success) { + return Err(parsed.error.issues.map((e) => e.message).join(', ')); + } + + const cronResult = CronExpression.create(parsed.data.cronExpression); + if (!cronResult.ok) { + return Err(cronResult.error); + } + + const id = UniqueId.create(); + const now = Date.now(); + + const schedule = new Schedule( + { + name: parsed.data.name, + url: parsed.data.url, + cronExpression: cronResult.value, + config: parsed.data.config, + enabled: parsed.data.enabled, + lastRunAt: null, + nextRunAt: now + 60_000, // approximate next run + createdAt: now, + }, + id + ); + + schedule.addDomainEvent( + new ScheduleCreated(id.toString(), { + name: parsed.data.name, + url: parsed.data.url, + cronExpression: parsed.data.cronExpression, + }) + ); + + return Ok(schedule); + } + + static reconstitute( + id: string, + props: { + name: string; + url: string; + cronExpression: string; + config: Record; + enabled: boolean; + lastRunAt: number | null; + nextRunAt: number | null; + createdAt: number; + } + ): Schedule { + const cronResult = CronExpression.create(props.cronExpression); + // If stored cron is invalid, store raw value — shouldn't happen in practice + const cronExpr = isOk(cronResult) + ? cronResult.value + : ({ props: { value: props.cronExpression }, value: props.cronExpression } as unknown as CronExpression); + + return new Schedule( + { + name: props.name, + url: props.url, + cronExpression: cronExpr, + config: props.config, + enabled: props.enabled, + lastRunAt: props.lastRunAt, + nextRunAt: props.nextRunAt, + createdAt: props.createdAt, + }, + UniqueId.from(id) + ); + } + + toggle(enabled: boolean): void { + this.props.enabled = enabled; + this.addDomainEvent( + new ScheduleToggled(this.id.toString(), { enabled }) + ); + } + + markFired(now: number): void { + this.props.lastRunAt = now; + this.props.nextRunAt = now + 60_000; // approximate + } + + update(fields: { name?: string; url?: string; cronExpression?: string; config?: Record }): Result { + if (fields.cronExpression !== undefined) { + const cronResult = CronExpression.create(fields.cronExpression); + if (!cronResult.ok) return Err(cronResult.error); + this.props.cronExpression = cronResult.value; + } + if (fields.name !== undefined) this.props.name = fields.name; + if (fields.url !== undefined) this.props.url = fields.url; + if (fields.config !== undefined) this.props.config = fields.config; + return Ok(undefined); + } +} diff --git a/src/modules/scheduling/domain/events/ScheduleCreated.ts b/src/modules/scheduling/domain/events/ScheduleCreated.ts new file mode 100644 index 0000000..cc578a1 --- /dev/null +++ b/src/modules/scheduling/domain/events/ScheduleCreated.ts @@ -0,0 +1,13 @@ +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; +import { randomUUID } from 'crypto'; + +export class ScheduleCreated implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'scheduling.schedule_created'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: { name: string; url: string; cronExpression: string } + ) {} +} diff --git a/src/modules/scheduling/domain/events/ScheduleFired.ts b/src/modules/scheduling/domain/events/ScheduleFired.ts new file mode 100644 index 0000000..d975842 --- /dev/null +++ b/src/modules/scheduling/domain/events/ScheduleFired.ts @@ -0,0 +1,13 @@ +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; +import { randomUUID } from 'crypto'; + +export class ScheduleFired implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'scheduling.schedule_fired'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: { scheduleId: string; url: string; jobId: string } + ) {} +} diff --git a/src/modules/scheduling/domain/events/ScheduleToggled.ts b/src/modules/scheduling/domain/events/ScheduleToggled.ts new file mode 100644 index 0000000..60e2387 --- /dev/null +++ b/src/modules/scheduling/domain/events/ScheduleToggled.ts @@ -0,0 +1,13 @@ +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; +import { randomUUID } from 'crypto'; + +export class ScheduleToggled implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'scheduling.schedule_toggled'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: { enabled: boolean } + ) {} +} diff --git a/src/modules/scheduling/domain/ports/IScheduleRepository.ts b/src/modules/scheduling/domain/ports/IScheduleRepository.ts new file mode 100644 index 0000000..12a63af --- /dev/null +++ b/src/modules/scheduling/domain/ports/IScheduleRepository.ts @@ -0,0 +1,10 @@ +import { Schedule } from '../entities/Schedule'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; + +export interface IScheduleRepository { + save(schedule: Schedule): Promise; + findById(id: UniqueId): Promise; + findAll(enabledOnly?: boolean): Promise; + update(schedule: Schedule): Promise; + delete(id: UniqueId): Promise; +} diff --git a/src/modules/scheduling/domain/value-objects/CronExpression.ts b/src/modules/scheduling/domain/value-objects/CronExpression.ts new file mode 100644 index 0000000..1fdcf8f --- /dev/null +++ b/src/modules/scheduling/domain/value-objects/CronExpression.ts @@ -0,0 +1,23 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import * as cron from 'node-cron'; + +interface CronExpressionProps { + value: string; +} + +export class CronExpression extends ValueObject { + get value(): string { + return this.props.value; + } + + static create(expression: string): Result { + if (!expression || expression.trim().length === 0) { + return Err('Cron expression cannot be empty'); + } + if (!cron.validate(expression)) { + return Err(`Invalid cron expression: "${expression}"`); + } + return Ok(new CronExpression({ value: expression })); + } +} diff --git a/src/modules/scheduling/index.ts b/src/modules/scheduling/index.ts new file mode 100644 index 0000000..5965949 --- /dev/null +++ b/src/modules/scheduling/index.ts @@ -0,0 +1,11 @@ +// Scheduling module public API +export { Schedule } from './domain/entities/Schedule'; +export { CronExpression } from './domain/value-objects/CronExpression'; +export { IScheduleRepository } from './domain/ports/IScheduleRepository'; +export { CreateScheduleCommand } from './application/commands/CreateScheduleCommand'; +export { ToggleScheduleCommand } from './application/commands/ToggleScheduleCommand'; +export { DeleteScheduleCommand } from './application/commands/DeleteScheduleCommand'; +export { ListSchedulesQuery } from './application/queries/ListSchedulesQuery'; +export { SchedulingService } from './application/SchedulingService'; +export { KyselyScheduleRepository } from './infrastructure/repositories/KyselyScheduleRepository'; +export { createSchedulingRouter, SchedulingControllerDeps } from './infrastructure/http/SchedulingController'; diff --git a/src/modules/scheduling/infrastructure/http/SchedulingController.ts b/src/modules/scheduling/infrastructure/http/SchedulingController.ts new file mode 100644 index 0000000..ae1df09 --- /dev/null +++ b/src/modules/scheduling/infrastructure/http/SchedulingController.ts @@ -0,0 +1,110 @@ +import { Router, Request, Response } from 'express'; +import { CreateScheduleCommand } from '../../application/commands/CreateScheduleCommand'; +import { ToggleScheduleCommand } from '../../application/commands/ToggleScheduleCommand'; +import { DeleteScheduleCommand } from '../../application/commands/DeleteScheduleCommand'; +import { ListSchedulesQuery } from '../../application/queries/ListSchedulesQuery'; +import { SchedulingService } from '../../application/SchedulingService'; +import { Schedule } from '../../domain/entities/Schedule'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { IScheduleRepository } from '../../domain/ports/IScheduleRepository'; + +export interface SchedulingControllerDeps { + createSchedule: CreateScheduleCommand; + toggleSchedule: ToggleScheduleCommand; + deleteSchedule: DeleteScheduleCommand; + listSchedules: ListSchedulesQuery; + schedulingService: SchedulingService; + scheduleRepo: IScheduleRepository; +} + +export function createSchedulingRouter(deps: SchedulingControllerDeps): Router { + const router = Router(); + const { createSchedule, toggleSchedule, deleteSchedule, listSchedules, schedulingService, scheduleRepo } = deps; + + // GET /api/schedules + router.get('/', async (_req: Request, res: Response) => { + const result = await listSchedules.execute({}); + if (!result.ok) { + res.status(500).json({ error: result.error }); + return; + } + res.json(result.value); + }); + + // POST /api/schedules + router.post('/', async (req: Request, res: Response) => { + const body = req.body as { + name?: string; + url?: string; + cronExpression?: string; + config?: Record; + enabled?: boolean; + }; + + const result = await createSchedule.execute({ + name: body.name ?? '', + url: body.url ?? '', + cronExpression: body.cronExpression ?? '', + config: body.config ?? {}, + enabled: body.enabled !== false, + }); + + if (!result.ok) { + res.status(400).json({ error: result.error }); + return; + } + + // Register cron after creation + const schedule = await scheduleRepo.findById(UniqueId.from(result.value.id)); + if (schedule) { + schedulingService.registerCron(schedule as Schedule); + } + + res.status(201).json(result.value); + }); + + // PATCH /api/schedules/:id/toggle + router.patch('/:id/toggle', async (req: Request, res: Response) => { + const id = String(req.params['id']); + const { enabled } = req.body as { enabled?: boolean }; + + if (enabled === undefined) { + res.status(400).json({ error: 'enabled is required' }); + return; + } + + const result = await toggleSchedule.execute({ id, enabled }); + if (!result.ok) { + res.status(result.error === 'Schedule not found' ? 404 : 400).json({ error: result.error }); + return; + } + + // Update cron registration + const schedule = await scheduleRepo.findById(UniqueId.from(id)); + if (schedule) { + if (enabled) { + schedulingService.registerCron(schedule as Schedule); + } else { + schedulingService.unregisterCron(id); + } + } + + res.json(result.value); + }); + + // DELETE /api/schedules/:id + router.delete('/:id', async (req: Request, res: Response) => { + const id = String(req.params['id']); + schedulingService.unregisterCron(id); + + const result = await deleteSchedule.execute({ id }); + if (!result.ok) { + res.status(result.error === 'Schedule not found' ? 404 : 400).json({ error: result.error }); + return; + } + + res.status(204).send(); + }); + + return router; +} diff --git a/src/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.ts b/src/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.ts new file mode 100644 index 0000000..ab4b76c --- /dev/null +++ b/src/modules/scheduling/infrastructure/repositories/KyselyScheduleRepository.ts @@ -0,0 +1,89 @@ +import { Kysely } from 'kysely'; +import { Database } from '../../../../shared/infrastructure/DatabaseConnection'; +import { IScheduleRepository } from '../../domain/ports/IScheduleRepository'; +import { Schedule } from '../../domain/entities/Schedule'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; + +export class KyselyScheduleRepository implements IScheduleRepository { + constructor(private readonly db: Kysely) {} + + async save(schedule: Schedule): Promise { + await this.db + .insertInto('schedules') + .values({ + id: schedule.id.toString(), + name: schedule.name, + url: schedule.url, + config_json: JSON.stringify(schedule.config), + cron_expression: schedule.cronExpression.value, + enabled: schedule.enabled ? 1 : 0, + last_run_at: schedule.lastRunAt, + next_run_at: schedule.nextRunAt, + created_at: schedule.createdAt, + }) + .execute(); + } + + async findById(id: UniqueId): Promise { + const row = await this.db + .selectFrom('schedules') + .selectAll() + .where('id', '=', id.toString()) + .executeTakeFirst(); + + if (!row) return null; + return this.toEntity(row); + } + + async findAll(enabledOnly = false): Promise { + let query = this.db.selectFrom('schedules').selectAll().orderBy('created_at', 'desc'); + if (enabledOnly) { + query = query.where('enabled', '=', 1); + } + const rows = await query.execute(); + return rows.map((r) => this.toEntity(r)); + } + + async update(schedule: Schedule): Promise { + await this.db + .updateTable('schedules') + .set({ + name: schedule.name, + url: schedule.url, + config_json: JSON.stringify(schedule.config), + cron_expression: schedule.cronExpression.value, + enabled: schedule.enabled ? 1 : 0, + last_run_at: schedule.lastRunAt, + next_run_at: schedule.nextRunAt, + }) + .where('id', '=', schedule.id.toString()) + .execute(); + } + + async delete(id: UniqueId): Promise { + await this.db.deleteFrom('schedules').where('id', '=', id.toString()).execute(); + } + + private toEntity(row: { + id: string; + name: string; + url: string; + config_json: string; + cron_expression: string; + enabled: number; + last_run_at: number | null; + next_run_at: number | null; + created_at: number; + }): Schedule { + return Schedule.reconstitute(row.id, { + name: row.name, + url: row.url, + cronExpression: row.cron_expression, + config: JSON.parse(row.config_json) as Record, + enabled: row.enabled === 1, + lastRunAt: row.last_run_at, + nextRunAt: row.next_run_at, + createdAt: row.created_at, + }); + } +}