@flusys/nestjs-task-manager 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,33 @@
1
+ /**
2
+ * Domain event actions published by the task manager 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 TASK_MANAGER_EVENT_ACTIONS () {
16
+ return TASK_MANAGER_EVENT_ACTIONS;
17
+ },
18
+ get TASK_MANAGER_EVENT_ENTITIES () {
19
+ return TASK_MANAGER_EVENT_ENTITIES;
20
+ }
21
+ });
22
+ const TASK_MANAGER_EVENT_ACTIONS = {
23
+ MOVED: 'moved',
24
+ ASSIGNED: 'assigned',
25
+ REORDERED: 'reordered'
26
+ };
27
+ const TASK_MANAGER_EVENT_ENTITIES = {
28
+ TASK: 'task',
29
+ TASK_BOARD: 'task_board',
30
+ TASK_LIST: 'task_list',
31
+ TASK_LABEL: 'task_label',
32
+ TASK_COMMENT: 'task_comment'
33
+ };
@@ -2,8 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", {
3
3
  value: true
4
4
  });
5
- _export_star(require("./task-manager.constants"), exports);
5
+ _export_star(require("./event-actions"), exports);
6
6
  _export_star(require("./message-keys"), exports);
7
+ _export_star(require("./task-manager.constants"), exports);
7
8
  function _export_star(from, to) {
8
9
  Object.keys(from).forEach(function(k) {
9
10
  if (k !== "default" && !Object.prototype.hasOwnProperty.call(to, k)) {
@@ -91,6 +91,7 @@ let TaskManagerModule = class TaskManagerModule {
91
91
  useValue: options
92
92
  });
93
93
  }
94
+ providers.push((0, _modules.createModuleEventsProvider)('task-manager', _taskmanagerconstants.TASK_MANAGER_MODULE_OPTIONS));
94
95
  return providers;
95
96
  }
96
97
  static createAsyncProviders(options) {
@@ -142,11 +142,13 @@ let TaskBoardService = class TaskBoardService extends _classes.ApiService {
142
142
  }, {
143
143
  role: dto.role ?? _entities.BoardMemberRole.MEMBER
144
144
  });
145
- return memberRepo.findOne({
145
+ const restored = await memberRepo.findOne({
146
146
  where: {
147
147
  id: softDeleted.id
148
148
  }
149
149
  });
150
+ await this.invalidateBoardCache(dto.boardId);
151
+ return restored;
150
152
  }
151
153
  const member = memberRepo.create({
152
154
  boardId: dto.boardId,
@@ -154,7 +156,9 @@ let TaskBoardService = class TaskBoardService extends _classes.ApiService {
154
156
  role: dto.role ?? _entities.BoardMemberRole.MEMBER,
155
157
  createdById: user.id
156
158
  });
157
- return memberRepo.save(member);
159
+ const saved = await memberRepo.save(member);
160
+ await this.invalidateBoardCache(dto.boardId);
161
+ return saved;
158
162
  }
159
163
  async removeMember(dto) {
160
164
  const memberRepo = await this.ensureMemberRepository();
@@ -174,6 +178,7 @@ let TaskBoardService = class TaskBoardService extends _classes.ApiService {
174
178
  }, {
175
179
  deletedAt: new Date()
176
180
  });
181
+ await this.invalidateBoardCache(dto.boardId);
177
182
  }
178
183
  async getMembers(boardId) {
179
184
  const memberRepo = await this.ensureMemberRepository();
@@ -207,6 +212,16 @@ let TaskBoardService = class TaskBoardService extends _classes.ApiService {
207
212
  createdById: user.id
208
213
  })));
209
214
  }
215
+ async invalidateBoardCache(boardId) {
216
+ await Promise.all([
217
+ this.clearCacheForAll(),
218
+ this.clearCacheForId([
219
+ {
220
+ id: boardId
221
+ }
222
+ ])
223
+ ]);
224
+ }
210
225
  async ensureMemberRepository() {
211
226
  if (!this.memberRepository) {
212
227
  this.memberRepository = await this.dataSourceProvider.getRepository(_entities.TaskBoardMember);
@@ -57,6 +57,15 @@ function mockQueryRunner(repo) {
57
57
  return qr;
58
58
  }
59
59
  describe('TaskBoardService', ()=>{
60
+ const mockUtilsService = {
61
+ clearCache: jest.fn(),
62
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
63
+ trackCacheKey: jest.fn()
64
+ };
65
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
66
+ beforeEach(()=>{
67
+ mockUtilsService.clearCache.mockClear();
68
+ });
60
69
  let service;
61
70
  let mockRepo;
62
71
  let mockMemberRepo;
@@ -84,11 +93,7 @@ describe('TaskBoardService', ()=>{
84
93
  },
85
94
  {
86
95
  provide: _modules.UtilsService,
87
- useValue: {
88
- clearCache: jest.fn(),
89
- getCacheKey: jest.fn(),
90
- trackCacheKey: jest.fn()
91
- }
96
+ useValue: mockUtilsService
92
97
  },
93
98
  {
94
99
  provide: _taskmanagerconfigservice.TaskManagerConfigService,
@@ -264,6 +269,23 @@ describe('TaskBoardService', ()=>{
264
269
  }));
265
270
  expect(result).toEqual(created);
266
271
  });
272
+ it('invalidates the board list cache and the board entry (membership drives visibility and total_members)', async ()=>{
273
+ mockRepo.findOne.mockResolvedValue(buildBoard({
274
+ id: 'board-1'
275
+ }));
276
+ mockMemberRepo.findOne.mockResolvedValueOnce(null);
277
+ mockMemberRepo.findOne.mockResolvedValueOnce(null);
278
+ mockMemberRepo.create.mockImplementation((data)=>data);
279
+ mockMemberRepo.save.mockResolvedValue(buildMember({
280
+ boardId: 'board-1'
281
+ }));
282
+ await service.addMember({
283
+ boardId: 'board-1',
284
+ userId: 'user-2'
285
+ }, (0, _loggedusermock.buildMockUser)());
286
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
287
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything(), 'board-1');
288
+ });
267
289
  it('defaults the role to MEMBER when none is given', async ()=>{
268
290
  mockRepo.findOne.mockResolvedValue(buildBoard({
269
291
  id: 'board-1'
@@ -347,6 +369,17 @@ describe('TaskBoardService', ()=>{
347
369
  deletedAt: expect.any(Date)
348
370
  });
349
371
  });
372
+ it('invalidates the board list cache and the board entry', async ()=>{
373
+ mockMemberRepo.findOne.mockResolvedValue(buildMember({
374
+ id: 'member-1'
375
+ }));
376
+ await service.removeMember({
377
+ boardId: 'board-1',
378
+ userId: 'user-uuid-1'
379
+ });
380
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
381
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything(), 'board-1');
382
+ });
350
383
  it('throws NotFoundException when the member is not found', async ()=>{
351
384
  mockMemberRepo.findOne.mockResolvedValue(null);
352
385
  await expect(service.removeMember({
@@ -42,6 +42,10 @@ function _ts_param(paramIndex, decorator) {
42
42
  };
43
43
  }
44
44
  let TaskListService = class TaskListService extends _classes.ApiService {
45
+ /** Boards cache denormalized `total_tasks` / `total_lists`, so board caches go stale on any write here. */ async clearCacheForAll() {
46
+ await super.clearCacheForAll();
47
+ await this.utilsService.clearCache('task_board', this.cacheManager);
48
+ }
45
49
  async convertSingleDtoToEntity(dto, _user) {
46
50
  await this.ensureDataSourceRepository();
47
51
  let entity = {};
@@ -93,7 +97,18 @@ let TaskListService = class TaskListService extends _classes.ApiService {
93
97
  }, {
94
98
  position: i * 100
95
99
  })));
96
- await this.clearCacheForAll();
100
+ await Promise.all([
101
+ this.clearCacheForAll(),
102
+ this.clearCacheForId(dto.orderedIds.map((id)=>({
103
+ id
104
+ })))
105
+ ]);
106
+ await this.publishDomainAction(_config.TASK_MANAGER_EVENT_ACTIONS.REORDERED, {
107
+ ids: dto.orderedIds,
108
+ metadata: {
109
+ boardId: dto.boardId
110
+ }
111
+ });
97
112
  }
98
113
  constructor(cacheManager, utilsService, dataSourceProvider){
99
114
  super('task_list', cacheManager, utilsService, TaskListService.name, true, 'task-manager', _entities.TaskList, dataSourceProvider), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), _define_property(this, "dataSourceProvider", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService, this.dataSourceProvider = dataSourceProvider;
@@ -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 _loggedusermock = require("@test-utils/mocks/logged-user.mock");
@@ -41,6 +42,15 @@ function mockQueryRunner(repo) {
41
42
  return qr;
42
43
  }
43
44
  describe('TaskListService', ()=>{
45
+ const mockUtilsService = {
46
+ clearCache: jest.fn(),
47
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
48
+ trackCacheKey: jest.fn()
49
+ };
50
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
51
+ beforeEach(()=>{
52
+ mockUtilsService.clearCache.mockClear();
53
+ });
44
54
  let service;
45
55
  let mockRepo;
46
56
  beforeEach(async ()=>{
@@ -55,11 +65,7 @@ describe('TaskListService', ()=>{
55
65
  },
56
66
  {
57
67
  provide: _modules.UtilsService,
58
- useValue: {
59
- clearCache: jest.fn(),
60
- getCacheKey: jest.fn(),
61
- trackCacheKey: jest.fn()
62
- }
68
+ useValue: mockUtilsService
63
69
  },
64
70
  {
65
71
  provide: _taskmanagerdatasourceprovider.TaskManagerDataSourceProvider,
@@ -188,6 +194,19 @@ describe('TaskListService', ()=>{
188
194
  position: 200
189
195
  });
190
196
  });
197
+ it('invalidates the list cache, each reordered entry, and the parent board cache', async ()=>{
198
+ await service.reorderLists({
199
+ boardId: 'board-1',
200
+ orderedIds: [
201
+ 'list-a',
202
+ 'list-b'
203
+ ]
204
+ });
205
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_list', expect.anything());
206
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_list', expect.anything(), 'list-a');
207
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_list', expect.anything(), 'list-b');
208
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
209
+ });
191
210
  it('handles reordering a single-item list (position 0)', async ()=>{
192
211
  await service.reorderLists({
193
212
  boardId: 'board-1',
@@ -211,4 +230,55 @@ describe('TaskListService', ()=>{
211
230
  expect(mockRepo.update).not.toHaveBeenCalled();
212
231
  });
213
232
  });
233
+ describe('domain events', ()=>{
234
+ let publish;
235
+ beforeEach(()=>{
236
+ publish = jest.fn().mockResolvedValue(undefined);
237
+ _classes.EventBusRegistry.reset();
238
+ _classes.EventBusRegistry.setBus({
239
+ publish,
240
+ subscribe: jest.fn(),
241
+ close: jest.fn()
242
+ });
243
+ _classes.EventBusRegistry.configureModule('task-manager', {
244
+ enabled: true
245
+ });
246
+ });
247
+ afterEach(()=>{
248
+ _classes.EventBusRegistry.reset();
249
+ });
250
+ it('publishes task-manager.task_list.reordered with every list id', async ()=>{
251
+ await service.reorderLists({
252
+ boardId: 'board-1',
253
+ orderedIds: [
254
+ 'list-a',
255
+ 'list-b'
256
+ ]
257
+ });
258
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
259
+ name: 'task-manager.task_list.reordered',
260
+ payload: expect.objectContaining({
261
+ ids: [
262
+ 'list-a',
263
+ 'list-b'
264
+ ],
265
+ metadata: {
266
+ boardId: 'board-1'
267
+ }
268
+ })
269
+ }));
270
+ });
271
+ it('does not publish when the task manager module has events disabled', async ()=>{
272
+ _classes.EventBusRegistry.configureModule('task-manager', {
273
+ enabled: false
274
+ });
275
+ await service.reorderLists({
276
+ boardId: 'board-1',
277
+ orderedIds: [
278
+ 'list-a'
279
+ ]
280
+ });
281
+ expect(publish).not.toHaveBeenCalled();
282
+ });
283
+ });
214
284
  });
@@ -45,6 +45,10 @@ function _ts_param(paramIndex, decorator) {
45
45
  };
46
46
  }
47
47
  let TaskService = class TaskService extends _classes.ApiService {
48
+ /** Boards cache denormalized `total_tasks` / `total_lists`, so board caches go stale on any write here. */ async clearCacheForAll() {
49
+ await super.clearCacheForAll();
50
+ await this.utilsService.clearCache('task_board', this.cacheManager);
51
+ }
48
52
  async convertSingleDtoToEntity(dto, user) {
49
53
  await this.ensureDataSourceRepository();
50
54
  let entity = {};
@@ -185,6 +189,24 @@ let TaskService = class TaskService extends _classes.ApiService {
185
189
  }, {
186
190
  listId: dto.targetListId
187
191
  });
192
+ await Promise.all([
193
+ this.clearCacheForAll(),
194
+ this.clearCacheForId([
195
+ saved
196
+ ])
197
+ ]);
198
+ await this.publishDomainAction(_config.TASK_MANAGER_EVENT_ACTIONS.MOVED, {
199
+ ids: [
200
+ saved.id
201
+ ],
202
+ metadata: {
203
+ boardId: saved.boardId,
204
+ fromListId: oldListId,
205
+ toListId: dto.targetListId,
206
+ position: saved.position
207
+ },
208
+ user
209
+ });
188
210
  return saved;
189
211
  }
190
212
  async assignTask(dto, user) {
@@ -221,7 +243,24 @@ let TaskService = class TaskService extends _classes.ApiService {
221
243
  }, {
222
244
  assigneeIds: dto.assigneeIds
223
245
  });
246
+ await Promise.all([
247
+ this.clearCacheForAll(),
248
+ this.clearCacheForId([
249
+ saved
250
+ ])
251
+ ]);
224
252
  saved.assignees = await this.loadAssigneesForTask(saved.id);
253
+ await this.publishDomainAction(_config.TASK_MANAGER_EVENT_ACTIONS.ASSIGNED, {
254
+ ids: [
255
+ saved.id
256
+ ],
257
+ metadata: {
258
+ boardId: saved.boardId,
259
+ previousAssigneeIds: oldIds,
260
+ assigneeIds: dto.assigneeIds
261
+ },
262
+ user
263
+ });
225
264
  return saved;
226
265
  }
227
266
  async syncAssignees(taskId, userIds) {
@@ -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 _loggedusermock = require("@test-utils/mocks/logged-user.mock");
@@ -49,6 +50,15 @@ function mockQueryRunner(repo) {
49
50
  return qr;
50
51
  }
51
52
  describe('TaskService', ()=>{
53
+ const mockUtilsService = {
54
+ clearCache: jest.fn(),
55
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
56
+ trackCacheKey: jest.fn()
57
+ };
58
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
59
+ beforeEach(()=>{
60
+ mockUtilsService.clearCache.mockClear();
61
+ });
52
62
  let service;
53
63
  let mockRepo;
54
64
  let mockLabelJunctionRepo;
@@ -96,11 +106,7 @@ describe('TaskService', ()=>{
96
106
  },
97
107
  {
98
108
  provide: _modules.UtilsService,
99
- useValue: {
100
- clearCache: jest.fn(),
101
- getCacheKey: jest.fn(),
102
- trackCacheKey: jest.fn()
103
- }
109
+ useValue: mockUtilsService
104
110
  },
105
111
  {
106
112
  provide: _taskmanagerdatasourceprovider.TaskManagerDataSourceProvider,
@@ -368,6 +374,25 @@ describe('TaskService', ()=>{
368
374
  listId: 'list-new'
369
375
  });
370
376
  });
377
+ it('invalidates the task list cache and the moved task entry', async ()=>{
378
+ const task = buildTask({
379
+ id: 'task-1',
380
+ listId: 'list-old',
381
+ boardId: 'board-1'
382
+ });
383
+ mockRepo.findOne.mockResolvedValue(task);
384
+ mockRepo.save.mockImplementation((t)=>Promise.resolve(t));
385
+ await service.moveTask({
386
+ taskId: 'task-1',
387
+ targetListId: 'list-new',
388
+ position: 1
389
+ }, (0, _loggedusermock.buildMockUser)({
390
+ id: 'mover-1'
391
+ }));
392
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task', expect.anything());
393
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task', expect.anything(), 'task-1');
394
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
395
+ });
371
396
  it('defaults position to 0 (first position) when no position is given', async ()=>{
372
397
  const task = buildTask({
373
398
  id: 'task-1',
@@ -607,4 +632,69 @@ describe('TaskService', ()=>{
607
632
  superGetAllSpy.mockRestore();
608
633
  });
609
634
  });
635
+ describe('domain events', ()=>{
636
+ let publish;
637
+ beforeEach(()=>{
638
+ publish = jest.fn().mockResolvedValue(undefined);
639
+ _classes.EventBusRegistry.reset();
640
+ _classes.EventBusRegistry.setBus({
641
+ publish,
642
+ subscribe: jest.fn(),
643
+ close: jest.fn()
644
+ });
645
+ _classes.EventBusRegistry.configureModule('task-manager', {
646
+ enabled: true
647
+ });
648
+ });
649
+ afterEach(()=>{
650
+ _classes.EventBusRegistry.reset();
651
+ });
652
+ it('publishes task-manager.task.moved with the source and target lists', async ()=>{
653
+ const task = buildTask({
654
+ id: 'task-1',
655
+ listId: 'list-old',
656
+ boardId: 'board-1',
657
+ position: 0
658
+ });
659
+ mockRepo.findOne.mockResolvedValue(task);
660
+ mockRepo.save.mockImplementation((t)=>Promise.resolve(t));
661
+ await service.moveTask({
662
+ taskId: 'task-1',
663
+ targetListId: 'list-new',
664
+ position: 3
665
+ }, (0, _loggedusermock.buildMockUser)({
666
+ id: 'mover-1'
667
+ }));
668
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
669
+ name: 'task-manager.task.moved',
670
+ payload: expect.objectContaining({
671
+ ids: [
672
+ 'task-1'
673
+ ],
674
+ metadata: expect.objectContaining({
675
+ boardId: 'board-1',
676
+ fromListId: 'list-old',
677
+ toListId: 'list-new'
678
+ })
679
+ })
680
+ }));
681
+ });
682
+ it('does not publish when the task manager module has events disabled', async ()=>{
683
+ _classes.EventBusRegistry.configureModule('task-manager', {
684
+ enabled: false
685
+ });
686
+ const task = buildTask({
687
+ id: 'task-1',
688
+ listId: 'list-old',
689
+ boardId: 'board-1'
690
+ });
691
+ mockRepo.findOne.mockResolvedValue(task);
692
+ mockRepo.save.mockImplementation((t)=>Promise.resolve(t));
693
+ await service.moveTask({
694
+ taskId: 'task-1',
695
+ targetListId: 'list-new'
696
+ }, (0, _loggedusermock.buildMockUser)());
697
+ expect(publish).not.toHaveBeenCalled();
698
+ });
699
+ });
610
700
  });
@@ -0,0 +1,12 @@
1
+ export declare const TASK_MANAGER_EVENT_ACTIONS: {
2
+ readonly MOVED: "moved";
3
+ readonly ASSIGNED: "assigned";
4
+ readonly REORDERED: "reordered";
5
+ };
6
+ export declare const TASK_MANAGER_EVENT_ENTITIES: {
7
+ readonly TASK: "task";
8
+ readonly TASK_BOARD: "task_board";
9
+ readonly TASK_LIST: "task_list";
10
+ readonly TASK_LABEL: "task_label";
11
+ readonly TASK_COMMENT: "task_comment";
12
+ };
package/config/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export * from './task-manager.constants';
1
+ export * from './event-actions';
2
2
  export * from './message-keys';
3
+ export * from './task-manager.constants';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Domain event actions published by the task manager module, alongside the CRUD
3
+ * lifecycle actions every ApiService emits.
4
+ */ export const TASK_MANAGER_EVENT_ACTIONS = {
5
+ MOVED: 'moved',
6
+ ASSIGNED: 'assigned',
7
+ REORDERED: 'reordered'
8
+ };
9
+ /** Every entity the task manager module publishes under */ export const TASK_MANAGER_EVENT_ENTITIES = {
10
+ TASK: 'task',
11
+ TASK_BOARD: 'task_board',
12
+ TASK_LIST: 'task_list',
13
+ TASK_LABEL: 'task_label',
14
+ TASK_COMMENT: 'task_comment'
15
+ };
@@ -1,2 +1,3 @@
1
- export * from './task-manager.constants';
1
+ export * from './event-actions';
2
2
  export * from './message-keys';
3
+ export * from './task-manager.constants';
@@ -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 { JwtService } from '@nestjs/jwt';
10
10
  import { TASK_MANAGER_MODULE_OPTIONS } from '../config/task-manager.constants';
@@ -81,6 +81,7 @@ export class TaskManagerModule {
81
81
  useValue: options
82
82
  });
83
83
  }
84
+ providers.push(createModuleEventsProvider('task-manager', TASK_MANAGER_MODULE_OPTIONS));
84
85
  return providers;
85
86
  }
86
87
  static createAsyncProviders(options) {
@@ -132,11 +132,13 @@ export class TaskBoardService extends ApiService {
132
132
  }, {
133
133
  role: dto.role ?? BoardMemberRole.MEMBER
134
134
  });
135
- return memberRepo.findOne({
135
+ const restored = await memberRepo.findOne({
136
136
  where: {
137
137
  id: softDeleted.id
138
138
  }
139
139
  });
140
+ await this.invalidateBoardCache(dto.boardId);
141
+ return restored;
140
142
  }
141
143
  const member = memberRepo.create({
142
144
  boardId: dto.boardId,
@@ -144,7 +146,9 @@ export class TaskBoardService extends ApiService {
144
146
  role: dto.role ?? BoardMemberRole.MEMBER,
145
147
  createdById: user.id
146
148
  });
147
- return memberRepo.save(member);
149
+ const saved = await memberRepo.save(member);
150
+ await this.invalidateBoardCache(dto.boardId);
151
+ return saved;
148
152
  }
149
153
  async removeMember(dto) {
150
154
  const memberRepo = await this.ensureMemberRepository();
@@ -164,6 +168,7 @@ export class TaskBoardService extends ApiService {
164
168
  }, {
165
169
  deletedAt: new Date()
166
170
  });
171
+ await this.invalidateBoardCache(dto.boardId);
167
172
  }
168
173
  async getMembers(boardId) {
169
174
  const memberRepo = await this.ensureMemberRepository();
@@ -197,6 +202,16 @@ export class TaskBoardService extends ApiService {
197
202
  createdById: user.id
198
203
  })));
199
204
  }
205
+ async invalidateBoardCache(boardId) {
206
+ await Promise.all([
207
+ this.clearCacheForAll(),
208
+ this.clearCacheForId([
209
+ {
210
+ id: boardId
211
+ }
212
+ ])
213
+ ]);
214
+ }
200
215
  async ensureMemberRepository() {
201
216
  if (!this.memberRepository) {
202
217
  this.memberRepository = await this.dataSourceProvider.getRepository(TaskBoardMember);
@@ -53,6 +53,15 @@ function mockQueryRunner(repo) {
53
53
  return qr;
54
54
  }
55
55
  describe('TaskBoardService', ()=>{
56
+ const mockUtilsService = {
57
+ clearCache: jest.fn(),
58
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
59
+ trackCacheKey: jest.fn()
60
+ };
61
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
62
+ beforeEach(()=>{
63
+ mockUtilsService.clearCache.mockClear();
64
+ });
56
65
  let service;
57
66
  let mockRepo;
58
67
  let mockMemberRepo;
@@ -80,11 +89,7 @@ describe('TaskBoardService', ()=>{
80
89
  },
81
90
  {
82
91
  provide: UtilsService,
83
- useValue: {
84
- clearCache: jest.fn(),
85
- getCacheKey: jest.fn(),
86
- trackCacheKey: jest.fn()
87
- }
92
+ useValue: mockUtilsService
88
93
  },
89
94
  {
90
95
  provide: TaskManagerConfigService,
@@ -260,6 +265,23 @@ describe('TaskBoardService', ()=>{
260
265
  }));
261
266
  expect(result).toEqual(created);
262
267
  });
268
+ it('invalidates the board list cache and the board entry (membership drives visibility and total_members)', async ()=>{
269
+ mockRepo.findOne.mockResolvedValue(buildBoard({
270
+ id: 'board-1'
271
+ }));
272
+ mockMemberRepo.findOne.mockResolvedValueOnce(null);
273
+ mockMemberRepo.findOne.mockResolvedValueOnce(null);
274
+ mockMemberRepo.create.mockImplementation((data)=>data);
275
+ mockMemberRepo.save.mockResolvedValue(buildMember({
276
+ boardId: 'board-1'
277
+ }));
278
+ await service.addMember({
279
+ boardId: 'board-1',
280
+ userId: 'user-2'
281
+ }, buildMockUser());
282
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
283
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything(), 'board-1');
284
+ });
263
285
  it('defaults the role to MEMBER when none is given', async ()=>{
264
286
  mockRepo.findOne.mockResolvedValue(buildBoard({
265
287
  id: 'board-1'
@@ -343,6 +365,17 @@ describe('TaskBoardService', ()=>{
343
365
  deletedAt: expect.any(Date)
344
366
  });
345
367
  });
368
+ it('invalidates the board list cache and the board entry', async ()=>{
369
+ mockMemberRepo.findOne.mockResolvedValue(buildMember({
370
+ id: 'member-1'
371
+ }));
372
+ await service.removeMember({
373
+ boardId: 'board-1',
374
+ userId: 'user-uuid-1'
375
+ });
376
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
377
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything(), 'board-1');
378
+ });
346
379
  it('throws NotFoundException when the member is not found', async ()=>{
347
380
  mockMemberRepo.findOne.mockResolvedValue(null);
348
381
  await expect(service.removeMember({
@@ -28,10 +28,14 @@ function _ts_param(paramIndex, decorator) {
28
28
  import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
29
29
  import { UtilsService } from '@flusys/nestjs-shared/modules';
30
30
  import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
31
- import { TASK_LIST_MESSAGES } from '../config';
31
+ import { TASK_LIST_MESSAGES, TASK_MANAGER_EVENT_ACTIONS } from '../config';
32
32
  import { TaskList } from '../entities';
33
33
  import { TaskManagerDataSourceProvider } from './task-manager-datasource.provider';
34
34
  export class TaskListService extends ApiService {
35
+ /** Boards cache denormalized `total_tasks` / `total_lists`, so board caches go stale on any write here. */ async clearCacheForAll() {
36
+ await super.clearCacheForAll();
37
+ await this.utilsService.clearCache('task_board', this.cacheManager);
38
+ }
35
39
  async convertSingleDtoToEntity(dto, _user) {
36
40
  await this.ensureDataSourceRepository();
37
41
  let entity = {};
@@ -83,7 +87,18 @@ export class TaskListService extends ApiService {
83
87
  }, {
84
88
  position: i * 100
85
89
  })));
86
- await this.clearCacheForAll();
90
+ await Promise.all([
91
+ this.clearCacheForAll(),
92
+ this.clearCacheForId(dto.orderedIds.map((id)=>({
93
+ id
94
+ })))
95
+ ]);
96
+ await this.publishDomainAction(TASK_MANAGER_EVENT_ACTIONS.REORDERED, {
97
+ ids: dto.orderedIds,
98
+ metadata: {
99
+ boardId: dto.boardId
100
+ }
101
+ });
87
102
  }
88
103
  constructor(cacheManager, utilsService, dataSourceProvider){
89
104
  super('task_list', cacheManager, utilsService, TaskListService.name, true, 'task-manager', TaskList, dataSourceProvider), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), _define_property(this, "dataSourceProvider", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService, this.dataSourceProvider = dataSourceProvider;
@@ -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, createMockQueryBuilder, createMockRepository } from '@test-utils/mocks/repository.mock';
4
5
  import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
@@ -37,6 +38,15 @@ function mockQueryRunner(repo) {
37
38
  return qr;
38
39
  }
39
40
  describe('TaskListService', ()=>{
41
+ const mockUtilsService = {
42
+ clearCache: jest.fn(),
43
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
44
+ trackCacheKey: jest.fn()
45
+ };
46
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
47
+ beforeEach(()=>{
48
+ mockUtilsService.clearCache.mockClear();
49
+ });
40
50
  let service;
41
51
  let mockRepo;
42
52
  beforeEach(async ()=>{
@@ -51,11 +61,7 @@ describe('TaskListService', ()=>{
51
61
  },
52
62
  {
53
63
  provide: UtilsService,
54
- useValue: {
55
- clearCache: jest.fn(),
56
- getCacheKey: jest.fn(),
57
- trackCacheKey: jest.fn()
58
- }
64
+ useValue: mockUtilsService
59
65
  },
60
66
  {
61
67
  provide: TaskManagerDataSourceProvider,
@@ -184,6 +190,19 @@ describe('TaskListService', ()=>{
184
190
  position: 200
185
191
  });
186
192
  });
193
+ it('invalidates the list cache, each reordered entry, and the parent board cache', async ()=>{
194
+ await service.reorderLists({
195
+ boardId: 'board-1',
196
+ orderedIds: [
197
+ 'list-a',
198
+ 'list-b'
199
+ ]
200
+ });
201
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_list', expect.anything());
202
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_list', expect.anything(), 'list-a');
203
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_list', expect.anything(), 'list-b');
204
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
205
+ });
187
206
  it('handles reordering a single-item list (position 0)', async ()=>{
188
207
  await service.reorderLists({
189
208
  boardId: 'board-1',
@@ -207,4 +226,55 @@ describe('TaskListService', ()=>{
207
226
  expect(mockRepo.update).not.toHaveBeenCalled();
208
227
  });
209
228
  });
229
+ describe('domain events', ()=>{
230
+ let publish;
231
+ beforeEach(()=>{
232
+ publish = jest.fn().mockResolvedValue(undefined);
233
+ EventBusRegistry.reset();
234
+ EventBusRegistry.setBus({
235
+ publish,
236
+ subscribe: jest.fn(),
237
+ close: jest.fn()
238
+ });
239
+ EventBusRegistry.configureModule('task-manager', {
240
+ enabled: true
241
+ });
242
+ });
243
+ afterEach(()=>{
244
+ EventBusRegistry.reset();
245
+ });
246
+ it('publishes task-manager.task_list.reordered with every list id', async ()=>{
247
+ await service.reorderLists({
248
+ boardId: 'board-1',
249
+ orderedIds: [
250
+ 'list-a',
251
+ 'list-b'
252
+ ]
253
+ });
254
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
255
+ name: 'task-manager.task_list.reordered',
256
+ payload: expect.objectContaining({
257
+ ids: [
258
+ 'list-a',
259
+ 'list-b'
260
+ ],
261
+ metadata: {
262
+ boardId: 'board-1'
263
+ }
264
+ })
265
+ }));
266
+ });
267
+ it('does not publish when the task manager module has events disabled', async ()=>{
268
+ EventBusRegistry.configureModule('task-manager', {
269
+ enabled: false
270
+ });
271
+ await service.reorderLists({
272
+ boardId: 'board-1',
273
+ orderedIds: [
274
+ 'list-a'
275
+ ]
276
+ });
277
+ expect(publish).not.toHaveBeenCalled();
278
+ });
279
+ });
210
280
  });
@@ -29,12 +29,16 @@ import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
29
29
  import { UtilsService } from '@flusys/nestjs-shared/modules';
30
30
  import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common';
31
31
  import { In, IsNull } from 'typeorm';
32
- import { TASK_MESSAGES } from '../config';
32
+ import { TASK_MANAGER_EVENT_ACTIONS, TASK_MESSAGES } from '../config';
33
33
  import { Task, TaskAssignee, TaskLabel, TaskTaskLabel } from '../entities';
34
34
  import { TaskActivityService } from './task-activity.service';
35
35
  import { TaskBoardService } from './task-board.service';
36
36
  import { TaskManagerDataSourceProvider } from './task-manager-datasource.provider';
37
37
  export class TaskService extends ApiService {
38
+ /** Boards cache denormalized `total_tasks` / `total_lists`, so board caches go stale on any write here. */ async clearCacheForAll() {
39
+ await super.clearCacheForAll();
40
+ await this.utilsService.clearCache('task_board', this.cacheManager);
41
+ }
38
42
  async convertSingleDtoToEntity(dto, user) {
39
43
  await this.ensureDataSourceRepository();
40
44
  let entity = {};
@@ -175,6 +179,24 @@ export class TaskService extends ApiService {
175
179
  }, {
176
180
  listId: dto.targetListId
177
181
  });
182
+ await Promise.all([
183
+ this.clearCacheForAll(),
184
+ this.clearCacheForId([
185
+ saved
186
+ ])
187
+ ]);
188
+ await this.publishDomainAction(TASK_MANAGER_EVENT_ACTIONS.MOVED, {
189
+ ids: [
190
+ saved.id
191
+ ],
192
+ metadata: {
193
+ boardId: saved.boardId,
194
+ fromListId: oldListId,
195
+ toListId: dto.targetListId,
196
+ position: saved.position
197
+ },
198
+ user
199
+ });
178
200
  return saved;
179
201
  }
180
202
  async assignTask(dto, user) {
@@ -211,7 +233,24 @@ export class TaskService extends ApiService {
211
233
  }, {
212
234
  assigneeIds: dto.assigneeIds
213
235
  });
236
+ await Promise.all([
237
+ this.clearCacheForAll(),
238
+ this.clearCacheForId([
239
+ saved
240
+ ])
241
+ ]);
214
242
  saved.assignees = await this.loadAssigneesForTask(saved.id);
243
+ await this.publishDomainAction(TASK_MANAGER_EVENT_ACTIONS.ASSIGNED, {
244
+ ids: [
245
+ saved.id
246
+ ],
247
+ metadata: {
248
+ boardId: saved.boardId,
249
+ previousAssigneeIds: oldIds,
250
+ assigneeIds: dto.assigneeIds
251
+ },
252
+ user
253
+ });
215
254
  return saved;
216
255
  }
217
256
  async syncAssignees(taskId, userIds) {
@@ -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 { createMockQueryBuilder, createMockRepository } from '@test-utils/mocks/repository.mock';
4
5
  import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
@@ -45,6 +46,15 @@ function mockQueryRunner(repo) {
45
46
  return qr;
46
47
  }
47
48
  describe('TaskService', ()=>{
49
+ const mockUtilsService = {
50
+ clearCache: jest.fn(),
51
+ getCacheKey: jest.fn().mockReturnValue('cache-key'),
52
+ trackCacheKey: jest.fn()
53
+ };
54
+ // Call history is shared across tests in this file; reset it so each cache assertion is exact.
55
+ beforeEach(()=>{
56
+ mockUtilsService.clearCache.mockClear();
57
+ });
48
58
  let service;
49
59
  let mockRepo;
50
60
  let mockLabelJunctionRepo;
@@ -92,11 +102,7 @@ describe('TaskService', ()=>{
92
102
  },
93
103
  {
94
104
  provide: UtilsService,
95
- useValue: {
96
- clearCache: jest.fn(),
97
- getCacheKey: jest.fn(),
98
- trackCacheKey: jest.fn()
99
- }
105
+ useValue: mockUtilsService
100
106
  },
101
107
  {
102
108
  provide: TaskManagerDataSourceProvider,
@@ -364,6 +370,25 @@ describe('TaskService', ()=>{
364
370
  listId: 'list-new'
365
371
  });
366
372
  });
373
+ it('invalidates the task list cache and the moved task entry', async ()=>{
374
+ const task = buildTask({
375
+ id: 'task-1',
376
+ listId: 'list-old',
377
+ boardId: 'board-1'
378
+ });
379
+ mockRepo.findOne.mockResolvedValue(task);
380
+ mockRepo.save.mockImplementation((t)=>Promise.resolve(t));
381
+ await service.moveTask({
382
+ taskId: 'task-1',
383
+ targetListId: 'list-new',
384
+ position: 1
385
+ }, buildMockUser({
386
+ id: 'mover-1'
387
+ }));
388
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task', expect.anything());
389
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task', expect.anything(), 'task-1');
390
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('task_board', expect.anything());
391
+ });
367
392
  it('defaults position to 0 (first position) when no position is given', async ()=>{
368
393
  const task = buildTask({
369
394
  id: 'task-1',
@@ -603,4 +628,69 @@ describe('TaskService', ()=>{
603
628
  superGetAllSpy.mockRestore();
604
629
  });
605
630
  });
631
+ describe('domain events', ()=>{
632
+ let publish;
633
+ beforeEach(()=>{
634
+ publish = jest.fn().mockResolvedValue(undefined);
635
+ EventBusRegistry.reset();
636
+ EventBusRegistry.setBus({
637
+ publish,
638
+ subscribe: jest.fn(),
639
+ close: jest.fn()
640
+ });
641
+ EventBusRegistry.configureModule('task-manager', {
642
+ enabled: true
643
+ });
644
+ });
645
+ afterEach(()=>{
646
+ EventBusRegistry.reset();
647
+ });
648
+ it('publishes task-manager.task.moved with the source and target lists', async ()=>{
649
+ const task = buildTask({
650
+ id: 'task-1',
651
+ listId: 'list-old',
652
+ boardId: 'board-1',
653
+ position: 0
654
+ });
655
+ mockRepo.findOne.mockResolvedValue(task);
656
+ mockRepo.save.mockImplementation((t)=>Promise.resolve(t));
657
+ await service.moveTask({
658
+ taskId: 'task-1',
659
+ targetListId: 'list-new',
660
+ position: 3
661
+ }, buildMockUser({
662
+ id: 'mover-1'
663
+ }));
664
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
665
+ name: 'task-manager.task.moved',
666
+ payload: expect.objectContaining({
667
+ ids: [
668
+ 'task-1'
669
+ ],
670
+ metadata: expect.objectContaining({
671
+ boardId: 'board-1',
672
+ fromListId: 'list-old',
673
+ toListId: 'list-new'
674
+ })
675
+ })
676
+ }));
677
+ });
678
+ it('does not publish when the task manager module has events disabled', async ()=>{
679
+ EventBusRegistry.configureModule('task-manager', {
680
+ enabled: false
681
+ });
682
+ const task = buildTask({
683
+ id: 'task-1',
684
+ listId: 'list-old',
685
+ boardId: 'board-1'
686
+ });
687
+ mockRepo.findOne.mockResolvedValue(task);
688
+ mockRepo.save.mockImplementation((t)=>Promise.resolve(t));
689
+ await service.moveTask({
690
+ taskId: 'task-1',
691
+ targetListId: 'list-new'
692
+ }, buildMockUser());
693
+ expect(publish).not.toHaveBeenCalled();
694
+ });
695
+ });
606
696
  });
@@ -1,7 +1,9 @@
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 ITaskManagerModuleConfig extends IDataSourceServiceOptions {
4
5
  jwtSecret?: string;
6
+ events?: IModuleEventsConfig;
5
7
  }
6
8
  export interface TaskManagerModuleOptions extends IDynamicModuleConfig {
7
9
  bootstrapAppConfig?: IBootstrapAppConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-task-manager",
3
- "version": "6.4.0",
3
+ "version": "7.0.0-beta.1",
4
4
  "description": "Task management module with dynamic boards and real-time collaboration",
5
5
  "main": "cjs/index.js",
6
6
  "module": "fesm/index.js",
@@ -85,7 +85,7 @@
85
85
  "socket.io": "^4.8.0"
86
86
  },
87
87
  "dependencies": {
88
- "@flusys/nestjs-core": "6.4.0",
89
- "@flusys/nestjs-shared": "6.4.0"
88
+ "@flusys/nestjs-core": "7.0.0-beta.1",
89
+ "@flusys/nestjs-shared": "7.0.0-beta.1"
90
90
  }
91
91
  }
@@ -28,5 +28,6 @@ export declare class TaskBoardService extends ApiService<CreateTaskBoardDto, Upd
28
28
  })[]>;
29
29
  isBoardMember(boardId: string, userId: string): Promise<boolean>;
30
30
  protected afterInsertOperation(entities: TaskBoardBase[], _dto: CreateTaskBoardDto | Array<CreateTaskBoardDto>, user: ILoggedUserInfo | null, _qr: any): Promise<void>;
31
+ private invalidateBoardCache;
31
32
  private ensureMemberRepository;
32
33
  }
@@ -10,6 +10,7 @@ export declare class TaskListService extends ApiService<CreateTaskListDto, Updat
10
10
  protected utilsService: UtilsService;
11
11
  private readonly dataSourceProvider;
12
12
  constructor(cacheManager: HybridCache, utilsService: UtilsService, dataSourceProvider: TaskManagerDataSourceProvider);
13
+ clearCacheForAll(): Promise<void>;
13
14
  convertSingleDtoToEntity(dto: CreateTaskListDto | UpdateTaskListDto, _user: ILoggedUserInfo | null): Promise<TaskList>;
14
15
  protected getFilterQuery(query: SelectQueryBuilder<TaskList>, filter: Record<string, unknown>, _user: ILoggedUserInfo | null): Promise<{
15
16
  query: SelectQueryBuilder<TaskList>;
@@ -20,6 +20,7 @@ export declare class TaskService extends ApiService<CreateTaskDto, UpdateTaskDto
20
20
  private pendingLabelIds;
21
21
  private pendingAssigneeIds;
22
22
  constructor(cacheManager: HybridCache, utilsService: UtilsService, dataSourceProvider: TaskManagerDataSourceProvider, boardService: TaskBoardService, activityService: TaskActivityService);
23
+ clearCacheForAll(): Promise<void>;
23
24
  convertSingleDtoToEntity(dto: CreateTaskDto | UpdateTaskDto, user: ILoggedUserInfo | null): Promise<Task>;
24
25
  protected afterInsertOperation(entities: Task[], _dto: CreateTaskDto | Array<CreateTaskDto>, user: ILoggedUserInfo | null, _qr: QueryRunner): Promise<void>;
25
26
  protected afterUpdateOperation(entities: Task[], _dto: UpdateTaskDto | Array<UpdateTaskDto>, _user: ILoggedUserInfo | null, _qr: QueryRunner): Promise<void>;