Skip to content

Commit 051535c

Browse files
committed
feat: add lifecycle preview functionality in API and UI
- Introduced new methods in LifecycleConfigService for previewing lifecycle mutations and filters. - Updated LifecycleController to handle preview requests for both mutations and filters. - Enhanced lifecycle-config.dto.ts with new DTOs for mutation and filter previews. - Modified lifecycle.vue to include UI elements for previewing lifecycle configurations, ensuring proper access controls.
1 parent 22c69b4 commit 051535c

6 files changed

Lines changed: 869 additions & 71 deletions

File tree

apps/api/src/_common/functions/resolve-config-variables.function.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ liquidEngine.registerFilter('unixSeconds', (value: unknown) => {
2828
return normalizeDate(value).unix();
2929
});
3030

31+
export function getTemplateContextSummary(): Record<string, string> {
32+
const now = dayjs();
33+
return {
34+
'date.today': now.format('YYYY-MM-DD'),
35+
'date.yesterday': now.subtract(1, 'day').format('YYYY-MM-DD'),
36+
'date.tomorrow': dayjs().add(1, 'day').format('YYYY-MM-DD'),
37+
'date.isoNow': dayjs().toISOString(),
38+
'date.unix': `${dayjs().valueOf()}`,
39+
'date.unixSeconds': `${dayjs().unix()}`,
40+
};
41+
}
42+
3143
function buildTemplateContext(): Record<string, unknown> {
3244
const now = dayjs();
3345
return {

apps/api/src/management/lifecycle/_dto/lifecycle-config.dto.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import { ApiProperty, PartialType } from '@nestjs/swagger';
22
import { Type } from 'class-transformer';
3-
import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsString, Matches, ValidateNested } from 'class-validator';
3+
import {
4+
IsArray,
5+
IsBoolean,
6+
IsNotEmpty,
7+
IsNumber,
8+
IsObject,
9+
IsOptional,
10+
IsString,
11+
Matches,
12+
Max,
13+
Min,
14+
ValidateNested,
15+
} from 'class-validator';
416
import { ConfigRulesObjectIdentitiesDTO } from './config-rules.dto';
517
import { LifecycleStateDTO } from './config-states.dto';
618

@@ -55,6 +67,57 @@ export class LifecycleRuleFileCreateDto {
5567

5668
export class LifecycleRuleFileUpdateDto extends PartialType(LifecycleRuleFileCreateDto) {}
5769

70+
export class LifecyclePreviewMutationDto {
71+
@IsObject()
72+
@ApiProperty({
73+
type: 'object',
74+
description: 'Mutation brute telle que définie dans la règle lifecycle',
75+
additionalProperties: true,
76+
})
77+
mutation: Record<string, unknown>;
78+
}
79+
80+
export class LifecyclePreviewFilterDto {
81+
@IsArray()
82+
@IsString({ each: true })
83+
@ApiProperty({ type: [String], description: 'États source de la règle' })
84+
sources: string[];
85+
86+
@IsOptional()
87+
@IsObject()
88+
@ApiProperty({
89+
type: 'object',
90+
description: 'Filtre MongoDB brut (rules)',
91+
additionalProperties: true,
92+
})
93+
rules?: Record<string, unknown>;
94+
95+
@IsOptional()
96+
@IsString()
97+
@ApiProperty({
98+
type: String,
99+
description: 'Valeur brute du trigger (-1, secondes, 90d, 5s, 50m, ...)',
100+
required: false,
101+
})
102+
triggerInput?: string;
103+
104+
@IsOptional()
105+
@IsString()
106+
@ApiProperty({
107+
type: String,
108+
description: 'Clé de date utilisée pour le filtre temporel',
109+
required: false,
110+
})
111+
dateKey?: string;
112+
113+
@IsOptional()
114+
@IsNumber()
115+
@Min(1)
116+
@Max(25)
117+
@ApiProperty({ type: Number, required: false, default: 5 })
118+
sampleLimit?: number;
119+
}
120+
58121
export class LifecycleStatesUpdateDto {
59122
@IsArray()
60123
@ValidateNested({ each: true })
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
const SECONDS_IN_DAY = 24 * 60 * 60;
2+
3+
export function parseLifecycleTriggerInput(
4+
input: string | number | null | undefined,
5+
): number | string | null | undefined {
6+
if (input === undefined || input === null) {
7+
return undefined;
8+
}
9+
10+
if (typeof input === 'number') {
11+
return input;
12+
}
13+
14+
const trimmed = `${input}`.trim();
15+
if (!trimmed) {
16+
return undefined;
17+
}
18+
19+
if (trimmed === '-1') {
20+
return -1;
21+
}
22+
23+
if (/^\d+[dms]$/.test(trimmed)) {
24+
return trimmed;
25+
}
26+
27+
if (/^\d+$/.test(trimmed)) {
28+
const seconds = parseInt(trimmed, 10);
29+
if (seconds > 0) {
30+
return seconds;
31+
}
32+
}
33+
34+
return null;
35+
}
36+
37+
export function lifecycleTriggerToSeconds(value: number | string | null | undefined): number | undefined {
38+
if (value === undefined || value === null) {
39+
return undefined;
40+
}
41+
42+
if (value === -1) {
43+
return -1;
44+
}
45+
46+
if (typeof value === 'number') {
47+
if (value >= SECONDS_IN_DAY) {
48+
return value;
49+
}
50+
return value * SECONDS_IN_DAY;
51+
}
52+
53+
const match = value.match(/^(\d+)([dms])$/);
54+
if (!match) {
55+
return undefined;
56+
}
57+
58+
const numValue = parseInt(match[1], 10);
59+
const unit = match[2];
60+
61+
switch (unit) {
62+
case 'd':
63+
return numValue * SECONDS_IN_DAY;
64+
case 'm':
65+
return numValue * 60;
66+
case 's':
67+
return numValue;
68+
default:
69+
return undefined;
70+
}
71+
}

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,15 @@ import {
1313
LifecycleRuleFileUpdateDto,
1414
LifecycleStatesUpdateDto,
1515
} from './_dto/lifecycle-config.dto';
16+
import { resolveConfigVariables, getTemplateContextSummary } from '~/_common/functions/resolve-config-variables.function';
17+
import { IdentitiesCrudService } from '../identities/identities-crud.service';
1618
import { loadLifecycleRules } from './_functions/load-lifecycle-rules.function';
19+
import {
20+
lifecycleTriggerToSeconds,
21+
parseLifecycleTriggerInput,
22+
} from './_functions/parse-lifecycle-trigger-input.function';
1723
import { validateLifecycleCronRules } from './_functions/validate-lifecycle-cron-rules.function';
24+
import { LifecyclePreviewFilterDto, LifecyclePreviewMutationDto } from './_dto/lifecycle-config.dto';
1825
import { LifecycleCrudService } from './lifecycle-crud.service';
1926
import { LifecycleHooksService } from './lifecycle-hooks.service';
2027

@@ -33,6 +40,7 @@ export class LifecycleConfigService {
3340
public constructor(
3441
private readonly lifecycleHooksService: LifecycleHooksService,
3542
private readonly lifecycleCrudService: LifecycleCrudService,
43+
private readonly identitiesCrudService: IdentitiesCrudService,
3644
) {}
3745

3846
public async searchRules(
@@ -153,6 +161,91 @@ export class LifecycleConfigService {
153161
return this.lifecycleCrudService.getCustomStates();
154162
}
155163

164+
public async previewMutation(payload: LifecyclePreviewMutationDto): Promise<{
165+
raw: Record<string, unknown>;
166+
resolved: Record<string, unknown>;
167+
templateVariables: Record<string, string>;
168+
}> {
169+
const raw = payload.mutation || {};
170+
const resolved = (await resolveConfigVariables(raw)) as Record<string, unknown>;
171+
172+
return {
173+
raw,
174+
resolved,
175+
templateVariables: getTemplateContextSummary(),
176+
};
177+
}
178+
179+
public async previewFilter(payload: LifecyclePreviewFilterDto): Promise<{
180+
query: Record<string, unknown>;
181+
resolvedRules: Record<string, unknown>;
182+
count: number;
183+
samples: Array<Record<string, unknown>>;
184+
temporalFilter: { applied: boolean; dateKey?: string; before?: string; note?: string };
185+
}> {
186+
if (!payload.sources?.length) {
187+
throw new BadRequestException('Au moins un état source est requis pour la prévisualisation.');
188+
}
189+
190+
const resolvedRules = (await resolveConfigVariables(payload.rules ?? {})) as Record<string, unknown>;
191+
const parsedTrigger = parseLifecycleTriggerInput(payload.triggerInput);
192+
const triggerSeconds = lifecycleTriggerToSeconds(parsedTrigger ?? undefined);
193+
const dateKey = payload.dateKey?.trim() || 'lastSync';
194+
195+
const query: Record<string, unknown> = {
196+
...resolvedRules,
197+
lifecycle: { $in: payload.sources },
198+
ignoreLifecycle: { $ne: true },
199+
deletedFlag: { $ne: true },
200+
};
201+
202+
const temporalFilter: {
203+
applied: boolean;
204+
dateKey?: string;
205+
before?: string;
206+
note?: string;
207+
} = {
208+
applied: false,
209+
note: 'Aucun filtre temporel appliqué (trigger absent, -1, ou immédiat).',
210+
};
211+
212+
if (typeof triggerSeconds === 'number' && triggerSeconds > 0) {
213+
const checkDate = new Date(Date.now() - triggerSeconds * 1000);
214+
query[dateKey] = { $lte: checkDate };
215+
temporalFilter.applied = true;
216+
temporalFilter.dateKey = dateKey;
217+
temporalFilter.before = checkDate.toISOString();
218+
temporalFilter.note = `Identités dont ${dateKey} est antérieur ou égal à ${checkDate.toISOString()}.`;
219+
} else if (triggerSeconds === -1) {
220+
temporalFilter.note = 'Trigger -1 : exécution cron/CLI sans filtre temporel.';
221+
}
222+
223+
const sampleLimit = payload.sampleLimit ?? 5;
224+
const [count, samples] = await Promise.all([
225+
this.identitiesCrudService.model.countDocuments(query),
226+
this.identitiesCrudService.model
227+
.find(query)
228+
.limit(sampleLimit)
229+
.select('_id lifecycle inetOrgPerson.cn inetOrgPerson.mail metadata.lastUpdatedAt lastSync')
230+
.lean(),
231+
]);
232+
233+
return {
234+
query,
235+
resolvedRules,
236+
count,
237+
samples: samples.map((sample) => ({
238+
_id: sample._id,
239+
lifecycle: sample.lifecycle,
240+
cn: (sample as { inetOrgPerson?: { cn?: string } }).inetOrgPerson?.cn,
241+
mail: (sample as { inetOrgPerson?: { mail?: string } }).inetOrgPerson?.mail,
242+
lastSync: (sample as { lastSync?: Date }).lastSync,
243+
lastUpdatedAt: (sample as { metadata?: { lastUpdatedAt?: Date } }).metadata?.lastUpdatedAt,
244+
})),
245+
temporalFilter,
246+
};
247+
}
248+
156249
public async updateCustomStates(payload: LifecycleStatesUpdateDto): Promise<IdentityLifecycleState[]> {
157250
const config = plainToInstance(ConfigStatesDTO, { states: payload.states });
158251
await validateOrReject(config, { whitelist: true });

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { AbstractController } from '~/_common/abstracts/abstract.controller';
2323
import { ObjectIdValidationPipe } from '~/_common/pipes/object-id-validation.pipe';
2424
import { Lifecycle } from './_schemas/lifecycle.schema';
2525
import {
26+
LifecyclePreviewFilterDto,
27+
LifecyclePreviewMutationDto,
2628
LifecycleRuleFileCreateDto,
2729
LifecycleRuleFileSummaryDto,
2830
LifecycleRuleFileUpdateDto,
@@ -178,6 +180,39 @@ export class LifecycleController extends AbstractController {
178180
* }
179181
* }
180182
*/
183+
@Post('config/preview/mutation')
184+
@UseRoles({
185+
resource: '/management/lifecycle',
186+
action: AC_ACTIONS.READ,
187+
possession: AC_DEFAULT_POSSESSION,
188+
})
189+
public async previewConfigMutation(
190+
@Body() body: LifecyclePreviewMutationDto,
191+
@Res() res: Response,
192+
): Promise<Response> {
193+
const data = await this.lifecycleConfigService.previewMutation(body);
194+
195+
return res.json({
196+
statusCode: HttpStatus.OK,
197+
data,
198+
});
199+
}
200+
201+
@Post('config/preview/filter')
202+
@UseRoles({
203+
resource: '/management/lifecycle',
204+
action: AC_ACTIONS.READ,
205+
possession: AC_DEFAULT_POSSESSION,
206+
})
207+
public async previewConfigFilter(@Body() body: LifecyclePreviewFilterDto, @Res() res: Response): Promise<Response> {
208+
const data = await this.lifecycleConfigService.previewFilter(body);
209+
210+
return res.json({
211+
statusCode: HttpStatus.OK,
212+
data,
213+
});
214+
}
215+
181216
@Get('config/rules')
182217
@UseRoles({
183218
resource: '/management/lifecycle',

0 commit comments

Comments
 (0)