@flusys/nestjs-localization 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.
@@ -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({
@@ -178,4 +178,79 @@ describe('LanguageService', ()=>{
178
178
  });
179
179
  });
180
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
+ });
181
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) {
@@ -141,6 +142,12 @@ let TranslationService = class TranslationService extends _classes.ApiService {
141
142
  this.clearCacheForAll(),
142
143
  this.clearCacheForId(saved)
143
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
+ });
144
151
  return saved;
145
152
  } catch (error) {
146
153
  await queryRunner.rollbackTransaction();
@@ -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");
@@ -446,4 +447,84 @@ describe('TranslationService', ()=>{
446
447
  expect(qb.andWhere).not.toHaveBeenCalled();
447
448
  });
448
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
+ });
449
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({
@@ -174,4 +174,79 @@ describe('LanguageService', ()=>{
174
174
  });
175
175
  });
176
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
+ });
177
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 {
@@ -131,6 +132,12 @@ export class TranslationService extends ApiService {
131
132
  this.clearCacheForAll(),
132
133
  this.clearCacheForId(saved)
133
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
+ });
134
141
  return saved;
135
142
  } catch (error) {
136
143
  await queryRunner.rollbackTransaction();
@@ -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';
@@ -442,4 +443,84 @@ describe('TranslationService', ()=>{
442
443
  expect(qb.andWhere).not.toHaveBeenCalled();
443
444
  });
444
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
+ });
445
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.1",
3
+ "version": "7.0.0",
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.1",
84
- "@flusys/nestjs-shared": "6.4.1"
83
+ "@flusys/nestjs-core": "7.0.0",
84
+ "@flusys/nestjs-shared": "7.0.0"
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>;