Skip to content

Commit 77208a9

Browse files
committed
refactor: replace LifecycleService with LifecycleCrudService and LifecycleHooksService
- Updated LifecycleListCommand to use LifecycleCrudService instead of LifecycleService. - Modified LifecycleController to utilize LifecycleCrudService and added LifecycleCacheInterceptor. - Refactored LifecycleModule to include LifecycleCrudService and LifecycleHooksService, removing LifecycleService. - Deleted LifecycleService as it is no longer needed.
1 parent 8448152 commit 77208a9

12 files changed

+1216
-981
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { AbstractServiceSchema } from '~/_common/abstracts/abstract.service.schema'
2+
import { LifecycleSource } from '../_interfaces/lifecycle-sources.interface'
3+
import { LifecycleStateDTO } from '../_dto/config-states.dto'
4+
import { InjectModel } from '@nestjs/mongoose'
5+
import { Model } from 'mongoose'
6+
import { Lifecycle } from '../_schemas/lifecycle.schema'
7+
import { IdentitiesCrudService } from '~/management/identities/identities-crud.service'
8+
import { Injectable, OnApplicationBootstrap, OnModuleInit } from '@nestjs/common'
9+
import { BackendsService } from '~/core/backends/backends.service'
10+
import { SchedulerRegistry } from '@nestjs/schedule'
11+
import { ConfigService } from '@nestjs/config'
12+
13+
@Injectable()
14+
export abstract class AbstractLifecycleService extends AbstractServiceSchema implements OnModuleInit, OnApplicationBootstrap {
15+
/**
16+
* Map des sources de cycle de vie et leurs règles associées
17+
* @protected
18+
* @type {LifecycleSource}
19+
*/
20+
protected lifecycleSources: LifecycleSource = {}
21+
22+
/**
23+
* Liste des états personnalisés chargés depuis states.yml
24+
* @protected
25+
* @type {LifecycleStateDTO[]}
26+
*/
27+
protected customStates: LifecycleStateDTO[] = []
28+
29+
/**
30+
* Timestamp de la dernière modification du fichier states.yml
31+
* @protected
32+
* @type {number}
33+
*/
34+
protected _stateFileAge = 0
35+
36+
/**
37+
* Constructeur du service de cycle de vie
38+
*
39+
* @param {Model<Lifecycle>} _model - Modèle Mongoose pour les événements de cycle de vie
40+
* @param {IdentitiesCrudService} identitiesService - Service CRUD des identités
41+
* @param {BackendsService} backendsService - Service de gestion des backends
42+
* @param {SchedulerRegistry} schedulerRegistry - Registre des tâches planifiées
43+
* @param {ConfigService} configService - Service de configuration
44+
*/
45+
public constructor(
46+
@InjectModel(Lifecycle.name) protected _model: Model<Lifecycle>,
47+
protected readonly identitiesService: IdentitiesCrudService,
48+
protected readonly backendsService: BackendsService,
49+
protected readonly schedulerRegistry: SchedulerRegistry,
50+
protected readonly configService: ConfigService,
51+
) {
52+
super()
53+
}
54+
55+
/**
56+
* Getter pour l'âge du fichier states.yml
57+
*
58+
* @returns {number} Timestamp de la dernière modification du fichier
59+
* @description Utilisé pour le cache HTTP des états de cycle de vie
60+
*/
61+
public get stateFileAge(): number {
62+
return this._stateFileAge
63+
}
64+
65+
/**
66+
* Récupère la map des sources de cycle de vie
67+
*
68+
* @method listLifecycleSources
69+
* @returns {LifecycleSource} Map des états source et leurs règles de transition
70+
*
71+
* @description Retourne la structure interne des sources de cycle de vie chargées.
72+
* Chaque clé correspond à un état source, et la valeur est un tableau de règles
73+
* de transition applicables depuis cet état.
74+
*
75+
* Utilisé principalement par les commandes CLI pour l'inspection de la configuration.
76+
*
77+
* @example
78+
* const sources = lifecycleService.listLifecycleSources();
79+
* // {
80+
* // 'OFFICIAL': [{ sources: ['OFFICIAL'], trigger: 90, target: 'MANUAL' }],
81+
* // 'MANUAL': [{ sources: ['MANUAL'], trigger: 30, target: 'ARCHIVED' }]
82+
* // }
83+
*/
84+
public listLifecycleSources(): LifecycleSource {
85+
return this.lifecycleSources
86+
}
87+
88+
public async onModuleInit(): Promise<void> {
89+
this.logger.warn(`LifecycleService (abstract) onModuleInit called - this should be implemented in subclasses !`)
90+
}
91+
92+
public async onApplicationBootstrap(): Promise<void> {
93+
this.logger.warn(`LifecycleService (abstract) onApplicationBootstrap called - this should be implemented in subclasses !`)
94+
}
95+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { ValidationError } from 'class-validator'
2+
3+
/**
4+
* Formate les erreurs de validation pour une meilleure lisibilité
5+
*
6+
* @export
7+
* @function formatValidationErrors
8+
* @param {ValidationError[]} errors - Tableau d'erreurs de class-validator
9+
* @param {string} file - Nom du fichier où la validation a échoué
10+
* @param {string} [basePath=''] - Chemin de base pour la construction du chemin de propriété
11+
* @param {boolean} [isInArrayContext=false] - Indique si on est dans un contexte de tableau
12+
* @returns {string} Message d'erreur formaté et lisible
13+
*
14+
* @description Transforme récursivement les erreurs de validation class-validator
15+
* en messages d'erreur lisibles avec le chemin complet de chaque propriété en erreur.
16+
*
17+
* Gère :
18+
* - Propriétés imbriquées (notation pointée)
19+
* - Tableaux (notation avec index)
20+
* - Contraintes multiples par propriété
21+
* - Erreurs hiérarchiques récursives
22+
*
23+
* @example
24+
* // Retourne :
25+
* // • Property 'identities[0].trigger': must be a number (constraint: isNumber)
26+
* // • Property 'identities[1].sources': should not be empty (constraint: isNotEmpty)
27+
*/
28+
export function formatValidationErrors(errors: ValidationError[], file: string, basePath: string = '', isInArrayContext: boolean = false): string {
29+
const formatError = (error: ValidationError, currentPath: string, inArrayContext: boolean): string[] => {
30+
let propertyPath = currentPath
31+
32+
/**
33+
* Check if error.property is defined, not null, not empty, and not the string 'undefined'.
34+
* If it is, we construct the property path based on whether we are in an array context or not.
35+
* If it is an array context, we use the index notation; otherwise, we use dot notation.
36+
*/
37+
if (error.property !== undefined &&
38+
error.property !== null &&
39+
error.property !== '' &&
40+
error.property !== 'undefined') {
41+
if (inArrayContext && !isNaN(Number(error.property))) {
42+
// C'est un index d'array
43+
propertyPath = currentPath ? `${currentPath}[${error.property}]` : `[${error.property}]`
44+
} else {
45+
// C'est une propriété normale
46+
propertyPath = currentPath ? `${currentPath}.${error.property}` : error.property
47+
}
48+
}
49+
50+
const errorMessages: string[] = []
51+
52+
/**
53+
* Check if error.constraints is defined and not empty.
54+
* If it is, we iterate over each constraint and format the error message.
55+
*/
56+
if (error.constraints) {
57+
Object.entries(error.constraints).forEach(([constraintKey, message]) => {
58+
errorMessages.push(`Property '${propertyPath}': ${message} (constraint: ${constraintKey})`)
59+
})
60+
}
61+
62+
/**
63+
* If the error has children, we recursively format each child error.
64+
* We check if the error has children and if they are defined.
65+
*/
66+
if (error.children && error.children.length > 0) {
67+
const isNextLevelArray = Array.isArray(error.value)
68+
error.children.forEach(childError => {
69+
errorMessages.push(...formatError(childError, propertyPath, isNextLevelArray))
70+
})
71+
}
72+
73+
return errorMessages
74+
}
75+
76+
const allErrorMessages: string[] = []
77+
errors.forEach(error => {
78+
allErrorMessages.push(...formatError(error, basePath, isInArrayContext))
79+
})
80+
81+
return allErrorMessages.map(msg => `• ${msg}`).join('\n')
82+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { readFileSync, statSync } from 'node:fs'
2+
import { plainToInstance } from 'class-transformer'
3+
import { parse } from 'yaml'
4+
import { validateOrReject } from 'class-validator'
5+
import { ConfigStatesDTO, LifecycleStateDTO } from '../_dto/config-states.dto'
6+
import { formatValidationErrors } from './format-validation-errors.function'
7+
import { IdentityLifecycleDefault } from '../../identities/_enums/lifecycle.enum'
8+
import { Logger } from '@nestjs/common'
9+
10+
// Cache simple en mémoire pour éviter les relectures inutiles du fichier states.yml
11+
// Le cache est invalidé automatiquement si la date de modification (mtimeMs) change.
12+
let __customStatesCache: LoadCustomStatesResult | null = null
13+
let __customStatesCacheMtime: number | null = null
14+
15+
/**
16+
* Résultat du chargement des états personnalisés
17+
*
18+
* @interface LoadCustomStatesResult
19+
* @property {LifecycleStateDTO[]} customStates - Tableau des états personnalisés chargés
20+
* @property {number} stateFileAge - Timestamp de la dernière modification du fichier
21+
*/
22+
export interface LoadCustomStatesResult {
23+
customStates: LifecycleStateDTO[]
24+
stateFileAge: number
25+
}
26+
27+
/**
28+
* Charge les états personnalisés depuis le fichier states.yml
29+
*
30+
* @export
31+
* @async
32+
* @function loadCustomStates
33+
* @param {Logger} logger - Instance du logger pour tracer les opérations
34+
* @returns {Promise<LoadCustomStatesResult>} Objet contenant les états chargés et l'âge du fichier
35+
*
36+
* @description Lit le fichier states.yml depuis `configs/lifecycle`, le parse,
37+
* le valide et retourne les états personnalisés. Également retourne l'âge du fichier
38+
* pour le cache HTTP.
39+
*
40+
* Validations effectuées :
41+
* - Clé d'état doit être exactement 1 caractère
42+
* - Clés doivent être uniques
43+
* - Clés ne doivent pas entrer en conflit avec les états par défaut
44+
*
45+
* @throws {Error} Si la validation échoue ou si une clé est en conflit
46+
*
47+
* @example
48+
* const { customStates, stateFileAge } = await loadCustomStates(logger);
49+
* // Charge states.yml
50+
* // Valide que les clés sont uniques et non conflictuelles
51+
* // Retourne les états pour utilisation par l'API
52+
*/
53+
export async function loadCustomStates(): Promise<LoadCustomStatesResult> {
54+
const logger = new Logger(loadCustomStates.name)
55+
56+
const customStates: LifecycleStateDTO[] = []
57+
let stateFileAge = 0
58+
logger.verbose('Loading custom lifecycle states from states.yml...')
59+
60+
try {
61+
const statesFilePath = `${process.cwd()}/configs/lifecycle/states.yml`
62+
// Vérifier l'âge du fichier pour décider d'utiliser le cache
63+
const { mtimeMs } = statSync(statesFilePath)
64+
// Si le cache est présent et que le mtime n'a pas changé, retourner directement
65+
if (__customStatesCache && __customStatesCacheMtime === mtimeMs) {
66+
logger.debug('Returning cached custom lifecycle states (states.yml unchanged)')
67+
return __customStatesCache
68+
}
69+
70+
const data = readFileSync(statesFilePath, 'utf-8')
71+
stateFileAge = mtimeMs
72+
logger.debug('Loaded custom states config: states.yml')
73+
74+
const yml = parse(data)
75+
const configStates = plainToInstance(ConfigStatesDTO, yml)
76+
77+
if (!configStates || !configStates.states || !Array.isArray(configStates.states)) {
78+
logger.error('Invalid schema in states.yml file')
79+
return { customStates, stateFileAge }
80+
}
81+
82+
try {
83+
logger.verbose('Validating schema for states.yml', JSON.stringify(configStates, null, 2))
84+
await validateOrReject(configStates, {
85+
whitelist: true,
86+
})
87+
logger.debug('Validated schema for states.yml')
88+
} catch (errors) {
89+
const formattedErrors = formatValidationErrors(errors, 'states.yml')
90+
const err = new Error(`Validation errors in states.yml:\n${formattedErrors}`)
91+
throw err
92+
}
93+
94+
// Valider que chaque clé est unique et d'une seule lettre
95+
const usedKeys = new Set<string>()
96+
for (const state of configStates.states) {
97+
if (state.key.length !== 1) {
98+
throw new Error(`State key '${state.key}' must be exactly one character`)
99+
}
100+
101+
if (usedKeys.has(state.key)) {
102+
throw new Error(`Duplicate state key '${state.key}' found in states.yml`)
103+
}
104+
105+
// Vérifier que la clé n'existe pas déjà dans l'enum par défaut
106+
if (Object.values(IdentityLifecycleDefault).includes(state.key as IdentityLifecycleDefault)) {
107+
throw new Error(`State key '${state.key}' conflicts with default lifecycle state`)
108+
}
109+
110+
usedKeys.add(state.key)
111+
customStates.push(state)
112+
}
113+
114+
logger.log(`Loaded <${customStates.length}> custom lifecycle states from states.yml`)
115+
116+
// Mettre à jour le cache après chargement et validation réussis
117+
__customStatesCache = { customStates, stateFileAge }
118+
__customStatesCacheMtime = stateFileAge
119+
120+
} catch (error) {
121+
logger.error('Error loading custom states from states.yml', error.message, error.stack)
122+
// En cas d'erreur, ne pas empoisonner le cache : on l'invalide
123+
__customStatesCache = null
124+
__customStatesCacheMtime = null
125+
}
126+
127+
return { customStates, stateFileAge }
128+
}

0 commit comments

Comments
 (0)