@flusys/nestjs-localization 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.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Domain event actions published by the localization module, alongside the CRUD
3
+ * lifecycle actions every ApiService emits.
4
+ */ "use strict";
5
+ Object.defineProperty(exports, "__esModule", {
6
+ value: true
7
+ });
8
+ function _export(target, all) {
9
+ for(var name in all)Object.defineProperty(target, name, {
10
+ enumerable: true,
11
+ get: Object.getOwnPropertyDescriptor(all, name).get
12
+ });
13
+ }
14
+ _export(exports, {
15
+ get LOCALIZATION_EVENT_ACTIONS () {
16
+ return LOCALIZATION_EVENT_ACTIONS;
17
+ },
18
+ get LOCALIZATION_EVENT_ENTITIES () {
19
+ return LOCALIZATION_EVENT_ENTITIES;
20
+ }
21
+ });
22
+ const LOCALIZATION_EVENT_ACTIONS = {
23
+ BULK_UPDATED: 'bulk-updated'
24
+ };
25
+ const LOCALIZATION_EVENT_ENTITIES = {
26
+ LANGUAGE: 'language',
27
+ TRANSLATION_KEY: 'translationKey',
28
+ TRANSLATION: 'translation'
29
+ };
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", {
3
3
  value: true
4
4
  });
5
+ _export_star(require("./event-actions"), exports);
5
6
  _export_star(require("./localization.constants"), exports);
6
7
  _export_star(require("./message-keys"), exports);
7
8
  function _export_star(from, to) {
@@ -23,7 +23,10 @@ _export(exports, {
23
23
  const LANGUAGE_MESSAGES = {
24
24
  ACTIVE_SUCCESS: 'language.active.success',
25
25
  SET_DEFAULT_SUCCESS: 'language.set.default.success',
26
- GET_DEFAULT_SUCCESS: 'language.get.default.success'
26
+ GET_DEFAULT_SUCCESS: 'language.get.default.success',
27
+ READONLY_UPDATE_FORBIDDEN: 'language.readonly.update.forbidden',
28
+ READONLY_DELETE_FORBIDDEN: 'language.readonly.delete.forbidden',
29
+ DEFAULT_DELETE_FORBIDDEN: 'language.default.delete.forbidden'
27
30
  };
28
31
  const TRANSLATION_KEY_MESSAGES = {
29
32
  MODULES_SUCCESS: 'translation.key.modules.success',
@@ -144,7 +144,7 @@ _ts_decorate([
144
144
  ], UpdateLanguageDto.prototype, "id", void 0);
145
145
  let LanguageResponseDto = class LanguageResponseDto extends _dtos.IdentityResponseDto {
146
146
  constructor(...args){
147
- super(...args), _define_property(this, "code", void 0), _define_property(this, "name", void 0), _define_property(this, "nativeName", void 0), _define_property(this, "direction", void 0), _define_property(this, "isActive", void 0), _define_property(this, "isDefault", void 0), _define_property(this, "serial", void 0);
147
+ super(...args), _define_property(this, "code", void 0), _define_property(this, "name", void 0), _define_property(this, "nativeName", void 0), _define_property(this, "direction", void 0), _define_property(this, "isActive", void 0), _define_property(this, "isDefault", void 0), _define_property(this, "isReadonly", void 0), _define_property(this, "serial", void 0);
148
148
  }
149
149
  };
150
150
  _ts_decorate([
@@ -193,6 +193,14 @@ _ts_decorate([
193
193
  (0, _classtransformer.Expose)(),
194
194
  _ts_metadata("design:type", Boolean)
195
195
  ], LanguageResponseDto.prototype, "isDefault", void 0);
196
+ _ts_decorate([
197
+ (0, _swagger.ApiProperty)({
198
+ example: false,
199
+ description: 'System language: cannot be renamed or deleted'
200
+ }),
201
+ (0, _classtransformer.Expose)(),
202
+ _ts_metadata("design:type", Boolean)
203
+ ], LanguageResponseDto.prototype, "isReadonly", void 0);
196
204
  _ts_decorate([
197
205
  (0, _swagger.ApiPropertyOptional)({
198
206
  example: 1
@@ -72,6 +72,7 @@ let LocalizationModule = class LocalizationModule {
72
72
  useValue: options
73
73
  });
74
74
  }
75
+ providers.push((0, _modules.createModuleEventsProvider)('localization', _localizationconstants.LOCALIZATION_MODULE_OPTIONS));
75
76
  return providers;
76
77
  }
77
78
  static createAsyncProviders(options) {
@@ -12,6 +12,7 @@ const _classes = require("@flusys/nestjs-shared/classes");
12
12
  const _modules = require("@flusys/nestjs-shared/modules");
13
13
  const _common = require("@nestjs/common");
14
14
  const _typeorm = require("typeorm");
15
+ const _config = require("../config");
15
16
  const _entities = require("../entities");
16
17
  const _localizationdatasourceprovider = require("./localization-datasource.provider");
17
18
  function _define_property(obj, key, value) {
@@ -42,6 +43,69 @@ function _ts_param(paramIndex, decorator) {
42
43
  };
43
44
  }
44
45
  let LanguageService = class LanguageService extends _classes.ApiService {
46
+ async beforeUpdateOperation(dto, _user, _queryRunner) {
47
+ const dtos = Array.isArray(dto) ? dto : [
48
+ dto
49
+ ];
50
+ const ids = dtos.map((d)=>d.id);
51
+ await this.ensureDataSourceRepository();
52
+ const existingLanguages = await this.repository.find({
53
+ where: {
54
+ id: (0, _typeorm.In)(ids),
55
+ deletedAt: (0, _typeorm.IsNull)()
56
+ }
57
+ });
58
+ for (const d of dtos){
59
+ const existing = existingLanguages.find((e)=>e.id === d.id);
60
+ if (!existing) {
61
+ continue;
62
+ }
63
+ const changesIdentity = d.code !== undefined && d.code !== existing.code || d.name !== undefined && d.name !== existing.name || d.nativeName !== undefined && d.nativeName !== (existing.nativeName ?? undefined) || d.direction !== undefined && d.direction !== existing.direction;
64
+ if (existing.isReadonly && changesIdentity) {
65
+ throw new _common.ForbiddenException({
66
+ message: `Cannot modify code/name of readonly language "${existing.name}"`,
67
+ messageKey: _config.LANGUAGE_MESSAGES.READONLY_UPDATE_FORBIDDEN,
68
+ messageVariables: {
69
+ name: existing.name
70
+ }
71
+ });
72
+ }
73
+ }
74
+ }
75
+ async beforeDeleteOperation(dto, _user, _queryRunner) {
76
+ const ids = Array.isArray(dto.id) ? dto.id : [
77
+ dto.id
78
+ ];
79
+ await this.ensureDataSourceRepository();
80
+ const languages = await this.repository.find({
81
+ where: {
82
+ id: (0, _typeorm.In)(ids),
83
+ deletedAt: (0, _typeorm.IsNull)()
84
+ }
85
+ });
86
+ const readonlyLanguages = languages.filter((l)=>l.isReadonly);
87
+ if (readonlyLanguages.length > 0) {
88
+ const names = readonlyLanguages.map((l)=>l.name).join(', ');
89
+ throw new _common.ForbiddenException({
90
+ message: `Cannot delete readonly language(s): ${names}`,
91
+ messageKey: _config.LANGUAGE_MESSAGES.READONLY_DELETE_FORBIDDEN,
92
+ messageVariables: {
93
+ names
94
+ }
95
+ });
96
+ }
97
+ const defaultLanguages = languages.filter((l)=>l.isDefault);
98
+ if (defaultLanguages.length > 0) {
99
+ const names = defaultLanguages.map((l)=>l.name).join(', ');
100
+ throw new _common.ForbiddenException({
101
+ message: `Cannot delete the default language(s): ${names}. Set another language as default first.`,
102
+ messageKey: _config.LANGUAGE_MESSAGES.DEFAULT_DELETE_FORBIDDEN,
103
+ messageVariables: {
104
+ names
105
+ }
106
+ });
107
+ }
108
+ }
45
109
  async getDefaultLanguage() {
46
110
  await this.ensureDataSourceRepository();
47
111
  return this.repository.findOne({
@@ -66,6 +130,11 @@ let LanguageService = class LanguageService extends _classes.ApiService {
66
130
  }
67
131
  async setAsDefault(id) {
68
132
  await this.ensureDataSourceRepository();
133
+ const previousDefaults = await this.repository.find({
134
+ where: {
135
+ isDefault: true
136
+ }
137
+ });
69
138
  await this.repository.update({
70
139
  isDefault: true
71
140
  }, {
@@ -76,11 +145,21 @@ let LanguageService = class LanguageService extends _classes.ApiService {
76
145
  }, {
77
146
  isDefault: true
78
147
  });
79
- return this.repository.findOne({
148
+ const language = await this.repository.findOne({
80
149
  where: {
81
150
  id
82
151
  }
83
152
  });
153
+ await Promise.all([
154
+ this.clearCacheForAll(),
155
+ this.clearCacheForId([
156
+ ...previousDefaults,
157
+ {
158
+ id
159
+ }
160
+ ])
161
+ ]);
162
+ return language;
84
163
  }
85
164
  constructor(cacheManager, utilsService, dataSourceProvider){
86
165
  super('language', cacheManager, utilsService, LanguageService.name, true, 'localization', _entities.Language, dataSourceProvider), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService;
@@ -30,6 +30,15 @@ function buildLanguage(overrides = {}) {
30
30
  describe('LanguageService', ()=>{
31
31
  let service;
32
32
  let mockRepo;
33
+ const mockUtilsService = {
34
+ clearCache: jest.fn(),
35
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
36
+ trackCacheKey: jest.fn()
37
+ };
38
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
39
+ beforeEach(()=>{
40
+ mockUtilsService.clearCache.mockClear();
41
+ });
33
42
  beforeEach(async ()=>{
34
43
  mockRepo = (0, _repositorymock.createMockRepository)();
35
44
  const mockDataSourceProvider = (0, _repositorymock.createMockDataSourceProvider)(mockRepo);
@@ -42,7 +51,7 @@ describe('LanguageService', ()=>{
42
51
  },
43
52
  {
44
53
  provide: _modules.UtilsService,
45
- useValue: {}
54
+ useValue: mockUtilsService
46
55
  },
47
56
  {
48
57
  provide: _localizationdatasourceprovider.LocalizationDataSourceProvider,
@@ -108,6 +117,9 @@ describe('LanguageService', ()=>{
108
117
  });
109
118
  });
110
119
  describe('setAsDefault', ()=>{
120
+ beforeEach(()=>{
121
+ mockRepo.find.mockResolvedValue([]);
122
+ });
111
123
  it('unsets the previous default and sets the requested language as default', async ()=>{
112
124
  const newDefault = buildLanguage({
113
125
  id: 'lang-2',
@@ -133,6 +145,24 @@ describe('LanguageService', ()=>{
133
145
  });
134
146
  expect(result).toEqual(newDefault);
135
147
  });
148
+ it('invalidates the language list cache and both the old and new default entries', async ()=>{
149
+ const previousDefault = buildLanguage({
150
+ id: 'lang-1',
151
+ isDefault: true,
152
+ code: 'en'
153
+ });
154
+ mockRepo.find.mockResolvedValue([
155
+ previousDefault
156
+ ]);
157
+ mockRepo.findOne.mockResolvedValue(buildLanguage({
158
+ id: 'lang-2',
159
+ isDefault: true
160
+ }));
161
+ await service.setAsDefault('lang-2');
162
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('language', expect.anything());
163
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('language', expect.anything(), 'lang-1');
164
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('language', expect.anything(), 'lang-2');
165
+ });
136
166
  it('returns null when the id does not correspond to any language after the update', async ()=>{
137
167
  mockRepo.findOne.mockResolvedValue(null);
138
168
  const result = await service.setAsDefault('non-existent-id');
@@ -148,4 +178,79 @@ describe('LanguageService', ()=>{
148
178
  });
149
179
  });
150
180
  });
181
+ describe('beforeUpdateOperation', ()=>{
182
+ it('rejects renaming the code of a readonly language', async ()=>{
183
+ mockRepo.find.mockResolvedValue([
184
+ buildLanguage({
185
+ isReadonly: true,
186
+ code: 'en'
187
+ })
188
+ ]);
189
+ await expect(service.beforeUpdateOperation({
190
+ id: 'language-uuid-1',
191
+ code: 'fr'
192
+ }, null, {})).rejects.toThrow('Cannot modify code/name of readonly language');
193
+ });
194
+ it('allows updating isActive on a readonly language when identity fields are unchanged', async ()=>{
195
+ mockRepo.find.mockResolvedValue([
196
+ buildLanguage({
197
+ isReadonly: true,
198
+ code: 'en',
199
+ name: 'English'
200
+ })
201
+ ]);
202
+ await expect(service.beforeUpdateOperation({
203
+ id: 'language-uuid-1',
204
+ code: 'en',
205
+ name: 'English',
206
+ isActive: false
207
+ }, null, {})).resolves.toBeUndefined();
208
+ });
209
+ it('allows any field change on a non-readonly language', async ()=>{
210
+ mockRepo.find.mockResolvedValue([
211
+ buildLanguage({
212
+ isReadonly: false,
213
+ code: 'en'
214
+ })
215
+ ]);
216
+ await expect(service.beforeUpdateOperation({
217
+ id: 'language-uuid-1',
218
+ code: 'fr'
219
+ }, null, {})).resolves.toBeUndefined();
220
+ });
221
+ });
222
+ describe('beforeDeleteOperation', ()=>{
223
+ it('rejects deleting a readonly language', async ()=>{
224
+ mockRepo.find.mockResolvedValue([
225
+ buildLanguage({
226
+ isReadonly: true
227
+ })
228
+ ]);
229
+ await expect(service.beforeDeleteOperation({
230
+ id: 'language-uuid-1'
231
+ }, null, {})).rejects.toThrow('Cannot delete readonly language');
232
+ });
233
+ it('rejects deleting the default language', async ()=>{
234
+ mockRepo.find.mockResolvedValue([
235
+ buildLanguage({
236
+ isReadonly: false,
237
+ isDefault: true
238
+ })
239
+ ]);
240
+ await expect(service.beforeDeleteOperation({
241
+ id: 'language-uuid-1'
242
+ }, null, {})).rejects.toThrow('Cannot delete the default language');
243
+ });
244
+ it('allows deleting a non-readonly, non-default language', async ()=>{
245
+ mockRepo.find.mockResolvedValue([
246
+ buildLanguage({
247
+ isReadonly: false,
248
+ isDefault: false
249
+ })
250
+ ]);
251
+ await expect(service.beforeDeleteOperation({
252
+ id: 'language-uuid-1'
253
+ }, null, {})).resolves.toBeUndefined();
254
+ });
255
+ });
151
256
  });
@@ -67,7 +67,8 @@ let TranslationKeyService = class TranslationKeyService extends _classes.ApiServ
67
67
  // Check if any readonly keys are being updated with key/module changes
68
68
  for (const d of dtos){
69
69
  const existing = existingKeys.find((e)=>e.id === d.id);
70
- if (existing?.isReadonly && (d.key || d.module)) {
70
+ const changesIdentity = d.key !== undefined && d.key !== existing?.key || d.module !== undefined && d.module !== existing?.module;
71
+ if (existing?.isReadonly && changesIdentity) {
71
72
  throw new _common.ForbiddenException({
72
73
  message: `Cannot modify key or module of readonly translation key "${existing.key}"`,
73
74
  messageKey: _config.TRANSLATION_KEY_MESSAGES.READONLY_UPDATE_FORBIDDEN,
@@ -217,6 +217,27 @@ describe('TranslationKeyService', ()=>{
217
217
  defaultMessage: 'Updated readonly message'
218
218
  }, (0, _loggedusermock.buildMockUser)())).resolves.toBeDefined();
219
219
  });
220
+ it('allows resubmitting the same unchanged key/module of a readonly translation key', async ()=>{
221
+ // Regression test: the edit form always resends key + module even when only
222
+ // other fields changed, so the check must compare values, not just presence.
223
+ const existing = buildTranslationKey({
224
+ id: 'tkey-1',
225
+ isReadonly: true,
226
+ key: 'system.ready',
227
+ module: 'system'
228
+ });
229
+ mockRepo.find.mockResolvedValue([
230
+ existing
231
+ ]);
232
+ mockRepo.findOne.mockResolvedValue(existing);
233
+ queryRunner.manager.save.mockResolvedValue(existing);
234
+ await expect(service.update({
235
+ id: 'tkey-1',
236
+ key: 'system.ready',
237
+ module: 'system',
238
+ defaultMessage: 'Updated readonly message'
239
+ }, (0, _loggedusermock.buildMockUser)())).resolves.toBeDefined();
240
+ });
220
241
  it('rejects with BadRequestException when the new key/module already exists on another row', async ()=>{
221
242
  const existing = buildTranslationKey({
222
243
  id: 'tkey-1',
@@ -12,6 +12,7 @@ const _classes = require("@flusys/nestjs-shared/classes");
12
12
  const _modules = require("@flusys/nestjs-shared/modules");
13
13
  const _common = require("@nestjs/common");
14
14
  const _typeorm = require("typeorm");
15
+ const _config = require("../config");
15
16
  const _entities = require("../entities");
16
17
  const _localizationdatasourceprovider = require("./localization-datasource.provider");
17
18
  function _define_property(obj, key, value) {
@@ -132,11 +133,22 @@ let TranslationService = class TranslationService extends _classes.ApiService {
132
133
  }
133
134
  }
134
135
  await queryRunner.commitTransaction();
135
- return this.repository.find({
136
+ const saved = await this.repository.find({
136
137
  where: {
137
138
  id: (0, _typeorm.In)(resultIds)
138
139
  }
139
140
  });
141
+ await Promise.all([
142
+ this.clearCacheForAll(),
143
+ this.clearCacheForId(saved)
144
+ ]);
145
+ await this.publishDomainAction(_config.LOCALIZATION_EVENT_ACTIONS.BULK_UPDATED, {
146
+ ids: saved.map((translation)=>translation.id),
147
+ metadata: {
148
+ count: saved.length
149
+ }
150
+ });
151
+ return saved;
140
152
  } catch (error) {
141
153
  await queryRunner.rollbackTransaction();
142
154
  throw error;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", {
3
3
  value: true
4
4
  });
5
5
  const _testing = require("@nestjs/testing");
6
+ const _classes = require("@flusys/nestjs-shared/classes");
6
7
  const _modules = require("@flusys/nestjs-shared/modules");
7
8
  const _repositorymock = require("@test-utils/mocks/repository.mock");
8
9
  const _entities = require("../entities");
@@ -66,6 +67,15 @@ function buildTranslation(overrides = {}) {
66
67
  };
67
68
  }
68
69
  describe('TranslationService', ()=>{
70
+ const mockUtilsService = {
71
+ clearCache: jest.fn(),
72
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
73
+ trackCacheKey: jest.fn()
74
+ };
75
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
76
+ beforeEach(()=>{
77
+ mockUtilsService.clearCache.mockClear();
78
+ });
69
79
  let service;
70
80
  let mockRepo;
71
81
  // `TranslationService` uses joins/aggregate query-builder methods (innerJoin, innerJoinAndSelect,
@@ -98,11 +108,6 @@ describe('TranslationService', ()=>{
98
108
  createQueryRunner: jest.fn()
99
109
  };
100
110
  mockDataSourceProvider.getDataSource.mockResolvedValue(fakeDataSource);
101
- const mockUtilsService = {
102
- clearCache: jest.fn(),
103
- getCacheKey: jest.fn().mockReturnValue('cache-key'),
104
- trackCacheKey: jest.fn()
105
- };
106
111
  const module = await _testing.Test.createTestingModule({
107
112
  providers: [
108
113
  _translationservice.TranslationService,
@@ -275,6 +280,25 @@ describe('TranslationService', ()=>{
275
280
  });
276
281
  expect(result).toHaveLength(1);
277
282
  });
283
+ it('invalidates the translation list cache and each written entry after committing', async ()=>{
284
+ const queryRunner = buildQueryRunner();
285
+ fakeDataSource.createQueryRunner.mockReturnValue(queryRunner);
286
+ mockRepo.find.mockResolvedValue([
287
+ buildTranslation({
288
+ id: 'translation-1'
289
+ })
290
+ ]);
291
+ await service.bulkUpdate([
292
+ {
293
+ id: 'translation-1',
294
+ languageId: 'lang-en',
295
+ translationKeyId: 'tkey-1',
296
+ value: 'Updated'
297
+ }
298
+ ]);
299
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('translation', expect.anything());
300
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('translation', expect.anything(), 'translation-1');
301
+ });
278
302
  it('creates new translations when no id is provided', async ()=>{
279
303
  const queryRunner = buildQueryRunner();
280
304
  fakeDataSource.createQueryRunner.mockReturnValue(queryRunner);
@@ -423,4 +447,84 @@ describe('TranslationService', ()=>{
423
447
  expect(qb.andWhere).not.toHaveBeenCalled();
424
448
  });
425
449
  });
450
+ describe('domain events', ()=>{
451
+ let publish;
452
+ function buildEventQueryRunner() {
453
+ return {
454
+ connect: jest.fn(),
455
+ startTransaction: jest.fn(),
456
+ commitTransaction: jest.fn(),
457
+ rollbackTransaction: jest.fn(),
458
+ release: jest.fn(),
459
+ manager: {
460
+ update: jest.fn(),
461
+ create: jest.fn((_entity, data)=>({
462
+ id: 'new-translation-id',
463
+ ...data
464
+ })),
465
+ save: jest.fn(async (entity)=>entity)
466
+ }
467
+ };
468
+ }
469
+ beforeEach(()=>{
470
+ publish = jest.fn().mockResolvedValue(undefined);
471
+ _classes.EventBusRegistry.reset();
472
+ _classes.EventBusRegistry.setBus({
473
+ publish,
474
+ subscribe: jest.fn(),
475
+ close: jest.fn()
476
+ });
477
+ _classes.EventBusRegistry.configureModule('localization', {
478
+ enabled: true
479
+ });
480
+ fakeDataSource.createQueryRunner.mockReturnValue(buildEventQueryRunner());
481
+ mockRepo.find.mockResolvedValue([
482
+ buildTranslation({
483
+ id: 'translation-1'
484
+ })
485
+ ]);
486
+ });
487
+ afterEach(()=>{
488
+ _classes.EventBusRegistry.reset();
489
+ });
490
+ it('publishes localization.translation.bulk-updated with the written count', async ()=>{
491
+ await service.bulkUpdate([
492
+ {
493
+ id: 'translation-1',
494
+ languageId: 'lang-en',
495
+ translationKeyId: 'tkey-1',
496
+ value: 'x'
497
+ }
498
+ ]);
499
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
500
+ name: 'localization.translation.bulk-updated',
501
+ payload: expect.objectContaining({
502
+ ids: [
503
+ 'translation-1'
504
+ ],
505
+ metadata: {
506
+ count: 1
507
+ }
508
+ })
509
+ }));
510
+ });
511
+ it('does not publish when the localization module has events disabled', async ()=>{
512
+ _classes.EventBusRegistry.configureModule('localization', {
513
+ enabled: false
514
+ });
515
+ await service.bulkUpdate([
516
+ {
517
+ id: 'translation-1',
518
+ languageId: 'lang-en',
519
+ translationKeyId: 'tkey-1',
520
+ value: 'x'
521
+ }
522
+ ]);
523
+ expect(publish).not.toHaveBeenCalled();
524
+ });
525
+ it('publishes nothing when there is nothing to update', async ()=>{
526
+ await service.bulkUpdate([]);
527
+ expect(publish).not.toHaveBeenCalled();
528
+ });
529
+ });
426
530
  });
@@ -0,0 +1,8 @@
1
+ export declare const LOCALIZATION_EVENT_ACTIONS: {
2
+ readonly BULK_UPDATED: "bulk-updated";
3
+ };
4
+ export declare const LOCALIZATION_EVENT_ENTITIES: {
5
+ readonly LANGUAGE: "language";
6
+ readonly TRANSLATION_KEY: "translationKey";
7
+ readonly TRANSLATION: "translation";
8
+ };
package/config/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export * from './event-actions';
1
2
  export * from './localization.constants';
2
3
  export * from './message-keys';
@@ -2,6 +2,9 @@ export declare const LANGUAGE_MESSAGES: {
2
2
  readonly ACTIVE_SUCCESS: "language.active.success";
3
3
  readonly SET_DEFAULT_SUCCESS: "language.set.default.success";
4
4
  readonly GET_DEFAULT_SUCCESS: "language.get.default.success";
5
+ readonly READONLY_UPDATE_FORBIDDEN: "language.readonly.update.forbidden";
6
+ readonly READONLY_DELETE_FORBIDDEN: "language.readonly.delete.forbidden";
7
+ readonly DEFAULT_DELETE_FORBIDDEN: "language.default.delete.forbidden";
5
8
  };
6
9
  export declare const TRANSLATION_KEY_MESSAGES: {
7
10
  readonly MODULES_SUCCESS: "translation.key.modules.success";
@@ -19,6 +19,7 @@ export declare class LanguageResponseDto extends IdentityResponseDto {
19
19
  direction: 'ltr' | 'rtl' | null;
20
20
  isActive: boolean;
21
21
  isDefault: boolean;
22
+ isReadonly: boolean;
22
23
  serial: number | null;
23
24
  }
24
25
  export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Domain event actions published by the localization module, alongside the CRUD
3
+ * lifecycle actions every ApiService emits.
4
+ */ export const LOCALIZATION_EVENT_ACTIONS = {
5
+ BULK_UPDATED: 'bulk-updated'
6
+ };
7
+ /** Every entity the localization module publishes under */ export const LOCALIZATION_EVENT_ENTITIES = {
8
+ LANGUAGE: 'language',
9
+ TRANSLATION_KEY: 'translationKey',
10
+ TRANSLATION: 'translation'
11
+ };
@@ -1,2 +1,3 @@
1
+ export * from './event-actions';
1
2
  export * from './localization.constants';
2
3
  export * from './message-keys';
@@ -2,7 +2,10 @@
2
2
  export const LANGUAGE_MESSAGES = {
3
3
  ACTIVE_SUCCESS: 'language.active.success',
4
4
  SET_DEFAULT_SUCCESS: 'language.set.default.success',
5
- GET_DEFAULT_SUCCESS: 'language.get.default.success'
5
+ GET_DEFAULT_SUCCESS: 'language.get.default.success',
6
+ READONLY_UPDATE_FORBIDDEN: 'language.readonly.update.forbidden',
7
+ READONLY_DELETE_FORBIDDEN: 'language.readonly.delete.forbidden',
8
+ DEFAULT_DELETE_FORBIDDEN: 'language.default.delete.forbidden'
6
9
  };
7
10
  export const TRANSLATION_KEY_MESSAGES = {
8
11
  MODULES_SUCCESS: 'translation.key.modules.success',
@@ -123,7 +123,7 @@ _ts_decorate([
123
123
  ], UpdateLanguageDto.prototype, "id", void 0);
124
124
  export class LanguageResponseDto extends IdentityResponseDto {
125
125
  constructor(...args){
126
- super(...args), _define_property(this, "code", void 0), _define_property(this, "name", void 0), _define_property(this, "nativeName", void 0), _define_property(this, "direction", void 0), _define_property(this, "isActive", void 0), _define_property(this, "isDefault", void 0), _define_property(this, "serial", void 0);
126
+ super(...args), _define_property(this, "code", void 0), _define_property(this, "name", void 0), _define_property(this, "nativeName", void 0), _define_property(this, "direction", void 0), _define_property(this, "isActive", void 0), _define_property(this, "isDefault", void 0), _define_property(this, "isReadonly", void 0), _define_property(this, "serial", void 0);
127
127
  }
128
128
  }
129
129
  _ts_decorate([
@@ -172,6 +172,14 @@ _ts_decorate([
172
172
  Expose(),
173
173
  _ts_metadata("design:type", Boolean)
174
174
  ], LanguageResponseDto.prototype, "isDefault", void 0);
175
+ _ts_decorate([
176
+ ApiProperty({
177
+ example: false,
178
+ description: 'System language: cannot be renamed or deleted'
179
+ }),
180
+ Expose(),
181
+ _ts_metadata("design:type", Boolean)
182
+ ], LanguageResponseDto.prototype, "isReadonly", void 0);
175
183
  _ts_decorate([
176
184
  ApiPropertyOptional({
177
185
  example: 1
@@ -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 { LOCALIZATION_MODULE_OPTIONS } from '../config/localization.constants';
10
10
  import { LanguageController, TranslationKeyController, TranslationController } from '../controllers';
@@ -62,6 +62,7 @@ export class LocalizationModule {
62
62
  useValue: options
63
63
  });
64
64
  }
65
+ providers.push(createModuleEventsProvider('localization', LOCALIZATION_MODULE_OPTIONS));
65
66
  return providers;
66
67
  }
67
68
  static createAsyncProviders(options) {
@@ -27,11 +27,75 @@ function _ts_param(paramIndex, decorator) {
27
27
  }
28
28
  import { HybridCache, ApiService } from '@flusys/nestjs-shared/classes';
29
29
  import { UtilsService } from '@flusys/nestjs-shared/modules';
30
- import { Inject, Injectable, Scope } from '@nestjs/common';
31
- import { IsNull } from 'typeorm';
30
+ import { ForbiddenException, Inject, Injectable, Scope } from '@nestjs/common';
31
+ import { In, IsNull } from 'typeorm';
32
+ import { LANGUAGE_MESSAGES } from '../config';
32
33
  import { Language } from '../entities';
33
34
  import { LocalizationDataSourceProvider } from './localization-datasource.provider';
34
35
  export class LanguageService extends ApiService {
36
+ async beforeUpdateOperation(dto, _user, _queryRunner) {
37
+ const dtos = Array.isArray(dto) ? dto : [
38
+ dto
39
+ ];
40
+ const ids = dtos.map((d)=>d.id);
41
+ await this.ensureDataSourceRepository();
42
+ const existingLanguages = await this.repository.find({
43
+ where: {
44
+ id: In(ids),
45
+ deletedAt: IsNull()
46
+ }
47
+ });
48
+ for (const d of dtos){
49
+ const existing = existingLanguages.find((e)=>e.id === d.id);
50
+ if (!existing) {
51
+ continue;
52
+ }
53
+ const changesIdentity = d.code !== undefined && d.code !== existing.code || d.name !== undefined && d.name !== existing.name || d.nativeName !== undefined && d.nativeName !== (existing.nativeName ?? undefined) || d.direction !== undefined && d.direction !== existing.direction;
54
+ if (existing.isReadonly && changesIdentity) {
55
+ throw new ForbiddenException({
56
+ message: `Cannot modify code/name of readonly language "${existing.name}"`,
57
+ messageKey: LANGUAGE_MESSAGES.READONLY_UPDATE_FORBIDDEN,
58
+ messageVariables: {
59
+ name: existing.name
60
+ }
61
+ });
62
+ }
63
+ }
64
+ }
65
+ async beforeDeleteOperation(dto, _user, _queryRunner) {
66
+ const ids = Array.isArray(dto.id) ? dto.id : [
67
+ dto.id
68
+ ];
69
+ await this.ensureDataSourceRepository();
70
+ const languages = await this.repository.find({
71
+ where: {
72
+ id: In(ids),
73
+ deletedAt: IsNull()
74
+ }
75
+ });
76
+ const readonlyLanguages = languages.filter((l)=>l.isReadonly);
77
+ if (readonlyLanguages.length > 0) {
78
+ const names = readonlyLanguages.map((l)=>l.name).join(', ');
79
+ throw new ForbiddenException({
80
+ message: `Cannot delete readonly language(s): ${names}`,
81
+ messageKey: LANGUAGE_MESSAGES.READONLY_DELETE_FORBIDDEN,
82
+ messageVariables: {
83
+ names
84
+ }
85
+ });
86
+ }
87
+ const defaultLanguages = languages.filter((l)=>l.isDefault);
88
+ if (defaultLanguages.length > 0) {
89
+ const names = defaultLanguages.map((l)=>l.name).join(', ');
90
+ throw new ForbiddenException({
91
+ message: `Cannot delete the default language(s): ${names}. Set another language as default first.`,
92
+ messageKey: LANGUAGE_MESSAGES.DEFAULT_DELETE_FORBIDDEN,
93
+ messageVariables: {
94
+ names
95
+ }
96
+ });
97
+ }
98
+ }
35
99
  async getDefaultLanguage() {
36
100
  await this.ensureDataSourceRepository();
37
101
  return this.repository.findOne({
@@ -56,6 +120,11 @@ export class LanguageService extends ApiService {
56
120
  }
57
121
  async setAsDefault(id) {
58
122
  await this.ensureDataSourceRepository();
123
+ const previousDefaults = await this.repository.find({
124
+ where: {
125
+ isDefault: true
126
+ }
127
+ });
59
128
  await this.repository.update({
60
129
  isDefault: true
61
130
  }, {
@@ -66,11 +135,21 @@ export class LanguageService extends ApiService {
66
135
  }, {
67
136
  isDefault: true
68
137
  });
69
- return this.repository.findOne({
138
+ const language = await this.repository.findOne({
70
139
  where: {
71
140
  id
72
141
  }
73
142
  });
143
+ await Promise.all([
144
+ this.clearCacheForAll(),
145
+ this.clearCacheForId([
146
+ ...previousDefaults,
147
+ {
148
+ id
149
+ }
150
+ ])
151
+ ]);
152
+ return language;
74
153
  }
75
154
  constructor(cacheManager, utilsService, dataSourceProvider){
76
155
  super('language', cacheManager, utilsService, LanguageService.name, true, 'localization', Language, dataSourceProvider), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService;
@@ -26,6 +26,15 @@ function buildLanguage(overrides = {}) {
26
26
  describe('LanguageService', ()=>{
27
27
  let service;
28
28
  let mockRepo;
29
+ const mockUtilsService = {
30
+ clearCache: jest.fn(),
31
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
32
+ trackCacheKey: jest.fn()
33
+ };
34
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
35
+ beforeEach(()=>{
36
+ mockUtilsService.clearCache.mockClear();
37
+ });
29
38
  beforeEach(async ()=>{
30
39
  mockRepo = createMockRepository();
31
40
  const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
@@ -38,7 +47,7 @@ describe('LanguageService', ()=>{
38
47
  },
39
48
  {
40
49
  provide: UtilsService,
41
- useValue: {}
50
+ useValue: mockUtilsService
42
51
  },
43
52
  {
44
53
  provide: LocalizationDataSourceProvider,
@@ -104,6 +113,9 @@ describe('LanguageService', ()=>{
104
113
  });
105
114
  });
106
115
  describe('setAsDefault', ()=>{
116
+ beforeEach(()=>{
117
+ mockRepo.find.mockResolvedValue([]);
118
+ });
107
119
  it('unsets the previous default and sets the requested language as default', async ()=>{
108
120
  const newDefault = buildLanguage({
109
121
  id: 'lang-2',
@@ -129,6 +141,24 @@ describe('LanguageService', ()=>{
129
141
  });
130
142
  expect(result).toEqual(newDefault);
131
143
  });
144
+ it('invalidates the language list cache and both the old and new default entries', async ()=>{
145
+ const previousDefault = buildLanguage({
146
+ id: 'lang-1',
147
+ isDefault: true,
148
+ code: 'en'
149
+ });
150
+ mockRepo.find.mockResolvedValue([
151
+ previousDefault
152
+ ]);
153
+ mockRepo.findOne.mockResolvedValue(buildLanguage({
154
+ id: 'lang-2',
155
+ isDefault: true
156
+ }));
157
+ await service.setAsDefault('lang-2');
158
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('language', expect.anything());
159
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('language', expect.anything(), 'lang-1');
160
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('language', expect.anything(), 'lang-2');
161
+ });
132
162
  it('returns null when the id does not correspond to any language after the update', async ()=>{
133
163
  mockRepo.findOne.mockResolvedValue(null);
134
164
  const result = await service.setAsDefault('non-existent-id');
@@ -144,4 +174,79 @@ describe('LanguageService', ()=>{
144
174
  });
145
175
  });
146
176
  });
177
+ describe('beforeUpdateOperation', ()=>{
178
+ it('rejects renaming the code of a readonly language', async ()=>{
179
+ mockRepo.find.mockResolvedValue([
180
+ buildLanguage({
181
+ isReadonly: true,
182
+ code: 'en'
183
+ })
184
+ ]);
185
+ await expect(service.beforeUpdateOperation({
186
+ id: 'language-uuid-1',
187
+ code: 'fr'
188
+ }, null, {})).rejects.toThrow('Cannot modify code/name of readonly language');
189
+ });
190
+ it('allows updating isActive on a readonly language when identity fields are unchanged', async ()=>{
191
+ mockRepo.find.mockResolvedValue([
192
+ buildLanguage({
193
+ isReadonly: true,
194
+ code: 'en',
195
+ name: 'English'
196
+ })
197
+ ]);
198
+ await expect(service.beforeUpdateOperation({
199
+ id: 'language-uuid-1',
200
+ code: 'en',
201
+ name: 'English',
202
+ isActive: false
203
+ }, null, {})).resolves.toBeUndefined();
204
+ });
205
+ it('allows any field change on a non-readonly language', async ()=>{
206
+ mockRepo.find.mockResolvedValue([
207
+ buildLanguage({
208
+ isReadonly: false,
209
+ code: 'en'
210
+ })
211
+ ]);
212
+ await expect(service.beforeUpdateOperation({
213
+ id: 'language-uuid-1',
214
+ code: 'fr'
215
+ }, null, {})).resolves.toBeUndefined();
216
+ });
217
+ });
218
+ describe('beforeDeleteOperation', ()=>{
219
+ it('rejects deleting a readonly language', async ()=>{
220
+ mockRepo.find.mockResolvedValue([
221
+ buildLanguage({
222
+ isReadonly: true
223
+ })
224
+ ]);
225
+ await expect(service.beforeDeleteOperation({
226
+ id: 'language-uuid-1'
227
+ }, null, {})).rejects.toThrow('Cannot delete readonly language');
228
+ });
229
+ it('rejects deleting the default language', async ()=>{
230
+ mockRepo.find.mockResolvedValue([
231
+ buildLanguage({
232
+ isReadonly: false,
233
+ isDefault: true
234
+ })
235
+ ]);
236
+ await expect(service.beforeDeleteOperation({
237
+ id: 'language-uuid-1'
238
+ }, null, {})).rejects.toThrow('Cannot delete the default language');
239
+ });
240
+ it('allows deleting a non-readonly, non-default language', async ()=>{
241
+ mockRepo.find.mockResolvedValue([
242
+ buildLanguage({
243
+ isReadonly: false,
244
+ isDefault: false
245
+ })
246
+ ]);
247
+ await expect(service.beforeDeleteOperation({
248
+ id: 'language-uuid-1'
249
+ }, null, {})).resolves.toBeUndefined();
250
+ });
251
+ });
147
252
  });
@@ -57,7 +57,8 @@ export class TranslationKeyService extends ApiService {
57
57
  // Check if any readonly keys are being updated with key/module changes
58
58
  for (const d of dtos){
59
59
  const existing = existingKeys.find((e)=>e.id === d.id);
60
- if (existing?.isReadonly && (d.key || d.module)) {
60
+ const changesIdentity = d.key !== undefined && d.key !== existing?.key || d.module !== undefined && d.module !== existing?.module;
61
+ if (existing?.isReadonly && changesIdentity) {
61
62
  throw new ForbiddenException({
62
63
  message: `Cannot modify key or module of readonly translation key "${existing.key}"`,
63
64
  messageKey: TRANSLATION_KEY_MESSAGES.READONLY_UPDATE_FORBIDDEN,
@@ -213,6 +213,27 @@ describe('TranslationKeyService', ()=>{
213
213
  defaultMessage: 'Updated readonly message'
214
214
  }, buildMockUser())).resolves.toBeDefined();
215
215
  });
216
+ it('allows resubmitting the same unchanged key/module of a readonly translation key', async ()=>{
217
+ // Regression test: the edit form always resends key + module even when only
218
+ // other fields changed, so the check must compare values, not just presence.
219
+ const existing = buildTranslationKey({
220
+ id: 'tkey-1',
221
+ isReadonly: true,
222
+ key: 'system.ready',
223
+ module: 'system'
224
+ });
225
+ mockRepo.find.mockResolvedValue([
226
+ existing
227
+ ]);
228
+ mockRepo.findOne.mockResolvedValue(existing);
229
+ queryRunner.manager.save.mockResolvedValue(existing);
230
+ await expect(service.update({
231
+ id: 'tkey-1',
232
+ key: 'system.ready',
233
+ module: 'system',
234
+ defaultMessage: 'Updated readonly message'
235
+ }, buildMockUser())).resolves.toBeDefined();
236
+ });
216
237
  it('rejects with BadRequestException when the new key/module already exists on another row', async ()=>{
217
238
  const existing = buildTranslationKey({
218
239
  id: 'tkey-1',
@@ -29,6 +29,7 @@ import { HybridCache, ApiService } from '@flusys/nestjs-shared/classes';
29
29
  import { UtilsService } from '@flusys/nestjs-shared/modules';
30
30
  import { Inject, Injectable, Scope } from '@nestjs/common';
31
31
  import { In, IsNull } from 'typeorm';
32
+ import { LOCALIZATION_EVENT_ACTIONS } from '../config';
32
33
  import { Language, Translation, TranslationKey } from '../entities';
33
34
  import { LocalizationDataSourceProvider } from './localization-datasource.provider';
34
35
  export class TranslationService extends ApiService {
@@ -122,11 +123,22 @@ export class TranslationService extends ApiService {
122
123
  }
123
124
  }
124
125
  await queryRunner.commitTransaction();
125
- return this.repository.find({
126
+ const saved = await this.repository.find({
126
127
  where: {
127
128
  id: In(resultIds)
128
129
  }
129
130
  });
131
+ await Promise.all([
132
+ this.clearCacheForAll(),
133
+ this.clearCacheForId(saved)
134
+ ]);
135
+ await this.publishDomainAction(LOCALIZATION_EVENT_ACTIONS.BULK_UPDATED, {
136
+ ids: saved.map((translation)=>translation.id),
137
+ metadata: {
138
+ count: saved.length
139
+ }
140
+ });
141
+ return saved;
130
142
  } catch (error) {
131
143
  await queryRunner.rollbackTransaction();
132
144
  throw error;
@@ -1,4 +1,5 @@
1
1
  import { Test } from '@nestjs/testing';
2
+ import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
2
3
  import { UtilsService } from '@flusys/nestjs-shared/modules';
3
4
  import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
4
5
  import { Language, Translation, TranslationKey } from '../entities';
@@ -62,6 +63,15 @@ function buildTranslation(overrides = {}) {
62
63
  };
63
64
  }
64
65
  describe('TranslationService', ()=>{
66
+ const mockUtilsService = {
67
+ clearCache: jest.fn(),
68
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
69
+ trackCacheKey: jest.fn()
70
+ };
71
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
72
+ beforeEach(()=>{
73
+ mockUtilsService.clearCache.mockClear();
74
+ });
65
75
  let service;
66
76
  let mockRepo;
67
77
  // `TranslationService` uses joins/aggregate query-builder methods (innerJoin, innerJoinAndSelect,
@@ -94,11 +104,6 @@ describe('TranslationService', ()=>{
94
104
  createQueryRunner: jest.fn()
95
105
  };
96
106
  mockDataSourceProvider.getDataSource.mockResolvedValue(fakeDataSource);
97
- const mockUtilsService = {
98
- clearCache: jest.fn(),
99
- getCacheKey: jest.fn().mockReturnValue('cache-key'),
100
- trackCacheKey: jest.fn()
101
- };
102
107
  const module = await Test.createTestingModule({
103
108
  providers: [
104
109
  TranslationService,
@@ -271,6 +276,25 @@ describe('TranslationService', ()=>{
271
276
  });
272
277
  expect(result).toHaveLength(1);
273
278
  });
279
+ it('invalidates the translation list cache and each written entry after committing', async ()=>{
280
+ const queryRunner = buildQueryRunner();
281
+ fakeDataSource.createQueryRunner.mockReturnValue(queryRunner);
282
+ mockRepo.find.mockResolvedValue([
283
+ buildTranslation({
284
+ id: 'translation-1'
285
+ })
286
+ ]);
287
+ await service.bulkUpdate([
288
+ {
289
+ id: 'translation-1',
290
+ languageId: 'lang-en',
291
+ translationKeyId: 'tkey-1',
292
+ value: 'Updated'
293
+ }
294
+ ]);
295
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('translation', expect.anything());
296
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('translation', expect.anything(), 'translation-1');
297
+ });
274
298
  it('creates new translations when no id is provided', async ()=>{
275
299
  const queryRunner = buildQueryRunner();
276
300
  fakeDataSource.createQueryRunner.mockReturnValue(queryRunner);
@@ -419,4 +443,84 @@ describe('TranslationService', ()=>{
419
443
  expect(qb.andWhere).not.toHaveBeenCalled();
420
444
  });
421
445
  });
446
+ describe('domain events', ()=>{
447
+ let publish;
448
+ function buildEventQueryRunner() {
449
+ return {
450
+ connect: jest.fn(),
451
+ startTransaction: jest.fn(),
452
+ commitTransaction: jest.fn(),
453
+ rollbackTransaction: jest.fn(),
454
+ release: jest.fn(),
455
+ manager: {
456
+ update: jest.fn(),
457
+ create: jest.fn((_entity, data)=>({
458
+ id: 'new-translation-id',
459
+ ...data
460
+ })),
461
+ save: jest.fn(async (entity)=>entity)
462
+ }
463
+ };
464
+ }
465
+ beforeEach(()=>{
466
+ publish = jest.fn().mockResolvedValue(undefined);
467
+ EventBusRegistry.reset();
468
+ EventBusRegistry.setBus({
469
+ publish,
470
+ subscribe: jest.fn(),
471
+ close: jest.fn()
472
+ });
473
+ EventBusRegistry.configureModule('localization', {
474
+ enabled: true
475
+ });
476
+ fakeDataSource.createQueryRunner.mockReturnValue(buildEventQueryRunner());
477
+ mockRepo.find.mockResolvedValue([
478
+ buildTranslation({
479
+ id: 'translation-1'
480
+ })
481
+ ]);
482
+ });
483
+ afterEach(()=>{
484
+ EventBusRegistry.reset();
485
+ });
486
+ it('publishes localization.translation.bulk-updated with the written count', async ()=>{
487
+ await service.bulkUpdate([
488
+ {
489
+ id: 'translation-1',
490
+ languageId: 'lang-en',
491
+ translationKeyId: 'tkey-1',
492
+ value: 'x'
493
+ }
494
+ ]);
495
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
496
+ name: 'localization.translation.bulk-updated',
497
+ payload: expect.objectContaining({
498
+ ids: [
499
+ 'translation-1'
500
+ ],
501
+ metadata: {
502
+ count: 1
503
+ }
504
+ })
505
+ }));
506
+ });
507
+ it('does not publish when the localization module has events disabled', async ()=>{
508
+ EventBusRegistry.configureModule('localization', {
509
+ enabled: false
510
+ });
511
+ await service.bulkUpdate([
512
+ {
513
+ id: 'translation-1',
514
+ languageId: 'lang-en',
515
+ translationKeyId: 'tkey-1',
516
+ value: 'x'
517
+ }
518
+ ]);
519
+ expect(publish).not.toHaveBeenCalled();
520
+ });
521
+ it('publishes nothing when there is nothing to update', async ()=>{
522
+ await service.bulkUpdate([]);
523
+ expect(publish).not.toHaveBeenCalled();
524
+ });
525
+ });
422
526
  });
@@ -1,9 +1,11 @@
1
1
  import { IBootstrapAppConfig, IDataSourceServiceOptions, IDynamicModuleConfig, IModuleOptionsFactory } from '@flusys/nestjs-core';
2
+ import { IModuleEventsConfig } from '@flusys/nestjs-shared/interfaces';
2
3
  import { ModuleMetadata, Type } from '@nestjs/common';
3
4
  export interface ILocalizationModuleConfig extends IDataSourceServiceOptions {
4
5
  defaultLanguageCode?: string;
5
6
  enableCaching?: boolean;
6
7
  cacheTtlSeconds?: number;
8
+ events?: IModuleEventsConfig;
7
9
  }
8
10
  export interface LocalizationModuleOptions extends IDynamicModuleConfig {
9
11
  bootstrapAppConfig?: IBootstrapAppConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-localization",
3
- "version": "6.4.0",
3
+ "version": "7.0.0-beta.1",
4
4
  "description": "Localization module for multi-language support",
5
5
  "main": "cjs/index.js",
6
6
  "module": "fesm/index.js",
@@ -80,7 +80,7 @@
80
80
  "express": "^5.0.0"
81
81
  },
82
82
  "dependencies": {
83
- "@flusys/nestjs-core": "6.4.0",
84
- "@flusys/nestjs-shared": "6.4.0"
83
+ "@flusys/nestjs-core": "7.0.0-beta.1",
84
+ "@flusys/nestjs-shared": "7.0.0-beta.1"
85
85
  }
86
86
  }
@@ -1,5 +1,8 @@
1
1
  import { HybridCache, ApiService } from '@flusys/nestjs-shared/classes';
2
+ import { DeleteDto } from '@flusys/nestjs-shared/dtos';
3
+ import { ILoggedUserInfo } from '@flusys/nestjs-shared/interfaces';
2
4
  import { UtilsService } from '@flusys/nestjs-shared/modules';
5
+ import { QueryRunner } from 'typeorm';
3
6
  import { CreateLanguageDto, UpdateLanguageDto } from '../dtos';
4
7
  import { Language } from '../entities';
5
8
  import { ILanguage } from '../interfaces';
@@ -8,6 +11,8 @@ export declare class LanguageService extends ApiService<CreateLanguageDto, Updat
8
11
  protected cacheManager: HybridCache;
9
12
  protected utilsService: UtilsService;
10
13
  constructor(cacheManager: HybridCache, utilsService: UtilsService, dataSourceProvider: LocalizationDataSourceProvider);
14
+ protected beforeUpdateOperation(dto: UpdateLanguageDto | UpdateLanguageDto[], _user: ILoggedUserInfo | null, _queryRunner: QueryRunner): Promise<void>;
15
+ protected beforeDeleteOperation(dto: DeleteDto, _user: ILoggedUserInfo | null, _queryRunner: QueryRunner): Promise<void>;
11
16
  getDefaultLanguage(): Promise<Language | null>;
12
17
  getActiveLanguages(): Promise<Language[]>;
13
18
  setAsDefault(id: string): Promise<Language | null>;