@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.
Files changed (84) hide show
  1. package/authentication/client/authentication.service.d.ts +1 -1
  2. package/authentication/client/authentication.service.js +23 -11
  3. package/notification/api/index.d.ts +1 -0
  4. package/notification/api/index.js +1 -0
  5. package/notification/api/notification.api.d.ts +8 -16
  6. package/notification/api/notification.api.js +13 -26
  7. package/notification/index.d.ts +1 -1
  8. package/notification/index.js +1 -1
  9. package/notification/models/in-app-notification.model.d.ts +9 -4
  10. package/notification/models/in-app-notification.model.js +25 -10
  11. package/notification/models/index.d.ts +1 -1
  12. package/notification/models/index.js +1 -1
  13. package/notification/models/notification-log.model.d.ts +42 -5
  14. package/notification/models/notification-log.model.js +34 -20
  15. package/notification/models/notification-preference.model.d.ts +2 -2
  16. package/notification/models/notification-preference.model.js +9 -9
  17. package/notification/models/notification-type.model.d.ts +17 -0
  18. package/notification/models/{notification-category.model.js → notification-type.model.js} +12 -13
  19. package/notification/models/web-push-subscription.model.d.ts +2 -2
  20. package/notification/models/web-push-subscription.model.js +8 -7
  21. package/notification/server/api/notification.api-controller.d.ts +2 -2
  22. package/notification/server/api/notification.api-controller.js +4 -3
  23. package/notification/server/drizzle/{0000_glorious_randall.sql → 0000_shiny_the_anarchist.sql} +27 -32
  24. package/notification/server/drizzle/meta/0000_snapshot.json +179 -179
  25. package/notification/server/drizzle/meta/_journal.json +2 -2
  26. package/notification/server/module.d.ts +2 -0
  27. package/notification/server/module.js +1 -0
  28. package/notification/server/providers/channel-provider.d.ts +4 -3
  29. package/notification/server/providers/channel-provider.js +2 -1
  30. package/notification/server/providers/email-channel-provider.d.ts +3 -3
  31. package/notification/server/providers/email-channel-provider.js +7 -9
  32. package/notification/server/providers/in-app-channel-provider.d.ts +5 -5
  33. package/notification/server/providers/in-app-channel-provider.js +15 -16
  34. package/notification/server/providers/index.d.ts +1 -1
  35. package/notification/server/providers/index.js +1 -1
  36. package/notification/server/providers/web-push-channel-provider.d.ts +5 -4
  37. package/notification/server/providers/web-push-channel-provider.js +8 -7
  38. package/notification/server/schemas.d.ts +3 -3
  39. package/notification/server/schemas.js +3 -4
  40. package/notification/server/services/index.d.ts +2 -4
  41. package/notification/server/services/index.js +2 -4
  42. package/notification/server/services/notification-delivery.worker.d.ts +7 -1
  43. package/notification/server/services/notification-delivery.worker.js +49 -37
  44. package/notification/server/services/notification-sse.service.d.ts +4 -7
  45. package/notification/server/services/notification-sse.service.js +4 -11
  46. package/notification/server/services/notification-template.d.ts +2 -2
  47. package/notification/server/services/notification-template.js +3 -1
  48. package/notification/server/services/notification-template.service.d.ts +1 -1
  49. package/notification/server/services/notification-template.service.js +7 -3
  50. package/notification/server/services/notification-type.service.d.ts +11 -0
  51. package/notification/server/services/notification-type.service.js +41 -0
  52. package/notification/server/services/notification.service.d.ts +4 -5
  53. package/notification/server/services/notification.service.js +44 -27
  54. package/notification/tests/notification-api.test.js +95 -0
  55. package/notification/tests/notification-flow.test.js +174 -28
  56. package/notification/tests/notification-type.service.test.d.ts +1 -0
  57. package/notification/tests/notification-type.service.test.js +35 -0
  58. package/package.json +1 -1
  59. package/rate-limit/postgres/postgres-rate-limiter.d.ts +9 -4
  60. package/rate-limit/postgres/postgres-rate-limiter.js +17 -10
  61. package/rate-limit/rate-limiter.d.ts +6 -6
  62. package/rate-limit/tests/postgres-rate-limiter.test.js +1 -1
  63. package/task-queue/postgres/task-queue.js +1 -1
  64. package/task-queue/task-context.d.ts +1 -14
  65. package/task-queue/task-context.js +0 -30
  66. package/task-queue/task-queue.d.ts +4 -12
  67. package/task-queue/task-queue.js +38 -89
  68. package/task-queue/tests/extensive-dependencies.test.d.ts +1 -0
  69. package/task-queue/tests/extensive-dependencies.test.js +234 -0
  70. package/task-queue/tests/worker.test.js +0 -21
  71. package/task-queue/types.d.ts +1 -8
  72. package/notification/enums.d.ts +0 -22
  73. package/notification/enums.js +0 -19
  74. package/notification/models/notification-category.model.d.ts +0 -17
  75. package/notification/server/services/notification-category.service.d.ts +0 -11
  76. package/notification/server/services/notification-category.service.js +0 -41
  77. package/notification/server/services/notification-delivery.task.d.ts +0 -9
  78. package/notification/server/services/notification-delivery.task.js +0 -1
  79. package/notification/server/services/singleton.d.ts +0 -3
  80. package/notification/server/services/singleton.js +0 -10
  81. package/notification/tests/notification-category.service.test.js +0 -36
  82. package/notification/tests/test-notification.model.d.ts +0 -4
  83. package/notification/tests/test-notification.model.js +0 -25
  84. /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 { inject } from '../../../injector/index.js';
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 { currentTimestamp } from '../../../utils/date-time.js';
11
- import { NotificationStatus } from '../../enums.js';
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(NotificationLog);
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 inAppNotifications = await this.#inAppNotificationRepository.loadManyByQuery({
33
- tenantId,
34
- userId,
35
- archivedAt: options.includeArchived ? undefined : null,
36
- }, {
37
- limit: options.limit,
38
- offset: options.offset,
39
- order: { 'metadata.createTimestamp': 'desc' },
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
- const logIds = inAppNotifications.map((n) => n.logId);
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 }, { readAt: currentTimestamp() });
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 }, { archivedAt: currentTimestamp() });
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, categoryId, channel, enabled) {
59
- await this.#preferenceRepository.upsert(['tenantId', 'userId', 'categoryId', 'channel'], {
63
+ async updatePreference(tenantId, userId, type, channel, enabled) {
64
+ await this.#preferenceRepository.upsert(['tenantId', 'userId', 'type', 'channel'], {
60
65
  tenantId,
61
66
  userId,
62
- categoryId,
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
- NotificationSingleton()
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 '../enums.js';
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 categoryService;
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
- categoryService = injector.resolve(NotificationCategoryService);
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', 'category', 'preference', 'web_push_subscription']);
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
- // Resolve Dependencies synchronously at start
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
- // Initialize Category with Escalation
63
- const categories = await categoryService.initializeCategories(tenantId, {
64
- TEST_CAT: {
62
+ await typeService.initializeTypes({
63
+ test: {
65
64
  label: 'Test Category',
66
- escalations: [{ delayMs: 1000, channel: NotificationChannel.Email }],
65
+ escalations: [{ delay: 1000, channel: NotificationChannel.Email }],
67
66
  },
68
67
  });
69
- const category = categories.TEST_CAT;
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
- expect(log.payload.testField).toBe('Test Value');
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
- // 4. Worker Execution - Step 1 (Email Escalation)
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,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,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.100",
3
+ "version": "0.93.102",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 refillIntervalMs: number;
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
- refillIntervalMs = isString(this.#config) ? 1000 : this.#config.refillIntervalMs;
21
- refillRate = this.burstCapacity / this.refillIntervalMs;
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 > this.burstCapacity) {
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}) * ${this.refillRate}`;
37
- const newTokensExpression = least(this.burstCapacity, sql `${rateLimit.tokens} + ${tokensToAdd}`);
38
- const result = await this.#repository.tryUpsert('resource', { resource, tokens: this.burstCapacity - cost, lastRefillTimestamp: TRANSACTION_TIMESTAMP }, { tokens: sql `${newTokensExpression} - ${cost}`, lastRefillTimestamp: TRANSACTION_TIMESTAMP }, { set: gte(newTokensExpression, cost) });
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
- await this.#repository.updateByQuery({ resource }, { tokens: least(this.burstCapacity, sql `${rateLimit.tokens} + ${amount}`) });
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
- refillIntervalMs: number;
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 refillIntervalMs: number;
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 transaction Optional transaction to participate in.
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 transaction Optional transaction to participate in.
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
- refillIntervalMs: 1000, // 10 tokens per second -> 1 token per 100ms
16
+ refillInterval: 1000, // 10 tokens per second -> 1 token per 100ms
17
17
  });
18
18
  }
19
19
  afterAll(async () => {