Skip to content

Commit 5d948b3

Browse files
committed
feat: implement manual lifecycle transition management in API and UI
- Added functionality to manage manual lifecycle transitions, including retrieval and updates through new endpoints in LifecycleController. - Introduced ManualTransitions schema and DTOs for validation and processing of manual transition rules. - Enhanced LifecycleConfigService to handle loading and updating of manual transitions, ensuring proper validation and error handling. - Updated UI components to display and manage manual transitions, including options for adding, editing, and removing rules. - Integrated checks for manual lifecycle transition permissions in the identities CRUD operations, improving user experience and control.
1 parent 0162d87 commit 5d948b3

17 files changed

Lines changed: 690 additions & 64 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Transitions manuelles uniquement (changement via l'interface identité).
2+
# N'affecte pas les règles automatiques (cron, fichiers rules/*.yml).
3+
# Si un état source n'est pas listé ici, toutes les destinations restent autorisées.
4+
5+
manualTransitions:
6+
# Officiel (O) : activation courante, pas de passage direct vers « A détruire » ou « Caché »
7+
- source: O
8+
targets: [I, M, A, P, V]
9+
10+
# Inactif (I) : réactivation ou mise en file de suppression
11+
- source: I
12+
targets: [O, M, D]
13+
14+
# Manuel (M) : état opérateur, retours usuels
15+
- source: M
16+
targets: [O, I, A, P]
17+
18+
# En attente (A) : validation ou retour provisoire
19+
- source: A
20+
targets: [O, P, I]
21+
22+
# Provisoire (P) : promotion ou mise en attente
23+
- source: P
24+
targets: [O, A, I]
25+
26+
# A détruire (D) : uniquement sortie de la file (pas de nouveau cycle destructeur)
27+
- source: D
28+
targets: [I, O]
29+
30+
# Verrouillé (V) : déverrouillage vers états stables
31+
- source: V
32+
targets: [O, I]

apps/api/src/_common/abstracts/abstract.service.schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ServiceSchemaInterface } from './interfaces/service.schema.interface';
1818
import { AbstractSchema } from './schemas/abstract.schema';
1919
import mongodb from 'mongodb';
2020
import { omit } from 'radash';
21+
import { normalizeMongoFilterValues } from '~/_common/functions/normalize-mongo-filter-values';
2122

2223
@Injectable()
2324
export abstract class AbstractServiceSchema<T extends AbstractSchema | Document = AbstractSchema | Document>
@@ -54,6 +55,7 @@ export abstract class AbstractServiceSchema<T extends AbstractSchema | Document
5455
}
5556
}
5657
this.logger.debug(['find', JSON.stringify(Object.values(arguments))].join(' '))
58+
filter = normalizeMongoFilterValues(filter)
5759
return await this._model.find<Query<Array<T>, T, any, T>>(filter, projection, options).exec()
5860
}
5961

@@ -71,6 +73,7 @@ export abstract class AbstractServiceSchema<T extends AbstractSchema | Document
7173
}
7274
}
7375
this.logger.debug(['count', JSON.stringify(Object.values(arguments))].join(' '))
76+
filter = normalizeMongoFilterValues(filter)
7477
return await this._model.countDocuments(filter, options as any).exec()
7578
}
7679

@@ -104,6 +107,7 @@ export abstract class AbstractServiceSchema<T extends AbstractSchema | Document
104107
}
105108
const softDelete = { deletedFlag: { $ne: true } }
106109
filter = { ...filter, ...softDelete }
110+
filter = normalizeMongoFilterValues(filter)
107111
let count = await this._model.countDocuments(filter).exec()
108112
let data = await this._model.find<T & Query<T, T, any, T>>(filter, projection, options).exec()
109113

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Types } from 'mongoose';
2+
3+
function isObjectIdLike(value: unknown): value is { _bsontype?: 'ObjectId'; toString(): string } {
4+
return (
5+
value instanceof Types.ObjectId ||
6+
(value !== null &&
7+
typeof value === 'object' &&
8+
'_bsontype' in value &&
9+
(value as { _bsontype: string })._bsontype === 'ObjectId')
10+
);
11+
}
12+
13+
/**
14+
* Re-hydrates ObjectId instances from foreign bson/mongoose copies (e.g. nested deps)
15+
* so MongoDB queries use the same bson version as the application mongoose driver.
16+
*/
17+
export function normalizeMongoFilterValues<T>(filter: T): T {
18+
if (filter === null || filter === undefined) {
19+
return filter;
20+
}
21+
22+
if (isObjectIdLike(filter)) {
23+
if (filter instanceof Types.ObjectId) {
24+
return filter as T;
25+
}
26+
27+
return new Types.ObjectId(filter.toString()) as T;
28+
}
29+
30+
if (Array.isArray(filter)) {
31+
return filter.map((item) => normalizeMongoFilterValues(item)) as T;
32+
}
33+
34+
if (typeof filter === 'object') {
35+
const result: Record<string, unknown> = {};
36+
for (const [key, value] of Object.entries(filter)) {
37+
result[key] = normalizeMongoFilterValues(value);
38+
}
39+
return result as T;
40+
}
41+
42+
return filter;
43+
}

apps/api/src/management/identities/identities-crud.controller.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { loadManualTransitions } from '~/management/lifecycle/_functions/load-manual-transitions.function';
2+
import { isManualLifecycleTransitionAllowed } from '~/management/lifecycle/_functions/is-manual-lifecycle-transition-allowed.function';
13
import { BadRequestException, Body, Controller, Get, HttpStatus, Param, Patch, Post, Query, Res } from '@nestjs/common';
24
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
35
import {
@@ -357,6 +359,16 @@ export class IdentitiesCrudController extends AbstractController {
357359
if (!identity) {
358360
throw new BadRequestException('Identity not found');
359361
}
362+
363+
if (body.lifecycle && body.lifecycle !== identity.lifecycle) {
364+
const manualTransitions = await loadManualTransitions();
365+
if (!isManualLifecycleTransitionAllowed(identity.lifecycle, body.lifecycle, manualTransitions)) {
366+
throw new BadRequestException(
367+
`Changement manuel de cycle de vie non autorisé : <${identity.lifecycle}> → <${body.lifecycle}>`,
368+
);
369+
}
370+
}
371+
360372
const data = await this._service.updateLifecycle(_id, body.lifecycle);
361373
return res.status(HttpStatus.OK).json({
362374
statusCode: HttpStatus.OK,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { Type } from 'class-transformer';
3+
import { IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
4+
5+
export class ManualTransitionRuleDto {
6+
@IsString()
7+
@IsNotEmpty()
8+
@ApiProperty({
9+
type: String,
10+
description: 'État source (cycle de vie actuel)',
11+
example: 'I',
12+
required: true,
13+
})
14+
source: string;
15+
16+
@IsArray()
17+
@IsString({ each: true })
18+
@IsNotEmpty({ each: true })
19+
@ApiProperty({
20+
type: [String],
21+
description: 'États cibles autorisés pour un changement manuel depuis cet état source',
22+
example: ['D', 'O'],
23+
required: true,
24+
})
25+
targets: string[];
26+
}
27+
28+
export class ManualTransitionsSchemaDto {
29+
@IsArray()
30+
@ValidateNested({ each: true })
31+
@Type(() => ManualTransitionRuleDto)
32+
@ApiProperty({
33+
type: [ManualTransitionRuleDto],
34+
description: 'Règles de filtrage des changements de cycle de vie manuels uniquement',
35+
required: true,
36+
})
37+
manualTransitions: ManualTransitionRuleDto[];
38+
}
39+
40+
export class ManualTransitionsUpdateDto {
41+
@IsArray()
42+
@ValidateNested({ each: true })
43+
@Type(() => ManualTransitionRuleDto)
44+
@ApiProperty({
45+
type: [ManualTransitionRuleDto],
46+
description: 'Règles de filtrage des changements de cycle de vie manuels uniquement',
47+
required: true,
48+
})
49+
manualTransitions: ManualTransitionRuleDto[];
50+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ManualTransitionRuleDto } from '../_dto/manual-transitions.dto';
2+
3+
export function getAllowedManualLifecycleTargets(
4+
source: string,
5+
rules: ManualTransitionRuleDto[],
6+
): string[] | null {
7+
const rule = rules.find((item) => item.source === source);
8+
if (!rule) {
9+
return null;
10+
}
11+
12+
return [...new Set(rule.targets || [])];
13+
}
14+
15+
export function isManualLifecycleTransitionAllowed(
16+
from: string,
17+
to: string,
18+
rules: ManualTransitionRuleDto[],
19+
): boolean {
20+
if (!from || !to || from === to) {
21+
return true;
22+
}
23+
24+
const allowedTargets = getAllowedManualLifecycleTargets(from, rules);
25+
if (allowedTargets === null) {
26+
return true;
27+
}
28+
29+
return allowedTargets.includes(to);
30+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { existsSync, readFileSync, statSync } from 'node:fs';
2+
import { plainToInstance } from 'class-transformer';
3+
import { parse } from 'yaml';
4+
import { validateOrReject } from 'class-validator';
5+
import { Logger } from '@nestjs/common';
6+
import { formatValidationErrors } from '../../../_common/functions/format-validation-errors.function';
7+
import { ManualTransitionRuleDto, ManualTransitionsSchemaDto } from '../_dto/manual-transitions.dto';
8+
9+
let __manualTransitionsCache: ManualTransitionRuleDto[] | null = null;
10+
let __manualTransitionsCacheMtime: number | null = null;
11+
12+
export function clearManualTransitionsCache(): void {
13+
__manualTransitionsCache = null;
14+
__manualTransitionsCacheMtime = null;
15+
}
16+
17+
export function getManualTransitionsFilePath(): string {
18+
return `${process.cwd()}/configs/lifecycle/manual-transitions.yml`;
19+
}
20+
21+
export async function loadManualTransitions(): Promise<ManualTransitionRuleDto[]> {
22+
const logger = new Logger(loadManualTransitions.name);
23+
const filePath = getManualTransitionsFilePath();
24+
25+
if (!existsSync(filePath)) {
26+
logger.debug('No manual transitions file found, returning empty rules array.');
27+
return [];
28+
}
29+
30+
try {
31+
const { mtimeMs } = statSync(filePath);
32+
if (__manualTransitionsCache && __manualTransitionsCacheMtime === mtimeMs) {
33+
logger.debug('Returning cached manual lifecycle transitions (manual-transitions.yml unchanged)');
34+
return __manualTransitionsCache;
35+
}
36+
37+
const data = readFileSync(filePath, 'utf-8');
38+
const yml = parse(data) || {};
39+
const schema = plainToInstance(ManualTransitionsSchemaDto, yml);
40+
41+
if (!schema?.manualTransitions || !Array.isArray(schema.manualTransitions)) {
42+
logger.warn('Invalid manual transitions schema, returning empty rules array.');
43+
return [];
44+
}
45+
46+
await validateOrReject(schema, { whitelist: true });
47+
48+
const usedSources = new Set<string>();
49+
for (const rule of schema.manualTransitions) {
50+
if (usedSources.has(rule.source)) {
51+
throw new Error(`Duplicate manual transition source '${rule.source}' found in manual-transitions.yml`);
52+
}
53+
usedSources.add(rule.source);
54+
}
55+
56+
__manualTransitionsCache = schema.manualTransitions;
57+
__manualTransitionsCacheMtime = mtimeMs;
58+
59+
logger.log(`Loaded <${schema.manualTransitions.length}> manual lifecycle transition rules.`);
60+
return schema.manualTransitions;
61+
} catch (error) {
62+
logger.error('Error loading manual lifecycle transitions', error?.message, error?.stack);
63+
clearManualTransitionsCache();
64+
throw error;
65+
}
66+
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { validateLifecycleCronRules } from './_functions/validate-lifecycle-cron
2424
import { LifecyclePreviewFilterDto, LifecyclePreviewMutationDto } from './_dto/lifecycle-config.dto';
2525
import { LifecycleCrudService } from './lifecycle-crud.service';
2626
import { LifecycleHooksService } from './lifecycle-hooks.service';
27+
import { ManualTransitionRuleDto, ManualTransitionsSchemaDto, ManualTransitionsUpdateDto } from './_dto/manual-transitions.dto';
28+
import { clearManualTransitionsCache, loadManualTransitions } from './_functions/load-manual-transitions.function';
2729

2830
export type LifecycleRuleFileSummary = {
2931
name: string;
@@ -157,6 +159,38 @@ export class LifecycleConfigService {
157159
);
158160
}
159161

162+
163+
public async getManualTransitions(): Promise<ManualTransitionRuleDto[]> {
164+
return loadManualTransitions();
165+
}
166+
167+
public async updateManualTransitions(payload: ManualTransitionsUpdateDto): Promise<ManualTransitionRuleDto[]> {
168+
const schema = plainToInstance(ManualTransitionsSchemaDto, {
169+
manualTransitions: payload.manualTransitions || [],
170+
});
171+
172+
try {
173+
await validateOrReject(schema, { whitelist: true });
174+
} catch (errors) {
175+
throw new BadRequestException(formatValidationErrors(errors, 'manual-transitions.yml'));
176+
}
177+
178+
const usedSources = new Set<string>();
179+
for (const rule of schema.manualTransitions) {
180+
if (usedSources.has(rule.source)) {
181+
throw new BadRequestException(`La source <${rule.source}> est définie plusieurs fois.`);
182+
}
183+
usedSources.add(rule.source);
184+
}
185+
186+
this.ensureLifecycleDir();
187+
writeFileSync(this.getManualTransitionsFilePath(), stringify({ manualTransitions: schema.manualTransitions }));
188+
clearManualTransitionsCache();
189+
190+
await this.syncAfterConfigChange();
191+
return loadManualTransitions();
192+
}
193+
160194
public getCustomStates(): IdentityLifecycleState[] {
161195
return this.lifecycleCrudService.getCustomStates();
162196
}
@@ -298,6 +332,10 @@ export class LifecycleConfigService {
298332
return path.join(process.cwd(), 'configs', 'lifecycle');
299333
}
300334

335+
private getManualTransitionsFilePath(): string {
336+
return path.join(this.getLifecycleDir(), 'manual-transitions.yml');
337+
}
338+
301339
private getStatesFilePath(): string {
302340
return path.join(this.getLifecycleDir(), 'states.yml');
303341
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export class LifecycleHooksService extends AbstractLifecycleService {
7272
}
7373

7474
for (const file of defaultFiles) {
75+
if (file === 'rules') {
76+
continue;
77+
}
7578
if (!files.includes(file)) {
7679
try {
7780
const defaultFile = readFileSync(`${process.cwd()}/defaults/lifecycle/${file}`, 'utf-8');

apps/api/src/management/lifecycle/lifecycle.controller.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
LifecycleRuleFileUpdateDto,
3131
LifecycleStatesUpdateDto,
3232
} from './_dto/lifecycle-config.dto';
33+
import { ManualTransitionsUpdateDto } from './_dto/manual-transitions.dto';
3334
import { LifecycleConfigService } from './lifecycle-config.service';
3435
import { LifecycleCrudService } from './lifecycle-crud.service';
3536
import { LifecycleHooksService } from './lifecycle-hooks.service';
@@ -346,6 +347,40 @@ export class LifecycleController extends AbstractController {
346347
});
347348
}
348349

350+
351+
@Get('config/manual-transitions')
352+
@UseRoles({
353+
resource: '/management/lifecycle',
354+
action: AC_ACTIONS.READ,
355+
possession: AC_DEFAULT_POSSESSION,
356+
})
357+
public async getConfigManualTransitions(@Res() res: Response): Promise<Response> {
358+
const data = await this.lifecycleConfigService.getManualTransitions();
359+
360+
return res.json({
361+
statusCode: HttpStatus.OK,
362+
data: { manualTransitions: data },
363+
});
364+
}
365+
366+
@Put('config/manual-transitions')
367+
@UseRoles({
368+
resource: '/management/lifecycle',
369+
action: AC_ACTIONS.UPDATE,
370+
possession: AC_DEFAULT_POSSESSION,
371+
})
372+
public async updateConfigManualTransitions(
373+
@Body() body: ManualTransitionsUpdateDto,
374+
@Res() res: Response,
375+
): Promise<Response> {
376+
const data = await this.lifecycleConfigService.updateManualTransitions(body);
377+
378+
return res.json({
379+
statusCode: HttpStatus.OK,
380+
data: { manualTransitions: data },
381+
});
382+
}
383+
349384
@Put('config/states')
350385
@UseRoles({
351386
resource: '/management/lifecycle',

0 commit comments

Comments
 (0)