@tstdl/base 0.93.121 → 0.93.123

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.
@@ -199,6 +199,11 @@ let ApiGateway = ApiGateway_1 = class ApiGateway {
199
199
  body,
200
200
  request: context.request,
201
201
  abortSignal: context.abortSignal,
202
+ serverSentEvents: {
203
+ get lastEventId() {
204
+ return context.request.headers.tryGetSingle('Last-Event-ID');
205
+ },
206
+ },
202
207
  tryGetToken: async () => {
203
208
  return await requestTokenProvider.tryGetToken(requestContext);
204
209
  },
@@ -238,7 +243,7 @@ let ApiGateway = ApiGateway_1 = class ApiGateway {
238
243
  const { errorResponse } = logAndGetErrorResponse(this.#logger, this.#supressedErrors, error);
239
244
  return errorResponse.error;
240
245
  };
241
- return ({ events: DataStreamSource.fromIterable(value, { errorFormatter }).eventSource });
246
+ return ({ events: DataStreamSource.fromIterable(value, { errorFormatter, ...context.endpoint.definition.dataStream }).eventSource });
242
247
  })
243
248
  .when(() => (context.endpoint.definition.result == String), (text) => ({ text: text }))
244
249
  .otherwise((json) => ({ json }));
package/api/types.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { Token } from '../authentication/index.js';
4
4
  import type { HttpServerRequest, HttpServerResponse } from '../http/server/index.js';
5
5
  import type { HttpMethod } from '../http/types.js';
6
6
  import type { SchemaOutput, SchemaTestable } from '../schema/index.js';
7
- import type { DataStream, DataStreamSource } from '../sse/index.js';
7
+ import type { DataStream, DataStreamSource, DataStreamSourceOptions } from '../sse/index.js';
8
8
  import type { ServerSentEventsSource } from '../sse/server-sent-events-source.js';
9
9
  import type { ServerSentEvents } from '../sse/server-sent-events.js';
10
10
  import type { NonUndefinable, OneOrMany, Record, ReturnTypeOrT } from '../types/index.js';
@@ -66,6 +66,7 @@ export type ApiEndpointDefinition = {
66
66
  maxBytes?: number;
67
67
  description?: string;
68
68
  data?: Record;
69
+ dataStream?: DataStreamSourceOptions<any>;
69
70
  /**
70
71
  * If true, sets browsers fetch to { credentials: 'include' } and enables 'Access-Control-Allow-Credentials' header.
71
72
  *
@@ -107,6 +108,9 @@ export type ApiRequestData<T extends ApiDefinition = ApiDefinition, K extends Ap
107
108
  body: ApiServerBody<T, K>;
108
109
  request: HttpServerRequest;
109
110
  abortSignal: AbortSignal;
111
+ serverSentEvents: {
112
+ lastEventId?: string;
113
+ };
110
114
  };
111
115
  export type ApiRequestContext<T extends ApiDefinition = ApiDefinition, K extends ApiEndpointKeys<T> = ApiEndpointKeys<T>> = ApiRequestData<T, K> & {
112
116
  tryGetToken<T extends Token>(): Promise<T | null>;
@@ -13,6 +13,9 @@ export declare const notificationApiDefinition: {
13
13
  parse<T>(eventSource: import("../../sse/server-sent-events.js").ServerSentEvents): import("rxjs").Observable<T>;
14
14
  };
15
15
  credentials: true;
16
+ dataStream: {
17
+ idProvider: (item: NotificationStreamItem) => import("../../types/tagged.js").Tagged<string, "column", import("drizzle-orm").IsPrimaryKey<import("drizzle-orm").HasDefault<import("drizzle-orm/pg-core").PgUUIDBuilderInitial<string>>>> | undefined;
18
+ };
16
19
  };
17
20
  types: {
18
21
  resource: string;
@@ -116,6 +119,9 @@ declare const _NotificationApiClient: import("../../api/client/index.js").ApiCli
116
119
  parse<T>(eventSource: import("../../sse/server-sent-events.js").ServerSentEvents): import("rxjs").Observable<T>;
117
120
  };
118
121
  credentials: true;
122
+ dataStream: {
123
+ idProvider: (item: NotificationStreamItem) => import("../../types/tagged.js").Tagged<string, "column", import("drizzle-orm").IsPrimaryKey<import("drizzle-orm").HasDefault<import("drizzle-orm/pg-core").PgUUIDBuilderInitial<string>>>> | undefined;
124
+ };
119
125
  };
120
126
  types: {
121
127
  resource: string;
@@ -19,6 +19,9 @@ export const notificationApiDefinition = defineApi({
19
19
  method: 'GET',
20
20
  result: (DataStream),
21
21
  credentials: true,
22
+ dataStream: {
23
+ idProvider: (item) => item.notification?.id,
24
+ },
22
25
  },
23
26
  types: {
24
27
  resource: 'types',
@@ -21,10 +21,25 @@ let NotificationClient = class NotificationClient {
21
21
  return of({ notifications: [], unreadCount: 0 });
22
22
  }
23
23
  return merge(defer(() => from(this.api.listInApp({ limit: 20 }))).pipe(map((notifications) => ({ type: 'set-notifications', notifications: notifications }))), defer(() => from(this.api.unreadCount())).pipe(map((unreadCount) => ({ type: 'set-unread-count', unreadCount }))), defer(() => from(this.api.stream())).pipe(switchAll(), forceCast(), switchMap((item) => {
24
- const actions = [{ type: 'set-unread-count', unreadCount: item.unreadCount }];
24
+ const actions = [];
25
+ if (isDefined(item.unreadCount)) {
26
+ actions.push({ type: 'set-unread-count', unreadCount: item.unreadCount });
27
+ }
25
28
  if (isDefined(item.notification)) {
26
29
  actions.push({ type: 'prepend-notification', notification: item.notification });
27
30
  }
31
+ if (isDefined(item.readId)) {
32
+ actions.push({ type: 'mark-read', id: item.readId });
33
+ }
34
+ if (item.readAll == true) {
35
+ actions.push({ type: 'mark-all-read' });
36
+ }
37
+ if (isDefined(item.archiveId)) {
38
+ actions.push({ type: 'archive', id: item.archiveId });
39
+ }
40
+ if (item.archiveAll == true) {
41
+ actions.push({ type: 'archive-all' });
42
+ }
28
43
  return from(actions);
29
44
  })), this.#pagination$.pipe(concatMap(({ count, after }) => from(this.api.listInApp({ limit: count, after }))), map((notifications) => ({ type: 'append-notifications', notifications: notifications })))).pipe(scan((acc, action) => {
30
45
  switch (action.type) {
@@ -38,6 +53,24 @@ let NotificationClient = class NotificationClient {
38
53
  case 'append-notifications':
39
54
  const filtered = action.notifications.filter((n) => !acc.notifications.some((c) => c.id == n.id));
40
55
  return { ...acc, notifications: [...acc.notifications, ...filtered] };
56
+ case 'mark-read':
57
+ return {
58
+ ...acc,
59
+ notifications: acc.notifications.map((notification) => (notification.id == action.id) ? { ...notification, readTimestamp: Date.now() } : notification),
60
+ };
61
+ case 'mark-all-read':
62
+ const now = Date.now();
63
+ return {
64
+ ...acc,
65
+ notifications: acc.notifications.map((notification) => ({ ...notification, readTimestamp: notification.readTimestamp ?? now })),
66
+ };
67
+ case 'archive':
68
+ return {
69
+ ...acc,
70
+ notifications: acc.notifications.filter((notification) => notification.id != action.id),
71
+ };
72
+ case 'archive-all':
73
+ return { ...acc, notifications: [] };
41
74
  case 'set-unread-count':
42
75
  return { ...acc, unreadCount: action.unreadCount };
43
76
  default:
@@ -7,7 +7,7 @@ export declare class NotificationApiController implements ApiController<Notifica
7
7
  protected readonly notificationService: NotificationService<Record<string, import("../../index.js").NotificationDefinition<import("../../../types/types.js").ObjectLiteral, import("../../../types/types.js").ObjectLiteral>>>;
8
8
  protected readonly notificationTypeService: NotificationTypeService;
9
9
  protected readonly sseService: NotificationSseService;
10
- stream({ abortSignal, getToken }: ApiRequestContext<NotificationApiDefinition, 'stream'>): ApiServerResult<NotificationApiDefinition, 'stream'>;
10
+ stream({ abortSignal, getToken, serverSentEvents: { lastEventId } }: ApiRequestContext<NotificationApiDefinition, 'stream'>): ApiServerResult<NotificationApiDefinition, 'stream'>;
11
11
  types(): Promise<Record<string, string>>;
12
12
  listInApp({ parameters, getToken }: ApiRequestContext<NotificationApiDefinition, 'listInApp'>): Promise<any>;
13
13
  markRead({ parameters, getToken }: ApiRequestContext<NotificationApiDefinition, 'markRead'>): Promise<'ok'>;
@@ -8,6 +8,7 @@ import { apiController } from '../../../api/server/index.js';
8
8
  import { inject } from '../../../injector/index.js';
9
9
  import { toAsyncIterable } from '../../../rxjs-utils/index.js';
10
10
  import { decodeBase64 } from '../../../utils/base64.js';
11
+ import { isDefined } from '../../../utils/type-guards.js';
11
12
  import { notificationApiDefinition } from '../../api/notification.api.js';
12
13
  import { NotificationSseService } from '../services/notification-sse.service.js';
13
14
  import { NotificationTypeService } from '../services/notification-type.service.js';
@@ -16,12 +17,25 @@ let NotificationApiController = class NotificationApiController {
16
17
  notificationService = inject(NotificationService);
17
18
  notificationTypeService = inject(NotificationTypeService);
18
19
  sseService = inject(NotificationSseService);
19
- async *stream({ abortSignal, getToken }) {
20
+ async *stream({ abortSignal, getToken, serverSentEvents: { lastEventId } }) {
20
21
  const token = await getToken();
21
22
  const source = this.sseService.register(token.payload.tenant, token.payload.subject);
22
23
  const asyncIterable = toAsyncIterable(source);
23
24
  abortSignal.addEventListener('abort', () => this.sseService.unregister(token.payload.tenant, token.payload.subject, source));
24
25
  try {
26
+ if (isDefined(lastEventId)) {
27
+ const { unreadCount, missedNotifications, readIds, archiveIds } = await this.notificationService.getCatchupData(token.payload.tenant, token.payload.subject, lastEventId);
28
+ yield { unreadCount };
29
+ for (const readId of readIds) {
30
+ yield { readId };
31
+ }
32
+ for (const archiveId of archiveIds) {
33
+ yield { archiveId };
34
+ }
35
+ for (const notification of missedNotifications.reverse()) {
36
+ yield { notification };
37
+ }
38
+ }
25
39
  yield* asyncIterable;
26
40
  }
27
41
  finally {
@@ -1,5 +1,6 @@
1
1
  export * from './api/index.js';
2
2
  export * from './module.js';
3
+ export * from './modules/index.js';
3
4
  export * from './providers/index.js';
4
5
  export * from './schemas.js';
5
6
  export * from './services/index.js';
@@ -1,5 +1,6 @@
1
1
  export * from './api/index.js';
2
2
  export * from './module.js';
3
+ export * from './modules/index.js';
3
4
  export * from './providers/index.js';
4
5
  export * from './schemas.js';
5
6
  export * from './services/index.js';
@@ -3,6 +3,7 @@ import { afterResolve } from '../../../injector/index.js';
3
3
  import { Module } from '../../../module/module.js';
4
4
  export declare class NotificationDeliveryWorkerModule extends Module {
5
5
  #private;
6
+ constructor();
6
7
  [afterResolve](): void;
7
8
  _run(cancellationSignal: CancellationSignal): Promise<void>;
8
9
  }
@@ -4,23 +4,34 @@ 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
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
7
10
  import { CancellationSignal } from '../../../cancellation/token.js';
8
11
  import { afterResolve, inject, Singleton } from '../../../injector/index.js';
9
12
  import { Module } from '../../../module/module.js';
10
13
  import { NotificationChannel } from '../../../notification/models/index.js';
11
- import { InAppChannelProvider } from '../providers/index.js';
14
+ import { EmailChannelProvider, InAppChannelProvider, WebPushChannelProvider } from '../providers/index.js';
12
15
  import { NotificationDeliveryWorker } from '../services/index.js';
13
16
  let NotificationDeliveryWorkerModule = class NotificationDeliveryWorkerModule extends Module {
14
17
  #notificationDeliveryWorker = inject(NotificationDeliveryWorker);
15
18
  #inAppChannelProvider = inject(InAppChannelProvider);
19
+ #emailChannelProvider = inject(EmailChannelProvider);
20
+ #webPushChannelProvider = inject(WebPushChannelProvider);
21
+ constructor() {
22
+ super('NotificationDeliveryWorker');
23
+ }
16
24
  [afterResolve]() {
17
25
  this.#notificationDeliveryWorker.registerProvider(NotificationChannel.InApp, this.#inAppChannelProvider);
26
+ this.#notificationDeliveryWorker.registerProvider(NotificationChannel.Email, this.#emailChannelProvider);
27
+ this.#notificationDeliveryWorker.registerProvider(NotificationChannel.WebPush, this.#webPushChannelProvider);
18
28
  }
19
29
  async _run(cancellationSignal) {
20
30
  await this.#notificationDeliveryWorker.run(cancellationSignal);
21
31
  }
22
32
  };
23
33
  NotificationDeliveryWorkerModule = __decorate([
24
- Singleton()
34
+ Singleton(),
35
+ __metadata("design:paramtypes", [])
25
36
  ], NotificationDeliveryWorkerModule);
26
37
  export { NotificationDeliveryWorkerModule };
@@ -24,7 +24,7 @@ let InAppChannelProvider = class InAppChannelProvider extends ChannelProvider {
24
24
  });
25
25
  const inAppNotificationView = toInAppNotificationView(inApp, notification);
26
26
  const unreadCount = await this.#notificationService.withOptionalTransaction(tx).unreadCount(notification.tenantId, notification.userId);
27
- await this.#sseService.send(inAppNotificationView, unreadCount);
27
+ await this.#sseService.dispatch(notification.tenantId, notification.userId, { notification: inAppNotificationView, unreadCount });
28
28
  }
29
29
  };
30
30
  InAppChannelProvider = __decorate([
@@ -1,13 +1,25 @@
1
1
  import { Subject } from 'rxjs';
2
2
  import { afterResolve } from '../../../injector/index.js';
3
+ import type { TypedOmit } from '../../../types/index.js';
3
4
  import type { InAppNotificationView } from '../../models/index.js';
4
5
  import type { NotificationStreamItem } from '../../types.js';
6
+ type NotificationBusMessage = {
7
+ tenantId: string;
8
+ userId: string;
9
+ notification?: InAppNotificationView;
10
+ readId?: string;
11
+ readAll?: boolean;
12
+ archiveId?: string;
13
+ archiveAll?: boolean;
14
+ unreadCount?: number;
15
+ };
16
+ export type NotificationBusMessageData = TypedOmit<NotificationBusMessage, 'tenantId' | 'userId'>;
5
17
  export declare class NotificationSseService {
6
18
  #private;
7
19
  [afterResolve](): void;
8
20
  register(tenantId: string, userId: string): Subject<NotificationStreamItem>;
9
21
  unregister(tenantId: string, userId: string, source: Subject<NotificationStreamItem>): void;
10
- send(notification: InAppNotificationView, unreadCount: number): Promise<void>;
11
- dispatchUnreadCountUpdate(tenantId: string, userId: string, unreadCount: number): Promise<void>;
22
+ dispatch(tenantId: string, userId: string, data: NotificationBusMessageData): Promise<void>;
12
23
  private dispatchToLocal;
13
24
  }
25
+ export {};
@@ -43,19 +43,11 @@ let NotificationSseService = class NotificationSseService {
43
43
  }
44
44
  source.complete();
45
45
  }
46
- async send(notification, unreadCount) {
47
- await this.#messageBus.publish({
48
- tenantId: notification.tenantId,
49
- userId: notification.userId,
50
- notification,
51
- unreadCount,
52
- });
53
- }
54
- async dispatchUnreadCountUpdate(tenantId, userId, unreadCount) {
46
+ async dispatch(tenantId, userId, data) {
55
47
  await this.#messageBus.publish({
56
48
  tenantId,
57
49
  userId,
58
- unreadCount,
50
+ ...data,
59
51
  });
60
52
  }
61
53
  dispatchToLocal(message) {
@@ -63,7 +55,14 @@ let NotificationSseService = class NotificationSseService {
63
55
  const userSources = tenantMap?.get(message.userId);
64
56
  if (userSources != null) {
65
57
  for (const source of userSources) {
66
- source.next({ notification: message.notification, unreadCount: message.unreadCount });
58
+ source.next({
59
+ notification: message.notification,
60
+ readId: message.readId,
61
+ readAll: message.readAll,
62
+ archiveId: message.archiveId,
63
+ archiveAll: message.archiveAll,
64
+ unreadCount: message.unreadCount,
65
+ });
67
66
  }
68
67
  }
69
68
  }
@@ -20,8 +20,14 @@ export declare class NotificationService<Definitions extends NotificationDefinit
20
20
  archive(tenantId: string, userId: string, id: string): Promise<void>;
21
21
  archiveAll(tenantId: string, userId: string): Promise<void>;
22
22
  unreadCount(tenantId: string, userId: string): Promise<number>;
23
+ getCatchupData(tenantId: string, userId: string, lastNotificationId: string): Promise<{
24
+ unreadCount: number;
25
+ missedNotifications: InAppNotificationView[];
26
+ readIds: string[];
27
+ archiveIds: string[];
28
+ }>;
29
+ getTimestamp(tenantId: string, userId: string, inAppId: string): Promise<number>;
23
30
  getPreferences(tenantId: string, userId: string): Promise<NotificationPreference[]>;
24
31
  updatePreference(tenantId: string, userId: string, type: string, channel: NotificationChannel, enabled: boolean): Promise<void>;
25
32
  registerWebPush(tenantId: string, userId: string, endpoint: string, p256dh: Uint8Array<ArrayBuffer>, auth: Uint8Array<ArrayBuffer>): Promise<void>;
26
- private dispatchUnreadCountUpdate;
27
33
  }
@@ -6,7 +6,7 @@ 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 } from 'drizzle-orm';
10
10
  import { BadRequestError } from '../../../errors/bad-request.error.js';
11
11
  import { inject, Singleton } from '../../../injector/index.js';
12
12
  import { Logger } from '../../../logger/logger.js';
@@ -14,7 +14,7 @@ import { TRANSACTION_TIMESTAMP } from '../../../orm/index.js';
14
14
  import { injectRepository, Transactional } from '../../../orm/server/index.js';
15
15
  import { TaskQueue } from '../../../task-queue/task-queue.js';
16
16
  import { tryIgnoreLogAsync } from '../../../utils/try-ignore.js';
17
- import { isDefined } from '../../../utils/type-guards.js';
17
+ import { isDefined, isNotNull } from '../../../utils/type-guards.js';
18
18
  import { InAppNotification, NotificationChannel, NotificationLogEntity, NotificationPreference, NotificationPriority, NotificationStatus, toInAppNotificationView, WebPushSubscription } from '../../models/index.js';
19
19
  import { inAppNotification, notificationLog } from '../schemas.js';
20
20
  import { NotificationAncillaryService } from './notification-ancillary.service.js';
@@ -77,19 +77,31 @@ let NotificationService = NotificationService_1 = class NotificationService exte
77
77
  }
78
78
  async markRead(tenantId, userId, id) {
79
79
  await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, { readTimestamp: TRANSACTION_TIMESTAMP });
80
- await this.dispatchUnreadCountUpdate(tenantId, userId);
80
+ await tryIgnoreLogAsync(this.#logger, async () => {
81
+ const unreadCount = await this.unreadCount(tenantId, userId);
82
+ await this.#sseService.dispatch(tenantId, userId, { readId: id, unreadCount });
83
+ });
81
84
  }
82
85
  async markAllRead(tenantId, userId) {
83
- await this.#inAppNotificationRepository.updateByQuery({ tenantId, userId, readTimestamp: null }, { readTimestamp: TRANSACTION_TIMESTAMP });
84
- await this.dispatchUnreadCountUpdate(tenantId, userId);
86
+ await this.#inAppNotificationRepository.updateManyByQuery({ tenantId, userId, readTimestamp: null }, { readTimestamp: TRANSACTION_TIMESTAMP });
87
+ await tryIgnoreLogAsync(this.#logger, async () => {
88
+ const unreadCount = await this.unreadCount(tenantId, userId);
89
+ await this.#sseService.dispatch(tenantId, userId, { readAll: true, unreadCount });
90
+ });
85
91
  }
86
92
  async archive(tenantId, userId, id) {
87
93
  await this.#inAppNotificationRepository.updateByQuery({ tenantId, id, userId }, { archiveTimestamp: TRANSACTION_TIMESTAMP });
88
- await this.dispatchUnreadCountUpdate(tenantId, userId);
94
+ await tryIgnoreLogAsync(this.#logger, async () => {
95
+ const unreadCount = await this.unreadCount(tenantId, userId);
96
+ await this.#sseService.dispatch(tenantId, userId, { archiveId: id, unreadCount });
97
+ });
89
98
  }
90
99
  async archiveAll(tenantId, userId) {
91
- await this.#inAppNotificationRepository.updateByQuery({ tenantId, userId, archiveTimestamp: null }, { archiveTimestamp: TRANSACTION_TIMESTAMP });
92
- await this.dispatchUnreadCountUpdate(tenantId, userId);
100
+ await this.#inAppNotificationRepository.updateManyByQuery({ tenantId, userId, archiveTimestamp: null }, { archiveTimestamp: TRANSACTION_TIMESTAMP });
101
+ await tryIgnoreLogAsync(this.#logger, async () => {
102
+ const unreadCount = await this.unreadCount(tenantId, userId);
103
+ await this.#sseService.dispatch(tenantId, userId, { archiveAll: true, unreadCount });
104
+ });
93
105
  }
94
106
  async unreadCount(tenantId, userId) {
95
107
  return await this.#inAppNotificationRepository.countByQuery({
@@ -99,6 +111,30 @@ let NotificationService = NotificationService_1 = class NotificationService exte
99
111
  archiveTimestamp: null,
100
112
  });
101
113
  }
114
+ async getCatchupData(tenantId, userId, lastNotificationId) {
115
+ const lastTimestamp = await this.getTimestamp(tenantId, userId, lastNotificationId);
116
+ const [unreadCount, missedNotifications, readAndArchived] = await Promise.all([
117
+ this.unreadCount(tenantId, userId),
118
+ this.listInApp(tenantId, userId, { after: lastNotificationId, limit: 50 }),
119
+ this.#inAppNotificationRepository.session
120
+ .select({
121
+ id: inAppNotification.id,
122
+ readTimestamp: inAppNotification.readTimestamp,
123
+ archiveTimestamp: inAppNotification.archiveTimestamp,
124
+ })
125
+ .from(inAppNotification)
126
+ .where(and(eq(inAppNotification.tenantId, tenantId), eq(inAppNotification.userId, userId), or(gt(inAppNotification.readTimestamp, lastTimestamp), gt(inAppNotification.archiveTimestamp, lastTimestamp))))
127
+ .limit(50),
128
+ ]);
129
+ const readIds = readAndArchived.filter((row) => isNotNull(row.readTimestamp) && (row.readTimestamp > lastTimestamp)).map((row) => row.id);
130
+ const archiveIds = readAndArchived.filter((row) => isNotNull(row.archiveTimestamp) && (row.archiveTimestamp > lastTimestamp)).map((row) => row.id);
131
+ return { unreadCount, missedNotifications, readIds, archiveIds };
132
+ }
133
+ async getTimestamp(tenantId, userId, inAppId) {
134
+ const inApp = await this.#inAppNotificationRepository.loadByQuery({ tenantId, userId, id: inAppId });
135
+ const log = await this.#notificationLogRepository.loadByQuery({ tenantId, userId, id: inApp.logId });
136
+ return log.timestamp;
137
+ }
102
138
  async getPreferences(tenantId, userId) {
103
139
  return await this.#preferenceRepository.loadManyByQuery({ tenantId, userId });
104
140
  }
@@ -132,12 +168,6 @@ let NotificationService = NotificationService_1 = class NotificationService exte
132
168
  auth,
133
169
  });
134
170
  }
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
171
  };
142
172
  NotificationService = NotificationService_1 = __decorate([
143
173
  Singleton()
@@ -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.send(msg, 1)).resolves.not.toThrow();
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
- await expect(service.dispatchUnreadCountUpdate(tenantId, userId, 5)).resolves.not.toThrow();
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
  });
@@ -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
- unreadCount: number;
12
+ readId?: string;
13
+ readAll?: boolean;
14
+ archiveId?: string;
15
+ archiveAll?: boolean;
16
+ unreadCount?: number;
9
17
  };
@@ -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
- unreadCount: number(),
5
+ readId: optional(string()),
6
+ readAll: optional(boolean()),
7
+ archiveId: optional(string()),
8
+ archiveAll: optional(boolean()),
9
+ unreadCount: optional(number()),
6
10
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.121",
3
+ "version": "0.93.123",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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;