@tstdl/base 0.93.122 → 0.93.125
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/api/server/gateway.js +6 -1
- package/api/types.d.ts +5 -1
- package/document-management/server/drizzle/{0000_silly_chimera.sql → 0000_complex_black_bird.sql} +1 -1
- package/document-management/server/drizzle/meta/0000_snapshot.json +3 -3
- package/document-management/server/drizzle/meta/_journal.json +2 -2
- package/notification/api/notification.api.d.ts +30 -4
- package/notification/api/notification.api.js +17 -3
- package/notification/client/notification-client.d.ts +6 -0
- package/notification/client/notification-client.js +47 -4
- package/notification/models/in-app-notification.model.d.ts +9 -3
- package/notification/models/in-app-notification.model.js +32 -11
- package/notification/models/notification-log.model.js +2 -3
- package/notification/server/api/notification.api-controller.d.ts +2 -1
- package/notification/server/api/notification.api-controller.js +21 -1
- package/notification/server/drizzle/{0000_oval_rage.sql → 0000_wise_pyro.sql} +22 -4
- package/notification/server/drizzle/meta/0000_snapshot.json +249 -37
- package/notification/server/drizzle/meta/_journal.json +2 -2
- package/notification/server/module.d.ts +5 -0
- package/notification/server/module.js +6 -1
- package/notification/server/providers/in-app-channel-provider.js +2 -1
- package/notification/server/schemas.d.ts +3 -2
- package/notification/server/schemas.js +3 -2
- package/notification/server/services/notification-sse.service.d.ts +14 -2
- package/notification/server/services/notification-sse.service.js +10 -11
- package/notification/server/services/notification.service.d.ts +16 -5
- package/notification/server/services/notification.service.js +160 -34
- package/notification/tests/notification-api.test.js +8 -1
- package/notification/tests/notification-flow.test.js +41 -4
- package/notification/tests/notification-sse.service.test.js +24 -3
- package/notification/types.d.ts +10 -2
- package/notification/types.js +6 -2
- package/orm/server/drizzle/schema-converter.js +5 -3
- package/orm/tests/schema-converter.test.js +1 -0
- package/package.json +1 -1
- package/sse/data-stream-source.d.ts +7 -4
- package/sse/data-stream-source.js +7 -4
|
@@ -6,26 +6,30 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
6
6
|
};
|
|
7
7
|
var _a;
|
|
8
8
|
var NotificationService_1;
|
|
9
|
-
import { and, desc, eq, isNull, lt, or } from 'drizzle-orm';
|
|
9
|
+
import { and, desc, eq, gt, isNull, lt, or, sql } from 'drizzle-orm';
|
|
10
|
+
import { match, P } from 'ts-pattern';
|
|
10
11
|
import { BadRequestError } from '../../../errors/bad-request.error.js';
|
|
11
12
|
import { inject, Singleton } from '../../../injector/index.js';
|
|
12
13
|
import { Logger } from '../../../logger/logger.js';
|
|
13
|
-
import { TRANSACTION_TIMESTAMP } from '../../../orm/index.js';
|
|
14
|
+
import { getEntityIds, TRANSACTION_TIMESTAMP } from '../../../orm/index.js';
|
|
14
15
|
import { injectRepository, Transactional } from '../../../orm/server/index.js';
|
|
15
16
|
import { TaskQueue } from '../../../task-queue/task-queue.js';
|
|
16
17
|
import { tryIgnoreLogAsync } from '../../../utils/try-ignore.js';
|
|
17
|
-
import { isDefined } from '../../../utils/type-guards.js';
|
|
18
|
-
import { InAppNotification, NotificationChannel, NotificationLogEntity, NotificationPreference, NotificationPriority, NotificationStatus, toInAppNotificationView, WebPushSubscription } from '../../models/index.js';
|
|
19
|
-
import {
|
|
18
|
+
import { isDefined, isUndefined } from '../../../utils/type-guards.js';
|
|
19
|
+
import { InAppNotification, InAppNotificationArchive, NotificationChannel, NotificationLogEntity, NotificationPreference, NotificationPriority, NotificationStatus, toInAppNotificationView, WebPushSubscription } from '../../models/index.js';
|
|
20
|
+
import { NotificationConfiguration } from '../module.js';
|
|
21
|
+
import { inAppNotification, inAppNotificationArchive, notificationLog } from '../schemas.js';
|
|
20
22
|
import { NotificationAncillaryService } from './notification-ancillary.service.js';
|
|
21
23
|
import { NotificationSseService } from './notification-sse.service.js';
|
|
22
24
|
let NotificationService = NotificationService_1 = class NotificationService extends Transactional {
|
|
23
25
|
#notificationLogRepository = injectRepository(NotificationLogEntity);
|
|
24
26
|
#inAppNotificationRepository = injectRepository(InAppNotification);
|
|
27
|
+
#inAppNotificationArchiveRepository = injectRepository(InAppNotificationArchive);
|
|
25
28
|
#preferenceRepository = injectRepository(NotificationPreference);
|
|
26
29
|
#webPushSubscriptionRepository = injectRepository(WebPushSubscription);
|
|
27
30
|
#notificationAncillaryService = inject(NotificationAncillaryService);
|
|
28
31
|
#taskQueue = inject((TaskQueue), 'notification');
|
|
32
|
+
#configuration = inject(NotificationConfiguration);
|
|
29
33
|
#sseService = inject(NotificationSseService);
|
|
30
34
|
#logger = inject(Logger, NotificationService_1.name);
|
|
31
35
|
async send(tenantId, userId, notification, options) {
|
|
@@ -44,61 +48,189 @@ let NotificationService = NotificationService_1 = class NotificationService exte
|
|
|
44
48
|
});
|
|
45
49
|
}
|
|
46
50
|
async listInApp(tenantId, userId, options = {}) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
return await this.#list(tenantId, userId, inAppNotification, this.#inAppNotificationRepository, options);
|
|
52
|
+
}
|
|
53
|
+
async listArchivedInApp(tenantId, userId, options = {}) {
|
|
54
|
+
return await this.#list(tenantId, userId, inAppNotificationArchive, this.#inAppNotificationArchiveRepository, options);
|
|
55
|
+
}
|
|
56
|
+
async #list(tenantId, userId, table, repository, options = {}) {
|
|
57
|
+
const db = this.#notificationLogRepository.session;
|
|
58
|
+
const { afterTimestamp, afterNotificationId } = await match(options.after)
|
|
59
|
+
.with(P.nullish, () => ({ afterTimestamp: undefined, afterNotificationId: undefined }))
|
|
60
|
+
.with(P.number, (value) => ({ afterTimestamp: value, afterNotificationId: undefined }))
|
|
61
|
+
.with(P.string, async (value) => {
|
|
62
|
+
const inApp = await repository.tryLoadByQuery({ tenantId, userId, id: value });
|
|
63
|
+
return { afterTimestamp: inApp?.timestamp, afterNotificationId: inApp?.logId };
|
|
64
|
+
})
|
|
65
|
+
.exhaustive();
|
|
66
|
+
const rows = await db
|
|
53
67
|
.select({
|
|
54
68
|
notification: notificationLog,
|
|
55
|
-
inApp:
|
|
69
|
+
inApp: table,
|
|
56
70
|
})
|
|
57
|
-
.from(
|
|
58
|
-
.innerJoin(
|
|
59
|
-
.where(and(eq(
|
|
60
|
-
?
|
|
71
|
+
.from(table)
|
|
72
|
+
.innerJoin(notificationLog, and(eq(notificationLog.tenantId, table.tenantId), eq(notificationLog.userId, table.userId), eq(notificationLog.id, table.logId)))
|
|
73
|
+
.where(and(eq(table.tenantId, tenantId), eq(table.userId, userId), options.unreadOnly ? isNull(table.readTimestamp) : undefined, isDefined(afterTimestamp)
|
|
74
|
+
? isDefined(afterNotificationId)
|
|
75
|
+
? or(lt(table.timestamp, afterTimestamp), and(eq(table.timestamp, afterTimestamp), lt(table.logId, afterNotificationId)))
|
|
76
|
+
: lt(table.timestamp, afterTimestamp)
|
|
61
77
|
: undefined))
|
|
62
78
|
.limit(options.limit ?? 50)
|
|
63
79
|
.offset(options.offset ?? 0)
|
|
64
|
-
.orderBy(desc(
|
|
80
|
+
.orderBy(desc(table.timestamp), desc(table.logId));
|
|
65
81
|
const inAppRows = rows.map((row) => row.inApp);
|
|
66
82
|
const notificationRows = rows.map((row) => row.notification);
|
|
67
83
|
const notificationEntities = await this.#notificationLogRepository.mapManyToEntity(notificationRows);
|
|
68
|
-
const inAppEntities = await
|
|
84
|
+
const inAppEntities = await repository.mapManyToEntity(inAppRows);
|
|
69
85
|
const notificationViewDatas = await this.#notificationAncillaryService.getViewData(tenantId, notificationEntities);
|
|
70
|
-
|
|
86
|
+
return notificationEntities.map((notification, index) => {
|
|
71
87
|
const inApp = inAppEntities[index];
|
|
72
88
|
const viewData = notificationViewDatas[index];
|
|
73
89
|
const payload = { ...notification.payload, ...viewData };
|
|
74
90
|
return toInAppNotificationView(inApp, { ...notification, payload });
|
|
75
91
|
});
|
|
76
|
-
return views;
|
|
77
92
|
}
|
|
78
93
|
async markRead(tenantId, userId, id) {
|
|
79
94
|
await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, { readTimestamp: TRANSACTION_TIMESTAMP });
|
|
80
|
-
await this
|
|
95
|
+
await tryIgnoreLogAsync(this.#logger, async () => {
|
|
96
|
+
const unreadCount = await this.unreadCount(tenantId, userId);
|
|
97
|
+
await this.#sseService.dispatch(tenantId, userId, { readId: id, unreadCount });
|
|
98
|
+
});
|
|
81
99
|
}
|
|
82
100
|
async markAllRead(tenantId, userId) {
|
|
83
|
-
await this.#inAppNotificationRepository.
|
|
84
|
-
await this
|
|
101
|
+
await this.#inAppNotificationRepository.updateManyByQuery({ tenantId, userId, readTimestamp: null }, { readTimestamp: TRANSACTION_TIMESTAMP });
|
|
102
|
+
await tryIgnoreLogAsync(this.#logger, async () => {
|
|
103
|
+
const unreadCount = await this.unreadCount(tenantId, userId);
|
|
104
|
+
await this.#sseService.dispatch(tenantId, userId, { readAll: true, unreadCount });
|
|
105
|
+
});
|
|
85
106
|
}
|
|
86
107
|
async archive(tenantId, userId, id) {
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
const db = this.#inAppNotificationRepository.session;
|
|
109
|
+
const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
|
|
110
|
+
.where(and(eq(inAppNotification.tenantId, tenantId), eq(inAppNotification.id, id), eq(inAppNotification.userId, userId)))
|
|
111
|
+
.returning());
|
|
112
|
+
await db.with(deletedRows)
|
|
113
|
+
.insert(inAppNotificationArchive)
|
|
114
|
+
.select(db
|
|
115
|
+
.select({
|
|
116
|
+
id: deletedRows.id,
|
|
117
|
+
tenantId: deletedRows.tenantId,
|
|
118
|
+
userId: deletedRows.userId,
|
|
119
|
+
logId: deletedRows.logId,
|
|
120
|
+
timestamp: deletedRows.timestamp,
|
|
121
|
+
readTimestamp: deletedRows.readTimestamp,
|
|
122
|
+
archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
|
|
123
|
+
})
|
|
124
|
+
.from(deletedRows));
|
|
125
|
+
await tryIgnoreLogAsync(this.#logger, async () => {
|
|
126
|
+
const unreadCount = await this.unreadCount(tenantId, userId);
|
|
127
|
+
await this.#sseService.dispatch(tenantId, userId, { archiveId: id, unreadCount });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async archiveByQuery(tenantId, query) {
|
|
131
|
+
const db = this.#inAppNotificationRepository.session;
|
|
132
|
+
const logQueryCondition = this.#notificationLogRepository.convertQuery(query);
|
|
133
|
+
const rowsToDelete = db.$with('rows_to_delete').as(db.select({ id: inAppNotification.id })
|
|
134
|
+
.from(inAppNotification)
|
|
135
|
+
.innerJoin(notificationLog, and(eq(inAppNotification.tenantId, notificationLog.tenantId), eq(inAppNotification.logId, notificationLog.id), eq(inAppNotification.userId, notificationLog.userId)))
|
|
136
|
+
.where(and(eq(inAppNotification.tenantId, tenantId), logQueryCondition)));
|
|
137
|
+
const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
|
|
138
|
+
.where(sql `${inAppNotification.id} IN (SELECT id FROM ${rowsToDelete})`)
|
|
139
|
+
.returning());
|
|
140
|
+
await db.with(rowsToDelete, deletedRows)
|
|
141
|
+
.insert(inAppNotificationArchive)
|
|
142
|
+
.select(db
|
|
143
|
+
.select({
|
|
144
|
+
id: deletedRows.id,
|
|
145
|
+
tenantId: deletedRows.tenantId,
|
|
146
|
+
userId: deletedRows.userId,
|
|
147
|
+
logId: deletedRows.logId,
|
|
148
|
+
timestamp: deletedRows.timestamp,
|
|
149
|
+
readTimestamp: deletedRows.readTimestamp,
|
|
150
|
+
archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
|
|
151
|
+
})
|
|
152
|
+
.from(deletedRows));
|
|
89
153
|
}
|
|
90
154
|
async archiveAll(tenantId, userId) {
|
|
91
|
-
|
|
92
|
-
|
|
155
|
+
const db = this.#inAppNotificationRepository.session;
|
|
156
|
+
const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
|
|
157
|
+
.where(and(eq(inAppNotification.tenantId, tenantId), eq(inAppNotification.userId, userId)))
|
|
158
|
+
.returning());
|
|
159
|
+
await db.with(deletedRows)
|
|
160
|
+
.insert(inAppNotificationArchive)
|
|
161
|
+
.select(db
|
|
162
|
+
.select({
|
|
163
|
+
id: deletedRows.id,
|
|
164
|
+
tenantId: deletedRows.tenantId,
|
|
165
|
+
userId: deletedRows.userId,
|
|
166
|
+
logId: deletedRows.logId,
|
|
167
|
+
timestamp: deletedRows.timestamp,
|
|
168
|
+
readTimestamp: deletedRows.readTimestamp,
|
|
169
|
+
archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
|
|
170
|
+
})
|
|
171
|
+
.from(deletedRows));
|
|
172
|
+
await tryIgnoreLogAsync(this.#logger, async () => {
|
|
173
|
+
const unreadCount = await this.unreadCount(tenantId, userId);
|
|
174
|
+
await this.#sseService.dispatch(tenantId, userId, { archiveAll: true, unreadCount });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async runAutoArchive() {
|
|
178
|
+
const autoArchiveAfter = this.#configuration.autoArchiveAfter;
|
|
179
|
+
if (isUndefined(autoArchiveAfter) || (autoArchiveAfter <= 0)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const threshold = new Date(Date.now() - autoArchiveAfter);
|
|
183
|
+
const db = this.#inAppNotificationRepository.session;
|
|
184
|
+
const rowsToDelete = db.$with('rows_to_delete').as(db.select({ id: inAppNotification.id })
|
|
185
|
+
.from(inAppNotification)
|
|
186
|
+
.where(lt(inAppNotification.timestamp, threshold.getTime())));
|
|
187
|
+
const deletedRows = db.$with('deleted_rows').as(db.delete(inAppNotification)
|
|
188
|
+
.where(sql `${inAppNotification.id} IN (SELECT id FROM ${rowsToDelete})`)
|
|
189
|
+
.returning());
|
|
190
|
+
await db.with(rowsToDelete, deletedRows)
|
|
191
|
+
.insert(inAppNotificationArchive)
|
|
192
|
+
.select(db
|
|
193
|
+
.select({
|
|
194
|
+
id: deletedRows.id,
|
|
195
|
+
tenantId: deletedRows.tenantId,
|
|
196
|
+
userId: deletedRows.userId,
|
|
197
|
+
logId: deletedRows.logId,
|
|
198
|
+
timestamp: deletedRows.timestamp,
|
|
199
|
+
readTimestamp: deletedRows.readTimestamp,
|
|
200
|
+
archiveTimestamp: TRANSACTION_TIMESTAMP.as('archive_timestamp'),
|
|
201
|
+
})
|
|
202
|
+
.from(deletedRows));
|
|
93
203
|
}
|
|
94
204
|
async unreadCount(tenantId, userId) {
|
|
95
205
|
return await this.#inAppNotificationRepository.countByQuery({
|
|
96
206
|
tenantId,
|
|
97
207
|
userId,
|
|
98
208
|
readTimestamp: null,
|
|
99
|
-
archiveTimestamp: null,
|
|
100
209
|
});
|
|
101
210
|
}
|
|
211
|
+
async getCatchupData(tenantId, userId, stateTimestamp) {
|
|
212
|
+
const db = this.#notificationLogRepository.session;
|
|
213
|
+
const [unreadCount, missedNotifications, readRows, archiveRows] = await Promise.all([
|
|
214
|
+
this.unreadCount(tenantId, userId),
|
|
215
|
+
this.listInApp(tenantId, userId, { after: stateTimestamp, limit: 50 }),
|
|
216
|
+
db.select({ id: inAppNotification.id })
|
|
217
|
+
.from(inAppNotification)
|
|
218
|
+
.where(and(eq(inAppNotification.tenantId, tenantId), eq(inAppNotification.userId, userId), gt(inAppNotification.readTimestamp, stateTimestamp)))
|
|
219
|
+
.orderBy(desc(inAppNotification.readTimestamp))
|
|
220
|
+
.limit(50),
|
|
221
|
+
db.select({ id: inAppNotificationArchive.id })
|
|
222
|
+
.from(inAppNotificationArchive)
|
|
223
|
+
.where(and(eq(inAppNotificationArchive.tenantId, tenantId), eq(inAppNotificationArchive.userId, userId), gt(inAppNotificationArchive.archiveTimestamp, stateTimestamp)))
|
|
224
|
+
.orderBy(desc(inAppNotificationArchive.archiveTimestamp))
|
|
225
|
+
.limit(50),
|
|
226
|
+
]);
|
|
227
|
+
return {
|
|
228
|
+
unreadCount,
|
|
229
|
+
missedNotifications,
|
|
230
|
+
readIds: getEntityIds(readRows),
|
|
231
|
+
archiveIds: getEntityIds(archiveRows),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
102
234
|
async getPreferences(tenantId, userId) {
|
|
103
235
|
return await this.#preferenceRepository.loadManyByQuery({ tenantId, userId });
|
|
104
236
|
}
|
|
@@ -132,12 +264,6 @@ let NotificationService = NotificationService_1 = class NotificationService exte
|
|
|
132
264
|
auth,
|
|
133
265
|
});
|
|
134
266
|
}
|
|
135
|
-
async dispatchUnreadCountUpdate(tenantId, userId) {
|
|
136
|
-
await tryIgnoreLogAsync(this.#logger, async () => {
|
|
137
|
-
const unreadCount = await this.unreadCount(tenantId, userId);
|
|
138
|
-
await this.#sseService.dispatchUnreadCountUpdate(tenantId, userId, unreadCount);
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
267
|
};
|
|
142
268
|
NotificationService = NotificationService_1 = __decorate([
|
|
143
269
|
Singleton()
|
|
@@ -44,6 +44,7 @@ describe('Notification API (Integration)', () => {
|
|
|
44
44
|
const createMockContext = (params = {}) => ({
|
|
45
45
|
parameters: params,
|
|
46
46
|
abortSignal: new AbortController().signal,
|
|
47
|
+
serverSentEvents: {},
|
|
47
48
|
getToken: async () => ({
|
|
48
49
|
payload: {
|
|
49
50
|
tenant: tenantId,
|
|
@@ -74,10 +75,16 @@ describe('Notification API (Integration)', () => {
|
|
|
74
75
|
});
|
|
75
76
|
test('listInApp should call service', async () => {
|
|
76
77
|
const listInAppSpy = vi.spyOn(notificationService, 'listInApp').mockResolvedValue([]);
|
|
77
|
-
const params = { limit: 10, offset: 0
|
|
78
|
+
const params = { limit: 10, offset: 0 };
|
|
78
79
|
await controller.listInApp(createMockContext(params));
|
|
79
80
|
expect(listInAppSpy).toHaveBeenCalledWith(tenantId, userId, params);
|
|
80
81
|
});
|
|
82
|
+
test('listArchivedInApp should call service', async () => {
|
|
83
|
+
const listArchivedInAppSpy = vi.spyOn(notificationService, 'listArchivedInApp').mockResolvedValue([]);
|
|
84
|
+
const params = { limit: 10, offset: 0 };
|
|
85
|
+
await controller.listArchivedInApp(createMockContext(params));
|
|
86
|
+
expect(listArchivedInAppSpy).toHaveBeenCalledWith(tenantId, userId, params);
|
|
87
|
+
});
|
|
81
88
|
test('markRead should call service', async () => {
|
|
82
89
|
const markReadSpy = vi.spyOn(notificationService, 'markRead').mockResolvedValue();
|
|
83
90
|
const params = { id: 'notif-id' };
|
|
@@ -64,7 +64,7 @@ describe('Notification Flow (Integration)', () => {
|
|
|
64
64
|
});
|
|
65
65
|
beforeEach(async () => {
|
|
66
66
|
await truncateTables(database, 'authentication', ['user', 'subject']);
|
|
67
|
-
await truncateTables(database, 'notification', ['log', 'in_app', 'type', 'preference', 'web_push_subscription']);
|
|
67
|
+
await truncateTables(database, 'notification', ['log', 'in_app', 'in_app_archive', 'type', 'preference', 'web_push_subscription']);
|
|
68
68
|
vi.clearAllMocks();
|
|
69
69
|
});
|
|
70
70
|
test('should execute full notification flow with escalation', async () => {
|
|
@@ -234,11 +234,11 @@ describe('Notification Flow (Integration)', () => {
|
|
|
234
234
|
expect(list[0].readTimestamp).not.toBeNull();
|
|
235
235
|
// Archive
|
|
236
236
|
await notificationService.archive(tenantId, user.id, list[0].id);
|
|
237
|
-
// List (
|
|
237
|
+
// List (excludes archived)
|
|
238
238
|
list = await notificationService.listInApp(tenantId, user.id);
|
|
239
239
|
expect(list).toHaveLength(0);
|
|
240
|
-
// List
|
|
241
|
-
list = await notificationService.
|
|
240
|
+
// List archived
|
|
241
|
+
list = await notificationService.listArchivedInApp(tenantId, user.id);
|
|
242
242
|
expect(list).toHaveLength(1);
|
|
243
243
|
expect(list[0].archiveTimestamp).not.toBeNull();
|
|
244
244
|
});
|
|
@@ -293,4 +293,41 @@ describe('Notification Flow (Integration)', () => {
|
|
|
293
293
|
expect(afterSecond[0].id).toBe(list[2].id);
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
test('should auto-archive old notifications', async () => {
|
|
297
|
+
await runInInjectionContext(injector, async () => {
|
|
298
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
299
|
+
const user = await subjectService.createUser({ tenantId, email: 'auto@example.com', firstName: 'Auto', lastName: 'User' });
|
|
300
|
+
await typeService.initializeTypes({ test: { label: 'Auto Test' } });
|
|
301
|
+
await notificationService.send(tenantId, user.id, { type: 'test', triggerSubjectId: user.id, payload: {} });
|
|
302
|
+
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
303
|
+
await worker.deliver(logs[0].id);
|
|
304
|
+
// Verify active
|
|
305
|
+
expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(1);
|
|
306
|
+
// Manually update timestamp to be old (31 days ago)
|
|
307
|
+
const oldTimestamp = Date.now() - 31 * 24 * 60 * 60 * 1000;
|
|
308
|
+
await logRepo.updateByQuery({ id: logs[0].id }, { timestamp: oldTimestamp });
|
|
309
|
+
await notificationService.runAutoArchive();
|
|
310
|
+
// Verify archived
|
|
311
|
+
expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(0);
|
|
312
|
+
expect(await notificationService.listArchivedInApp(tenantId, user.id)).toHaveLength(1);
|
|
313
|
+
vi.useRealTimers();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
test('should archive all notifications for a user', async () => {
|
|
317
|
+
await runInInjectionContext(injector, async () => {
|
|
318
|
+
const logRepo = injectRepository(NotificationLogEntity);
|
|
319
|
+
const user = await subjectService.createUser({ tenantId, email: 'archiveall@example.com', firstName: 'Archive', lastName: 'All' });
|
|
320
|
+
await typeService.initializeTypes({ test: { label: 'Archive All Test' } });
|
|
321
|
+
await notificationService.send(tenantId, user.id, { type: 'test', triggerSubjectId: user.id, payload: {} });
|
|
322
|
+
await notificationService.send(tenantId, user.id, { type: 'test', triggerSubjectId: user.id, payload: {} });
|
|
323
|
+
const logs = await logRepo.loadManyByQuery({ tenantId });
|
|
324
|
+
for (const log of logs) {
|
|
325
|
+
await worker.deliver(log.id);
|
|
326
|
+
}
|
|
327
|
+
expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(2);
|
|
328
|
+
await notificationService.archiveAll(tenantId, user.id);
|
|
329
|
+
expect(await notificationService.listInApp(tenantId, user.id)).toHaveLength(0);
|
|
330
|
+
expect(await notificationService.listArchivedInApp(tenantId, user.id)).toHaveLength(2);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
296
333
|
});
|
|
@@ -13,8 +13,8 @@ describe('NotificationSseService', () => {
|
|
|
13
13
|
expect(source).toBeDefined();
|
|
14
14
|
// We can't easily spy on the LocalMessageBus internals without more complex setup,
|
|
15
15
|
// but we can verify that sending doesn't throw.
|
|
16
|
-
const msg = { tenantId, userId, logId: 'l1' };
|
|
17
|
-
await expect(service.
|
|
16
|
+
const msg = { id: 'l1', tenantId, userId, logId: 'l1' };
|
|
17
|
+
await expect(service.dispatch(tenantId, userId, { notification: msg, unreadCount: 1 })).resolves.not.toThrow();
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
20
|
test('should dispatch unread count update', async () => {
|
|
@@ -23,7 +23,28 @@ describe('NotificationSseService', () => {
|
|
|
23
23
|
const tenantId = 't1';
|
|
24
24
|
const userId = 'u1';
|
|
25
25
|
await runInInjectionContext(injector, async () => {
|
|
26
|
-
|
|
26
|
+
const source = service.register(tenantId, userId);
|
|
27
|
+
const messages = [];
|
|
28
|
+
source.subscribe((msg) => messages.push(msg));
|
|
29
|
+
await service.dispatch(tenantId, userId, { unreadCount: 5 });
|
|
30
|
+
expect(messages).toHaveLength(1);
|
|
31
|
+
expect(messages[0]).toEqual({ unreadCount: 5 });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
test('should dispatch mark read and mark all read', async () => {
|
|
35
|
+
const { injector } = await setupIntegrationTest({ modules: { messageBus: true, signals: true } });
|
|
36
|
+
const service = injector.resolve(NotificationSseService);
|
|
37
|
+
const tenantId = 't1';
|
|
38
|
+
const userId = 'u1';
|
|
39
|
+
await runInInjectionContext(injector, async () => {
|
|
40
|
+
const source = service.register(tenantId, userId);
|
|
41
|
+
const messages = [];
|
|
42
|
+
source.subscribe((msg) => messages.push(msg));
|
|
43
|
+
await service.dispatch(tenantId, userId, { readId: 'n1', unreadCount: 2 });
|
|
44
|
+
await service.dispatch(tenantId, userId, { readAll: true, unreadCount: 0 });
|
|
45
|
+
expect(messages).toHaveLength(2);
|
|
46
|
+
expect(messages[0]).toEqual({ readId: 'n1', unreadCount: 2 });
|
|
47
|
+
expect(messages[1]).toEqual({ readAll: true, unreadCount: 0 });
|
|
27
48
|
});
|
|
28
49
|
});
|
|
29
50
|
});
|
package/notification/types.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { InAppNotificationView, type NotificationDefinitionMap } from './models/index.js';
|
|
2
2
|
export declare const notificationStreamItemSchema: import("../schema/index.js").ObjectSchema<{
|
|
3
|
-
unreadCount: number;
|
|
4
3
|
notification?: InAppNotificationView<Record<string, import("./models/notification-log.model.js").NotificationDefinition<import("../types/types.js").ObjectLiteral, import("../types/types.js").ObjectLiteral>>> | undefined;
|
|
4
|
+
readId?: string | undefined;
|
|
5
|
+
readAll?: boolean | undefined;
|
|
6
|
+
archiveId?: string | undefined;
|
|
7
|
+
archiveAll?: boolean | undefined;
|
|
8
|
+
unreadCount?: number | undefined;
|
|
5
9
|
}>;
|
|
6
10
|
export type NotificationStreamItem<Definitions extends NotificationDefinitionMap = NotificationDefinitionMap> = {
|
|
7
11
|
notification?: InAppNotificationView<Definitions>;
|
|
8
|
-
|
|
12
|
+
readId?: string;
|
|
13
|
+
readAll?: boolean;
|
|
14
|
+
archiveId?: string;
|
|
15
|
+
archiveAll?: boolean;
|
|
16
|
+
unreadCount?: number;
|
|
9
17
|
};
|
package/notification/types.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { number, object, optional } from '../schema/index.js';
|
|
1
|
+
import { boolean, number, object, optional, string } from '../schema/index.js';
|
|
2
2
|
import { InAppNotificationView } from './models/index.js';
|
|
3
3
|
export const notificationStreamItemSchema = object({
|
|
4
4
|
notification: optional(InAppNotificationView),
|
|
5
|
-
|
|
5
|
+
readId: optional(string()),
|
|
6
|
+
readAll: optional(boolean()),
|
|
7
|
+
archiveId: optional(string()),
|
|
8
|
+
archiveAll: optional(boolean()),
|
|
9
|
+
unreadCount: optional(number()),
|
|
6
10
|
});
|
|
@@ -86,7 +86,8 @@ export function _getDrizzleTableFromType(type, fallbackSchemaName) {
|
|
|
86
86
|
return column;
|
|
87
87
|
});
|
|
88
88
|
const indexFn = (data.options?.unique == true) ? uniqueIndex : index;
|
|
89
|
-
let builder = indexFn(data.options?.name ?? getIndexName(tableName, columns, { naming: data.options?.naming
|
|
89
|
+
let builder = indexFn(data.options?.name ?? getIndexName(tableName, columns, { naming: data.options?.naming, partial: isDefined(data.options?.where) }))
|
|
90
|
+
.using(data.options?.using ?? 'btree', ...columns);
|
|
90
91
|
if (isDefined(data.options?.where)) {
|
|
91
92
|
const query = convertQuery(data.options.where(table), table, columnDefinitionsMap);
|
|
92
93
|
builder = builder.where(query.inlineParams());
|
|
@@ -198,7 +199,7 @@ export function _getDrizzleTableFromType(type, fallbackSchemaName) {
|
|
|
198
199
|
const paradeOptions = columnDef.reflectionData.paradeField;
|
|
199
200
|
return buildParadeCast(columnSql, paradeOptions);
|
|
200
201
|
});
|
|
201
|
-
const indexName = getIndexName(tableName, 'parade', { naming });
|
|
202
|
+
const indexName = getIndexName(tableName, 'parade', { naming, partial: isDefined(where) });
|
|
202
203
|
const indexColumns = [
|
|
203
204
|
table.id, // this orm always uses 'id' as key field as every entity has it
|
|
204
205
|
...classLevelColumnSqls,
|
|
@@ -468,7 +469,8 @@ function getPrimaryKeyName(tableName, columnsOrBaseName, options) {
|
|
|
468
469
|
return getIdentifier(tableName, columnsOrBaseName, 'pk', options);
|
|
469
470
|
}
|
|
470
471
|
function getIndexName(tableName, columnsOrBaseName, options) {
|
|
471
|
-
|
|
472
|
+
const suffix = (options?.partial == true) ? 'partial_idx' : 'idx';
|
|
473
|
+
return getIdentifier(tableName, columnsOrBaseName, suffix, options);
|
|
472
474
|
}
|
|
473
475
|
function getUniqueName(tableName, columnsOrBaseName, options) {
|
|
474
476
|
return getIdentifier(tableName, columnsOrBaseName, 'unique', options);
|
|
@@ -42,6 +42,7 @@ describe('ORM Schema Generation - Detailed Configurations', () => {
|
|
|
42
42
|
expect(config.indexes.length).toBeGreaterThan(0);
|
|
43
43
|
const idx = config.indexes[0];
|
|
44
44
|
expect(idx).toBeDefined();
|
|
45
|
+
expect(idx.config.name).toContain('partial_idx');
|
|
45
46
|
expect(idx.config.where).toBeDefined();
|
|
46
47
|
});
|
|
47
48
|
test('should handle index with nulls ordering', () => {
|
package/package.json
CHANGED
|
@@ -2,20 +2,23 @@ import type { UndefinableJson } from '../types/types.js';
|
|
|
2
2
|
import type { AnyIterable } from '../utils/any-iterable-iterator.js';
|
|
3
3
|
import { ServerSentEventsSource } from './server-sent-events-source.js';
|
|
4
4
|
export type DataStreamErrorFormatter = (error: unknown) => UndefinableJson;
|
|
5
|
-
export type DataStreamSourceOptions = {
|
|
5
|
+
export type DataStreamSourceOptions<T> = {
|
|
6
6
|
/**
|
|
7
7
|
* Whether to send deltas (the changes) between the last and current data or always the full data.
|
|
8
|
+
* Useful when the target data is a full "state object".
|
|
9
|
+
* For notification-like streams, where only new items are sent, delta should usually be disabled.
|
|
8
10
|
*/
|
|
9
11
|
delta?: boolean;
|
|
10
12
|
errorFormatter?: DataStreamErrorFormatter;
|
|
13
|
+
idProvider?: (data: T) => string | undefined;
|
|
11
14
|
};
|
|
12
15
|
export declare class DataStreamSource<T> {
|
|
13
16
|
#private;
|
|
14
17
|
readonly eventSource: ServerSentEventsSource;
|
|
15
18
|
readonly closed: import("../signals/api.js").Signal<boolean>;
|
|
16
|
-
constructor({ delta, errorFormatter }?: DataStreamSourceOptions);
|
|
17
|
-
static fromIterable<T>(iterable: AnyIterable<T>, options?: DataStreamSourceOptions): DataStreamSource<T>;
|
|
18
|
-
send(data: T): Promise<void>;
|
|
19
|
+
constructor({ delta, errorFormatter, idProvider }?: DataStreamSourceOptions<T>);
|
|
20
|
+
static fromIterable<T>(iterable: AnyIterable<T>, options?: DataStreamSourceOptions<T>): DataStreamSource<T>;
|
|
21
|
+
send(data: T, id?: string): Promise<void>;
|
|
19
22
|
close(): Promise<void>;
|
|
20
23
|
error(error: unknown): Promise<void>;
|
|
21
24
|
}
|
|
@@ -16,12 +16,14 @@ const jsonDiffPatch = createDiffPatch({
|
|
|
16
16
|
export class DataStreamSource {
|
|
17
17
|
#useDelta;
|
|
18
18
|
#errorFormatter;
|
|
19
|
+
#idProvider;
|
|
19
20
|
eventSource = new ServerSentEventsSource();
|
|
20
21
|
closed = this.eventSource.closed;
|
|
21
22
|
#lastData;
|
|
22
|
-
constructor({ delta = true, errorFormatter } = {}) {
|
|
23
|
+
constructor({ delta = true, errorFormatter, idProvider } = {}) {
|
|
23
24
|
this.#useDelta = delta;
|
|
24
25
|
this.#errorFormatter = errorFormatter ?? defaultErrorFormatter;
|
|
26
|
+
this.#idProvider = idProvider;
|
|
25
27
|
}
|
|
26
28
|
static fromIterable(iterable, options) {
|
|
27
29
|
const source = new DataStreamSource(options);
|
|
@@ -43,16 +45,17 @@ export class DataStreamSource {
|
|
|
43
45
|
})();
|
|
44
46
|
return source;
|
|
45
47
|
}
|
|
46
|
-
async send(data) {
|
|
48
|
+
async send(data, id) {
|
|
47
49
|
if (this.eventSource.closed()) {
|
|
48
50
|
throw new Error('Cannot send data to a closed DataStreamSource connection.');
|
|
49
51
|
}
|
|
52
|
+
const eventId = id ?? this.#idProvider?.(data);
|
|
50
53
|
if (this.#useDelta && isDefined(this.#lastData)) {
|
|
51
54
|
const delta = jsonDiffPatch.diff(this.#lastData, data);
|
|
52
|
-
await this.eventSource.sendJson({ name: 'delta', data: delta });
|
|
55
|
+
await this.eventSource.sendJson({ name: 'delta', data: delta, id: eventId });
|
|
53
56
|
}
|
|
54
57
|
else {
|
|
55
|
-
await this.eventSource.sendJson({ name: 'data', data });
|
|
58
|
+
await this.eventSource.sendJson({ name: 'data', data, id: eventId });
|
|
56
59
|
}
|
|
57
60
|
if (this.#useDelta) {
|
|
58
61
|
this.#lastData = data;
|