Skip to content

Commit c667c6c

Browse files
committed
feat: add create and delete functionality for cron tasks in API and UI
- Implemented create and delete endpoints in CronController and CronService, allowing for management of cron tasks. - Updated cron.controller.spec.ts to include tests for the new create and delete functionalities. - Enhanced cron.vue to provide UI elements for creating and deleting cron tasks, with permission checks for user actions. - Improved error handling for task deletion when the specified task does not exist.
1 parent 338d686 commit c667c6c

6 files changed

Lines changed: 578 additions & 45 deletions

File tree

apps/api/src/core/cron/cron.controller.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ConflictException,
44
Controller,
55
DefaultValuePipe,
6+
Delete,
67
Get,
78
HttpStatus,
89
NotFoundException,
@@ -20,7 +21,7 @@ import { UseRoles } from '~/_common/decorators/use-roles.decorator';
2021
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types';
2122
import { ApiPaginatedDecorator } from '~/_common/decorators/api-paginated.decorator';
2223
import { PickProjectionHelper } from '~/_common/helpers/pick-projection.helper';
23-
import { CronDto, CronUpdateDto } from './_dto/cron.dto';
24+
import { CronCreateDto, CronDto, CronUpdateDto } from './_dto/cron.dto';
2425
import { PartialProjectionType } from '~/_common/types/partial-projection.type';
2526
import { ApiReadResponseDecorator } from '~/_common/decorators/api-read-response.decorator';
2627
import { IsBoolean } from 'class-validator';
@@ -93,6 +94,22 @@ export class CronController {
9394
});
9495
}
9596

97+
@Post()
98+
@UseRoles({
99+
resource: '/core/cron',
100+
action: AC_ACTIONS.CREATE,
101+
possession: AC_DEFAULT_POSSESSION,
102+
})
103+
@ApiReadResponseDecorator(CronDto)
104+
public async create(@Body() body: CronCreateDto, @Res() res: Response): Promise<Response> {
105+
const data = await this.cronService.create(body);
106+
107+
return res.status(HttpStatus.CREATED).json({
108+
statusCode: HttpStatus.CREATED,
109+
data,
110+
});
111+
}
112+
96113
@Get(':name')
97114
@UseRoles({
98115
resource: '/core/cron',
@@ -200,4 +217,25 @@ export class CronController {
200217
},
201218
});
202219
}
220+
221+
@Delete(':name')
222+
@UseRoles({
223+
resource: '/core/cron',
224+
action: AC_ACTIONS.DELETE,
225+
possession: AC_DEFAULT_POSSESSION,
226+
})
227+
public async delete(@Param('name') name: string, @Res() res: Response): Promise<Response> {
228+
const deleted = await this.cronService.delete(name);
229+
if (!deleted) {
230+
throw new NotFoundException(`Cron task <${name}> not found`);
231+
}
232+
233+
return res.json({
234+
statusCode: HttpStatus.OK,
235+
data: {
236+
name,
237+
deleted: true,
238+
},
239+
});
240+
}
203241
}

apps/api/src/core/cron/cron.service.ts

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
1+
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
22
import { SchedulerRegistry } from '@nestjs/schedule';
33
import { CronHooksService } from './cron-hooks.service';
44
import { pick } from 'radash';
55
import { ConfigTaskDTO, CronTaskDTO } from './_dto/config-task.dto';
6-
import { CronUpdateDto } from './_dto/cron.dto';
6+
import { CronCreateDto, CronUpdateDto } from './_dto/cron.dto';
77
import { CronJob } from 'cron';
88
import path from 'node:path';
9-
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from 'node:fs';
9+
import {
10+
closeSync,
11+
existsSync,
12+
mkdirSync,
13+
openSync,
14+
readFileSync,
15+
readSync,
16+
readdirSync,
17+
statSync,
18+
unlinkSync,
19+
writeFileSync,
20+
} from 'node:fs';
1021
import { ConfigService } from '@nestjs/config';
1122
import { toSafeHandlerName } from '~/_common/functions/handler-logger';
1223
import { formatValidationErrors } from '~/_common/functions/format-validation-errors.function';
@@ -118,6 +129,55 @@ export class CronService {
118129
return this.update(name, { enabled });
119130
}
120131

132+
public async create(payload: CronCreateDto): Promise<(CronTaskDTO & { _job: Partial<CronJob> }) | null> {
133+
const existing = this.cronHooksService.getCronTasks().find((task) => task.name === payload.name);
134+
if (existing) {
135+
throw new ConflictException(`Cron task <${payload.name}> already exists`);
136+
}
137+
138+
try {
139+
if (payload.options !== undefined) {
140+
validateCronTaskOptions(payload.handler, payload.options);
141+
}
142+
} catch (error) {
143+
if (error instanceof BadRequestException) {
144+
throw error;
145+
}
146+
throw new BadRequestException(`Options invalides pour le handler "${payload.handler}".`);
147+
}
148+
149+
const task = plainToInstance(CronTaskDTO, payload);
150+
151+
try {
152+
await validateOrReject(task, { whitelist: true });
153+
} catch (errors) {
154+
throw new BadRequestException(formatValidationErrors(errors, payload.name));
155+
}
156+
157+
const created = this.createTaskInConfig(task);
158+
if (!created) {
159+
throw new BadRequestException(`Impossible de créer la tâche cron <${payload.name}> dans la configuration.`);
160+
}
161+
162+
await this.cronHooksService.syncCronJobs();
163+
return this.read(payload.name);
164+
}
165+
166+
public async delete(name: string): Promise<boolean> {
167+
const current = this.cronHooksService.getCronTasks().find((task) => task.name === name);
168+
if (!current) {
169+
return false;
170+
}
171+
172+
const deleted = this.deleteTaskFromConfig(name);
173+
if (!deleted) {
174+
return false;
175+
}
176+
177+
await this.cronHooksService.syncCronJobs();
178+
return true;
179+
}
180+
121181
public async update(
122182
name: string,
123183
payload: CronUpdateDto,
@@ -278,16 +338,72 @@ export class CronService {
278338
};
279339
}
280340

281-
private updateTaskInConfig(name: string, updater: (task: CronTaskDTO) => void): boolean {
282-
const configDir = path.join(process.cwd(), 'configs', 'cron');
341+
private getConfigDir(): string {
342+
return path.join(process.cwd(), 'configs', 'cron');
343+
}
344+
345+
private listConfigFiles(): string[] {
346+
const configDir = this.getConfigDir();
283347
if (!existsSync(configDir)) {
284-
return false;
348+
return [];
349+
}
350+
351+
return readdirSync(configDir).filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
352+
}
353+
354+
private createTaskInConfig(task: CronTaskDTO): boolean {
355+
const configDir = this.getConfigDir();
356+
if (!existsSync(configDir)) {
357+
mkdirSync(configDir, { recursive: true });
358+
}
359+
360+
const fileName = `${toSafeHandlerName(task.name)}.yml`;
361+
const filePath = path.join(configDir, fileName);
362+
363+
if (existsSync(filePath)) {
364+
const raw = readFileSync(filePath, 'utf-8');
365+
const parsed = parse(raw) as ConfigTaskDTO;
366+
if (parsed?.tasks?.length) {
367+
throw new ConflictException(`Le fichier de configuration <${fileName}> existe déjà.`);
368+
}
369+
}
370+
371+
writeFileSync(filePath, stringify({ tasks: [task] } satisfies ConfigTaskDTO));
372+
return true;
373+
}
374+
375+
private deleteTaskFromConfig(name: string): boolean {
376+
for (const file of this.listConfigFiles()) {
377+
const filePath = path.join(this.getConfigDir(), file);
378+
const raw = readFileSync(filePath, 'utf-8');
379+
const parsed = parse(raw) as ConfigTaskDTO;
380+
const tasks = parsed?.tasks;
381+
382+
if (!tasks || !Array.isArray(tasks)) {
383+
continue;
384+
}
385+
386+
const taskIndex = tasks.findIndex((task) => task.name === name);
387+
if (taskIndex === -1) {
388+
continue;
389+
}
390+
391+
tasks.splice(taskIndex, 1);
392+
if (tasks.length === 0) {
393+
unlinkSync(filePath);
394+
} else {
395+
writeFileSync(filePath, stringify(parsed));
396+
}
397+
398+
return true;
285399
}
286400

287-
const files = readdirSync(configDir).filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
401+
return false;
402+
}
288403

289-
for (const file of files) {
290-
const filePath = path.join(configDir, file);
404+
private updateTaskInConfig(name: string, updater: (task: CronTaskDTO) => void): boolean {
405+
for (const file of this.listConfigFiles()) {
406+
const filePath = path.join(this.getConfigDir(), file);
291407
const raw = readFileSync(filePath, 'utf-8');
292408
const parsed = parse(raw) as ConfigTaskDTO;
293409
const tasks = parsed?.tasks;

apps/api/tests/unit/core/cron/cron.controller.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ describe('CronController', () => {
99
const update = jest.fn();
1010
const getConsoleHandlers = jest.fn();
1111
const runImmediately = jest.fn();
12+
const create = jest.fn();
13+
const deleteTask = jest.fn();
1214

1315
const cronService = {
1416
search,
@@ -18,6 +20,8 @@ describe('CronController', () => {
1820
update,
1921
getConsoleHandlers,
2022
runImmediately,
23+
create,
24+
delete: deleteTask,
2125
};
2226

2327
const createRes = () => {
@@ -160,4 +164,58 @@ describe('CronController', () => {
160164
},
161165
});
162166
});
167+
168+
it('should return created task on create', async () => {
169+
create.mockResolvedValue({ name: 'task-new', enabled: false });
170+
const res = {
171+
status: jest.fn().mockReturnThis(),
172+
json: jest.fn(),
173+
};
174+
175+
await controller.create(
176+
{
177+
name: 'task-new',
178+
description: 'new task',
179+
enabled: false,
180+
schedule: '0 * * * *',
181+
handler: 'agents-list',
182+
} as any,
183+
res as any,
184+
);
185+
186+
expect(create).toHaveBeenCalledWith({
187+
name: 'task-new',
188+
description: 'new task',
189+
enabled: false,
190+
schedule: '0 * * * *',
191+
handler: 'agents-list',
192+
});
193+
expect(res.status).toHaveBeenCalledWith(201);
194+
expect(res.json).toHaveBeenCalledWith({
195+
statusCode: 201,
196+
data: { name: 'task-new', enabled: false },
197+
});
198+
});
199+
200+
it('should throw NotFoundException when deleting missing task', async () => {
201+
deleteTask.mockResolvedValue(false);
202+
203+
await expect(controller.delete('missing-task', {} as any)).rejects.toThrow(NotFoundException);
204+
});
205+
206+
it('should return deleted payload on delete', async () => {
207+
deleteTask.mockResolvedValue(true);
208+
const res = createRes();
209+
210+
await controller.delete('task-1', res as any);
211+
212+
expect(deleteTask).toHaveBeenCalledWith('task-1');
213+
expect(res.json).toHaveBeenCalledWith({
214+
statusCode: 200,
215+
data: {
216+
name: 'task-1',
217+
deleted: true,
218+
},
219+
});
220+
});
163221
});

0 commit comments

Comments
 (0)