Skip to content

Commit ef41079

Browse files
authored
perf(api-service): Refactor preference merging and template selection logic (#9132)
1 parent e549667 commit ef41079

File tree

17 files changed

+568
-803
lines changed

17 files changed

+568
-803
lines changed

apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"deep-object-diff": "^1.1.9",
8585
"dotenv": "^16.5.0",
8686
"envalid": "^8.0.0",
87+
"es-toolkit": "^1.39.10",
8788
"handlebars": "^4.7.7",
8889
"helmet": "^6.0.1",
8990
"i18next": "^23.7.6",
@@ -150,9 +151,9 @@
150151
"typescript": "5.6.2"
151152
},
152153
"optionalDependencies": {
154+
"@novu/ee-api": "workspace:*",
153155
"@novu/ee-auth": "workspace:*",
154156
"@novu/ee-billing": "workspace:*",
155-
"@novu/ee-api": "workspace:*",
156157
"@novu/ee-shared-services": "workspace:*",
157158
"@novu/ee-translation": "workspace:*"
158159
},

apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
import { addBreadcrumb } from '@sentry/node';
4343
import Ajv from 'ajv';
4444
import addFormats from 'ajv-formats';
45-
import { merge } from 'lodash';
45+
import { merge } from 'es-toolkit';
4646
import { generateTransactionId } from '../../../shared/helpers/generate-transaction-id';
4747
import { PayloadValidationException } from '../../exceptions/payload-validation-exception';
4848
import { RecipientSchema, RecipientsSchema } from '../../utils/trigger-recipient-validation';
@@ -216,7 +216,7 @@ export class ParseEventRequest {
216216
})
217217
);
218218
// eslint-disable-next-line no-param-reassign
219-
command.payload = merge({}, defaultPayload, command.payload);
219+
command.payload = merge(defaultPayload, command.payload);
220220

221221
const result = await this.dispatchEventToWorkflowQueue({
222222
requestId,

apps/api/src/app/layouts-v2/usecases/build-layout-issues/build-layout-issues.usecase.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22
import { dashboardSanitizeControlValues, Instrument, InstrumentUsecase, PinoLogger } from '@novu/application-generic';
33
import { ContentIssueEnum, LAYOUT_CONTENT_VARIABLE, LayoutIssuesDto, ResourceOriginEnum } from '@novu/shared';
4-
import merge from 'lodash/merge';
4+
import { merge } from 'es-toolkit/compat';
55
import { hasMailyVariable, isStringifiedMailyJSONContent } from '../../../shared/helpers/maily-utils';
66
import {
77
ControlIssues,

apps/api/src/app/shared/services/control-value-sanitizer.service.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { actionStepSchemas, channelStepSchemas } from '@novu/framework/internal'
99
import { ResourceOriginEnum } from '@novu/shared';
1010
import Ajv, { ErrorObject } from 'ajv';
1111
import addFormats from 'ajv-formats';
12-
import _ from 'lodash';
13-
import get from 'lodash/get';
12+
import { cloneDeep, merge } from 'es-toolkit';
13+
import { get, set } from 'es-toolkit/compat';
1414
import { previewControlValueDefault } from '../../workflows-v2/usecases/preview/preview.constants';
1515
import { ControlValueProcessingResult, PreviewTemplateData } from '../../workflows-v2/usecases/preview/preview.types';
1616
import { replaceAll } from '../../workflows-v2/usecases/preview/utils/variable-helpers';
@@ -77,7 +77,7 @@ export class ControlValueSanitizerService {
7777
sanitizedControls[controlKey] = processedControlValues;
7878

7979
previewTemplateData = {
80-
payloadExample: _.merge(previewTemplateData.payloadExample, variablesObject),
80+
payloadExample: merge(previewTemplateData.payloadExample, variablesObject),
8181
controlValues: {
8282
...previewTemplateData.controlValues,
8383
[controlKey]: isObjectMailyJSONContent(processedControlValues)
@@ -120,16 +120,16 @@ export class ControlValueSanitizerService {
120120
normalizedControlValues: Record<string, unknown>,
121121
errors: ErrorObject[]
122122
): Record<string, unknown> {
123-
const fixedValues = _.cloneDeep(normalizedControlValues);
123+
const fixedValues = cloneDeep(normalizedControlValues);
124124

125125
for (const error of errors) {
126126
if (error.keyword === 'additionalProperties') {
127127
continue;
128128
}
129129

130130
const path = this.getErrorPath(error);
131-
const defaultValue = _.get(previewControlValueDefault, path);
132-
_.set(fixedValues, path, defaultValue);
131+
const defaultValue = get(previewControlValueDefault, path);
132+
set(fixedValues, path, defaultValue);
133133
}
134134

135135
return fixedValues;

apps/api/src/app/shared/usecases/create-variables-object/create-variables-object.usecase.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable } from '@nestjs/common';
22
import { Instrument, InstrumentUsecase } from '@novu/application-generic';
3-
import _ from 'lodash';
3+
import { merge } from 'es-toolkit';
44
import { JsonSchemaMock } from '../../../workflows-v2/util/json-schema-mock';
55
import { collectKeys, keysToObject } from '../../../workflows-v2/util/utils';
66
import { JSONSchemaDto } from '../../dtos/json-schema.dto';
@@ -143,7 +143,7 @@ export class CreateVariablesObject {
143143
}
144144
}, {});
145145

146-
return _.merge(obj, val);
146+
return merge(obj, val);
147147
}
148148

149149
/**

apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts

Lines changed: 50 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
MergePreferencesCommand,
1010
mapTemplateConfiguration,
1111
overridePreferences,
12+
PinoLogger,
1213
PreferenceSet,
1314
} from '@novu/application-generic';
1415
import {
@@ -34,7 +35,8 @@ export class GetSubscriberPreference {
3435
constructor(
3536
private subscriberRepository: SubscriberRepository,
3637
private notificationTemplateRepository: NotificationTemplateRepository,
37-
private preferencesRepository: PreferencesRepository
38+
private preferencesRepository: PreferencesRepository,
39+
protected logger: PinoLogger
3840
) {}
3941

4042
@InstrumentUsecase()
@@ -53,6 +55,8 @@ export class GetSubscriberPreference {
5355
severity: command.severity,
5456
});
5557

58+
this.logger.info(`Processing preferences for ${workflowList.length} workflows`);
59+
5660
const workflowIds = workflowList.map((wf) => wf._id);
5761

5862
const [
@@ -86,6 +90,8 @@ export class GetSubscriberPreference {
8690
...subscriberWorkflowPreferences,
8791
];
8892

93+
this.logger.info(`Found ${allWorkflowPreferences.length} workflow preferences entries`);
94+
8995
const workflowPreferenceSets = allWorkflowPreferences.reduce<Record<string, PreferenceSet>>((acc, preference) => {
9096
const workflowId = preference._templateId;
9197

@@ -164,47 +170,48 @@ export class GetSubscriberPreference {
164170
setImmediate(() => resolve());
165171
});
166172

167-
const chunkResults = chunk
168-
.map((workflow) => {
169-
const preferences = workflowPreferenceSets[workflow._id];
170-
171-
if (!preferences) {
172-
return null;
173-
}
174-
175-
const merged = this.mergePreferences(preferences, subscriberGlobalPreference);
176-
177-
const includedChannels = this.getChannels(workflow, includeInactiveChannels);
178-
179-
const initialChannels = filteredPreference(
180-
{
181-
email: true,
182-
sms: true,
183-
in_app: true,
184-
chat: true,
185-
push: true,
186-
},
187-
includedChannels
188-
);
189-
190-
const { channels, overrides } = this.calculateChannelsAndOverrides(merged, initialChannels);
191-
192-
return {
193-
preference: {
194-
channels,
195-
enabled: true,
196-
overrides,
197-
},
198-
template: mapTemplateConfiguration({
199-
...workflow,
200-
critical: merged.preferences.all.readOnly,
201-
}),
202-
type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW,
203-
};
204-
})
205-
.filter(Boolean);
206-
207-
results.push(...chunkResults);
173+
const chunkPromises = chunk.map(async (workflow) => {
174+
const preferences = workflowPreferenceSets[workflow._id];
175+
176+
if (!preferences) {
177+
return null;
178+
}
179+
180+
const merged = await this.mergePreferences(preferences, subscriberGlobalPreference);
181+
182+
const includedChannels = this.getChannels(workflow, includeInactiveChannels);
183+
184+
const initialChannels = filteredPreference(
185+
{
186+
email: true,
187+
sms: true,
188+
in_app: true,
189+
chat: true,
190+
push: true,
191+
},
192+
includedChannels
193+
);
194+
195+
const { channels, overrides } = this.calculateChannelsAndOverrides(merged, initialChannels);
196+
197+
return {
198+
preference: {
199+
channels,
200+
enabled: true,
201+
overrides,
202+
},
203+
template: mapTemplateConfiguration({
204+
...workflow,
205+
critical: merged.preferences.all.readOnly,
206+
}),
207+
type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW,
208+
};
209+
});
210+
211+
const chunkResults = await Promise.all(chunkPromises);
212+
const filteredResults = chunkResults.filter(Boolean);
213+
214+
results.push(...filteredResults);
208215
}
209216

210217
return results;
@@ -223,7 +230,7 @@ export class GetSubscriberPreference {
223230
}
224231

225232
@Instrument()
226-
private mergePreferences(preferences: PreferenceSet, subscriberGlobalPreference: PreferencesEntity | null) {
233+
private async mergePreferences(preferences: PreferenceSet, subscriberGlobalPreference: PreferencesEntity | null) {
227234
const mergeCommand = MergePreferencesCommand.create({
228235
workflowResourcePreference: preferences.workflowResourcePreference,
229236
workflowUserPreference: preferences.workflowUserPreference,

apps/api/src/app/workflows-v2/usecases/build-step-issues/build-step-issues.usecase.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ import {
1818
StepTypeEnum,
1919
UserSessionData,
2020
} from '@novu/shared';
21+
import { merge } from 'es-toolkit/compat';
2122
import { AdditionalOperation, RulesLogic } from 'json-logic-js';
2223
import isEmpty from 'lodash/isEmpty';
23-
import merge from 'lodash/merge';
2424
import { JSONSchemaDto } from '../../../shared/dtos/json-schema.dto';
2525
import {
2626
QueryIssueTypeEnum,

apps/api/src/app/workflows-v2/usecases/preview/services/payload-merger.service.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Injectable } from '@nestjs/common';
2-
import { FeatureFlagsService } from '@novu/application-generic';
32
import { NotificationTemplateEntity } from '@novu/dal';
43
import { createMockObjectFromSchema, ResourceOriginEnum, UserSessionData } from '@novu/shared';
4+
import { isPlainObject, merge, mergeWith, pick } from 'es-toolkit';
5+
import { keys } from 'es-toolkit/compat';
56
import _ from 'lodash';
67
import { PreviewPayloadDto, StepResponseDto } from '../../../dtos';
78
import { JsonSchemaMock } from '../../../util/json-schema-mock';
@@ -12,7 +13,6 @@ import { MockDataGeneratorService } from './mock-data-generator.service';
1213
@Injectable()
1314
export class PayloadMergerService {
1415
constructor(
15-
private readonly featureFlagService: FeatureFlagsService,
1616
private readonly mockDataGenerator: MockDataGeneratorService,
1717
private readonly buildStepDataUsecase: BuildStepDataUsecase
1818
) {}
@@ -87,7 +87,7 @@ export class PayloadMergerService {
8787
});
8888
}
8989

90-
let mergedPayload = _.merge({}, schemaBasedPayloadExample);
90+
let mergedPayload = merge({}, schemaBasedPayloadExample);
9191

9292
if (userPayloadExample && Object.keys(userPayloadExample).length > 0) {
9393
// Filter userPayloadExample to only include keys that exist in schemaBasedPayloadExample
@@ -96,7 +96,7 @@ export class PayloadMergerService {
9696
schemaBasedPayloadExample
9797
);
9898

99-
mergedPayload = _.mergeWith(mergedPayload, filteredUserPayload, (objValue, srcValue) => {
99+
mergedPayload = mergeWith(mergedPayload, filteredUserPayload, (objValue, srcValue) => {
100100
if (Array.isArray(srcValue)) {
101101
return srcValue;
102102
}
@@ -292,23 +292,23 @@ export class PayloadMergerService {
292292
schemaPayload: Record<string, unknown>
293293
): Record<string, unknown> {
294294
// Use lodash pick to only include keys that exist in the schema
295-
const filtered = _.pick(userPayload, _.keys(schemaPayload));
295+
const filtered = pick(userPayload, keys(schemaPayload));
296296

297297
// Recursively filter nested objects and arrays
298298
for (const [key, value] of Object.entries(filtered)) {
299-
if (_.isPlainObject(value) && _.isPlainObject(schemaPayload[key])) {
299+
if (isPlainObject(value) && isPlainObject(schemaPayload[key])) {
300300
filtered[key] = this.filterPayloadBySchema(
301301
value as Record<string, unknown>,
302302
schemaPayload[key] as Record<string, unknown>
303303
);
304304
} else if (Array.isArray(value) && Array.isArray(schemaPayload[key])) {
305305
// Handle arrays by filtering each element
306306
filtered[key] = value.map((item) => {
307-
if (_.isPlainObject(item) && schemaPayload[key] && Array.isArray(schemaPayload[key])) {
307+
if (isPlainObject(item) && schemaPayload[key] && Array.isArray(schemaPayload[key])) {
308308
const schemaArray = schemaPayload[key] as unknown[];
309309
// Use the first element of the schema array as the template for filtering
310310
const schemaTemplate =
311-
schemaArray.length > 0 && _.isPlainObject(schemaArray[0])
311+
schemaArray.length > 0 && isPlainObject(schemaArray[0])
312312
? (schemaArray[0] as Record<string, unknown>)
313313
: {};
314314

apps/api/src/app/workflows-v2/usecases/preview/services/schema-builder.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22
import { JsonSchemaFormatEnum, JsonSchemaTypeEnum, NotificationTemplateEntity } from '@novu/dal';
33

4-
import _ from 'lodash';
4+
import { merge } from 'es-toolkit';
55
import { JSONSchemaDto } from '../../../../shared/dtos/json-schema.dto';
66
import { buildVariablesSchema } from '../../../../shared/utils/create-schema';
77
import { PreviewPayloadDto } from '../../../dtos';
@@ -19,7 +19,7 @@ export class SchemaBuilderService {
1919
return variables;
2020
}
2121

22-
return _.merge(variables, { properties: { payload: payloadSchema } });
22+
return merge(variables, { properties: { payload: payloadSchema } });
2323
}
2424

2525
async buildPreviewPayloadSchema(

libs/application-generic/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"cron-parser": "^4.9.0",
7979
"date-fns": "^2.29.2",
8080
"date-fns-tz": "^3.2.0",
81+
"es-toolkit": "^1.39.10",
8182
"got": "^11.8.6",
8283
"handlebars": "^4.7.7",
8384
"i18next": "^23.7.6",

0 commit comments

Comments
 (0)