@tstdl/base 0.93.100 → 0.93.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/authentication/client/authentication.service.d.ts +1 -1
- package/authentication/client/authentication.service.js +23 -11
- package/notification/api/index.d.ts +1 -0
- package/notification/api/index.js +1 -0
- package/notification/api/notification.api.d.ts +8 -16
- package/notification/api/notification.api.js +13 -26
- package/notification/index.d.ts +1 -1
- package/notification/index.js +1 -1
- package/notification/models/in-app-notification.model.d.ts +9 -4
- package/notification/models/in-app-notification.model.js +25 -10
- package/notification/models/index.d.ts +1 -1
- package/notification/models/index.js +1 -1
- package/notification/models/notification-log.model.d.ts +42 -5
- package/notification/models/notification-log.model.js +34 -20
- package/notification/models/notification-preference.model.d.ts +2 -2
- package/notification/models/notification-preference.model.js +9 -9
- package/notification/models/notification-type.model.d.ts +17 -0
- package/notification/models/{notification-category.model.js → notification-type.model.js} +12 -13
- package/notification/models/web-push-subscription.model.d.ts +2 -2
- package/notification/models/web-push-subscription.model.js +8 -7
- package/notification/server/api/notification.api-controller.d.ts +2 -2
- package/notification/server/api/notification.api-controller.js +4 -3
- package/notification/server/drizzle/{0000_glorious_randall.sql → 0000_shiny_the_anarchist.sql} +27 -32
- package/notification/server/drizzle/meta/0000_snapshot.json +179 -179
- package/notification/server/drizzle/meta/_journal.json +2 -2
- package/notification/server/module.d.ts +2 -0
- package/notification/server/module.js +1 -0
- package/notification/server/providers/channel-provider.d.ts +4 -3
- package/notification/server/providers/channel-provider.js +2 -1
- package/notification/server/providers/email-channel-provider.d.ts +3 -3
- package/notification/server/providers/email-channel-provider.js +7 -9
- package/notification/server/providers/in-app-channel-provider.d.ts +5 -5
- package/notification/server/providers/in-app-channel-provider.js +15 -16
- package/notification/server/providers/index.d.ts +1 -1
- package/notification/server/providers/index.js +1 -1
- package/notification/server/providers/web-push-channel-provider.d.ts +5 -4
- package/notification/server/providers/web-push-channel-provider.js +8 -7
- package/notification/server/schemas.d.ts +3 -3
- package/notification/server/schemas.js +3 -4
- package/notification/server/services/index.d.ts +2 -4
- package/notification/server/services/index.js +2 -4
- package/notification/server/services/notification-delivery.worker.d.ts +7 -1
- package/notification/server/services/notification-delivery.worker.js +49 -37
- package/notification/server/services/notification-sse.service.d.ts +4 -7
- package/notification/server/services/notification-sse.service.js +4 -11
- package/notification/server/services/notification-template.d.ts +2 -2
- package/notification/server/services/notification-template.js +3 -1
- package/notification/server/services/notification-template.service.d.ts +1 -1
- package/notification/server/services/notification-template.service.js +7 -3
- package/notification/server/services/notification-type.service.d.ts +11 -0
- package/notification/server/services/notification-type.service.js +41 -0
- package/notification/server/services/notification.service.d.ts +4 -5
- package/notification/server/services/notification.service.js +44 -27
- package/notification/tests/notification-api.test.js +95 -0
- package/notification/tests/notification-flow.test.js +174 -28
- package/notification/tests/notification-type.service.test.d.ts +1 -0
- package/notification/tests/notification-type.service.test.js +35 -0
- package/package.json +1 -1
- package/rate-limit/postgres/postgres-rate-limiter.d.ts +9 -4
- package/rate-limit/postgres/postgres-rate-limiter.js +17 -10
- package/rate-limit/rate-limiter.d.ts +6 -6
- package/rate-limit/tests/postgres-rate-limiter.test.js +1 -1
- package/task-queue/postgres/task-queue.js +1 -1
- package/task-queue/task-context.d.ts +1 -14
- package/task-queue/task-context.js +0 -30
- package/task-queue/task-queue.d.ts +4 -12
- package/task-queue/task-queue.js +38 -89
- package/task-queue/tests/extensive-dependencies.test.d.ts +1 -0
- package/task-queue/tests/extensive-dependencies.test.js +234 -0
- package/task-queue/tests/worker.test.js +0 -21
- package/task-queue/types.d.ts +1 -8
- package/notification/enums.d.ts +0 -22
- package/notification/enums.js +0 -19
- package/notification/models/notification-category.model.d.ts +0 -17
- package/notification/server/services/notification-category.service.d.ts +0 -11
- package/notification/server/services/notification-category.service.js +0 -41
- package/notification/server/services/notification-delivery.task.d.ts +0 -9
- package/notification/server/services/notification-delivery.task.js +0 -1
- package/notification/server/services/singleton.d.ts +0 -3
- package/notification/server/services/singleton.js +0 -10
- package/notification/tests/notification-category.service.test.js +0 -36
- package/notification/tests/test-notification.model.d.ts +0 -4
- package/notification/tests/test-notification.model.js +0 -25
- /package/notification/tests/{notification-category.service.test.d.ts → notification-api.test.d.ts} +0 -0
|
@@ -4,15 +4,16 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
4
4
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
|
-
import {
|
|
7
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
8
|
+
import { BadRequestError } from '../../../errors/bad-request.error.js';
|
|
9
|
+
import { inject, Singleton } from '../../../injector/index.js';
|
|
10
|
+
import { TRANSACTION_TIMESTAMP } from '../../../orm/index.js';
|
|
8
11
|
import { injectRepository, Transactional } from '../../../orm/server/index.js';
|
|
9
12
|
import { TaskQueue } from '../../../task-queue/task-queue.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { InAppNotification, NotificationLog, NotificationPreference, WebPushSubscription } from '../../models/index.js';
|
|
13
|
-
import { NotificationSingleton } from './singleton.js';
|
|
13
|
+
import { InAppNotification, NotificationLogEntity, NotificationPreference, NotificationStatus, toInAppNotificationView, WebPushSubscription } from '../../models/index.js';
|
|
14
|
+
import { inAppNotification, notificationLog } from '../schemas.js';
|
|
14
15
|
let NotificationService = class NotificationService extends Transactional {
|
|
15
|
-
#notificationLogRepository = injectRepository(
|
|
16
|
+
#notificationLogRepository = injectRepository(NotificationLogEntity);
|
|
16
17
|
#inAppNotificationRepository = injectRepository(InAppNotification);
|
|
17
18
|
#preferenceRepository = injectRepository(NotificationPreference);
|
|
18
19
|
#webPushSubscriptionRepository = injectRepository(WebPushSubscription);
|
|
@@ -29,42 +30,58 @@ let NotificationService = class NotificationService extends Transactional {
|
|
|
29
30
|
});
|
|
30
31
|
}
|
|
31
32
|
async listInApp(tenantId, userId, options = {}) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
const rows = await this.#notificationLogRepository.session
|
|
34
|
+
.select({
|
|
35
|
+
notification: notificationLog,
|
|
36
|
+
inApp: inAppNotification,
|
|
37
|
+
})
|
|
38
|
+
.from(notificationLog)
|
|
39
|
+
.innerJoin(inAppNotification, eq(inAppNotification.logId, notificationLog.id))
|
|
40
|
+
.where(and(eq(inAppNotification.tenantId, tenantId), eq(notificationLog.userId, userId), options.includeArchived ? undefined : isNull(inAppNotification.archiveTimestamp)))
|
|
41
|
+
.limit(options.limit ?? 50)
|
|
42
|
+
.offset(options.offset ?? 0)
|
|
43
|
+
.orderBy(desc(notificationLog.createTimestamp));
|
|
44
|
+
const inAppRows = rows.map((row) => row.inApp);
|
|
45
|
+
const notificationRows = rows.map((row) => row.notification);
|
|
46
|
+
const notificationEntities = await this.#notificationLogRepository.mapManyToEntity(notificationRows);
|
|
47
|
+
const inAppEntities = await this.#inAppNotificationRepository.mapManyToEntity(inAppRows);
|
|
48
|
+
const views = notificationEntities.map((notification, index) => {
|
|
49
|
+
const inApp = inAppEntities[index];
|
|
50
|
+
return toInAppNotificationView(inApp, notification);
|
|
40
51
|
});
|
|
41
|
-
|
|
42
|
-
const logs = await this.#notificationLogRepository.loadMany(logIds);
|
|
43
|
-
const logsMap = new Map(logs.map((log) => [log.id, log]));
|
|
44
|
-
return inAppNotifications.map((n) => ({
|
|
45
|
-
...n,
|
|
46
|
-
log: logsMap.get(n.logId),
|
|
47
|
-
}));
|
|
52
|
+
return views;
|
|
48
53
|
}
|
|
49
54
|
async markRead(tenantId, userId, id) {
|
|
50
|
-
await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, {
|
|
55
|
+
await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, { readTimestamp: TRANSACTION_TIMESTAMP });
|
|
51
56
|
}
|
|
52
57
|
async archive(tenantId, userId, id) {
|
|
53
|
-
await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, {
|
|
58
|
+
await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, { archiveTimestamp: TRANSACTION_TIMESTAMP });
|
|
54
59
|
}
|
|
55
60
|
async getPreferences(tenantId, userId) {
|
|
56
61
|
return await this.#preferenceRepository.loadManyByQuery({ tenantId, userId });
|
|
57
62
|
}
|
|
58
|
-
async updatePreference(tenantId, userId,
|
|
59
|
-
await this.#preferenceRepository.upsert(['tenantId', 'userId', '
|
|
63
|
+
async updatePreference(tenantId, userId, type, channel, enabled) {
|
|
64
|
+
await this.#preferenceRepository.upsert(['tenantId', 'userId', 'type', 'channel'], {
|
|
60
65
|
tenantId,
|
|
61
66
|
userId,
|
|
62
|
-
|
|
67
|
+
type,
|
|
63
68
|
channel,
|
|
64
69
|
enabled,
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
async registerWebPush(tenantId, userId, endpoint, p256dh, auth) {
|
|
73
|
+
try {
|
|
74
|
+
new URL(endpoint);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new BadRequestError('Invalid Web Push endpoint');
|
|
78
|
+
}
|
|
79
|
+
if (p256dh.length < 20) {
|
|
80
|
+
throw new BadRequestError('Invalid Web Push p256dh key');
|
|
81
|
+
}
|
|
82
|
+
if (auth.length < 10) {
|
|
83
|
+
throw new BadRequestError('Invalid Web Push auth secret');
|
|
84
|
+
}
|
|
68
85
|
await this.#webPushSubscriptionRepository.upsert(['tenantId', 'userId', 'endpoint'], {
|
|
69
86
|
tenantId,
|
|
70
87
|
userId,
|
|
@@ -75,6 +92,6 @@ let NotificationService = class NotificationService extends Transactional {
|
|
|
75
92
|
}
|
|
76
93
|
};
|
|
77
94
|
NotificationService = __decorate([
|
|
78
|
-
|
|
95
|
+
Singleton()
|
|
79
96
|
], NotificationService);
|
|
80
97
|
export { NotificationService };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { SubjectService } from '../../authentication/server/subject.service.js';
|
|
3
|
+
import {} from '../../injector/index.js';
|
|
4
|
+
import { setupIntegrationTest } from '../../unit-test/index.js';
|
|
5
|
+
import { NotificationChannel } from '../models/index.js';
|
|
6
|
+
import { NotificationApiController } from '../server/api/notification.api-controller.js';
|
|
7
|
+
import { NotificationSseService } from '../server/services/notification-sse.service.js';
|
|
8
|
+
import { NotificationService } from '../server/services/notification.service.js';
|
|
9
|
+
describe('Notification API (Integration)', () => {
|
|
10
|
+
let injector;
|
|
11
|
+
let controller;
|
|
12
|
+
let notificationService;
|
|
13
|
+
let sseService;
|
|
14
|
+
let subjectService;
|
|
15
|
+
const schema = 'notification';
|
|
16
|
+
const tenantId = '00000000-0000-0000-0000-000000000000';
|
|
17
|
+
let userId;
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
({ injector } = await setupIntegrationTest({
|
|
20
|
+
orm: { schema },
|
|
21
|
+
modules: {
|
|
22
|
+
notification: true,
|
|
23
|
+
authentication: true,
|
|
24
|
+
taskQueue: true,
|
|
25
|
+
rateLimiter: true,
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
controller = injector.resolve(NotificationApiController);
|
|
29
|
+
notificationService = injector.resolve(NotificationService);
|
|
30
|
+
sseService = injector.resolve(NotificationSseService);
|
|
31
|
+
subjectService = injector.resolve(SubjectService);
|
|
32
|
+
// Create a dummy user
|
|
33
|
+
const user = await subjectService.createUser({
|
|
34
|
+
tenantId,
|
|
35
|
+
email: 'api-test@example.com',
|
|
36
|
+
firstName: 'Api',
|
|
37
|
+
lastName: 'Test',
|
|
38
|
+
});
|
|
39
|
+
userId = user.id;
|
|
40
|
+
});
|
|
41
|
+
const createMockContext = (params = {}) => ({
|
|
42
|
+
parameters: params,
|
|
43
|
+
getToken: async () => ({
|
|
44
|
+
payload: {
|
|
45
|
+
tenant: tenantId,
|
|
46
|
+
subject: userId,
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
test('stream should register sse client', async () => {
|
|
51
|
+
const registerSpy = vi.spyOn(sseService, 'register');
|
|
52
|
+
// Mock register to return a dummy source or similar if needed,
|
|
53
|
+
// but the real one might work if it just returns an object.
|
|
54
|
+
// Looking at sseService, register returns a ServerSentEventsSource.
|
|
55
|
+
// We might need to mock implementation if it tries to do something complex
|
|
56
|
+
registerSpy.mockImplementation(() => ({}));
|
|
57
|
+
await controller.stream(createMockContext());
|
|
58
|
+
expect(registerSpy).toHaveBeenCalledWith(tenantId, userId);
|
|
59
|
+
});
|
|
60
|
+
test('listInApp should call service', async () => {
|
|
61
|
+
const listInAppSpy = vi.spyOn(notificationService, 'listInApp').mockResolvedValue([]);
|
|
62
|
+
const params = { limit: 10, offset: 0, includeArchived: false };
|
|
63
|
+
await controller.listInApp(createMockContext(params));
|
|
64
|
+
expect(listInAppSpy).toHaveBeenCalledWith(tenantId, userId, params);
|
|
65
|
+
});
|
|
66
|
+
test('markRead should call service', async () => {
|
|
67
|
+
const markReadSpy = vi.spyOn(notificationService, 'markRead').mockResolvedValue();
|
|
68
|
+
const params = { id: 'notif-id' };
|
|
69
|
+
await controller.markRead(createMockContext(params));
|
|
70
|
+
expect(markReadSpy).toHaveBeenCalledWith(tenantId, userId, 'notif-id');
|
|
71
|
+
});
|
|
72
|
+
test('archive should call service', async () => {
|
|
73
|
+
const archiveSpy = vi.spyOn(notificationService, 'archive').mockResolvedValue();
|
|
74
|
+
const params = { id: 'notif-id' };
|
|
75
|
+
await controller.archive(createMockContext(params));
|
|
76
|
+
expect(archiveSpy).toHaveBeenCalledWith(tenantId, userId, 'notif-id');
|
|
77
|
+
});
|
|
78
|
+
test('getPreferences should call service', async () => {
|
|
79
|
+
const getPreferencesSpy = vi.spyOn(notificationService, 'getPreferences').mockResolvedValue([]);
|
|
80
|
+
await controller.getPreferences(createMockContext());
|
|
81
|
+
expect(getPreferencesSpy).toHaveBeenCalledWith(tenantId, userId);
|
|
82
|
+
});
|
|
83
|
+
test('updatePreference should call service', async () => {
|
|
84
|
+
const updatePreferenceSpy = vi.spyOn(notificationService, 'updatePreference').mockResolvedValue();
|
|
85
|
+
const params = { type: 'test', channel: NotificationChannel.Email, enabled: true };
|
|
86
|
+
await controller.updatePreference(createMockContext(params));
|
|
87
|
+
expect(updatePreferenceSpy).toHaveBeenCalledWith(tenantId, userId, 'test', NotificationChannel.Email, true);
|
|
88
|
+
});
|
|
89
|
+
test('registerWebPush should call service', async () => {
|
|
90
|
+
const registerWebPushSpy = vi.spyOn(notificationService, 'registerWebPush').mockResolvedValue();
|
|
91
|
+
const params = { endpoint: 'url', keys: { p256dh: 'key', auth: 'auth' } };
|
|
92
|
+
await controller.registerWebPush(createMockContext(params));
|
|
93
|
+
expect(registerWebPushSpy).toHaveBeenCalledWith(tenantId, userId, 'url', 'key', 'auth');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
import { beforeAll, describe, expect, test, vi } from 'vitest';
|
|
1
|
+
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
2
|
import { SubjectService } from '../../authentication/server/subject.service.js';
|
|
3
3
|
import { runInInjectionContext } from '../../injector/index.js';
|
|
4
4
|
import { MailService } from '../../mail/mail.service.js';
|
|
5
5
|
import { injectRepository } from '../../orm/server/index.js';
|
|
6
6
|
import { setupIntegrationTest, truncateTables } from '../../unit-test/index.js';
|
|
7
|
-
import { NotificationChannel, NotificationStatus } from '../
|
|
8
|
-
import { InAppNotification, NotificationLog } from '../models/index.js';
|
|
7
|
+
import { InAppNotification, NotificationChannel, NotificationLogEntity, NotificationStatus, WebPushSubscription } from '../models/index.js';
|
|
9
8
|
import { EmailChannelProvider } from '../server/providers/email-channel-provider.js';
|
|
10
|
-
import { NotificationCategoryService } from '../server/services/notification-category.service.js';
|
|
11
9
|
import { NotificationDeliveryWorker } from '../server/services/notification-delivery.worker.js';
|
|
10
|
+
import { NotificationTypeService } from '../server/services/notification-type.service.js';
|
|
12
11
|
import { NotificationService } from '../server/services/notification.service.js';
|
|
13
12
|
describe('Notification Flow (Integration)', () => {
|
|
14
13
|
let injector;
|
|
15
14
|
let database;
|
|
16
15
|
let notificationService;
|
|
17
16
|
let worker;
|
|
18
|
-
let
|
|
17
|
+
let typeService;
|
|
19
18
|
let subjectService;
|
|
20
19
|
let mailServiceMock;
|
|
21
20
|
const schema = 'notification';
|
|
@@ -38,75 +37,222 @@ describe('Notification Flow (Integration)', () => {
|
|
|
38
37
|
// Resolve Services
|
|
39
38
|
notificationService = injector.resolve(NotificationService);
|
|
40
39
|
worker = injector.resolve(NotificationDeliveryWorker);
|
|
41
|
-
|
|
40
|
+
typeService = injector.resolve(NotificationTypeService);
|
|
42
41
|
subjectService = injector.resolve(SubjectService);
|
|
43
42
|
// Register providers
|
|
44
43
|
worker.registerProvider(NotificationChannel.Email, injector.resolve(EmailChannelProvider));
|
|
45
44
|
const InAppChannelProvider = (await import('../server/providers/in-app-channel-provider.js')).InAppChannelProvider;
|
|
46
45
|
worker.registerProvider(NotificationChannel.InApp, injector.resolve(InAppChannelProvider));
|
|
46
|
+
});
|
|
47
|
+
beforeEach(async () => {
|
|
47
48
|
await truncateTables(database, 'authentication', ['user', 'subject']);
|
|
48
|
-
await truncateTables(database, 'notification', ['log', 'in_app', '
|
|
49
|
+
await truncateTables(database, 'notification', ['log', 'in_app', 'type', 'preference', 'web_push_subscription']);
|
|
50
|
+
vi.clearAllMocks();
|
|
49
51
|
});
|
|
50
52
|
test('should execute full notification flow with escalation', async () => {
|
|
51
53
|
await runInInjectionContext(injector, async () => {
|
|
52
|
-
|
|
53
|
-
const logRepo = injectRepository(NotificationLog);
|
|
54
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
54
55
|
const inAppRepo = injectRepository(InAppNotification);
|
|
55
|
-
// 1. Setup Data
|
|
56
56
|
const user = await subjectService.createUser({
|
|
57
57
|
tenantId,
|
|
58
58
|
email: 'test@example.com',
|
|
59
59
|
firstName: 'Test',
|
|
60
60
|
lastName: 'User',
|
|
61
61
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
TEST_CAT: {
|
|
62
|
+
await typeService.initializeTypes({
|
|
63
|
+
test: {
|
|
65
64
|
label: 'Test Category',
|
|
66
|
-
escalations: [{
|
|
65
|
+
escalations: [{ delay: 1000, channel: NotificationChannel.Email }],
|
|
67
66
|
},
|
|
68
67
|
});
|
|
69
|
-
const
|
|
70
|
-
// 2. Send Notification
|
|
71
|
-
const notification = Object.assign(new NotificationLog(), {
|
|
68
|
+
const notification = Object.assign(new NotificationLogEntity(), {
|
|
72
69
|
tenantId,
|
|
73
70
|
userId: user.id,
|
|
74
|
-
categoryId: category.id,
|
|
75
71
|
type: 'test',
|
|
76
72
|
priority: 'high',
|
|
77
73
|
payload: { message: 'Hello', testField: 'Test Value' },
|
|
78
74
|
});
|
|
79
75
|
await notificationService.send(notification);
|
|
80
|
-
// Verify Log Created
|
|
81
76
|
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
82
77
|
expect(logs).toHaveLength(1);
|
|
83
78
|
const log = logs[0];
|
|
84
79
|
expect(log.status).toBe(NotificationStatus.Pending);
|
|
85
80
|
expect(log.currentStep).toBe(0);
|
|
86
|
-
|
|
87
|
-
// 3. Worker Execution - Step 0 (In-App)
|
|
81
|
+
// Step 0 (In-App)
|
|
88
82
|
const result0 = await worker.deliver(log.id);
|
|
89
|
-
// Verify Result
|
|
90
83
|
expect(result0.payload.action).toBe('reschedule');
|
|
91
|
-
// Verify In-App Created
|
|
92
84
|
const inApps = await inAppRepo.loadManyByQuery({ tenantId });
|
|
93
85
|
expect(inApps).toHaveLength(1);
|
|
94
86
|
expect(inApps[0].logId).toBe(log.id);
|
|
95
|
-
// Verify Log Updated
|
|
96
87
|
const logAfterStep0 = await logRepo.load(log.id);
|
|
97
88
|
expect(logAfterStep0.status).toBe(NotificationStatus.Sent);
|
|
98
89
|
expect(logAfterStep0.currentStep).toBe(1);
|
|
99
|
-
//
|
|
90
|
+
// Step 1 (Email Escalation)
|
|
100
91
|
const result1 = await worker.deliver(log.id);
|
|
101
|
-
// Verify Result
|
|
102
92
|
expect(result1.payload.action).toBe('complete');
|
|
103
|
-
// Verify Email Sent
|
|
104
93
|
expect(mailServiceMock.send).toHaveBeenCalled();
|
|
105
94
|
const mailArgs = mailServiceMock.send.mock.calls[0][0];
|
|
106
95
|
expect(mailArgs.to).toBe('test@example.com');
|
|
107
|
-
// Verify Log Updated
|
|
108
96
|
const logAfterStep1 = await logRepo.load(log.id);
|
|
109
97
|
expect(logAfterStep1.currentStep).toBe(2);
|
|
110
98
|
});
|
|
111
99
|
});
|
|
100
|
+
test('should handle throttling correctly', async () => {
|
|
101
|
+
await runInInjectionContext(injector, async () => {
|
|
102
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
103
|
+
const user = await subjectService.createUser({ tenantId, email: 'throttled@example.com', firstName: 'Throttled', lastName: 'User' });
|
|
104
|
+
await typeService.initializeTypes({
|
|
105
|
+
throttled: {
|
|
106
|
+
label: 'Throttled Category',
|
|
107
|
+
throttling: { limit: 1, interval: 60000 },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const notification1 = Object.assign(new NotificationLogEntity(), {
|
|
111
|
+
tenantId, userId: user.id, type: 'throttled', priority: 'medium', payload: {},
|
|
112
|
+
});
|
|
113
|
+
const notification2 = Object.assign(new NotificationLogEntity(), {
|
|
114
|
+
tenantId, userId: user.id, type: 'throttled', priority: 'medium', payload: {},
|
|
115
|
+
});
|
|
116
|
+
await notificationService.send(notification1);
|
|
117
|
+
await notificationService.send(notification2);
|
|
118
|
+
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
119
|
+
logs.sort((a, b) => Number(a.metadata.createTimestamp) - Number(b.metadata.createTimestamp));
|
|
120
|
+
// First one should pass
|
|
121
|
+
const result1 = await worker.deliver(logs[0].id);
|
|
122
|
+
expect(result1.payload.action).toBe('complete'); // No escalations
|
|
123
|
+
// Second one should be throttled
|
|
124
|
+
const result2 = await worker.deliver(logs[1].id);
|
|
125
|
+
expect(result2.payload.action).toBe('reschedule');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
test('should skip escalation if notification is read', async () => {
|
|
129
|
+
await runInInjectionContext(injector, async () => {
|
|
130
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
131
|
+
const user = await subjectService.createUser({ tenantId, email: 'read@example.com', firstName: 'Read', lastName: 'User' });
|
|
132
|
+
await typeService.initializeTypes({
|
|
133
|
+
readTest: {
|
|
134
|
+
label: 'Read Test',
|
|
135
|
+
escalations: [{ delay: 1000, channel: NotificationChannel.Email }],
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const notification = Object.assign(new NotificationLogEntity(), {
|
|
139
|
+
tenantId, userId: user.id, type: 'readTest', priority: 'medium', payload: {},
|
|
140
|
+
});
|
|
141
|
+
await notificationService.send(notification);
|
|
142
|
+
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
143
|
+
const log = logs[0];
|
|
144
|
+
// Step 0: Deliver In-App
|
|
145
|
+
await worker.deliver(log.id);
|
|
146
|
+
// Mark as Read
|
|
147
|
+
const inAppNotifications = await notificationService.listInApp(tenantId, user.id);
|
|
148
|
+
expect(inAppNotifications).toHaveLength(1);
|
|
149
|
+
await notificationService.markRead(tenantId, user.id, inAppNotifications[0].id);
|
|
150
|
+
// Step 1: Attempt Escalation
|
|
151
|
+
const result = await worker.deliver(log.id);
|
|
152
|
+
expect(result.payload.action).toBe('complete');
|
|
153
|
+
expect(mailServiceMock.send).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
test('should respect user preferences', async () => {
|
|
157
|
+
await runInInjectionContext(injector, async () => {
|
|
158
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
159
|
+
const user = await subjectService.createUser({ tenantId, email: 'pref@example.com', firstName: 'Pref', lastName: 'User' });
|
|
160
|
+
await typeService.initializeTypes({
|
|
161
|
+
prefTest: { label: 'Preference Test' },
|
|
162
|
+
});
|
|
163
|
+
// Disable InApp, Enable Email (even though not default)
|
|
164
|
+
await notificationService.updatePreference(tenantId, user.id, 'prefTest', NotificationChannel.InApp, false);
|
|
165
|
+
await notificationService.updatePreference(tenantId, user.id, 'prefTest', NotificationChannel.Email, true);
|
|
166
|
+
const notification = Object.assign(new NotificationLogEntity(), {
|
|
167
|
+
tenantId, userId: user.id, type: 'prefTest', priority: 'medium', payload: {},
|
|
168
|
+
});
|
|
169
|
+
await notificationService.send(notification);
|
|
170
|
+
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
171
|
+
const log = logs[0];
|
|
172
|
+
// Deliver
|
|
173
|
+
await worker.deliver(log.id);
|
|
174
|
+
// Should have sent Email but NOT InApp
|
|
175
|
+
expect(mailServiceMock.send).toHaveBeenCalled();
|
|
176
|
+
const inAppNotifications = await notificationService.listInApp(tenantId, user.id);
|
|
177
|
+
expect(inAppNotifications).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
test('should handle unknown channels gracefully', async () => {
|
|
181
|
+
await runInInjectionContext(injector, async () => {
|
|
182
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
183
|
+
const user = await subjectService.createUser({ tenantId, email: 'unknown@example.com', firstName: 'Unknown', lastName: 'User' });
|
|
184
|
+
await typeService.initializeTypes({
|
|
185
|
+
unknownTest: { label: 'Unknown Test' },
|
|
186
|
+
});
|
|
187
|
+
// Force a preference for an unknown channel/unregistered provider (e.g., WebPush if not registered or some random string if type allowed)
|
|
188
|
+
// Since NotificationChannel is an enum, we use WebPush which we haven't registered in the test setup (wait, we didn't register WebPush)
|
|
189
|
+
await notificationService.updatePreference(tenantId, user.id, 'unknownTest', NotificationChannel.WebPush, true);
|
|
190
|
+
// Disable InApp so only WebPush is attempted
|
|
191
|
+
await notificationService.updatePreference(tenantId, user.id, 'unknownTest', NotificationChannel.InApp, false);
|
|
192
|
+
const notification = Object.assign(new NotificationLogEntity(), {
|
|
193
|
+
tenantId, userId: user.id, type: 'unknownTest', priority: 'medium', payload: {},
|
|
194
|
+
});
|
|
195
|
+
await notificationService.send(notification);
|
|
196
|
+
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
197
|
+
const log = logs[0];
|
|
198
|
+
// Deliver
|
|
199
|
+
const result = await worker.deliver(log.id);
|
|
200
|
+
// Should complete without error, just logging a warning (which we can't easily assert on unless we spy logger)
|
|
201
|
+
expect(result.payload.action).toBe('complete');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
test('should manage notification lists and status (markRead, archive)', async () => {
|
|
205
|
+
await runInInjectionContext(injector, async () => {
|
|
206
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
207
|
+
const inAppRepo = injectRepository(InAppNotification);
|
|
208
|
+
const user = await subjectService.createUser({ tenantId, email: 'manage@example.com', firstName: 'Manage', lastName: 'User' });
|
|
209
|
+
await typeService.initializeTypes({ manageTest: { label: 'Manage Test' } });
|
|
210
|
+
const notification = Object.assign(new NotificationLogEntity(), {
|
|
211
|
+
tenantId, userId: user.id, type: 'manageTest', priority: 'medium', payload: {},
|
|
212
|
+
});
|
|
213
|
+
await notificationService.send(notification);
|
|
214
|
+
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
215
|
+
await worker.deliver(logs[0].id);
|
|
216
|
+
// List
|
|
217
|
+
let list = await notificationService.listInApp(tenantId, user.id);
|
|
218
|
+
expect(list).toHaveLength(1);
|
|
219
|
+
expect(list[0].readTimestamp).toBeNull();
|
|
220
|
+
expect(list[0].notification).toBeDefined();
|
|
221
|
+
// Mark Read
|
|
222
|
+
await notificationService.markRead(tenantId, user.id, list[0].id);
|
|
223
|
+
list = await notificationService.listInApp(tenantId, user.id);
|
|
224
|
+
expect(list[0].readTimestamp).not.toBeNull();
|
|
225
|
+
// Archive
|
|
226
|
+
await notificationService.archive(tenantId, user.id, list[0].id);
|
|
227
|
+
// List (default excludes archived)
|
|
228
|
+
list = await notificationService.listInApp(tenantId, user.id);
|
|
229
|
+
expect(list).toHaveLength(0);
|
|
230
|
+
// List (include archived)
|
|
231
|
+
list = await notificationService.listInApp(tenantId, user.id, { includeArchived: true });
|
|
232
|
+
expect(list).toHaveLength(1);
|
|
233
|
+
expect(list[0].archiveTimestamp).not.toBeNull();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
test('should register web push subscription', async () => {
|
|
237
|
+
await runInInjectionContext(injector, async () => {
|
|
238
|
+
const webPushRepo = injectRepository(WebPushSubscription);
|
|
239
|
+
const user = await subjectService.createUser({ tenantId, email: 'push@example.com', firstName: 'Push', lastName: 'User' });
|
|
240
|
+
await notificationService.registerWebPush(tenantId, user.id, 'https://endpoint.com', new Uint8Array(32), new Uint8Array(32));
|
|
241
|
+
const subs = await webPushRepo.loadManyByQuery({ tenantId, userId: user.id });
|
|
242
|
+
expect(subs).toHaveLength(1);
|
|
243
|
+
expect(subs[0].endpoint).toBe('https://endpoint.com');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
test('should retrieve user preferences', async () => {
|
|
247
|
+
await runInInjectionContext(injector, async () => {
|
|
248
|
+
const user = await subjectService.createUser({ tenantId, email: 'getpref@example.com', firstName: 'GetPref', lastName: 'User' });
|
|
249
|
+
await typeService.initializeTypes({ testType: { label: 'Test Type' } });
|
|
250
|
+
await notificationService.updatePreference(tenantId, user.id, 'testType', NotificationChannel.Email, true);
|
|
251
|
+
const prefs = await notificationService.getPreferences(tenantId, user.id);
|
|
252
|
+
expect(prefs).toHaveLength(1);
|
|
253
|
+
expect(prefs[0].type).toBe('testType');
|
|
254
|
+
expect(prefs[0].channel).toBe(NotificationChannel.Email);
|
|
255
|
+
expect(prefs[0].enabled).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
112
258
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { runInInjectionContext } from '../../injector/index.js';
|
|
3
|
+
import { setupIntegrationTest, truncateTables } from '../../unit-test/index.js';
|
|
4
|
+
import { NotificationTypeService } from '../server/services/notification-type.service.js';
|
|
5
|
+
describe('NotificationTypeService', () => {
|
|
6
|
+
test('should initialize types correctly', async () => {
|
|
7
|
+
const { injector, database } = await setupIntegrationTest({ modules: { notification: true, authentication: true } });
|
|
8
|
+
// Cleanup
|
|
9
|
+
await truncateTables(database, 'notification', ['type']);
|
|
10
|
+
const service = injector.resolve(NotificationTypeService);
|
|
11
|
+
const typeData = {
|
|
12
|
+
TYPE1: { label: 'Type 1' },
|
|
13
|
+
TYPE2: { label: 'Type 2', throttling: { limit: 1, interval: 1000 } }
|
|
14
|
+
};
|
|
15
|
+
await runInInjectionContext(injector, async () => {
|
|
16
|
+
const result = await service.initializeTypes(typeData);
|
|
17
|
+
expect(result.TYPE1.label).toBe('Type 1');
|
|
18
|
+
expect(result.TYPE2.key).toBe('TYPE2');
|
|
19
|
+
expect(result.TYPE2.throttling?.limit).toBe(1);
|
|
20
|
+
// Verify persistence
|
|
21
|
+
const dbTypes = await service.repository.loadAll();
|
|
22
|
+
expect(dbTypes).toHaveLength(2);
|
|
23
|
+
// Update
|
|
24
|
+
const updatedData = {
|
|
25
|
+
TYPE1: { label: 'Type 1 Updated' },
|
|
26
|
+
TYPE2: { label: 'Type 2', throttling: { limit: 1, interval: 1000 } }
|
|
27
|
+
};
|
|
28
|
+
const resultUpdated = await service.initializeTypes(updatedData);
|
|
29
|
+
expect(resultUpdated.TYPE1.label).toBe('Type 1 Updated');
|
|
30
|
+
const dbTypesUpdated = await service.repository.loadAll();
|
|
31
|
+
expect(dbTypesUpdated).toHaveLength(2);
|
|
32
|
+
expect(dbTypesUpdated.find((c) => c.key == 'TYPE1')?.label).toBe('Type 1 Updated');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { RateLimiter } from '../rate-limiter.js';
|
|
1
|
+
import { RateLimiter, type RateLimiterArgument, type RateLimiterConfig } from '../rate-limiter.js';
|
|
2
|
+
type PostgresRateLimiterContext = {
|
|
3
|
+
config: RateLimiterArgument;
|
|
4
|
+
};
|
|
2
5
|
export declare class PostgresRateLimiter extends RateLimiter {
|
|
3
6
|
#private;
|
|
4
7
|
readonly burstCapacity: number;
|
|
5
|
-
readonly
|
|
8
|
+
readonly refillInterval: number;
|
|
6
9
|
readonly refillRate: number;
|
|
7
|
-
tryAcquire(resource: string, cost?: number): Promise<boolean>;
|
|
8
|
-
refund(resource: string, amount: number): Promise<void>;
|
|
10
|
+
tryAcquire(resource: string, cost?: number, options?: Partial<RateLimiterConfig>): Promise<boolean>;
|
|
11
|
+
refund(resource: string, amount: number, options?: Partial<Pick<RateLimiterConfig, 'burstCapacity'>>): Promise<void>;
|
|
12
|
+
protected getTransactionalContextData(): PostgresRateLimiterContext;
|
|
9
13
|
}
|
|
14
|
+
export {};
|
|
@@ -15,15 +15,18 @@ import { PostgresRateLimit } from './rate-limit.model.js';
|
|
|
15
15
|
import { rateLimit } from './schemas.js';
|
|
16
16
|
let PostgresRateLimiter = class PostgresRateLimiter extends RateLimiter {
|
|
17
17
|
#repository = injectRepository(PostgresRateLimit);
|
|
18
|
-
#config = injectArgument(this);
|
|
18
|
+
#config = this.transactionalContextData?.config ?? injectArgument(this);
|
|
19
19
|
burstCapacity = isString(this.#config) ? 100 : this.#config.burstCapacity;
|
|
20
|
-
|
|
21
|
-
refillRate = this.burstCapacity / this.
|
|
22
|
-
async tryAcquire(resource, cost = 1) {
|
|
20
|
+
refillInterval = isString(this.#config) ? 1000 : this.#config.refillInterval;
|
|
21
|
+
refillRate = this.burstCapacity / this.refillInterval;
|
|
22
|
+
async tryAcquire(resource, cost = 1, options) {
|
|
23
|
+
const burstCapacity = options?.burstCapacity ?? this.burstCapacity;
|
|
24
|
+
const refillInterval = options?.refillInterval ?? this.refillInterval;
|
|
25
|
+
const refillRate = burstCapacity / refillInterval;
|
|
23
26
|
if (cost <= 0) {
|
|
24
27
|
return true;
|
|
25
28
|
}
|
|
26
|
-
if (cost >
|
|
29
|
+
if (cost > burstCapacity) {
|
|
27
30
|
return false;
|
|
28
31
|
}
|
|
29
32
|
// Atomic Upsert with Conditional Update
|
|
@@ -33,16 +36,20 @@ let PostgresRateLimiter = class PostgresRateLimiter extends RateLimiter {
|
|
|
33
36
|
// RETURNING clause will only return a row if the INSERT or UPDATE actually happened.
|
|
34
37
|
const nowMs = sql `(EXTRACT(EPOCH FROM ${TRANSACTION_TIMESTAMP}) * 1000)`;
|
|
35
38
|
const lastRefillMs = sql `(EXTRACT(EPOCH FROM ${rateLimit.lastRefillTimestamp}) * 1000)`;
|
|
36
|
-
const tokensToAdd = sql `(${nowMs} - ${lastRefillMs}) * ${
|
|
37
|
-
const newTokensExpression = least(
|
|
38
|
-
const result = await this.#repository.tryUpsert('resource', { resource, tokens:
|
|
39
|
+
const tokensToAdd = sql `(${nowMs} - ${lastRefillMs}) * ${refillRate}`;
|
|
40
|
+
const newTokensExpression = least(burstCapacity, sql `${rateLimit.tokens} + ${tokensToAdd}`);
|
|
41
|
+
const result = await this.#repository.tryUpsert('resource', { resource, tokens: burstCapacity - cost, lastRefillTimestamp: TRANSACTION_TIMESTAMP }, { tokens: sql `${newTokensExpression} - ${cost}`, lastRefillTimestamp: TRANSACTION_TIMESTAMP }, { set: gte(newTokensExpression, cost) });
|
|
39
42
|
return isDefined(result);
|
|
40
43
|
}
|
|
41
|
-
async refund(resource, amount) {
|
|
44
|
+
async refund(resource, amount, options) {
|
|
42
45
|
if (amount <= 0) {
|
|
43
46
|
return;
|
|
44
47
|
}
|
|
45
|
-
|
|
48
|
+
const burstCapacity = options?.burstCapacity ?? this.burstCapacity;
|
|
49
|
+
await this.#repository.updateByQuery({ resource }, { tokens: least(burstCapacity, sql `${rateLimit.tokens} + ${amount}`) });
|
|
50
|
+
}
|
|
51
|
+
getTransactionalContextData() {
|
|
52
|
+
return { config: this.#config };
|
|
46
53
|
}
|
|
47
54
|
};
|
|
48
55
|
PostgresRateLimiter = __decorate([
|
|
@@ -8,7 +8,7 @@ export type RateLimiterConfig = {
|
|
|
8
8
|
/**
|
|
9
9
|
* Time in milliseconds in which the bucket refills to its full burst capacity.
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
refillInterval: number;
|
|
12
12
|
};
|
|
13
13
|
export type RateLimiterArgument = string | (RateLimiterConfig & {
|
|
14
14
|
resource: string;
|
|
@@ -16,20 +16,20 @@ export type RateLimiterArgument = string | (RateLimiterConfig & {
|
|
|
16
16
|
export declare abstract class RateLimiter extends Transactional implements Resolvable<RateLimiterArgument> {
|
|
17
17
|
readonly [resolveArgumentType]: RateLimiterArgument;
|
|
18
18
|
abstract readonly burstCapacity: number;
|
|
19
|
-
abstract readonly
|
|
19
|
+
abstract readonly refillInterval: number;
|
|
20
20
|
/**
|
|
21
21
|
* Attempts to acquire the specified number of tokens for a resource.
|
|
22
22
|
* @param resource The resource identifier to acquire tokens for.
|
|
23
23
|
* @param cost The number of tokens to acquire. Defaults to 1.
|
|
24
|
-
* @param
|
|
24
|
+
* @param options Optional configuration overrides for this acquisition.
|
|
25
25
|
* @returns A promise that resolves to true if tokens were successfully acquired, false otherwise.
|
|
26
26
|
*/
|
|
27
|
-
abstract tryAcquire(resource: string, cost?: number): Promise<boolean>;
|
|
27
|
+
abstract tryAcquire(resource: string, cost?: number, options?: Partial<RateLimiterConfig>): Promise<boolean>;
|
|
28
28
|
/**
|
|
29
29
|
* Refunds tokens back to the bucket for a resource.
|
|
30
30
|
* @param resource The resource identifier to refund tokens to.
|
|
31
31
|
* @param amount The number of tokens to refund.
|
|
32
|
-
* @param
|
|
32
|
+
* @param options Optional configuration overrides for this refund.
|
|
33
33
|
*/
|
|
34
|
-
abstract refund(resource: string, amount: number): Promise<void>;
|
|
34
|
+
abstract refund(resource: string, amount: number, options?: Partial<Pick<RateLimiterConfig, 'burstCapacity'>>): Promise<void>;
|
|
35
35
|
}
|
|
@@ -13,7 +13,7 @@ describe('PostgresRateLimiter Integration Tests', () => {
|
|
|
13
13
|
const rateLimiterProvider = injector.resolve(RateLimiterProvider);
|
|
14
14
|
return rateLimiterProvider.get(limiterName, {
|
|
15
15
|
burstCapacity: 10,
|
|
16
|
-
|
|
16
|
+
refillInterval: 1000, // 10 tokens per second -> 1 token per 100ms
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
afterAll(async () => {
|