@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.
- package/cjs/config/event-actions.js +33 -0
- package/cjs/config/index.js +2 -1
- package/cjs/modules/task-manager.module.js +1 -0
- package/cjs/services/task-board.service.js +17 -2
- package/cjs/services/task-board.service.spec.js +38 -5
- package/cjs/services/task-list.service.js +16 -1
- package/cjs/services/task-list.service.spec.js +75 -5
- package/cjs/services/task.service.js +39 -0
- package/cjs/services/task.service.spec.js +95 -5
- package/config/event-actions.d.ts +12 -0
- package/config/index.d.ts +2 -1
- package/fesm/config/event-actions.js +15 -0
- package/fesm/config/index.js +2 -1
- package/fesm/modules/task-manager.module.js +2 -1
- package/fesm/services/task-board.service.js +17 -2
- package/fesm/services/task-board.service.spec.js +38 -5
- package/fesm/services/task-list.service.js +17 -2
- package/fesm/services/task-list.service.spec.js +75 -5
- package/fesm/services/task.service.js +40 -1
- package/fesm/services/task.service.spec.js +95 -5
- package/interfaces/task-manager-module.interface.d.ts +2 -0
- package/package.json +3 -3
- package/services/task-board.service.d.ts +1 -0
- package/services/task-list.service.d.ts +1 -0
- package/services/task.service.d.ts +1 -0
|
@@ -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
|
+
};
|
package/cjs/config/index.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", {
|
|
3
3
|
value: true
|
|
4
4
|
});
|
|
5
|
-
_export_star(require("./
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
+
};
|
package/fesm/config/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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": "
|
|
89
|
-
"@flusys/nestjs-shared": "
|
|
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>;
|