@flusys/nestjs-auth 6.4.0 → 7.0.0-beta.1

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 (40) hide show
  1. package/cjs/config/event-actions.js +45 -0
  2. package/cjs/config/index.js +1 -0
  3. package/cjs/config/message-keys.js +12 -0
  4. package/cjs/modules/auth.module.js +2 -1
  5. package/cjs/services/auth-email.service.js +21 -0
  6. package/cjs/services/auth-email.service.spec.js +52 -0
  7. package/cjs/services/authentication.service.js +62 -4
  8. package/cjs/services/authentication.service.spec.js +58 -0
  9. package/cjs/services/branch.service.js +20 -0
  10. package/cjs/services/branch.service.spec.js +15 -0
  11. package/cjs/services/company.service.js +20 -0
  12. package/cjs/services/company.service.spec.js +14 -0
  13. package/cjs/services/user-permission.service.js +20 -0
  14. package/cjs/services/user-permission.service.spec.js +52 -0
  15. package/cjs/services/user.service.js +36 -18
  16. package/cjs/services/user.service.spec.js +15 -1
  17. package/config/event-actions.d.ts +21 -0
  18. package/config/index.d.ts +1 -0
  19. package/config/message-keys.d.ts +6 -0
  20. package/fesm/config/event-actions.js +24 -0
  21. package/fesm/config/index.js +1 -0
  22. package/fesm/config/message-keys.js +6 -0
  23. package/fesm/modules/auth.module.js +3 -2
  24. package/fesm/services/auth-email.service.js +22 -1
  25. package/fesm/services/auth-email.service.spec.js +52 -0
  26. package/fesm/services/authentication.service.js +64 -6
  27. package/fesm/services/authentication.service.spec.js +58 -0
  28. package/fesm/services/branch.service.js +22 -2
  29. package/fesm/services/branch.service.spec.js +15 -0
  30. package/fesm/services/company.service.js +22 -2
  31. package/fesm/services/company.service.spec.js +14 -0
  32. package/fesm/services/user-permission.service.js +21 -1
  33. package/fesm/services/user-permission.service.spec.js +52 -0
  34. package/fesm/services/user.service.js +36 -18
  35. package/fesm/services/user.service.spec.js +15 -1
  36. package/interfaces/auth-module-options.interface.d.ts +2 -0
  37. package/package.json +3 -3
  38. package/services/auth-email.service.d.ts +1 -0
  39. package/services/authentication.service.d.ts +3 -0
  40. package/services/user-permission.service.d.ts +1 -0
@@ -130,10 +130,13 @@ let UserService = class UserService extends _classes.ApiService {
130
130
  await this.repository.update(id, {
131
131
  emailVerified: true
132
132
  });
133
- await this.clearCacheForId([
134
- {
135
- id
136
- }
133
+ await Promise.all([
134
+ this.clearCacheForAll(),
135
+ this.clearCacheForId([
136
+ {
137
+ id
138
+ }
139
+ ])
137
140
  ]);
138
141
  }
139
142
  async verifyPhone(id) {
@@ -141,10 +144,13 @@ let UserService = class UserService extends _classes.ApiService {
141
144
  await this.repository.update(id, {
142
145
  phoneVerified: true
143
146
  });
144
- await this.clearCacheForId([
145
- {
146
- id
147
- }
147
+ await Promise.all([
148
+ this.clearCacheForAll(),
149
+ this.clearCacheForId([
150
+ {
151
+ id
152
+ }
153
+ ])
148
154
  ]);
149
155
  }
150
156
  async updateStatus(id, isActive, user) {
@@ -174,10 +180,13 @@ let UserService = class UserService extends _classes.ApiService {
174
180
  isActive
175
181
  });
176
182
  }
177
- await this.clearCacheForId([
178
- {
179
- id
180
- }
183
+ await Promise.all([
184
+ this.clearCacheForAll(),
185
+ this.clearCacheForId([
186
+ {
187
+ id
188
+ }
189
+ ])
181
190
  ]);
182
191
  }
183
192
  async convertSingleDtoToEntity(dto, user) {
@@ -401,8 +410,11 @@ let UserService = class UserService extends _classes.ApiService {
401
410
  }
402
411
  }
403
412
  await queryRunner.commitTransaction();
404
- await this.clearCacheForId([
405
- savedUser
413
+ await Promise.all([
414
+ this.clearCacheForAll(),
415
+ this.clearCacheForId([
416
+ savedUser
417
+ ])
406
418
  ]);
407
419
  return this.convertEntityToResponseDto(savedUser, false);
408
420
  } catch (error) {
@@ -471,8 +483,11 @@ let UserService = class UserService extends _classes.ApiService {
471
483
  try {
472
484
  await this.userEnricher.updateProfileSection(userId, sectionId, data, queryRunner);
473
485
  await queryRunner.commitTransaction();
474
- await this.clearCacheForId([
475
- userEntity
486
+ await Promise.all([
487
+ this.clearCacheForAll(),
488
+ this.clearCacheForId([
489
+ userEntity
490
+ ])
476
491
  ]);
477
492
  } catch (error) {
478
493
  await queryRunner.rollbackTransaction();
@@ -541,8 +556,11 @@ let UserService = class UserService extends _classes.ApiService {
541
556
  await this.userEnricher.updateProfileExtras(userId, extras, queryRunner);
542
557
  }
543
558
  await queryRunner.commitTransaction();
544
- await this.clearCacheForId([
545
- findUser
559
+ await Promise.all([
560
+ this.clearCacheForAll(),
561
+ this.clearCacheForId([
562
+ findUser
563
+ ])
546
564
  ]);
547
565
  return findUser.id;
548
566
  } catch (error) {
@@ -124,6 +124,13 @@ describe('UserService', ()=>{
124
124
  };
125
125
  service = new _userservice.UserService({}, mockUtilsService, mockDataSourceProvider, mockAuthConfig, mockUserEnricher);
126
126
  });
127
+ /**
128
+ * Every write path has to drop both the list cache and the row's own cache, or a
129
+ * stale user keeps being served from `get-all`.
130
+ */ function expectCacheCleared(id) {
131
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('appuser', expect.anything());
132
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('appuser', expect.anything(), id);
133
+ }
127
134
  describe('ensureDataSourceRepository', ()=>{
128
135
  it('does not load the permission repository when the company feature is disabled', async ()=>{
129
136
  await service.ensureDataSourceRepository();
@@ -142,13 +149,14 @@ describe('UserService', ()=>{
142
149
  expect(userRepo.update).toHaveBeenCalledWith('user-1', {
143
150
  emailVerified: true
144
151
  });
145
- expect(mockUtilsService.clearCache).toHaveBeenCalled();
152
+ expectCacheCleared('user-1');
146
153
  });
147
154
  it('marks the phone as verified and clears the cache', async ()=>{
148
155
  await service.verifyPhone('user-1');
149
156
  expect(userRepo.update).toHaveBeenCalledWith('user-1', {
150
157
  phoneVerified: true
151
158
  });
159
+ expectCacheCleared('user-1');
152
160
  });
153
161
  });
154
162
  describe('updateStatus', ()=>{
@@ -157,6 +165,7 @@ describe('UserService', ()=>{
157
165
  expect(userRepo.update).toHaveBeenCalledWith('user-1', {
158
166
  isActive: false
159
167
  });
168
+ expectCacheCleared('user-1');
160
169
  });
161
170
  it('throws NotFoundException when the company permission is missing', async ()=>{
162
171
  companyFeatureEnabled = true;
@@ -179,6 +188,7 @@ describe('UserService', ()=>{
179
188
  isActive: false
180
189
  }));
181
190
  expect(userRepo.update).not.toHaveBeenCalled();
191
+ expectCacheCleared('user-1');
182
192
  });
183
193
  });
184
194
  describe('convertSingleDtoToEntity', ()=>{
@@ -389,6 +399,7 @@ describe('UserService', ()=>{
389
399
  expect(result).toEqual(expect.objectContaining({
390
400
  id: 'user-1'
391
401
  }));
402
+ expectCacheCleared(savedUser.id);
392
403
  });
393
404
  it('rolls back the transaction if permission assignment fails', async ()=>{
394
405
  companyFeatureEnabled = true;
@@ -577,6 +588,7 @@ describe('UserService', ()=>{
577
588
  foo: 'bar'
578
589
  }, queryRunner);
579
590
  expect(queryRunner.commitTransaction).toHaveBeenCalled();
591
+ expectCacheCleared('user-1');
580
592
  });
581
593
  it('rolls back the transaction when the enricher throws', async ()=>{
582
594
  userRepo.findOne.mockResolvedValue(buildAppUser());
@@ -670,6 +682,7 @@ describe('UserService', ()=>{
670
682
  expect(queryRunner.manager.save).toHaveBeenCalled();
671
683
  expect(queryRunner.commitTransaction).toHaveBeenCalled();
672
684
  expect(result).toBe('user-1');
685
+ expectCacheCleared('user-1');
673
686
  });
674
687
  it('does not require the current password when an admin updates another user profile', async ()=>{
675
688
  service.dataSource = mockDataSource;
@@ -686,6 +699,7 @@ describe('UserService', ()=>{
686
699
  }));
687
700
  expect(queryRunner.manager.save).toHaveBeenCalled();
688
701
  expect(queryRunner.commitTransaction).toHaveBeenCalled();
702
+ expectCacheCleared('other-user');
689
703
  });
690
704
  it('rolls back the transaction when save fails', async ()=>{
691
705
  service.dataSource = mockDataSource;
@@ -0,0 +1,21 @@
1
+ export declare const AUTH_EVENT_ACTIONS: {
2
+ readonly LOGGED_IN: "logged-in";
3
+ readonly LOGGED_OUT: "logged-out";
4
+ readonly TOKEN_REFRESHED: "token-refreshed";
5
+ readonly COMPANY_SWITCHED: "company-switched";
6
+ readonly REGISTERED: "registered";
7
+ readonly PASSWORD_CHANGED: "password-changed";
8
+ readonly PASSWORD_RESET_REQUESTED: "password-reset-requested";
9
+ readonly PASSWORD_RESET: "password-reset";
10
+ readonly EMAIL_VERIFIED: "email-verified";
11
+ readonly PERMISSION_GRANTED: "permission-granted";
12
+ readonly PERMISSION_REVOKED: "permission-revoked";
13
+ };
14
+ export declare const AUTH_EVENT_ENTITIES: {
15
+ readonly USER: "appuser";
16
+ readonly COMPANY: "company";
17
+ readonly COMPANY_BRANCH: "company_branch";
18
+ readonly SESSION: "session";
19
+ readonly USER_PERMISSION: "user_company_permission";
20
+ };
21
+ export declare const AUTH_EVENT_MODULE = "auth";
package/config/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './auth.constants';
2
+ export * from './event-actions';
2
3
  export * from './message-keys';
@@ -62,6 +62,12 @@ export declare const USER_MESSAGES: {
62
62
  readonly STATUS_UPDATE_SUCCESS: "user.status.update.success";
63
63
  readonly LOOKUP_SUCCESS: "user.lookup.success";
64
64
  };
65
+ export declare const COMPANY_MESSAGES: {
66
+ readonly READONLY_DELETE_FORBIDDEN: "company.readonly.delete.forbidden";
67
+ };
68
+ export declare const BRANCH_MESSAGES: {
69
+ readonly READONLY_DELETE_FORBIDDEN: "branch.readonly.delete.forbidden";
70
+ };
65
71
  export declare const USER_PERMISSION_MESSAGES: {
66
72
  readonly COMPANY_LIST_SUCCESS: "user.permission.company.list.success";
67
73
  readonly BRANCH_LIST_SUCCESS: "user.permission.branch.list.success";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Domain event actions published by the auth module, alongside the CRUD
3
+ * lifecycle actions every ApiService emits.
4
+ */ export const AUTH_EVENT_ACTIONS = {
5
+ LOGGED_IN: 'logged-in',
6
+ LOGGED_OUT: 'logged-out',
7
+ TOKEN_REFRESHED: 'token-refreshed',
8
+ COMPANY_SWITCHED: 'company-switched',
9
+ REGISTERED: 'registered',
10
+ PASSWORD_CHANGED: 'password-changed',
11
+ PASSWORD_RESET_REQUESTED: 'password-reset-requested',
12
+ PASSWORD_RESET: 'password-reset',
13
+ EMAIL_VERIFIED: 'email-verified',
14
+ PERMISSION_GRANTED: 'permission-granted',
15
+ PERMISSION_REVOKED: 'permission-revoked'
16
+ };
17
+ /** Every entity the auth module publishes under, ApiService backed or not */ export const AUTH_EVENT_ENTITIES = {
18
+ USER: 'appuser',
19
+ COMPANY: 'company',
20
+ COMPANY_BRANCH: 'company_branch',
21
+ SESSION: 'session',
22
+ USER_PERMISSION: 'user_company_permission'
23
+ };
24
+ export const AUTH_EVENT_MODULE = 'auth';
@@ -1,2 +1,3 @@
1
1
  export * from './auth.constants';
2
+ export * from './event-actions';
2
3
  export * from './message-keys';
@@ -63,6 +63,12 @@ export const USER_MESSAGES = {
63
63
  STATUS_UPDATE_SUCCESS: 'user.status.update.success',
64
64
  LOOKUP_SUCCESS: 'user.lookup.success'
65
65
  };
66
+ export const COMPANY_MESSAGES = {
67
+ READONLY_DELETE_FORBIDDEN: 'company.readonly.delete.forbidden'
68
+ };
69
+ export const BRANCH_MESSAGES = {
70
+ READONLY_DELETE_FORBIDDEN: 'branch.readonly.delete.forbidden'
71
+ };
66
72
  export const USER_PERMISSION_MESSAGES = {
67
73
  COMPANY_LIST_SUCCESS: 'user.permission.company.list.success',
68
74
  BRANCH_LIST_SUCCESS: 'user.permission.branch.list.success',
@@ -4,7 +4,7 @@ function _ts_decorate(decorators, target, key, desc) {
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 { CacheModule, UtilsModule } from '@flusys/nestjs-shared/modules';
7
+ import { CacheModule, UtilsModule, createModuleEventsProvider } from '@flusys/nestjs-shared/modules';
8
8
  import { Module } from '@nestjs/common';
9
9
  import { JwtModule } from '@nestjs/jwt';
10
10
  import { PassportModule } from '@nestjs/passport';
@@ -93,7 +93,8 @@ export class AuthModule {
93
93
  AuthenticationService,
94
94
  SetToken,
95
95
  ClearToken,
96
- UserService
96
+ UserService,
97
+ createModuleEventsProvider('auth', AUTH_MODULE_OPTIONS)
97
98
  ];
98
99
  if (flags.enableEmailVerification) {
99
100
  providers.push(AuthEmailService);
@@ -26,11 +26,12 @@ function _ts_param(paramIndex, decorator) {
26
26
  };
27
27
  }
28
28
  import { LogAction } from '@flusys/nestjs-shared';
29
+ import { publishDomainEvent } from '@flusys/nestjs-shared/classes';
29
30
  import { SYSTEM_MESSAGES } from '@flusys/nestjs-shared/constants';
30
31
  import { BadRequestException, Inject, Injectable, InternalServerErrorException, Optional, Scope } from '@nestjs/common';
31
32
  import * as bcrypt from 'bcrypt';
32
33
  import * as crypto from 'crypto';
33
- import { AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, USER_MESSAGES } from '../config';
34
+ import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, USER_MESSAGES } from '../config';
34
35
  import { AppUser } from '../entities/app-user.entity';
35
36
  import { PendingRegistration } from '../entities/pending-registration.entity';
36
37
  import { TokenType, UserToken } from '../entities/user-token.entity';
@@ -191,6 +192,9 @@ export class AuthEmailService {
191
192
  }
192
193
  });
193
194
  }
195
+ await this.publishUserEvent(AUTH_EVENT_ACTIONS.PASSWORD_RESET_REQUESTED, user.id, {
196
+ email: user.email
197
+ });
194
198
  return {
195
199
  success: true,
196
200
  message: 'If the email exists, a password reset link has been sent.',
@@ -232,6 +236,9 @@ export class AuthEmailService {
232
236
  // Mark token as used
233
237
  userToken.usedAt = new Date();
234
238
  await tokenRepository.save(userToken);
239
+ await this.publishUserEvent(AUTH_EVENT_ACTIONS.PASSWORD_RESET, user.id, {
240
+ email: user.email
241
+ });
235
242
  return {
236
243
  success: true,
237
244
  message: 'Password has been reset successfully.',
@@ -315,12 +322,26 @@ export class AuthEmailService {
315
322
  // Mark token as used
316
323
  userToken.usedAt = new Date();
317
324
  await tokenRepository.save(userToken);
325
+ await this.publishUserEvent(AUTH_EVENT_ACTIONS.EMAIL_VERIFIED, user.id, {
326
+ email: user.email
327
+ });
318
328
  return {
319
329
  type: 'verified',
320
330
  message: 'Email has been verified successfully.',
321
331
  messageKey: EMAIL_VERIFICATION_MESSAGES.VERIFICATION_SUCCESS
322
332
  };
323
333
  }
334
+ async publishUserEvent(action, userId, metadata) {
335
+ await publishDomainEvent({
336
+ module: AUTH_EVENT_MODULE,
337
+ entity: AUTH_EVENT_ENTITIES.USER,
338
+ action,
339
+ ids: [
340
+ userId
341
+ ],
342
+ metadata
343
+ });
344
+ }
324
345
  async resendVerificationEmail(email, verifyUrlBase) {
325
346
  this.assertEmailEnabled();
326
347
  const pendingRepository = await this.getRepository(PendingRegistration);
@@ -1,6 +1,7 @@
1
1
  import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
2
2
  import * as bcrypt from 'bcrypt';
3
3
  import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
4
5
  import { AuthEmailService } from './auth-email.service';
5
6
  import { AppUser } from '../entities/app-user.entity';
6
7
  import { PendingRegistration } from '../entities/pending-registration.entity';
@@ -356,4 +357,55 @@ describe('AuthEmailService', ()=>{
356
357
  })).rejects.toThrow(InternalServerErrorException);
357
358
  });
358
359
  });
360
+ describe('domain events', ()=>{
361
+ let publish;
362
+ beforeEach(()=>{
363
+ publish = jest.fn().mockResolvedValue(undefined);
364
+ EventBusRegistry.reset();
365
+ EventBusRegistry.setBus({
366
+ publish,
367
+ subscribe: jest.fn(),
368
+ close: jest.fn()
369
+ });
370
+ EventBusRegistry.configureModule('auth', {
371
+ enabled: true
372
+ });
373
+ });
374
+ afterEach(()=>{
375
+ EventBusRegistry.reset();
376
+ });
377
+ it('publishes auth.appuser.password-reset-requested once the reset email is sent', async ()=>{
378
+ userRepo.findOne.mockResolvedValue(buildUser());
379
+ tokenRepo.save.mockResolvedValue(buildUserToken());
380
+ await service.forgotPassword('user@example.com', 'https://app.test/reset');
381
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
382
+ name: 'auth.appuser.password-reset-requested'
383
+ }));
384
+ });
385
+ it('publishes nothing for an unknown email so enumeration stays impossible', async ()=>{
386
+ userRepo.findOne.mockResolvedValue(null);
387
+ await service.forgotPassword('missing@example.com', 'https://app.test/reset');
388
+ expect(publish).not.toHaveBeenCalled();
389
+ });
390
+ it('publishes auth.appuser.password-reset when the token is consumed', async ()=>{
391
+ const token = buildUserToken();
392
+ tokenRepo.findOne.mockResolvedValue(token);
393
+ userRepo.findOne.mockResolvedValue(buildUser());
394
+ userRepo.save.mockResolvedValue(buildUser());
395
+ tokenRepo.save.mockResolvedValue(token);
396
+ await service.resetPassword('raw-token', 'NewPassword1!');
397
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
398
+ name: 'auth.appuser.password-reset'
399
+ }));
400
+ });
401
+ it('does not publish when the auth module has events disabled', async ()=>{
402
+ EventBusRegistry.configureModule('auth', {
403
+ enabled: false
404
+ });
405
+ userRepo.findOne.mockResolvedValue(buildUser());
406
+ tokenRepo.save.mockResolvedValue(buildUserToken());
407
+ await service.forgotPassword('user@example.com', 'https://app.test/reset');
408
+ expect(publish).not.toHaveBeenCalled();
409
+ });
410
+ });
359
411
  });
@@ -26,7 +26,7 @@ function _ts_param(paramIndex, decorator) {
26
26
  };
27
27
  }
28
28
  import { LogAction } from '@flusys/nestjs-shared';
29
- import { HybridCache } from '@flusys/nestjs-shared/classes';
29
+ import { HybridCache, publishDomainEvent } from '@flusys/nestjs-shared/classes';
30
30
  import { SYSTEM_MESSAGES } from '@flusys/nestjs-shared/constants';
31
31
  import { NotificationType } from '@flusys/nestjs-shared/enums';
32
32
  import { INotificationAdapter, NOTIFICATION_ADAPTER } from '@flusys/nestjs-shared/interfaces';
@@ -34,7 +34,7 @@ import { generateUniqueSlug } from '@flusys/nestjs-shared/utils';
34
34
  import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, NotFoundException, Optional, Scope, UnauthorizedException } from '@nestjs/common';
35
35
  import { JwtService } from '@nestjs/jwt';
36
36
  import * as bcrypt from 'bcrypt';
37
- import { AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, TOKEN_REVOKED_PREFIX, USER_ENRICHER, USER_MESSAGES } from '../config';
37
+ import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, AUTH_MESSAGES, BCRYPT_SALT_ROUNDS, EMAIL_VERIFICATION_MESSAGES, TOKEN_REVOKED_PREFIX, USER_ENRICHER, USER_MESSAGES } from '../config';
38
38
  import { RegistrationDto } from '../dtos/authentication.dto';
39
39
  import { AppUser } from '../entities/app-user.entity';
40
40
  import { CompanyBranch } from '../entities/company-branch.entity';
@@ -528,7 +528,12 @@ export class AuthenticationService {
528
528
  messageKey: AUTH_MESSAGES.BRANCH_NOT_FOUND
529
529
  });
530
530
  }
531
- return this.generateLoginResponseWithCompany(user, company, branch);
531
+ const response = this.generateLoginResponseWithCompany(user, company, branch);
532
+ this.publishSessionEvent(AUTH_EVENT_ACTIONS.COMPANY_SWITCHED, user, {
533
+ companyId: company.id,
534
+ branchId: branch.id
535
+ });
536
+ return response;
532
537
  }
533
538
  async refreshToken(refreshToken) {
534
539
  try {
@@ -622,7 +627,7 @@ export class AuthenticationService {
622
627
  }
623
628
  }
624
629
  }
625
- return generateTokens(this.jwtService, this.authConfig, {
630
+ const tokens = generateTokens(this.jwtService, this.authConfig, {
626
631
  id: user.id,
627
632
  email: user.email,
628
633
  type: 'access',
@@ -634,6 +639,11 @@ export class AuthenticationService {
634
639
  branchId: payload.branchId
635
640
  }
636
641
  });
642
+ this.publishSessionEvent(AUTH_EVENT_ACTIONS.TOKEN_REFRESHED, user, {
643
+ companyId: payload.companyId ?? null,
644
+ branchId: payload.branchId ?? null
645
+ });
646
+ return tokens;
637
647
  } catch {
638
648
  throw new UnauthorizedException({
639
649
  message: 'Invalid or expired refresh token',
@@ -660,6 +670,7 @@ export class AuthenticationService {
660
670
  }
661
671
  user.password = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
662
672
  await userRepository.save(user);
673
+ this.publishUserEvent(AUTH_EVENT_ACTIONS.PASSWORD_CHANGED, user);
663
674
  }
664
675
  async logout(userId) {
665
676
  if (!this.cacheManager) {
@@ -673,6 +684,17 @@ export class AuthenticationService {
673
684
  const refreshTokenExpiry = this.authConfig.getRefreshTokenExpiration();
674
685
  const ttlMs = parseExpiryToMs(refreshTokenExpiry);
675
686
  await this.cacheManager.set(cacheKey, revokedAt, ttlMs);
687
+ await publishDomainEvent({
688
+ module: AUTH_EVENT_MODULE,
689
+ entity: AUTH_EVENT_ENTITIES.SESSION,
690
+ action: AUTH_EVENT_ACTIONS.LOGGED_OUT,
691
+ ids: [
692
+ userId
693
+ ],
694
+ user: {
695
+ id: userId
696
+ }
697
+ });
676
698
  }
677
699
  async isTokenRevoked(userId, tokenIssuedAt) {
678
700
  if (!this.cacheManager || !tokenIssuedAt) {
@@ -846,6 +868,7 @@ export class AuthenticationService {
846
868
  type: 'access'
847
869
  });
848
870
  this.sendLoginNotification(user.id);
871
+ this.publishSessionEvent(AUTH_EVENT_ACTIONS.LOGGED_IN, user);
849
872
  return {
850
873
  accessToken,
851
874
  refreshToken,
@@ -861,6 +884,10 @@ export class AuthenticationService {
861
884
  branchId: branch?.id
862
885
  });
863
886
  this.sendLoginNotification(user.id, company.id);
887
+ this.publishSessionEvent(AUTH_EVENT_ACTIONS.LOGGED_IN, user, {
888
+ companyId: company.id,
889
+ branchId: branch?.id ?? null
890
+ });
864
891
  return {
865
892
  accessToken,
866
893
  refreshToken,
@@ -907,16 +934,47 @@ export class AuthenticationService {
907
934
  };
908
935
  }
909
936
  generateRegistrationResponse(user) {
910
- return {
937
+ const response = {
911
938
  ...this.generateLoginResponse(user),
912
939
  requiresEmailVerification: false
913
940
  };
941
+ this.publishUserEvent(AUTH_EVENT_ACTIONS.REGISTERED, user);
942
+ return response;
914
943
  }
915
944
  generateRegistrationResponseWithCompany(user, company, branch) {
916
- return {
945
+ const response = {
917
946
  ...this.generateLoginResponseWithCompany(user, company, branch),
918
947
  requiresEmailVerification: false
919
948
  };
949
+ this.publishUserEvent(AUTH_EVENT_ACTIONS.REGISTERED, user, {
950
+ companyId: company.id,
951
+ branchId: branch?.id ?? null
952
+ });
953
+ return response;
954
+ }
955
+ publishSessionEvent(action, user, metadata = {}) {
956
+ this.publishAuthEvent(AUTH_EVENT_ENTITIES.SESSION, action, user, metadata);
957
+ }
958
+ publishUserEvent(action, user, metadata = {}) {
959
+ this.publishAuthEvent(AUTH_EVENT_ENTITIES.USER, action, user, metadata);
960
+ }
961
+ /** Never awaited - a login must not wait on, or fail because of, the event bus */ publishAuthEvent(entity, action, user, metadata) {
962
+ void publishDomainEvent({
963
+ module: AUTH_EVENT_MODULE,
964
+ entity,
965
+ action,
966
+ ids: [
967
+ user.id
968
+ ],
969
+ metadata: {
970
+ email: user.email,
971
+ ...metadata
972
+ },
973
+ user: {
974
+ id: user.id,
975
+ email: user.email
976
+ }
977
+ });
920
978
  }
921
979
  // NOTE: @Inject() required for bundled code - type metadata may be lost during esbuild
922
980
  constructor(jwtService, authConfig, cacheManager, dataSourceProvider, sessionService, authEmailService, userEnricher, notificationAdapter){
@@ -1,6 +1,7 @@
1
1
  import { BadRequestException, ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common';
2
2
  import * as bcrypt from 'bcrypt';
3
3
  import { createMockRepository } from '@test-utils/mocks/repository.mock';
4
+ import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
4
5
  import { AuthenticationService } from './authentication.service';
5
6
  import { AppUser } from '../entities/app-user.entity';
6
7
  import { Company } from '../entities/company.entity';
@@ -856,4 +857,61 @@ describe('AuthenticationService', ()=>{
856
857
  }));
857
858
  });
858
859
  });
860
+ describe('domain events', ()=>{
861
+ let publish;
862
+ beforeEach(()=>{
863
+ publish = jest.fn().mockResolvedValue(undefined);
864
+ EventBusRegistry.reset();
865
+ EventBusRegistry.setBus({
866
+ publish,
867
+ subscribe: jest.fn(),
868
+ close: jest.fn()
869
+ });
870
+ EventBusRegistry.configureModule('auth', {
871
+ enabled: true
872
+ });
873
+ });
874
+ afterEach(()=>{
875
+ EventBusRegistry.reset();
876
+ });
877
+ it('publishes auth.session.logged-in when a session is issued', async ()=>{
878
+ userRepo.findOne.mockResolvedValue(buildUser());
879
+ bcrypt.compare.mockResolvedValue(true);
880
+ const service = buildService();
881
+ await service.login('user@example.com', 'password');
882
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
883
+ name: 'auth.session.logged-in',
884
+ action: 'logged-in',
885
+ payload: expect.objectContaining({
886
+ ids: [
887
+ 'user-1'
888
+ ]
889
+ })
890
+ }));
891
+ });
892
+ it('publishes auth.session.logged-out on logout', async ()=>{
893
+ const service = buildService();
894
+ await service.logout('user-1');
895
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
896
+ name: 'auth.session.logged-out'
897
+ }));
898
+ });
899
+ it('publishes auth.appuser.password-changed after a successful password change', async ()=>{
900
+ userRepo.findOne.mockResolvedValue(buildUser());
901
+ bcrypt.compare.mockResolvedValue(true);
902
+ const service = buildService();
903
+ await service.changePassword('user-1', 'old', 'new');
904
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
905
+ name: 'auth.appuser.password-changed'
906
+ }));
907
+ });
908
+ it('does not publish when the auth module has events disabled', async ()=>{
909
+ EventBusRegistry.configureModule('auth', {
910
+ enabled: false
911
+ });
912
+ const service = buildService();
913
+ await service.logout('user-1');
914
+ expect(publish).not.toHaveBeenCalled();
915
+ });
916
+ });
859
917
  });
@@ -29,8 +29,9 @@ import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
29
29
  import { IPermissionSyncAdapter, PERMISSION_SYNC_ADAPTER } from '@flusys/nestjs-shared/interfaces';
30
30
  import { UtilsService } from '@flusys/nestjs-shared/modules';
31
31
  import { applyCompanyFilter } from '@flusys/nestjs-shared/utils';
32
- import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
33
- import { In } from 'typeorm';
32
+ import { ForbiddenException, Inject, Injectable, Optional, Scope } from '@nestjs/common';
33
+ import { In, IsNull } from 'typeorm';
34
+ import { BRANCH_MESSAGES } from '../config';
34
35
  import { CompanyBranch } from '../entities/company-branch.entity';
35
36
  import { UserCompanyPermission } from '../entities/user-company-permission.entity';
36
37
  import { AuthConfigService } from './auth-config.service';
@@ -40,6 +41,24 @@ export class BranchService extends ApiService {
40
41
  const branchIds = Array.isArray(dto.id) ? dto.id : [
41
42
  dto.id
42
43
  ];
44
+ await this.ensureDataSourceRepository();
45
+ const readonlyBranches = await this.repository.find({
46
+ where: {
47
+ id: In(branchIds),
48
+ readOnly: true,
49
+ deletedAt: IsNull()
50
+ }
51
+ });
52
+ if (readonlyBranches.length > 0) {
53
+ const names = readonlyBranches.map((b)=>b.name).join(', ');
54
+ throw new ForbiddenException({
55
+ message: `Cannot delete readonly branch(es): ${names}`,
56
+ messageKey: BRANCH_MESSAGES.READONLY_DELETE_FORBIDDEN,
57
+ messageVariables: {
58
+ names
59
+ }
60
+ });
61
+ }
43
62
  await queryRunner.manager.getRepository(UserCompanyPermission).delete({
44
63
  targetId: In(branchIds)
45
64
  });
@@ -67,6 +86,7 @@ export class BranchService extends ApiService {
67
86
  'email',
68
87
  'phone',
69
88
  'isActive',
89
+ 'readOnly',
70
90
  'createdAt'
71
91
  ];
72
92
  }