Skip to content

Commit dd84f03

Browse files
committed
refactor(events): streamline bulk event ID validation and enhance error handling in bulkToggleEventMark and bulkUpdateAssignee methods
1 parent d497afd commit dd84f03

7 files changed

Lines changed: 216 additions & 103 deletions

File tree

src/models/eventsFactory.js

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -919,37 +919,16 @@ class EventsFactory extends Factory {
919919
}
920920

921921
/**
922-
* Bulk mark for resolved / ignored / starred (not the same as per-event toggleEventMark).
923-
* - If every found event already has the mark: remove it from all (bulk "undo").
924-
* - Otherwise: set the mark on every found event that does not have it yet (never remove
925-
* from a subset when the selection is mixed).
926-
* Only 'resolved', 'ignored' and 'starred' are allowed for bulk.
922+
* Bulk toggle mark for original events.
927923
*
928924
* @param {string[]} eventIds - original event ids
929925
* @param {string} mark - 'resolved' | 'ignored' | 'starred'
930926
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
931927
*/
932928
async bulkToggleEventMark(eventIds, mark) {
933929
const unique = [ ...new Set((eventIds || []).map(id => String(id))) ];
934-
935930
const failedEventIds = [];
936-
const validObjectIds = [];
937-
938-
for (const id of unique) {
939-
if (!ObjectId.isValid(id)) {
940-
failedEventIds.push(id);
941-
} else {
942-
validObjectIds.push(new ObjectId(id));
943-
}
944-
}
945-
946-
if (validObjectIds.length === 0) {
947-
return {
948-
updatedCount: 0,
949-
updatedEventIds: [],
950-
failedEventIds,
951-
};
952-
}
931+
const validObjectIds = unique.map(id => new ObjectId(id));
953932

954933
const collection = this.getCollection(this.TYPES.EVENTS);
955934
const found = await collection.find({ _id: { $in: validObjectIds } }).toArray();
@@ -1017,23 +996,7 @@ class EventsFactory extends Factory {
1017996
async bulkUpdateAssignee(eventIds, assignee) {
1018997
const unique = [ ...new Set((eventIds || []).map(id => String(id))) ];
1019998
const failedEventIds = [];
1020-
const validObjectIds = [];
1021-
1022-
for (const id of unique) {
1023-
if (!ObjectId.isValid(id)) {
1024-
failedEventIds.push(id);
1025-
} else {
1026-
validObjectIds.push(new ObjectId(id));
1027-
}
1028-
}
1029-
1030-
if (validObjectIds.length === 0) {
1031-
return {
1032-
updatedCount: 0,
1033-
updatedEventIds: [],
1034-
failedEventIds,
1035-
};
1036-
}
999+
const validObjectIds = unique.map(id => new ObjectId(id));
10371000

10381001
const collection = this.getCollection(this.TYPES.EVENTS);
10391002
const found = await collection.find({ _id: { $in: validObjectIds } }).toArray();

src/resolvers/event.js

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,12 @@
11
const getEventsFactory = require('./helpers/eventsFactory').default;
2-
const sendPersonalNotification = require('../utils/personalNotifications').default;
2+
const {
3+
fireAndForgetAssigneeNotifications,
4+
parseBulkEventIds,
5+
mergeFailedEventIds,
6+
} = require('./helpers/bulkEvents');
37
const { aiService } = require('../services/ai');
48
const { UserInputError } = require('apollo-server-express');
59

6-
/**
7-
* Enqueue assignee notifications in background (do not block resolver response)
8-
*
9-
* @param {object} args - notification args
10-
* @param {object} args.assigneeData - assigned user data
11-
* @param {string[]} args.eventIds - original event ids
12-
* @param {string} args.projectId - project id
13-
* @param {string} args.assigneeId - assignee id
14-
* @param {string} args.whoAssignedId - user id who performed assignment
15-
* @returns {void}
16-
*/
17-
function fireAndForgetAssigneeNotifications({
18-
assigneeData,
19-
eventIds,
20-
projectId,
21-
assigneeId,
22-
whoAssignedId,
23-
}) {
24-
void Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, {
25-
type: 'assignee',
26-
payload: {
27-
assigneeId,
28-
projectId,
29-
whoAssignedId,
30-
eventId,
31-
},
32-
}))).catch((error) => {
33-
console.error('Failed to enqueue assignee notifications', error);
34-
});
35-
}
36-
3710
/**
3811
* See all types and fields here {@see ../typeDefs/event.graphql}
3912
*/
@@ -201,13 +174,23 @@ module.exports = {
201174
throw new UserInputError('bulkToggleEventMarks supports only resolved, ignored and starred marks');
202175
}
203176

204-
if (!eventIds || !eventIds.length) {
205-
throw new UserInputError('eventIds must contain at least one id');
177+
const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds);
178+
179+
if (validEventIds.length === 0) {
180+
return {
181+
updatedCount: 0,
182+
updatedEventIds: [],
183+
failedEventIds: invalidEventIds,
184+
};
206185
}
207186

208187
const factory = getEventsFactory(context, projectId);
188+
const result = await factory.bulkToggleEventMark(validEventIds, mark);
209189

210-
return factory.bulkToggleEventMark(eventIds, mark);
190+
return {
191+
...result,
192+
failedEventIds: mergeFailedEventIds(result, invalidEventIds),
193+
};
211194
},
212195

213196
/**
@@ -296,12 +279,18 @@ module.exports = {
296279
*/
297280
async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) {
298281
const { projectId, eventIds, assignee } = input;
299-
const factory = getEventsFactory(context, projectId);
282+
const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds);
300283

301-
if (!eventIds || !eventIds.length) {
302-
throw new UserInputError('eventIds must contain at least one id');
284+
if (validEventIds.length === 0) {
285+
return {
286+
updatedCount: 0,
287+
updatedEventIds: [],
288+
failedEventIds: invalidEventIds,
289+
};
303290
}
304291

292+
const factory = getEventsFactory(context, projectId);
293+
305294
if (assignee) {
306295
const userExists = await factories.usersFactory.findById(assignee);
307296

@@ -318,14 +307,18 @@ module.exports = {
318307
}
319308
}
320309

321-
const result = await factory.bulkUpdateAssignee(eventIds, assignee);
310+
const result = await factory.bulkUpdateAssignee(validEventIds, assignee);
311+
const resultWithInvalid = {
312+
...result,
313+
failedEventIds: mergeFailedEventIds(result, invalidEventIds),
314+
};
322315

323-
if (assignee && result.updatedEventIds.length > 0) {
316+
if (assignee && resultWithInvalid.updatedEventIds.length > 0) {
324317
void factories.usersFactory.dataLoaders.userById.load(assignee)
325318
.then((assigneeData) => {
326319
fireAndForgetAssigneeNotifications({
327320
assigneeData,
328-
eventIds: result.updatedEventIds,
321+
eventIds: resultWithInvalid.updatedEventIds,
329322
projectId,
330323
assigneeId: assignee,
331324
whoAssignedId: user.id,
@@ -336,7 +329,7 @@ module.exports = {
336329
});
337330
}
338331

339-
return result;
332+
return resultWithInvalid;
340333
},
341334
},
342335
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const sendPersonalNotification = require('../../utils/personalNotifications').default;
2+
const { UserInputError } = require('apollo-server-express');
3+
const { ObjectId } = require('mongodb');
4+
5+
/**
6+
* Enqueue assignee notifications in background (do not block resolver response)
7+
*
8+
* @param {object} args - notification args
9+
* @param {object} args.assigneeData - assigned user data
10+
* @param {string[]} args.eventIds - original event ids
11+
* @param {string} args.projectId - project id
12+
* @param {string} args.assigneeId - assignee id
13+
* @param {string} args.whoAssignedId - user id who performed assignment
14+
* @returns {void}
15+
*/
16+
function fireAndForgetAssigneeNotifications({
17+
assigneeData,
18+
eventIds,
19+
projectId,
20+
assigneeId,
21+
whoAssignedId,
22+
}) {
23+
void Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, {
24+
type: 'assignee',
25+
payload: {
26+
assigneeId,
27+
projectId,
28+
whoAssignedId,
29+
eventId,
30+
},
31+
}))).catch((error) => {
32+
console.error('Failed to enqueue assignee notifications', error);
33+
});
34+
}
35+
36+
/**
37+
* Validate and normalize bulk event ids from resolver input.
38+
*
39+
* @param {string[]} eventIds - raw event ids from GraphQL input
40+
* @returns {{ validEventIds: string[], invalidEventIds: string[] }}
41+
*/
42+
function parseBulkEventIds(eventIds) {
43+
if (!eventIds || !eventIds.length) {
44+
throw new UserInputError('eventIds must contain at least one id');
45+
}
46+
47+
const uniqueEventIds = [ ...new Set(eventIds.map(id => String(id))) ];
48+
const invalidEventIds = [];
49+
const validEventIds = [];
50+
51+
uniqueEventIds.forEach((id) => {
52+
if (ObjectId.isValid(id)) {
53+
validEventIds.push(id);
54+
} else {
55+
invalidEventIds.push(id);
56+
}
57+
});
58+
59+
return {
60+
validEventIds,
61+
invalidEventIds,
62+
};
63+
}
64+
65+
/**
66+
* Merge failed ids returned by factory with invalid ids from resolver validation.
67+
*
68+
* @param {{ failedEventIds?: string[] }} result - factory response
69+
* @param {string[]} invalidEventIds - invalid ids detected on resolver level
70+
* @returns {string[]}
71+
*/
72+
function mergeFailedEventIds(result, invalidEventIds) {
73+
return [ ...new Set([ ...(result.failedEventIds || []), ...invalidEventIds ]) ];
74+
}
75+
76+
module.exports = {
77+
fireAndForgetAssigneeNotifications,
78+
parseBulkEventIds,
79+
mergeFailedEventIds,
80+
};

test/models/eventsFactory-bulk-toggle.test.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,6 @@ describe('EventsFactory.bulkToggleEventMark', () => {
107107
expect(ops).toHaveLength(1);
108108
});
109109

110-
it('should return failedEventIds for invalid ObjectIds and skip bulkWrite', async () => {
111-
const factory = new EventsFactory(projectId);
112-
113-
const result = await factory.bulkToggleEventMark([ 'not-a-valid-id' ], 'resolved');
114-
115-
expect(result.updatedCount).toBe(0);
116-
expect(result.updatedEventIds).toEqual([]);
117-
expect(result.failedEventIds).toContain('not-a-valid-id');
118-
expect(collectionMock.bulkWrite).not.toHaveBeenCalled();
119-
});
120-
121110
it('should list valid but missing document ids in failedEventIds', async () => {
122111
const factory = new EventsFactory(projectId);
123112
const missing = new ObjectId();

test/models/eventsFactory-bulk-update-assignee.test.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,6 @@ describe('EventsFactory.bulkUpdateAssignee', () => {
4343
collectionMock.updateMany.mockResolvedValue({ modifiedCount: 0 });
4444
});
4545

46-
it('should return failed ids for invalid ObjectIds and skip updateMany', async () => {
47-
const factory = new EventsFactory(projectId);
48-
const result = await factory.bulkUpdateAssignee([ 'bad-id' ], 'user-1');
49-
50-
expect(result.updatedCount).toBe(0);
51-
expect(result.updatedEventIds).toEqual([]);
52-
expect(result.failedEventIds).toEqual([ 'bad-id' ]);
53-
expect(collectionMock.updateMany).not.toHaveBeenCalled();
54-
});
55-
5646
it('should update only events with changed assignee', async () => {
5747
const factory = new EventsFactory(projectId);
5848
const a = new ObjectId();

test/resolvers/event-bulk-toggle-marks.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,51 @@ describe('Mutation.bulkToggleEventMarks', () => {
108108
expect(result).toEqual(payload);
109109
});
110110

111+
it('should validate ids on resolver level and merge invalid ids into failedEventIds', async () => {
112+
bulkToggleEventMark.mockResolvedValue({
113+
updatedCount: 1,
114+
updatedEventIds: [ '507f1f77bcf86cd799439011' ],
115+
failedEventIds: [ '507f1f77bcf86cd799439099' ],
116+
});
117+
118+
const result = await eventResolvers.Mutation.bulkToggleEventMarks(
119+
{},
120+
{
121+
projectId: 'p1',
122+
eventIds: [ '507f1f77bcf86cd799439011', 'invalid-id' ],
123+
mark: 'ignored',
124+
},
125+
ctx
126+
);
127+
128+
expect(bulkToggleEventMark).toHaveBeenCalledWith(
129+
[ '507f1f77bcf86cd799439011' ],
130+
'ignored'
131+
);
132+
expect(result).toEqual({
133+
updatedCount: 1,
134+
updatedEventIds: [ '507f1f77bcf86cd799439011' ],
135+
failedEventIds: [ '507f1f77bcf86cd799439099', 'invalid-id' ],
136+
});
137+
});
138+
139+
it('should return early when all ids are invalid', async () => {
140+
const result = await eventResolvers.Mutation.bulkToggleEventMarks(
141+
{},
142+
{
143+
projectId: 'p1',
144+
eventIds: [ 'bad-1', 'bad-2' ],
145+
mark: 'ignored',
146+
},
147+
ctx
148+
);
149+
150+
expect(bulkToggleEventMark).not.toHaveBeenCalled();
151+
expect(result).toEqual({
152+
updatedCount: 0,
153+
updatedEventIds: [],
154+
failedEventIds: [ 'bad-1', 'bad-2' ],
155+
});
156+
});
157+
111158
});

0 commit comments

Comments
 (0)