Skip to content

Commit 221a100

Browse files
authored
Merge branch 'next' into provider-integrations-limits
2 parents a78a19e + f8106ed commit 221a100

File tree

25 files changed

+333
-129
lines changed

25 files changed

+333
-129
lines changed

apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ export class SubscriberSessionResponseDto {
22
readonly token: string;
33
readonly totalUnreadCount: number;
44
readonly removeNovuBranding: boolean;
5-
readonly isSnoozeEnabled: boolean;
5+
readonly maxSnoozeDurationHours: number;
66
readonly isDevelopmentMode: boolean;
77
}

apps/api/src/app/inbox/usecases/session/session.spec.ts

+11-21
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import sinon from 'sinon';
22
import { expect } from 'chai';
33
import { NotFoundException, BadRequestException } from '@nestjs/common';
44
import { CommunityOrganizationRepository, EnvironmentRepository, IntegrationRepository } from '@novu/dal';
5-
import {
6-
AnalyticsService,
7-
CreateOrUpdateSubscriberUseCase,
8-
FeatureFlagsService,
9-
SelectIntegration,
10-
} from '@novu/application-generic';
5+
import { AnalyticsService, CreateOrUpdateSubscriberUseCase, SelectIntegration } from '@novu/application-generic';
116
import { ApiServiceLevelEnum, ChannelTypeEnum, InAppProviderIdEnum } from '@novu/shared';
127
import { AuthService } from '../../../auth/services/auth.service';
138
import { Session } from './session.usecase';
@@ -45,7 +40,6 @@ describe('Session', () => {
4540
let notificationsCount: sinon.SinonStubbedInstance<NotificationsCount>;
4641
let integrationRepository: sinon.SinonStubbedInstance<IntegrationRepository>;
4742
let organizationRepository: sinon.SinonStubbedInstance<CommunityOrganizationRepository>;
48-
let featureFlagsService: sinon.SinonStubbedInstance<FeatureFlagsService>;
4943

5044
beforeEach(() => {
5145
environmentRepository = sinon.createStubInstance(EnvironmentRepository);
@@ -56,7 +50,6 @@ describe('Session', () => {
5650
notificationsCount = sinon.createStubInstance(NotificationsCount);
5751
integrationRepository = sinon.createStubInstance(IntegrationRepository);
5852
organizationRepository = sinon.createStubInstance(CommunityOrganizationRepository);
59-
featureFlagsService = sinon.createStubInstance(FeatureFlagsService);
6053

6154
session = new Session(
6255
environmentRepository as any,
@@ -66,8 +59,7 @@ describe('Session', () => {
6659
analyticsService as any,
6760
notificationsCount as any,
6861
integrationRepository as any,
69-
organizationRepository as any,
70-
featureFlagsService as any
62+
organizationRepository as any
7163
);
7264
});
7365

@@ -226,9 +218,7 @@ describe('Session', () => {
226218
).to.be.true;
227219
});
228220

229-
it('should return the correct isSnoozeEnabled value for different service levels', async () => {
230-
featureFlagsService.getFlag.resolves(true);
231-
221+
it('should return the correct maxSnoozeDurationHours value for different service levels', async () => {
232222
const command: SessionCommand = {
233223
applicationIdentifier: 'app-id',
234224
subscriber: { subscriberId: 'subscriber-id' },
@@ -247,24 +237,24 @@ describe('Session', () => {
247237
notificationsCount.execute.resolves(notificationCount);
248238
authService.getSubscriberWidgetToken.resolves(token);
249239

250-
// FREE plan should have snooze disabled
240+
// FREE plan should have 24 hours max snooze duration
251241
organizationRepository.findOne.resolves({ apiServiceLevel: ApiServiceLevelEnum.FREE } as any);
252242
const freeResponse: SubscriberSessionResponseDto = await session.execute(command);
253-
expect(freeResponse.isSnoozeEnabled).to.equal(false);
243+
expect(freeResponse.maxSnoozeDurationHours).to.equal(24);
254244

255-
// PRO plan should have snooze enabled
245+
// PRO plan should have 90 days max snooze duration
256246
organizationRepository.findOne.resolves({ apiServiceLevel: ApiServiceLevelEnum.PRO } as any);
257247
const proResponse: SubscriberSessionResponseDto = await session.execute(command);
258-
expect(proResponse.isSnoozeEnabled).to.equal(true);
248+
expect(proResponse.maxSnoozeDurationHours).to.equal(90 * 24);
259249

260-
// BUSINESS/TEAM plan should have snooze enabled
250+
// BUSINESS/TEAM plan should have 90 days max snooze duration
261251
organizationRepository.findOne.resolves({ apiServiceLevel: ApiServiceLevelEnum.BUSINESS } as any);
262252
const businessResponse: SubscriberSessionResponseDto = await session.execute(command);
263-
expect(businessResponse.isSnoozeEnabled).to.equal(true);
253+
expect(businessResponse.maxSnoozeDurationHours).to.equal(90 * 24);
264254

265-
// ENTERPRISE plan should have snooze enabled
255+
// ENTERPRISE plan should have 90 days max snooze duration
266256
organizationRepository.findOne.resolves({ apiServiceLevel: ApiServiceLevelEnum.ENTERPRISE } as any);
267257
const enterpriseResponse: SubscriberSessionResponseDto = await session.execute(command);
268-
expect(enterpriseResponse.isSnoozeEnabled).to.equal(true);
258+
expect(enterpriseResponse.maxSnoozeDurationHours).to.equal(90 * 24);
269259
});
270260
});

apps/api/src/app/inbox/usecases/session/session.usecase.ts

+6-27
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,16 @@ import {
66
LogDecorator,
77
SelectIntegration,
88
SelectIntegrationCommand,
9-
FeatureFlagsService,
109
} from '@novu/application-generic';
1110
import {
1211
CommunityOrganizationRepository,
1312
EnvironmentEntity,
1413
EnvironmentRepository,
1514
IntegrationRepository,
16-
OrganizationEntity,
17-
UserEntity,
1815
} from '@novu/dal';
1916
import {
2017
ApiServiceLevelEnum,
2118
ChannelTypeEnum,
22-
FeatureFlagsKeysEnum,
2319
FeatureNameEnum,
2420
getFeatureForTierAsNumber,
2521
InAppProviderIdEnum,
@@ -46,8 +42,7 @@ export class Session {
4642
private analyticsService: AnalyticsService,
4743
private notificationsCount: NotificationsCount,
4844
private integrationRepository: IntegrationRepository,
49-
private organizationRepository: CommunityOrganizationRepository,
50-
private featureFlagsService: FeatureFlagsService
45+
private organizationRepository: CommunityOrganizationRepository
5146
) {}
5247

5348
@LogDecorator()
@@ -116,11 +111,7 @@ export class Session {
116111
const token = await this.authService.getSubscriberWidgetToken(subscriber);
117112

118113
const removeNovuBranding = inAppIntegration.removeNovuBranding || false;
119-
const isSnoozeEnabled = await this.isSnoozeEnabled(
120-
environment._organizationId,
121-
environment._id,
122-
command.subscriber.subscriberId
123-
);
114+
const maxSnoozeDurationHours = await this.getMaxSnoozeDurationHours(environment);
124115

125116
/**
126117
* We want to prevent the playground inbox demo from marking the integration as connected
@@ -151,34 +142,22 @@ export class Session {
151142
token,
152143
totalUnreadCount,
153144
removeNovuBranding,
154-
isSnoozeEnabled,
145+
maxSnoozeDurationHours,
155146
isDevelopmentMode: environment.name.toLowerCase() !== 'production',
156147
};
157148
}
158149

159-
private async isSnoozeEnabled(organizationId: string, environmentId: string, subscriberId: string) {
150+
private async getMaxSnoozeDurationHours(environment: EnvironmentEntity) {
160151
const organization = await this.organizationRepository.findOne({
161-
_id: organizationId,
162-
});
163-
164-
const isSnoozeEnabled = await this.featureFlagsService.getFlag({
165-
key: FeatureFlagsKeysEnum.IS_SNOOZE_ENABLED,
166-
defaultValue: false,
167-
organization: { _id: organizationId } as OrganizationEntity,
168-
environment: { _id: environmentId } as EnvironmentEntity,
169-
user: { _id: subscriberId } as UserEntity,
152+
_id: environment._organizationId,
170153
});
171154

172-
if (!isSnoozeEnabled) {
173-
return false;
174-
}
175-
176155
const tierLimitMs = getFeatureForTierAsNumber(
177156
FeatureNameEnum.PLATFORM_MAX_SNOOZE_DURATION,
178157
organization?.apiServiceLevel || ApiServiceLevelEnum.FREE,
179158
true
180159
);
181160

182-
return tierLimitMs > 0;
161+
return tierLimitMs / 1000 / 60 / 60;
183162
}
184163
}

apps/api/src/app/inbox/usecases/snooze-notification/snooze-notification.usecase.ts

+21-48
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
DetailEnum,
1313
StandardQueueService,
1414
FeatureFlagsService,
15-
SYSTEM_LIMITS,
1615
PinoLogger,
1716
} from '@novu/application-generic';
1817
import {
@@ -57,20 +56,18 @@ export class SnoozeNotification {
5756
) {}
5857

5958
public async execute(command: SnoozeNotificationCommand): Promise<InboxNotification> {
60-
await this.isSnoozeEnabled(command);
61-
62-
const delayAmountMs = this.calculateDelayInMs(command.snoozeUntil);
63-
await this.validateDelayDuration(command, delayAmountMs);
59+
const snoozeDurationMs = this.calculateDelayInMs(command.snoozeUntil);
60+
await this.validateSnoozeDuration(command, snoozeDurationMs);
6461
const notification = await this.findNotification(command);
6562

6663
try {
6764
let scheduledJob = {} as JobEntity;
6865
let snoozedNotification = {} as InboxNotification;
6966

7067
await this.messageRepository.withTransaction(async () => {
71-
scheduledJob = await this.createScheduledUnsnoozeJob(notification, delayAmountMs);
68+
scheduledJob = await this.createScheduledUnsnoozeJob(notification, snoozeDurationMs);
7269
snoozedNotification = await this.markNotificationAsSnoozed(command);
73-
await this.enqueueJob(scheduledJob, delayAmountMs);
70+
await this.enqueueJob(scheduledJob, snoozeDurationMs);
7471
});
7572

7673
// fire and forget
@@ -113,7 +110,7 @@ export class SnoozeNotification {
113110
});
114111
}
115112

116-
private async isSnoozeEnabled(command: SnoozeNotificationCommand) {
113+
private async validateSnoozeDuration(command: SnoozeNotificationCommand, snoozeDurationMs: number) {
117114
const isSnoozeEnabled = await this.featureFlagsService.getFlag({
118115
key: FeatureFlagsKeysEnum.IS_SNOOZE_ENABLED,
119116
defaultValue: false,
@@ -125,26 +122,16 @@ export class SnoozeNotification {
125122
if (!isSnoozeEnabled) {
126123
throw new NotImplementedException();
127124
}
128-
}
129-
130-
private calculateDelayInMs(snoozeUntil: Date): number {
131-
return snoozeUntil.getTime() - new Date().getTime();
132-
}
133125

134-
private async validateDelayDuration(command: SnoozeNotificationCommand, delay: number) {
135-
const tierLimit = await this.getTierLimit(command);
126+
const organization = await this.getOrganization(command.organizationId);
136127

137-
if (tierLimit === 0) {
138-
throw new HttpException(
139-
{
140-
message: 'Feature Not Available',
141-
reason: 'Snooze functionality is not available on your current plan. Please upgrade to access this feature.',
142-
},
143-
HttpStatus.PAYMENT_REQUIRED
144-
);
145-
}
128+
const tierLimitMs = getFeatureForTierAsNumber(
129+
FeatureNameEnum.PLATFORM_MAX_SNOOZE_DURATION,
130+
organization?.apiServiceLevel || ApiServiceLevelEnum.FREE,
131+
true
132+
);
146133

147-
if (delay > tierLimit) {
134+
if (snoozeDurationMs > tierLimitMs) {
148135
throw new HttpException(
149136
{
150137
message: 'Snooze Duration Limit Exceeded',
@@ -157,34 +144,20 @@ export class SnoozeNotification {
157144
}
158145
}
159146

160-
private async getTierLimit(command: SnoozeNotificationCommand) {
161-
const systemLimitMs = await this.featureFlagsService.getFlag({
162-
key: FeatureFlagsKeysEnum.MAX_DEFER_DURATION_IN_MS_NUMBER,
163-
defaultValue: SYSTEM_LIMITS.DEFER_DURATION_MS,
164-
environment: { _id: command.environmentId },
165-
organization: { _id: command.organizationId },
166-
});
167-
168-
/**
169-
* If the system limit is not the default, we need to use it as the limit
170-
* for the specific customer exceptions from the tier limit
171-
*/
172-
const isSpecialLimit = systemLimitMs !== SYSTEM_LIMITS.DEFER_DURATION_MS;
173-
if (isSpecialLimit) {
174-
return systemLimitMs;
175-
}
147+
private calculateDelayInMs(snoozeUntil: Date): number {
148+
return snoozeUntil.getTime() - new Date().getTime();
149+
}
176150

151+
private async getOrganization(organizationId: string): Promise<OrganizationEntity> {
177152
const organization = await this.organizationRepository.findOne({
178-
_id: command.organizationId,
153+
_id: organizationId,
179154
});
180155

181-
const tierLimitMs = getFeatureForTierAsNumber(
182-
FeatureNameEnum.PLATFORM_MAX_SNOOZE_DURATION,
183-
organization?.apiServiceLevel || ApiServiceLevelEnum.FREE,
184-
true
185-
);
156+
if (!organization) {
157+
throw new NotFoundException(`Organization id: '${organizationId}' not found`);
158+
}
186159

187-
return Math.min(systemLimitMs, tierLimitMs);
160+
return organization;
188161
}
189162

190163
private async findNotification(command: SnoozeNotificationCommand): Promise<MessageEntity> {

apps/api/src/app/inbox/usecases/unsnooze-notification/unsnooze-notification.usecase.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class UnsnoozeNotification {
7171
_environmentId: command.environmentId,
7272
delay: { $exists: true },
7373
status: JobStatusEnum.PENDING,
74-
'payload.snooze': true,
74+
'payload.unsnooze': true,
7575
});
7676

7777
unsnoozedNotification = await this.markNotificationAs.execute(
@@ -103,7 +103,7 @@ export class UnsnoozeNotification {
103103
});
104104
} else {
105105
this.logger.error(
106-
`Could not find a scheduled job for snoozed notification '${command.notificationId}'. ` +
106+
`Could not find a scheduled job for snoozed notification '${notificationId}'. ` +
107107
'The notification may have already been unsnoozed or the scheduled job was deleted.'
108108
);
109109
}

apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts

+41
Original file line numberDiff line numberDiff line change
@@ -1500,6 +1500,47 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview #novu-v
15001500
});
15011501
expect(previewResponse4.result.result.preview.body).to.contain('hello');
15021502
expect(previewResponse4.result.result.preview.body).to.contain('new');
1503+
1504+
const controlValues4 = {
1505+
body: `{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"payload.items","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]}]}`,
1506+
subject: 'events length',
1507+
};
1508+
1509+
const resultForPayloadItems = {
1510+
payload: {
1511+
items: 'items',
1512+
},
1513+
};
1514+
1515+
const previewResponse7 = await novuClient.workflows.steps.generatePreview({
1516+
generatePreviewRequestDto: {
1517+
controlValues: controlValues4,
1518+
previewPayload: {},
1519+
},
1520+
stepId: emailStepDatabaseId,
1521+
workflowId,
1522+
});
1523+
expect(previewResponse7.result.previewPayloadExample).to.deep.equal(resultForPayloadItems);
1524+
1525+
const editedItemsToArray = {
1526+
payload: {
1527+
items: [
1528+
{
1529+
name: 'name',
1530+
},
1531+
],
1532+
},
1533+
};
1534+
const previewResponse8 = await novuClient.workflows.steps.generatePreview({
1535+
generatePreviewRequestDto: {
1536+
controlValues: controlValues4,
1537+
previewPayload: editedItemsToArray,
1538+
},
1539+
stepId: emailStepDatabaseId,
1540+
workflowId,
1541+
});
1542+
1543+
expect(previewResponse8.result.previewPayloadExample).to.deep.equal(editedItemsToArray);
15031544
});
15041545
});
15051546

apps/worker/src/app/workflow/usecases/process-unsnooze-job/process-unsnooze-job.usecase.ts

+19-8
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,28 @@ export class ProcessUnsnoozeJob {
5050
);
5151
}
5252

53+
const nowDate = new Date();
54+
const createdAtDate = new Date(snoozedNotification.createdAt);
55+
5356
await this.messageRepository.update(
5457
{ _environmentId: job._environmentId, _notificationId: job._notificationId },
55-
{
56-
$set: {
57-
snoozedUntil: null,
58-
createdAt: new Date(),
59-
},
60-
$addToSet: {
61-
deliveredAt: { $each: [snoozedNotification.createdAt, new Date()] },
58+
[
59+
{
60+
$set: {
61+
snoozedUntil: null,
62+
createdAt: nowDate,
63+
read: false,
64+
lastReadDate: null,
65+
deliveredAt: {
66+
$cond: {
67+
if: { $isArray: '$deliveredAt' },
68+
then: { $concatArrays: ['$deliveredAt', [nowDate]] },
69+
else: [createdAtDate, nowDate],
70+
},
71+
},
72+
},
6273
},
63-
},
74+
],
6475
{
6576
timestamps: false,
6677
strict: false,

0 commit comments

Comments
 (0)