@flusys/nestjs-auth 6.4.1 → 7.0.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.
Files changed (36) 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/config/event-actions.d.ts +21 -0
  16. package/config/index.d.ts +1 -0
  17. package/config/message-keys.d.ts +6 -0
  18. package/fesm/config/event-actions.js +24 -0
  19. package/fesm/config/index.js +1 -0
  20. package/fesm/config/message-keys.js +6 -0
  21. package/fesm/modules/auth.module.js +3 -2
  22. package/fesm/services/auth-email.service.js +22 -1
  23. package/fesm/services/auth-email.service.spec.js +52 -0
  24. package/fesm/services/authentication.service.js +64 -6
  25. package/fesm/services/authentication.service.spec.js +58 -0
  26. package/fesm/services/branch.service.js +22 -2
  27. package/fesm/services/branch.service.spec.js +15 -0
  28. package/fesm/services/company.service.js +22 -2
  29. package/fesm/services/company.service.spec.js +14 -0
  30. package/fesm/services/user-permission.service.js +21 -1
  31. package/fesm/services/user-permission.service.spec.js +52 -0
  32. package/interfaces/auth-module-options.interface.d.ts +2 -0
  33. package/package.json +3 -3
  34. package/services/auth-email.service.d.ts +1 -0
  35. package/services/authentication.service.d.ts +3 -0
  36. package/services/user-permission.service.d.ts +1 -0
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
  }
@@ -10,6 +10,7 @@ describe('BranchService', ()=>{
10
10
  let mockQueryRunner;
11
11
  beforeEach(()=>{
12
12
  mockRepo = createMockRepository();
13
+ mockRepo.find.mockResolvedValue([]);
13
14
  Object.assign(mockRepo.createQueryBuilder(), {
14
15
  leftJoinAndSelect: jest.fn().mockReturnThis(),
15
16
  addOrderBy: jest.fn().mockReturnThis()
@@ -78,6 +79,20 @@ describe('BranchService', ()=>{
78
79
  }, null, mockQueryRunner);
79
80
  expect(mockPermissionSyncAdapter.onBranchDeleted).toHaveBeenCalledWith('branch-1', 'company-1');
80
81
  });
82
+ it('rejects deleting a readonly branch', async ()=>{
83
+ mockRepo.find.mockResolvedValue([
84
+ {
85
+ id: 'branch-1',
86
+ name: 'Default Branch',
87
+ readOnly: true
88
+ }
89
+ ]);
90
+ const permissionRepo = mockQueryRunner.manager.getRepository(UserCompanyPermission);
91
+ await expect(service.beforeDeleteOperation({
92
+ id: 'branch-1'
93
+ }, null, mockQueryRunner)).rejects.toThrow('Cannot delete readonly branch');
94
+ expect(permissionRepo.delete).not.toHaveBeenCalled();
95
+ });
81
96
  });
82
97
  describe('getSelectQuery', ()=>{
83
98
  it('applies the default selected fields when none are provided', async ()=>{
@@ -28,8 +28,9 @@ function _ts_param(paramIndex, decorator) {
28
28
  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
- import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
32
- import { In } from 'typeorm';
31
+ import { ForbiddenException, Inject, Injectable, Optional, Scope } from '@nestjs/common';
32
+ import { In, IsNull } from 'typeorm';
33
+ import { COMPANY_MESSAGES } from '../config';
33
34
  import { CompanyBranch } from '../entities/company-branch.entity';
34
35
  import { Company } from '../entities/company.entity';
35
36
  import { UserCompanyPermission } from '../entities/user-company-permission.entity';
@@ -39,6 +40,24 @@ export class CompanyService extends ApiService {
39
40
  const companyIds = Array.isArray(dto.id) ? dto.id : [
40
41
  dto.id
41
42
  ];
43
+ await this.ensureDataSourceRepository();
44
+ const readonlyCompanies = await this.repository.find({
45
+ where: {
46
+ id: In(companyIds),
47
+ readOnly: true,
48
+ deletedAt: IsNull()
49
+ }
50
+ });
51
+ if (readonlyCompanies.length > 0) {
52
+ const names = readonlyCompanies.map((c)=>c.name).join(', ');
53
+ throw new ForbiddenException({
54
+ message: `Cannot delete readonly company(ies): ${names}`,
55
+ messageKey: COMPANY_MESSAGES.READONLY_DELETE_FORBIDDEN,
56
+ messageVariables: {
57
+ names
58
+ }
59
+ });
60
+ }
42
61
  const branchRows = await queryRunner.manager.getRepository(CompanyBranch).find({
43
62
  where: {
44
63
  companyId: In(companyIds)
@@ -69,6 +88,7 @@ export class CompanyService extends ApiService {
69
88
  'email',
70
89
  'phone',
71
90
  'isActive',
91
+ 'readOnly',
72
92
  'createdAt'
73
93
  ];
74
94
  }
@@ -11,6 +11,7 @@ describe('CompanyService', ()=>{
11
11
  let permissionRepo;
12
12
  beforeEach(()=>{
13
13
  mockRepo = createMockRepository();
14
+ mockRepo.find.mockResolvedValue([]);
14
15
  const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
15
16
  mockPermissionSyncAdapter = {
16
17
  onCompanyDeleted: jest.fn()
@@ -75,6 +76,19 @@ describe('CompanyService', ()=>{
75
76
  expect(mockPermissionSyncAdapter.onCompanyDeleted).toHaveBeenCalledWith('company-1');
76
77
  expect(mockPermissionSyncAdapter.onCompanyDeleted).toHaveBeenCalledWith('company-2');
77
78
  });
79
+ it('rejects deleting a readonly company', async ()=>{
80
+ mockRepo.find.mockResolvedValue([
81
+ {
82
+ id: 'company-1',
83
+ name: 'Default Company',
84
+ readOnly: true
85
+ }
86
+ ]);
87
+ await expect(service.beforeDeleteOperation({
88
+ id: 'company-1'
89
+ }, null, mockQueryRunner)).rejects.toThrow('Cannot delete readonly company');
90
+ expect(branchRepo.find).not.toHaveBeenCalled();
91
+ });
78
92
  });
79
93
  describe('getSelectQuery', ()=>{
80
94
  it('applies the default selected fields when none are provided', async ()=>{
@@ -26,9 +26,10 @@ 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 { IPermissionSyncAdapter, PERMISSION_SYNC_ADAPTER } from '@flusys/nestjs-shared/interfaces';
30
31
  import { ConflictException, Inject, Injectable, NotFoundException, Optional, Scope } from '@nestjs/common';
31
- import { USER_PERMISSION_MESSAGES } from '../config';
32
+ import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, USER_PERMISSION_MESSAGES } from '../config';
32
33
  import { PermissionType, UserCompanyPermission } from '../entities/user-company-permission.entity';
33
34
  import { AuthDataSourceProvider } from './auth-datasource.provider';
34
35
  export class UserPermissionService {
@@ -92,6 +93,7 @@ export class UserPermissionService {
92
93
  }
93
94
  throw error;
94
95
  }
96
+ await this.publishPermissionEvent(AUTH_EVENT_ACTIONS.PERMISSION_GRANTED, userId, targetId, type);
95
97
  }
96
98
  async revokePermission(userId, targetId, type) {
97
99
  const repository = await this.getRepository();
@@ -124,6 +126,24 @@ export class UserPermissionService {
124
126
  } else {
125
127
  if (companyId) await this.permissionSyncAdapter?.onUserBranchAccessRevoked?.(userId, targetId, companyId);
126
128
  }
129
+ await this.publishPermissionEvent(AUTH_EVENT_ACTIONS.PERMISSION_REVOKED, userId, targetId, type, companyId);
130
+ }
131
+ async publishPermissionEvent(action, userId, targetId, type, companyId) {
132
+ await publishDomainEvent({
133
+ module: AUTH_EVENT_MODULE,
134
+ entity: AUTH_EVENT_ENTITIES.USER_PERMISSION,
135
+ action,
136
+ ids: [
137
+ targetId
138
+ ],
139
+ metadata: {
140
+ targetUserId: userId,
141
+ permissionType: type,
142
+ ...companyId ? {
143
+ companyId
144
+ } : {}
145
+ }
146
+ });
127
147
  }
128
148
  async revokeBranchPermissionsForCompany(repository, userId, companyId) {
129
149
  const branchPermissions = await repository.createQueryBuilder('permission').innerJoin('permission.branch', 'branch').where('permission.userId = :userId', {