Skip to content

Commit 22c69b4

Browse files
committed
feat: implement lifecycle configuration management in API and UI
- Added new endpoints in LifecycleController for managing lifecycle rules, including search, create, read, update, and delete functionalities. - Introduced LifecycleConfigService to handle lifecycle rule operations. - Updated LifecycleHooksService to include a method for syncing after configuration changes. - Enhanced the settings UI to include a section for lifecycle management with appropriate access controls.
1 parent c667c6c commit 22c69b4

7 files changed

Lines changed: 1169 additions & 1 deletion

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { ApiProperty, PartialType } from '@nestjs/swagger';
2+
import { Type } from 'class-transformer';
3+
import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsString, Matches, ValidateNested } from 'class-validator';
4+
import { ConfigRulesObjectIdentitiesDTO } from './config-rules.dto';
5+
import { LifecycleStateDTO } from './config-states.dto';
6+
7+
export class LifecycleRuleFileSummaryDto {
8+
@IsString()
9+
@ApiProperty({ type: String })
10+
name: string;
11+
12+
@IsNumber()
13+
@ApiProperty({ type: Number })
14+
rulesCount: number;
15+
16+
@IsBoolean()
17+
@ApiProperty({ type: Boolean })
18+
cronExecutable: boolean;
19+
20+
@IsArray()
21+
@IsString({ each: true })
22+
@ApiProperty({ type: [String] })
23+
sources: string[];
24+
25+
@IsArray()
26+
@IsString({ each: true })
27+
@ApiProperty({ type: [String] })
28+
targets: string[];
29+
}
30+
31+
export class LifecycleRuleFileCreateDto {
32+
@IsString()
33+
@IsNotEmpty()
34+
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
35+
message: 'Le nom de fichier doit contenir uniquement des lettres, chiffres, tirets et underscores',
36+
})
37+
@ApiProperty({
38+
type: String,
39+
description: 'Nom du fichier de règles sans extension (ex. 01-etd)',
40+
example: '01-etd',
41+
required: true,
42+
})
43+
name: string;
44+
45+
@IsArray()
46+
@ValidateNested({ each: true })
47+
@Type(() => ConfigRulesObjectIdentitiesDTO)
48+
@ApiProperty({
49+
type: [ConfigRulesObjectIdentitiesDTO],
50+
description: 'Règles de transition pour les identités',
51+
required: true,
52+
})
53+
identities: ConfigRulesObjectIdentitiesDTO[];
54+
}
55+
56+
export class LifecycleRuleFileUpdateDto extends PartialType(LifecycleRuleFileCreateDto) {}
57+
58+
export class LifecycleStatesUpdateDto {
59+
@IsArray()
60+
@ValidateNested({ each: true })
61+
@Type(() => LifecycleStateDTO)
62+
@ApiProperty({
63+
type: [LifecycleStateDTO],
64+
description: 'États personnalisés à enregistrer dans states.yml',
65+
required: true,
66+
})
67+
states: LifecycleStateDTO[];
68+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
2+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3+
import path from 'node:path';
4+
import { plainToInstance } from 'class-transformer';
5+
import { validateOrReject } from 'class-validator';
6+
import { parse, stringify } from 'yaml';
7+
import { formatValidationErrors } from '~/_common/functions/format-validation-errors.function';
8+
import { ConfigRulesObjectIdentitiesDTO, ConfigRulesObjectSchemaDTO } from './_dto/config-rules.dto';
9+
import { IdentityLifecycleState } from '../identities/_enums/lifecycle.enum';
10+
import { ConfigStatesDTO, LifecycleStateDTO } from './_dto/config-states.dto';
11+
import {
12+
LifecycleRuleFileCreateDto,
13+
LifecycleRuleFileUpdateDto,
14+
LifecycleStatesUpdateDto,
15+
} from './_dto/lifecycle-config.dto';
16+
import { loadLifecycleRules } from './_functions/load-lifecycle-rules.function';
17+
import { validateLifecycleCronRules } from './_functions/validate-lifecycle-cron-rules.function';
18+
import { LifecycleCrudService } from './lifecycle-crud.service';
19+
import { LifecycleHooksService } from './lifecycle-hooks.service';
20+
21+
export type LifecycleRuleFileSummary = {
22+
name: string;
23+
rulesCount: number;
24+
cronExecutable: boolean;
25+
sources: string[];
26+
targets: string[];
27+
};
28+
29+
@Injectable()
30+
export class LifecycleConfigService {
31+
private readonly logger = new Logger(LifecycleConfigService.name);
32+
33+
public constructor(
34+
private readonly lifecycleHooksService: LifecycleHooksService,
35+
private readonly lifecycleCrudService: LifecycleCrudService,
36+
) {}
37+
38+
public async searchRules(
39+
search?: string,
40+
options?: { page?: number; limit?: number },
41+
): Promise<[LifecycleRuleFileSummary[], number]> {
42+
options = { page: 1, limit: 10, ...options };
43+
44+
const rules = await loadLifecycleRules();
45+
const summaries = rules.map((ruleFile) => this.toRuleSummary(ruleFile));
46+
47+
const filtered = summaries.filter((item) => {
48+
if (!search) {
49+
return true;
50+
}
51+
const haystack = [item.name, ...item.sources, ...item.targets].join(' ').toLowerCase();
52+
return haystack.includes(search.toLowerCase());
53+
});
54+
55+
const total = filtered.length;
56+
const data = filtered.slice((options.page - 1) * options.limit, options.page * options.limit);
57+
58+
return [data, total];
59+
}
60+
61+
public async readRule(name: string): Promise<ConfigRulesObjectSchemaDTO | null> {
62+
const filePath = this.getRuleFilePath(name);
63+
if (!existsSync(filePath)) {
64+
return null;
65+
}
66+
67+
const raw = readFileSync(filePath, 'utf-8');
68+
const parsed = parse(raw) as ConfigRulesObjectSchemaDTO;
69+
const schema = plainToInstance(ConfigRulesObjectSchemaDTO, parsed);
70+
71+
await this.validateRuleSchema(schema, name);
72+
73+
schema.ruleFileBasename = name;
74+
return schema;
75+
}
76+
77+
public async createRule(payload: LifecycleRuleFileCreateDto): Promise<ConfigRulesObjectSchemaDTO> {
78+
const name = payload.name.trim();
79+
const filePath = this.getRuleFilePath(name);
80+
81+
if (existsSync(filePath)) {
82+
throw new ConflictException(`Le fichier de règles <${name}> existe déjà.`);
83+
}
84+
85+
const schema = plainToInstance(ConfigRulesObjectSchemaDTO, {
86+
identities: payload.identities,
87+
});
88+
89+
await this.validateRuleSchema(schema, name);
90+
this.ensureRulesDir();
91+
writeFileSync(filePath, stringify({ identities: payload.identities }));
92+
93+
await this.syncAfterConfigChange();
94+
const created = await this.readRule(name);
95+
if (!created) {
96+
throw new BadRequestException(`Impossible de créer le fichier de règles <${name}>.`);
97+
}
98+
99+
return created;
100+
}
101+
102+
public async updateRule(name: string, payload: LifecycleRuleFileUpdateDto): Promise<ConfigRulesObjectSchemaDTO | null> {
103+
const current = await this.readRule(name);
104+
if (!current) {
105+
return null;
106+
}
107+
108+
if (payload.identities === undefined) {
109+
return current;
110+
}
111+
112+
const schema = plainToInstance(ConfigRulesObjectSchemaDTO, {
113+
identities: payload.identities,
114+
});
115+
116+
await this.validateRuleSchema(schema, name);
117+
118+
const filePath = this.getRuleFilePath(name);
119+
writeFileSync(filePath, stringify({ identities: payload.identities }));
120+
121+
await this.syncAfterConfigChange();
122+
return this.readRule(name);
123+
}
124+
125+
public async deleteRule(name: string): Promise<boolean> {
126+
const filePath = this.getRuleFilePath(name);
127+
if (!existsSync(filePath)) {
128+
return false;
129+
}
130+
131+
unlinkSync(filePath);
132+
await this.syncAfterConfigChange();
133+
return true;
134+
}
135+
136+
public async executeRule(name: string): Promise<'started' | 'not_found' | 'not_executable'> {
137+
const exists = existsSync(this.getRuleFilePath(name));
138+
if (!exists) {
139+
return 'not_found';
140+
}
141+
142+
const executed = await this.lifecycleHooksService.executeCronForSource(name);
143+
return executed ? 'started' : 'not_executable';
144+
}
145+
146+
public getDefaultStates() {
147+
return this.lifecycleCrudService.getAllAvailableStates().filter((state) =>
148+
['O', 'I', 'M'].includes(state.key),
149+
);
150+
}
151+
152+
public getCustomStates(): IdentityLifecycleState[] {
153+
return this.lifecycleCrudService.getCustomStates();
154+
}
155+
156+
public async updateCustomStates(payload: LifecycleStatesUpdateDto): Promise<IdentityLifecycleState[]> {
157+
const config = plainToInstance(ConfigStatesDTO, { states: payload.states });
158+
await validateOrReject(config, { whitelist: true });
159+
160+
const statesPath = this.getStatesFilePath();
161+
this.ensureLifecycleDir();
162+
writeFileSync(statesPath, stringify({ states: payload.states }));
163+
164+
await this.syncAfterConfigChange();
165+
await this.lifecycleCrudService.ensureStatesCacheFresh(0);
166+
167+
return this.getCustomStates();
168+
}
169+
170+
private toRuleSummary(ruleFile: ConfigRulesObjectSchemaDTO): LifecycleRuleFileSummary {
171+
const name = ruleFile.ruleFileBasename || 'unknown';
172+
const identities = ruleFile.identities || [];
173+
const validation = validateLifecycleCronRules(name, identities);
174+
175+
return {
176+
name,
177+
rulesCount: identities.length,
178+
cronExecutable: validation.executable,
179+
sources: [...new Set(identities.flatMap((rule) => rule.sources || []))],
180+
targets: [...new Set(identities.map((rule) => rule.target).filter(Boolean))],
181+
};
182+
}
183+
184+
private async validateRuleSchema(schema: ConfigRulesObjectSchemaDTO, context: string): Promise<void> {
185+
if (!schema?.identities || !Array.isArray(schema.identities)) {
186+
throw new BadRequestException(`Le fichier <${context}> doit contenir un tableau identities.`);
187+
}
188+
189+
try {
190+
await validateOrReject(schema, { whitelist: true });
191+
} catch (errors) {
192+
throw new BadRequestException(formatValidationErrors(errors, context));
193+
}
194+
195+
for (const rule of schema.identities) {
196+
plainToInstance(ConfigRulesObjectIdentitiesDTO, rule);
197+
}
198+
}
199+
200+
private getRulesDir(): string {
201+
return path.join(process.cwd(), 'configs', 'lifecycle', 'rules');
202+
}
203+
204+
private getLifecycleDir(): string {
205+
return path.join(process.cwd(), 'configs', 'lifecycle');
206+
}
207+
208+
private getStatesFilePath(): string {
209+
return path.join(this.getLifecycleDir(), 'states.yml');
210+
}
211+
212+
private getRuleFilePath(name: string): string {
213+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
214+
return path.join(this.getRulesDir(), `${safeName}.yml`);
215+
}
216+
217+
private ensureRulesDir(): void {
218+
const rulesDir = this.getRulesDir();
219+
if (!existsSync(rulesDir)) {
220+
mkdirSync(rulesDir, { recursive: true });
221+
}
222+
}
223+
224+
private ensureLifecycleDir(): void {
225+
const lifecycleDir = this.getLifecycleDir();
226+
if (!existsSync(lifecycleDir)) {
227+
mkdirSync(lifecycleDir, { recursive: true });
228+
}
229+
}
230+
231+
private async syncAfterConfigChange(): Promise<void> {
232+
await this.lifecycleHooksService.syncAfterConfigChange();
233+
this.logger.debug('Lifecycle configuration synchronized after file change.');
234+
}
235+
}

apps/api/src/management/lifecycle/lifecycle-hooks.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ export class LifecycleHooksService extends AbstractLifecycleService {
194194
* @param ttlMs Durée de validité du cache en millisecondes (par défaut 60s)
195195
* @returns Les règles actuelles (rechargées si nécessaire)
196196
*/
197+
public async syncAfterConfigChange(): Promise<void> {
198+
await this.refreshLifecycleCache();
199+
this._lastLifecycleCacheRefresh = Date.now();
200+
}
201+
197202
public async ensureLifecycleCacheFresh(ttlMs: number = 60_000): Promise<ConfigRulesObjectSchemaDTO[]> {
198203
const now = Date.now();
199204
if (!this._lastLifecycleCacheRefresh || now - this._lastLifecycleCacheRefresh > ttlMs) {

0 commit comments

Comments
 (0)