@flusys/nestjs-notification 9.1.1 → 9.2.0

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/README.md CHANGED
@@ -49,7 +49,7 @@ import { NotificationModule } from '@flusys/nestjs-notification';
49
49
  export class AppModule {}
50
50
  ```
51
51
 
52
- The Socket.IO gateway is always registered. Real-time delivery is chosen per send: pass `realtime: false` to store a notification without pushing it.
52
+ The Socket.IO gateway is always registered. Real-time delivery is chosen per send: pass `realtime: false` to store a notification without pushing it. Clients connect to the `/notifications` namespace with an **access** token (`auth.token`, `?token=` or `Authorization: Bearer`) signed with `config.jwtSecret` (HS256); refresh tokens and tokens without a user id are refused.
53
53
 
54
54
  #### Mode 2: Multi-Tenant
55
55
 
@@ -201,6 +201,13 @@ export class OrderService {
201
201
 
202
202
  `NotificationModule` provides and exports `NOTIFICATION_ADAPTER` (`nestjs-shared`), implemented by `NotificationAdapter`: `send({ userId, ...content })`, `sendToMany({ userIds, ...content })` and `isUserOnline(userId)`. It is a singleton: it resolves the request-scoped `NotificationHelperService` (tenant datasource) per call and asks the gateway who is online, so a singleton that injects the token stays one. A feature package injects it `@Optional()` and never imports this package (auth's login notice and the entity builder's **Send notification** flow step use it).
203
203
 
204
+ ## 5. Caching and Multiple Instances
205
+
206
+ - **No cached reads.** `NotificationService` is not cacheable: every list is scoped to the caller (user + company), and the unread count is always a live `COUNT`. Every write still bumps the `notification` cache stamp after it commits (`ApiService` CRUD, `mark-read`, `mark-all-read`, `send`, `send-bulk`), so a service that lists `NOTIFICATION_EVENT_ENTITIES.NOTIFICATION` in its `cacheDependencies()` stays correct.
207
+ - **`notification:new` is pushed after commit.** CRUD inserts emit from `publishLifecycleEvents` (after the transaction commits), so a client that refetches on the event always finds the row.
208
+ - **Socket fan-out is per instance.** The gateway uses Socket.IO's default in-memory adapter: an emit reaches only sockets connected to the instance that handled the write, and `isUserOnline()` / `getOnlineUserCount()` only see local sockets. Behind several instances, install a Socket.IO pub/sub adapter (e.g. `@socket.io/redis-adapter` via a custom `IoAdapter` in the app) so rooms span instances; the online checks stay instance-local even then.
209
+ - **Socket auth is checked at connect only.** The handshake verifies the access token's signature, expiry and `type`, but not nestjs-auth's session revocation markers (logout, deactivation): a revoked but unexpired token can still connect, and an open socket keeps receiving its rooms' events after revocation or expiry until it reconnects.
210
+
204
211
  ## License
205
212
 
206
213
  MIT © FLUSYS
@@ -7,6 +7,7 @@ declare const NotificationController_base: abstract new (service: NotificationSe
7
7
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
8
8
  service: NotificationService;
9
9
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
10
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
10
11
  insert(addDto: CreateNotificationDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<NotificationResponseDto>>;
11
12
  insertMany(addDto: CreateNotificationDto[], user: ILoggedUserInfo | null): Promise<BulkResponseDto<NotificationResponseDto>>;
12
13
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<NotificationResponseDto>>;
package/fesm/244.js CHANGED
@@ -389,12 +389,16 @@ class NotificationGateway {
389
389
  if (!secret) {
390
390
  return null;
391
391
  }
392
- return this.jwtService.verify(token, {
392
+ const payload = this.jwtService.verify(token, {
393
393
  secret,
394
394
  algorithms: [
395
395
  'HS256'
396
396
  ]
397
397
  });
398
+ if (payload?.type !== 'access' || typeof payload.id !== 'string' || !payload.id) {
399
+ return null;
400
+ }
401
+ return payload;
398
402
  } catch {
399
403
  return null;
400
404
  }
@@ -729,8 +733,10 @@ class NotificationHelperService {
729
733
  createdAt: notification.createdAt
730
734
  };
731
735
  }
736
+ // Nothing caches notifications today, but NotificationService bumps this stamp on every write too, so a
737
+ // service that later lists 'notification' as a cache dependency can rely on it
732
738
  async clearCache() {
733
- await this.utilsService.clearCache(_config_index_js__rspack_import_10/* .NOTIFICATION_EVENT_ENTITIES.NOTIFICATION */.BA.NOTIFICATION, this.cacheManager, undefined, (0,_flusys_nestjs_shared_utils__rspack_import_4.cacheTenantId)(this.dataSourceProvider));
739
+ await this.utilsService.clearCache(_config_index_js__rspack_import_10/* .NOTIFICATION_EVENT_ENTITIES.NOTIFICATION */.BA.NOTIFICATION, this.cacheManager, (0,_flusys_nestjs_shared_utils__rspack_import_4.cacheTenantId)(this.dataSourceProvider));
734
740
  }
735
741
  }
736
742
  _ts_decorate([
package/fesm/446.js CHANGED
@@ -1,4 +1,4 @@
1
- import { IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
1
+ import { IsArray, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
2
2
  import * as __rspack_external__nestjs_swagger_7cd450b5 from "@nestjs/swagger";
3
3
  import * as __rspack_external_class_transformer_c5a479fb from "class-transformer";
4
4
  import * as __rspack_external__flusys_nestjs_shared_decorators_c129b601 from "@flusys/nestjs-shared/decorators";
@@ -453,6 +453,7 @@ _ts_decorate([
453
453
  ]
454
454
  }),
455
455
  IsNotEmpty(),
456
+ IsArray(),
456
457
  IsUUID('4', {
457
458
  each: true
458
459
  }),
@@ -674,13 +675,16 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
674
675
  }
675
676
  async convertSingleDtoToEntity(dto, user) {
676
677
  let entity = {};
677
- if ('id' in dto && dto.id) {
678
+ const companyFeature = this.notificationConfig.isCompanyFeatureEnabled();
679
+ const isUpdate = 'id' in dto && !!dto.id;
680
+ if (isUpdate) {
678
681
  const existing = await this.repository.findOne({
679
682
  where: {
680
683
  id: dto.id
681
684
  }
682
685
  });
683
- if (!existing) {
686
+ const otherCompany = companyFeature && (!user?.companyId || existing?.companyId !== user.companyId);
687
+ if (!existing || otherCompany) {
684
688
  throw new _nestjs_common__rspack_import_4.NotFoundException({
685
689
  message: 'Notification not found',
686
690
  messageKey: _config_index_js__rspack_import_10/* .NOTIFICATION_MESSAGES.NOT_FOUND */.x.NOT_FOUND
@@ -688,9 +692,10 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
688
692
  }
689
693
  entity = existing;
690
694
  }
695
+ const recipient = 'userId' in dto ? dto.userId : undefined;
691
696
  const merged = {
692
697
  ...entity,
693
- userId: user?.id ?? entity.userId,
698
+ userId: isUpdate ? entity.userId : recipient ?? user?.id,
694
699
  type: dto.type ?? entity.type ?? 'info',
695
700
  title: dto.title ?? entity.title,
696
701
  message: dto.message ?? entity.message,
@@ -698,8 +703,8 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
698
703
  isRead: entity.isRead ?? false,
699
704
  readAt: entity.readAt
700
705
  };
701
- if (this.notificationConfig.isCompanyFeatureEnabled()) {
702
- merged.companyId = dto.companyId ?? user?.companyId ?? entity.companyId ?? null;
706
+ if (companyFeature && !isUpdate) {
707
+ merged.companyId = dto.companyId ?? user?.companyId ?? null;
703
708
  }
704
709
  return merged;
705
710
  }
@@ -759,11 +764,16 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
759
764
  }
760
765
  return result;
761
766
  }
762
- async afterInsertOperation(entities, _dto, _user, _qr) {
763
- for (const entity of entities){
764
- const ext = entity;
765
- this.emitToUser(entity.userId, ext.companyId, 'notification:new', this.toPayload(entity));
767
+ // Pushed only once the insert has committed: a client refetching on `notification:new` must find the row
768
+ async publishLifecycleEvents(groups, user) {
769
+ for (const group of groups){
770
+ if (group.action !== 'created') continue;
771
+ for (const entity of group.entities){
772
+ const ext = entity;
773
+ this.emitToUser(entity.userId, ext.companyId, 'notification:new', this.toPayload(entity));
774
+ }
766
775
  }
776
+ await super.publishLifecycleEvents(groups, user);
767
777
  }
768
778
  async markAsRead(id, user) {
769
779
  await this.ensureDataSourceRepository();
@@ -782,13 +792,7 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
782
792
  notification.isRead = true;
783
793
  notification.readAt = new Date();
784
794
  await this.repository.save(notification);
785
- await Promise.all([
786
- this.clearCacheForAll(),
787
- this.clearCacheForId([
788
- notification
789
- ])
790
- ]);
791
- // Emit to user's sockets in the same company context
795
+ await this.invalidateCache();
792
796
  const ext = notification;
793
797
  this.emitToUser(user.id, ext.companyId ?? user.companyId, 'notification:read', {
794
798
  id
@@ -802,20 +806,14 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
802
806
  return notification;
803
807
  }
804
808
  async markAllAsRead(user) {
809
+ const where = this.unreadWhere(user);
810
+ if (!where) return 0;
805
811
  await this.ensureDataSourceRepository();
806
- const where = {
807
- userId: user.id,
808
- isRead: false
809
- };
810
- if (this.notificationConfig.isCompanyFeatureEnabled() && user.companyId) {
811
- where.companyId = user.companyId;
812
- }
813
812
  const result = await this.repository.update(where, {
814
813
  isRead: true,
815
814
  readAt: new Date()
816
815
  });
817
- await this.clearCacheForAll();
818
- // Emit to user's sockets in the current company context
816
+ await this.invalidateCache();
819
817
  this.emitToUser(user.id, user.companyId, 'notification:read-all', {});
820
818
  await this.publishDomainAction(_config_index_js__rspack_import_9/* .NOTIFICATION_EVENT_ACTIONS.ALL_READ */.R.ALL_READ, {
821
819
  metadata: {
@@ -826,18 +824,27 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
826
824
  return result.affected ?? 0;
827
825
  }
828
826
  async getUnreadCount(user) {
827
+ const where = this.unreadWhere(user);
828
+ if (!where) return 0;
829
829
  await this.ensureDataSourceRepository();
830
- const where = {
831
- userId: user.id,
832
- isRead: false
833
- };
834
- if (this.notificationConfig.isCompanyFeatureEnabled() && user.companyId) {
835
- where.companyId = user.companyId;
836
- }
837
830
  return this.repository.count({
838
831
  where
839
832
  });
840
833
  }
834
+ unreadWhere(user) {
835
+ if (!this.notificationConfig.isCompanyFeatureEnabled()) {
836
+ return {
837
+ userId: user.id,
838
+ isRead: false
839
+ };
840
+ }
841
+ if (!user.companyId) return null;
842
+ return {
843
+ userId: user.id,
844
+ isRead: false,
845
+ companyId: user.companyId
846
+ };
847
+ }
841
848
  toPayload(entity) {
842
849
  return {
843
850
  id: entity.id,
@@ -74,9 +74,9 @@ Real-time notification system with WebSocket support.
74
74
 
75
75
  ### WebSocket Events
76
76
 
77
- Connect to WebSocket with JWT authentication:
77
+ Connect to the \`/notifications\` Socket.IO namespace with an access token (\`auth.token\`, \`?token=\` or \`Authorization: Bearer\`):
78
78
  \`\`\`
79
- ws://localhost:2002/notification?token=<jwt>
79
+ ws://localhost:2002/notifications?token=<access-token>
80
80
  \`\`\`
81
81
 
82
82
  **Events:**
@@ -1,4 +1,4 @@
1
- import { IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
1
+ import { IsArray, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
2
2
  import * as __rspack_external__flusys_nestjs_shared_enums_327421f8 from "@flusys/nestjs-shared/enums";
3
3
  import * as __rspack_external__nestjs_swagger_7cd450b5 from "@nestjs/swagger";
4
4
  import * as __rspack_external_class_transformer_c5a479fb from "class-transformer";
@@ -111,6 +111,7 @@ _ts_decorate([
111
111
  ]
112
112
  }),
113
113
  IsNotEmpty(),
114
+ IsArray(),
114
115
  IsUUID('4', {
115
116
  each: true
116
117
  }),
@@ -173,12 +173,16 @@ class NotificationGateway {
173
173
  if (!secret) {
174
174
  return null;
175
175
  }
176
- return this.jwtService.verify(token, {
176
+ const payload = this.jwtService.verify(token, {
177
177
  secret,
178
178
  algorithms: [
179
179
  'HS256'
180
180
  ]
181
181
  });
182
+ if (payload?.type !== 'access' || typeof payload.id !== 'string' || !payload.id) {
183
+ return null;
184
+ }
185
+ return payload;
182
186
  } catch {
183
187
  return null;
184
188
  }
package/fesm/index.js CHANGED
@@ -175,9 +175,9 @@ Real-time notification system with WebSocket support.
175
175
 
176
176
  ### WebSocket Events
177
177
 
178
- Connect to WebSocket with JWT authentication:
178
+ Connect to the \`/notifications\` Socket.IO namespace with an access token (\`auth.token\`, \`?token=\` or \`Authorization: Bearer\`):
179
179
  \`\`\`
180
- ws://localhost:2002/notification?token=<jwt>
180
+ ws://localhost:2002/notifications?token=<access-token>
181
181
  \`\`\`
182
182
 
183
183
  **Events:**
@@ -93,13 +93,16 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
93
93
  }
94
94
  async convertSingleDtoToEntity(dto, user) {
95
95
  let entity = {};
96
- if ('id' in dto && dto.id) {
96
+ const companyFeature = this.notificationConfig.isCompanyFeatureEnabled();
97
+ const isUpdate = 'id' in dto && !!dto.id;
98
+ if (isUpdate) {
97
99
  const existing = await this.repository.findOne({
98
100
  where: {
99
101
  id: dto.id
100
102
  }
101
103
  });
102
- if (!existing) {
104
+ const otherCompany = companyFeature && (!user?.companyId || existing?.companyId !== user.companyId);
105
+ if (!existing || otherCompany) {
103
106
  throw new _nestjs_common__rspack_import_4.NotFoundException({
104
107
  message: 'Notification not found',
105
108
  messageKey: _config_index_js__rspack_import_10/* .NOTIFICATION_MESSAGES.NOT_FOUND */.x.NOT_FOUND
@@ -107,9 +110,10 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
107
110
  }
108
111
  entity = existing;
109
112
  }
113
+ const recipient = 'userId' in dto ? dto.userId : undefined;
110
114
  const merged = {
111
115
  ...entity,
112
- userId: user?.id ?? entity.userId,
116
+ userId: isUpdate ? entity.userId : recipient ?? user?.id,
113
117
  type: dto.type ?? entity.type ?? 'info',
114
118
  title: dto.title ?? entity.title,
115
119
  message: dto.message ?? entity.message,
@@ -117,8 +121,8 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
117
121
  isRead: entity.isRead ?? false,
118
122
  readAt: entity.readAt
119
123
  };
120
- if (this.notificationConfig.isCompanyFeatureEnabled()) {
121
- merged.companyId = dto.companyId ?? user?.companyId ?? entity.companyId ?? null;
124
+ if (companyFeature && !isUpdate) {
125
+ merged.companyId = dto.companyId ?? user?.companyId ?? null;
122
126
  }
123
127
  return merged;
124
128
  }
@@ -178,11 +182,16 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
178
182
  }
179
183
  return result;
180
184
  }
181
- async afterInsertOperation(entities, _dto, _user, _qr) {
182
- for (const entity of entities){
183
- const ext = entity;
184
- this.emitToUser(entity.userId, ext.companyId, 'notification:new', this.toPayload(entity));
185
+ // Pushed only once the insert has committed: a client refetching on `notification:new` must find the row
186
+ async publishLifecycleEvents(groups, user) {
187
+ for (const group of groups){
188
+ if (group.action !== 'created') continue;
189
+ for (const entity of group.entities){
190
+ const ext = entity;
191
+ this.emitToUser(entity.userId, ext.companyId, 'notification:new', this.toPayload(entity));
192
+ }
185
193
  }
194
+ await super.publishLifecycleEvents(groups, user);
186
195
  }
187
196
  async markAsRead(id, user) {
188
197
  await this.ensureDataSourceRepository();
@@ -201,13 +210,7 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
201
210
  notification.isRead = true;
202
211
  notification.readAt = new Date();
203
212
  await this.repository.save(notification);
204
- await Promise.all([
205
- this.clearCacheForAll(),
206
- this.clearCacheForId([
207
- notification
208
- ])
209
- ]);
210
- // Emit to user's sockets in the same company context
213
+ await this.invalidateCache();
211
214
  const ext = notification;
212
215
  this.emitToUser(user.id, ext.companyId ?? user.companyId, 'notification:read', {
213
216
  id
@@ -221,20 +224,14 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
221
224
  return notification;
222
225
  }
223
226
  async markAllAsRead(user) {
227
+ const where = this.unreadWhere(user);
228
+ if (!where) return 0;
224
229
  await this.ensureDataSourceRepository();
225
- const where = {
226
- userId: user.id,
227
- isRead: false
228
- };
229
- if (this.notificationConfig.isCompanyFeatureEnabled() && user.companyId) {
230
- where.companyId = user.companyId;
231
- }
232
230
  const result = await this.repository.update(where, {
233
231
  isRead: true,
234
232
  readAt: new Date()
235
233
  });
236
- await this.clearCacheForAll();
237
- // Emit to user's sockets in the current company context
234
+ await this.invalidateCache();
238
235
  this.emitToUser(user.id, user.companyId, 'notification:read-all', {});
239
236
  await this.publishDomainAction(_config_index_js__rspack_import_9/* .NOTIFICATION_EVENT_ACTIONS.ALL_READ */.R.ALL_READ, {
240
237
  metadata: {
@@ -245,18 +242,27 @@ class NotificationService extends _flusys_nestjs_shared_classes__rspack_import_1
245
242
  return result.affected ?? 0;
246
243
  }
247
244
  async getUnreadCount(user) {
245
+ const where = this.unreadWhere(user);
246
+ if (!where) return 0;
248
247
  await this.ensureDataSourceRepository();
249
- const where = {
250
- userId: user.id,
251
- isRead: false
252
- };
253
- if (this.notificationConfig.isCompanyFeatureEnabled() && user.companyId) {
254
- where.companyId = user.companyId;
255
- }
256
248
  return this.repository.count({
257
249
  where
258
250
  });
259
251
  }
252
+ unreadWhere(user) {
253
+ if (!this.notificationConfig.isCompanyFeatureEnabled()) {
254
+ return {
255
+ userId: user.id,
256
+ isRead: false
257
+ };
258
+ }
259
+ if (!user.companyId) return null;
260
+ return {
261
+ userId: user.id,
262
+ isRead: false,
263
+ companyId: user.companyId
264
+ };
265
+ }
260
266
  toPayload(entity) {
261
267
  return {
262
268
  id: entity.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-notification",
3
- "version": "9.1.1",
3
+ "version": "9.2.0",
4
4
  "description": "Notification module with real-time Socket.IO support",
5
5
  "type": "module",
6
6
  "main": "./fesm/index.js",
@@ -82,7 +82,7 @@
82
82
  "express": "^5.0.0"
83
83
  },
84
84
  "dependencies": {
85
- "@flusys/nestjs-core": "9.1.1",
86
- "@flusys/nestjs-shared": "9.1.1"
85
+ "@flusys/nestjs-core": "9.2.0",
86
+ "@flusys/nestjs-shared": "9.2.0"
87
87
  }
88
88
  }
@@ -1,7 +1,7 @@
1
1
  import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
2
- import { type ILoggedUserInfo } from '@flusys/nestjs-shared/interfaces';
2
+ import { type CrudEventActionName, type ILoggedUserInfo } from '@flusys/nestjs-shared/interfaces';
3
3
  import { UtilsService } from '@flusys/nestjs-shared/modules';
4
- import { type QueryRunner, SelectQueryBuilder } from 'typeorm';
4
+ import { SelectQueryBuilder } from 'typeorm';
5
5
  import { CreateNotificationDto, UpdateNotificationDto } from '../dtos/index.js';
6
6
  import { NotificationBase } from '../entities/index.js';
7
7
  import { NotificationGateway } from '../gateways/index.js';
@@ -27,10 +27,15 @@ export declare class NotificationService extends ApiService<CreateNotificationDt
27
27
  query: SelectQueryBuilder<NotificationBase>;
28
28
  isRaw: boolean;
29
29
  }>;
30
- protected afterInsertOperation(entities: NotificationBase[], _dto: CreateNotificationDto | Array<CreateNotificationDto>, _user: ILoggedUserInfo | null, _qr: QueryRunner): Promise<void>;
30
+ protected publishLifecycleEvents(groups: {
31
+ action: CrudEventActionName;
32
+ entities: NotificationBase[];
33
+ idsOnly?: boolean;
34
+ }[], user: ILoggedUserInfo | null): Promise<void>;
31
35
  markAsRead(id: string, user: ILoggedUserInfo): Promise<NotificationBase>;
32
36
  markAllAsRead(user: ILoggedUserInfo): Promise<number>;
33
37
  getUnreadCount(user: ILoggedUserInfo): Promise<number>;
38
+ private unreadWhere;
34
39
  private toPayload;
35
40
  private emitToUser;
36
41
  }