|
| 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 | +} |
0 commit comments