Skip to content

Commit c02c236

Browse files
tacxouGithub Action
andauthored
Feature/lifecycle management (#58)
* Enhance LifecycleService to handle identity upsert events and record lifecycle changes * Refactor lifecycle DTOs, schemas, and service methods for improved clarity and functionality * Refactor lifecycle handling to use string enums and add migration script for legacy number values * Refactor lifecycle configuration and DTOs for improved structure and clarity * Refactor LifecycleSource interface to remove dependency on IdentityLifecycle enum * Refactor lifecycle configuration and module structure for improved organization and clarity * Add ignoreLifecycle property to Identities schema and update migration script * Refactor event emitter event names in AbstractServiceSchema and add before state tracking for create, update, and delete operations * Remove console log from findAndCount method and retain event emission for beforeFindAndCount * Refactor lifecycle migration logic to improve clarity and consistency * Add lifecycle field to additionalFields in IdentitiesCrudController * Add LifecycleRefId constant and update lifecycle history retrieval to include total count * Remove TODO comment for implementing lifecycle update event in LifecycleService * v1.4.0 * Enhance validation error handling and improve trigger value transformation logic in lifecycle DTO * Skip GitHub release fetch in development mode for bootstrap and cron jobs * Implement cron job scheduling for lifecycle triggers and enhance job execution logic * Refactor getRecentChanges method to return total count and data array in LifecycleService; update controller to handle new response structure --------- Co-authored-by: Github Action <github@action.com>
1 parent edf60ec commit c02c236

File tree

17 files changed

+995
-27
lines changed

17 files changed

+995
-27
lines changed

configs/lifecycle/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

defaults/lifecycle/01-etd.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
identities:
2+
- sources: ['I', 'W']
3+
# rules: {
4+
# 'inetOrgPerson.employeeType': 'etd',
5+
# lastSyncBackup: { $ne : null }
6+
# }
7+
# trigger: null
8+
target: D

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sesame-orchestrator",
3-
"version": "1.3.13",
3+
"version": "1.4.0",
44
"description": "Synchronisation d'Identités Multi-sources",
55
"private": true,
66
"contributors": [

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ export abstract class AbstractServiceSchema extends AbstractService implements S
8585
): Promise<[Array<T & Query<T, T, any, T>>, number]> {
8686
this.logger.debug(['findAndCount', JSON.stringify(Object.values(arguments))].join(' '))
8787
if (this.eventEmitter) {
88-
console.log('de', [this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'beforeFindAndCount'].join(EventEmitterSeparator))
8988
const beforeEvents = await this.eventEmitter?.emitAsync(
9089
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'beforeFindAndCount'].join(EventEmitterSeparator),
9190
{ filter, projection, options },
@@ -221,7 +220,7 @@ export abstract class AbstractServiceSchema extends AbstractService implements S
221220
let created = document.save(options)
222221
if (this.eventEmitter) {
223222
const afterEvents = await this.eventEmitter?.emitAsync(
224-
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'afterFindAndCount'].join(EventEmitterSeparator),
223+
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'afterCreate'].join(EventEmitterSeparator),
225224
{ created },
226225
)
227226
for (const afterEvent of afterEvents) {
@@ -255,6 +254,7 @@ export abstract class AbstractServiceSchema extends AbstractService implements S
255254
if (beforeEvent?.options) options = { ...options, ...beforeEvent.options }
256255
}
257256
}
257+
const before = await this._model.findOne<Query<T | null, T, any, T>>({ _id }).exec();
258258
let updated = await this._model
259259
.findOneAndUpdate<Query<T | null, T, any, T>>(
260260
{ _id },
@@ -283,8 +283,8 @@ export abstract class AbstractServiceSchema extends AbstractService implements S
283283
}
284284
if (this.eventEmitter) {
285285
const afterEvents = await this.eventEmitter?.emitAsync(
286-
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'afterFindAndCount'].join(EventEmitterSeparator),
287-
{ updated },
286+
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'afterUpdate'].join(EventEmitterSeparator),
287+
{ before, updated },
288288
)
289289
for (const afterEvent of afterEvents) {
290290
if (afterEvent?.updated) updated = { ...updated, ...afterEvent.updated }
@@ -311,6 +311,7 @@ export abstract class AbstractServiceSchema extends AbstractService implements S
311311
if (beforeEvent?.options) options = { ...options, ...beforeEvent.options };
312312
}
313313
}
314+
const before = await this._model.findOne<Query<T | null, T, any, T>>(filter).exec();
314315
let result = await this._model
315316
.findOneAndUpdate<Query<T | null, T, any, T>>(
316317
filter,
@@ -339,7 +340,7 @@ export abstract class AbstractServiceSchema extends AbstractService implements S
339340
if (this.eventEmitter) {
340341
const afterEvents = await this.eventEmitter?.emitAsync(
341342
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'afterUpsert'].join(EventEmitterSeparator),
342-
{ result },
343+
{ result, before },
343344
);
344345
for (const afterEvent of afterEvents) {
345346
if (afterEvent?.result) result = { ...result, ...afterEvent.result };
@@ -366,15 +367,16 @@ export abstract class AbstractServiceSchema extends AbstractService implements S
366367
if (beforeEvent?.options) options = { ...options, ...beforeEvent.options }
367368
}
368369
}
370+
const before = await this._model.findOne<Query<T | null, T, any, T>>({ _id }).exec();
369371
let deleted = await this._model.findByIdAndDelete<Query<T | null, T, any, T>>({ _id }, options).exec()
370372
if (!deleted) {
371373
this.logger.debug(['findById', JSON.stringify(Object.values(arguments))].join(' '))
372374
throw new NotFoundException()
373375
}
374376
if (this.eventEmitter) {
375377
const afterEvents = await this.eventEmitter?.emitAsync(
376-
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'afterFindAndCount'].join(EventEmitterSeparator),
377-
{ deleted },
378+
[this.moduleName.toLowerCase(), this.serviceName.toLowerCase(), 'service', 'afterDelete'].join(EventEmitterSeparator),
379+
{ before, deleted },
378380
)
379381
for (const afterEvent of afterEvents) {
380382
if (afterEvent?.deleted) deleted = { ...deleted, ...afterEvent.deleted }

src/app.service.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,13 @@ export class AppService extends AbstractService implements OnApplicationBootstra
9090
public async onApplicationBootstrap(): Promise<void> {
9191
this.logger.debug('Application service bootstrap starting...');
9292

93-
for (const project of Object.values(ProjectsList)) {
94-
this.logger.verbose(`Checking for updates for project: ${project}`);
95-
96-
await this.fetchGithubRelease(project);
93+
if (process.env.NODE_ENV === 'development') {
94+
this.logger.warn('Skipping GitHub release fetch in development mode.');
95+
} else {
96+
for (const project of Object.values(ProjectsList)) {
97+
this.logger.verbose(`Checking for updates for project: ${project}`);
98+
await this.fetchGithubRelease(project);
99+
}
97100
}
98101

99102
this.logger.log('Application service bootstrap completed.');
@@ -113,10 +116,13 @@ export class AppService extends AbstractService implements OnApplicationBootstra
113116
public async handleCron(): Promise<void> {
114117
this.logger.debug('Cron job started.');
115118

116-
for (const project of Object.values(ProjectsList)) {
117-
this.logger.verbose(`Checking for updates for project: ${project}`);
118-
119-
await this.fetchGithubRelease(project);
119+
if (process.env.NODE_ENV === 'development') {
120+
this.logger.warn('Skipping GitHub release fetch in development mode.');
121+
} else {
122+
for (const project of Object.values(ProjectsList)) {
123+
this.logger.verbose(`Checking for updates for project: ${project}`);
124+
await this.fetchGithubRelease(project);
125+
}
120126
}
121127

122128
this.logger.debug('Cron job completed.');

src/management/identities/_dto/identities.dto.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ApiProperty, IntersectionType, PartialType } from '@nestjs/swagger';
2-
import { IsOptional, IsObject, IsEnum, IsNumber, ValidateNested } from 'class-validator';
2+
import { IsOptional, IsObject, IsEnum, IsNumber, ValidateNested, IsString } from 'class-validator';
33
import { inetOrgPersonDto } from './_parts/inetOrgPerson.dto';
44
import { IdentityState } from '../_enums/states.enum';
55
import { IdentityLifecycle } from '../_enums/lifecycle.enum';
@@ -29,11 +29,11 @@ export class IdentitiesCreateDto extends IntersectionType(CustomFieldsDto, Metad
2929
@ApiProperty({ enum: DataStatusEnum })
3030
public dataStatus: DataStatusEnum;
3131

32-
@IsNumber()
32+
@IsString()
3333
@IsOptional()
3434
@IsEnum(IdentityLifecycle)
3535
@ApiProperty({ enum: IdentityLifecycle })
36-
public lifecycle: number;
36+
public lifecycle: string;
3737

3838
@IsObject()
3939
@Type(() => inetOrgPersonDto)
Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
export enum IdentityLifecycle {
2-
IMPORTED = 3,
3-
OFFICIAL = 2,
4-
ACTIVE = 1,
5-
PROVISIONAL = 0,
6-
INACTIVE = -1,
7-
DELETED = -2,
2+
WAIT = "W",
3+
OFFICIAL = "O",
4+
ACTIVE = "A",
5+
PROVISIONAL = "P",
6+
INACTIVE = "I",
7+
DELETED = "D",
8+
9+
// IMPORTED = 3,
10+
// OFFICIAL = 2,
11+
// ACTIVE = 1,
12+
// PROVISIONAL = 0,
13+
// INACTIVE = -1,
14+
// DELETED = -2,
815
}

src/management/identities/_schemas/identities.schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class Identities extends AbstractSchema {
1919
@Prop({ type: Number, enum: IdentityState, default: IdentityState.TO_CREATE })
2020
public state: IdentityState;
2121

22-
@Prop({ type: Number, enum: IdentityLifecycle, default: IdentityLifecycle.INACTIVE })
22+
@Prop({ type: String, enum: IdentityLifecycle, default: IdentityLifecycle.ACTIVE })
2323
public lifecycle: IdentityLifecycle;
2424

2525
@Prop({ type: Number, enum: DataStatusEnum, default: DataStatusEnum.NOTINITIALIZED })
@@ -28,6 +28,9 @@ export class Identities extends AbstractSchema {
2828
@Prop({ type: Boolean, default: false })
2929
public deletedFlag: boolean;
3030

31+
@Prop({ type: Boolean, default: false })
32+
public ignoreLifecycle: boolean;
33+
3134
@Prop({ type: inetOrgPersonSchema, required: true })
3235
public inetOrgPerson: inetOrgPerson;
3336

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export class IdentitiesCrudController extends AbstractController {
5555
additionalFields: 1,
5656
metadata: 1,
5757
dataStatus: 1,
58+
lifecycle: 1,
5859
};
5960

6061
@Post()
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { Type, Transform } from 'class-transformer';
3+
import { IsArray, IsEnum, IsNegative, IsNotEmpty, IsNumber, IsObject, IsOptional, ValidateNested, registerDecorator, ValidationOptions, ValidationArguments, isString, isNumber } from 'class-validator';
4+
import { IdentityLifecycle } from '~/management/identities/_enums/lifecycle.enum';
5+
6+
/**
7+
* Transform trigger values to seconds.
8+
* - Numbers are interpreted as days and converted to seconds
9+
* - Strings with 'd' suffix are interpreted as days and converted to seconds
10+
* - Strings with 'm' suffix are interpreted as minutes and converted to seconds
11+
* - Strings with 's' suffix are already in seconds
12+
*
13+
* @param value The trigger value to transform
14+
* @returns The value converted to seconds
15+
*/
16+
function transformTriggerToSeconds(value: number | string): number | undefined {
17+
let isValid = false;
18+
19+
if (value === undefined || value === null) {
20+
return undefined;
21+
}
22+
23+
/**
24+
* Check if the value is a negative number.
25+
* If it's a number, we check if it's less than 0.
26+
* If it's a string, we check if it matches the regex for negative time strings.
27+
*/
28+
if (isNumber(value)) {
29+
isValid = value < 0;
30+
} else if (isString(value)) {
31+
const timeRegex = /^-?\d+[dms]$/;
32+
if (timeRegex.test(value)) {
33+
// Extract the number part and check if it's negative
34+
const numberPart = value.replace(/[dms]$/, '');
35+
const num = parseInt(numberPart, 10);
36+
isValid = num < 0;
37+
}
38+
}
39+
40+
if (!isValid) {
41+
throw new Error('Trigger must be a negative number (days) or a negative time string with units (e.g., "-90d", "-10m", "-45s")');
42+
}
43+
44+
/**
45+
* If the value is a number, we assume it's in days and convert it to seconds.
46+
* We multiply by 24 (hours) * 60 (minutes) * 60 (seconds) to get the total seconds.
47+
* This conversion preserves the sign of the number,
48+
* so if the input is negative, the output will also be negative.
49+
*/
50+
if (isNumber(value)) {
51+
return value * 24 * 60 * 60; // Convert days to seconds, preserving sign
52+
}
53+
54+
/**
55+
* If the value is a string, we check if it matches the regex for negative time strings.
56+
* If it does, we extract the number and unit, then convert it to seconds.
57+
* - 'd' is converted to seconds by multiplying by 24 * 60 * 60
58+
* - 'm' is converted to seconds by multiplying by 60
59+
* - 's' is already in seconds
60+
* This conversion preserves the sign of the number,
61+
* so if the input is negative, the output will also be negative.
62+
*/
63+
if (isString(value)) {
64+
const match = value.match(/^(-?\d+)([dms])$/);
65+
if (match) {
66+
const numValue = parseInt(match[1], 10);
67+
const unit = match[2];
68+
69+
switch (unit) {
70+
case 'd': // days
71+
return numValue * 24 * 60 * 60;
72+
73+
case 'm': // minutes
74+
return numValue * 60;
75+
76+
case 's': // seconds
77+
return numValue;
78+
79+
default:
80+
throw new Error(`Unsupported time unit: ${unit}`);
81+
}
82+
}
83+
}
84+
85+
// If we can't parse it, try to convert to number
86+
return Number(value) || undefined;
87+
}
88+
89+
/**
90+
* Custom decorator to validate that at least one of the properties 'rules' or 'trigger' is defined and not empty.
91+
* This decorator can be applied to a class to enforce this validation rule.
92+
*
93+
* @param validationOptions
94+
* @returns
95+
*/
96+
function ValidateRulesOrTrigger(validationOptions?: ValidationOptions) {
97+
return function (constructor: Function) {
98+
registerDecorator({
99+
name: 'validateRulesOrTrigger',
100+
target: constructor,
101+
propertyName: undefined,
102+
options: validationOptions,
103+
validator: {
104+
validate(_: any, args: ValidationArguments) {
105+
const obj = args.object as ConfigObjectIdentitiesDTO;
106+
107+
/**
108+
* Check if either 'rules' or 'trigger' is defined and not empty.
109+
* 'rules' should be an object with at least one key-value pair,
110+
* and 'trigger' should be a number that is not null.
111+
*/
112+
const hasRules = obj.rules !== undefined && obj.rules !== null && (typeof obj.rules === 'object' && Object.keys(obj.rules).length > 0);
113+
const hasTrigger = obj.trigger !== undefined && obj.trigger !== null;
114+
return hasRules || hasTrigger;
115+
},
116+
defaultMessage(_: ValidationArguments) {
117+
return 'Either rules or trigger must be provided';
118+
}
119+
}
120+
});
121+
};
122+
}
123+
124+
@ValidateRulesOrTrigger({ message: 'Either rules or trigger must be provided' })
125+
export class ConfigObjectIdentitiesDTO {
126+
@IsEnum(IdentityLifecycle, { each: true })
127+
@ApiProperty({
128+
type: String,
129+
enum: IdentityLifecycle,
130+
description: 'Lifecycle state of the identity',
131+
example: IdentityLifecycle.ACTIVE,
132+
required: true,
133+
})
134+
public sources: IdentityLifecycle[];
135+
136+
@IsOptional()
137+
@IsObject()
138+
public rules: object;
139+
140+
@IsOptional()
141+
@Transform(({ value }) => transformTriggerToSeconds(value))
142+
@IsNumber()
143+
@ApiProperty({
144+
oneOf: [
145+
{ type: 'number', description: 'Negative number representing days' },
146+
{ type: 'string', description: 'Negative time string with units (d=days, m=minutes, s=seconds)' }
147+
],
148+
required: false,
149+
description: 'Trigger time as negative number (days) or negative time string with units (converted to negative seconds internally)',
150+
examples: [-90, '-90d', '-10m', '-45s']
151+
})
152+
public trigger: number;
153+
154+
@IsNotEmpty()
155+
@IsEnum(IdentityLifecycle)
156+
@ApiProperty({
157+
type: String,
158+
enum: IdentityLifecycle,
159+
description: 'Target lifecycle state for the identity',
160+
example: IdentityLifecycle.DELETED,
161+
required: true,
162+
})
163+
public target: IdentityLifecycle;
164+
}
165+
166+
export class ConfigObjectSchemaDTO {
167+
@IsOptional()
168+
@IsArray()
169+
@ApiProperty({
170+
type: ConfigObjectIdentitiesDTO,
171+
required: false,
172+
})
173+
@ValidateNested({ each: true })
174+
@Type(() => ConfigObjectIdentitiesDTO)
175+
public identities: ConfigObjectIdentitiesDTO[]
176+
}

0 commit comments

Comments
 (0)