Skip to content

Commit d497afd

Browse files
committed
feat(events): add bulkUpdateAssignee functionality to manage event assignees
1 parent 7ed3481 commit d497afd

7 files changed

Lines changed: 408 additions & 66 deletions

File tree

src/models/eventsFactory.js

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -918,13 +918,6 @@ class EventsFactory extends Factory {
918918
return collection.updateOne(query, update);
919919
}
920920

921-
/**
922-
* Max original event ids per bulkToggleEventMark request
923-
*/
924-
static get BULK_TOGGLE_EVENT_MARK_MAX() {
925-
return 100;
926-
}
927-
928921
/**
929922
* Bulk mark for resolved / ignored / starred (not the same as per-event toggleEventMark).
930923
* - If every found event already has the mark: remove it from all (bulk "undo").
@@ -937,17 +930,8 @@ class EventsFactory extends Factory {
937930
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
938931
*/
939932
async bulkToggleEventMark(eventIds, mark) {
940-
if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') {
941-
throw new Error(`bulkToggleEventMark: mark must be resolved, ignored or starred, got ${mark}`);
942-
}
943-
944-
const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX;
945933
const unique = [ ...new Set((eventIds || []).map(id => String(id))) ];
946934

947-
if (unique.length > max) {
948-
throw new Error(`bulkToggleEventMark: at most ${max} event ids allowed`);
949-
}
950-
951935
const failedEventIds = [];
952936
const validObjectIds = [];
953937

@@ -1023,6 +1007,70 @@ class EventsFactory extends Factory {
10231007
};
10241008
}
10251009

1010+
/**
1011+
* Bulk set/clear assignee for many original events.
1012+
*
1013+
* @param {string[]} eventIds - original event ids
1014+
* @param {string|null|undefined} assignee - target assignee id, null/undefined to clear
1015+
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
1016+
*/
1017+
async bulkUpdateAssignee(eventIds, assignee) {
1018+
const unique = [ ...new Set((eventIds || []).map(id => String(id))) ];
1019+
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+
}
1037+
1038+
const collection = this.getCollection(this.TYPES.EVENTS);
1039+
const found = await collection.find({ _id: { $in: validObjectIds } }).toArray();
1040+
const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc]));
1041+
1042+
for (const oid of validObjectIds) {
1043+
const idStr = oid.toString();
1044+
1045+
if (!foundByIdStr.has(idStr)) {
1046+
failedEventIds.push(idStr);
1047+
}
1048+
}
1049+
1050+
const normalizedAssignee = assignee ? String(assignee) : '';
1051+
const docsToUpdate = found.filter(doc => String(doc.assignee || '') !== normalizedAssignee);
1052+
const updatedEventIds = docsToUpdate.map(doc => doc._id.toString());
1053+
1054+
if (docsToUpdate.length === 0) {
1055+
return {
1056+
updatedCount: 0,
1057+
updatedEventIds: [],
1058+
failedEventIds,
1059+
};
1060+
}
1061+
1062+
const updateManyResult = await collection.updateMany(
1063+
{ _id: { $in: docsToUpdate.map(doc => doc._id) } },
1064+
{ $set: { assignee: normalizedAssignee } }
1065+
);
1066+
1067+
return {
1068+
updatedCount: updateManyResult.modifiedCount,
1069+
updatedEventIds,
1070+
failedEventIds,
1071+
};
1072+
}
1073+
10261074
/**
10271075
* Remove all project events
10281076
*

src/resolvers/event.js

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,37 @@ const sendPersonalNotification = require('../utils/personalNotifications').defau
33
const { aiService } = require('../services/ai');
44
const { UserInputError } = require('apollo-server-express');
55

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+
637
/**
738
* See all types and fields here {@see ../typeDefs/event.graphql}
839
*/
@@ -163,7 +194,7 @@ module.exports = {
163194
* @param {string[]} eventIds - original event ids
164195
* @param {string} mark - EventMark enum value
165196
* @param {object} context - gql context
166-
* @return {Promise<{ updatedCount: number, failedEventIds: string[] }>}
197+
* @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
167198
*/
168199
async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) {
169200
if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') {
@@ -176,15 +207,7 @@ module.exports = {
176207

177208
const factory = getEventsFactory(context, projectId);
178209

179-
try {
180-
return await factory.bulkToggleEventMark(eventIds, mark);
181-
} catch (err) {
182-
if (err.message && err.message.includes('bulkToggleEventMark: at most')) {
183-
throw new UserInputError(err.message);
184-
}
185-
186-
throw err;
187-
}
210+
return factory.bulkToggleEventMark(eventIds, mark);
188211
},
189212

190213
/**
@@ -230,14 +253,12 @@ module.exports = {
230253

231254
const assigneeData = await factories.usersFactory.dataLoaders.userById.load(assignee);
232255

233-
await sendPersonalNotification(assigneeData, {
234-
type: 'assignee',
235-
payload: {
236-
assigneeId: assignee,
237-
projectId,
238-
whoAssignedId: user.id,
239-
eventId,
240-
},
256+
fireAndForgetAssigneeNotifications({
257+
assigneeData,
258+
eventIds: [ eventId ],
259+
projectId,
260+
assigneeId: assignee,
261+
whoAssignedId: user.id,
241262
});
242263

243264
return {
@@ -264,5 +285,58 @@ module.exports = {
264285
success: !!result.acknowledged,
265286
};
266287
},
288+
289+
/**
290+
* Bulk set/clear assignee for selected original events
291+
*
292+
* @param {ResolverObj} _obj - resolver context
293+
* @param {BulkUpdateAssigneeInput} input - object of arguments
294+
* @param factories - factories for working with models
295+
* @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
296+
*/
297+
async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) {
298+
const { projectId, eventIds, assignee } = input;
299+
const factory = getEventsFactory(context, projectId);
300+
301+
if (!eventIds || !eventIds.length) {
302+
throw new UserInputError('eventIds must contain at least one id');
303+
}
304+
305+
if (assignee) {
306+
const userExists = await factories.usersFactory.findById(assignee);
307+
308+
if (!userExists) {
309+
throw new UserInputError('assignee not found');
310+
}
311+
312+
const project = await factories.projectsFactory.findById(projectId);
313+
const workspace = await factories.workspacesFactory.findById(project.workspaceId);
314+
const assigneeExistsInWorkspace = await workspace.getMemberInfo(assignee);
315+
316+
if (!assigneeExistsInWorkspace) {
317+
throw new UserInputError('assignee is not a workspace member');
318+
}
319+
}
320+
321+
const result = await factory.bulkUpdateAssignee(eventIds, assignee);
322+
323+
if (assignee && result.updatedEventIds.length > 0) {
324+
void factories.usersFactory.dataLoaders.userById.load(assignee)
325+
.then((assigneeData) => {
326+
fireAndForgetAssigneeNotifications({
327+
assigneeData,
328+
eventIds: result.updatedEventIds,
329+
projectId,
330+
assigneeId: assignee,
331+
whoAssignedId: user.id,
332+
});
333+
})
334+
.catch((error) => {
335+
console.error('Failed to load assignee data for bulk notifications', error);
336+
});
337+
}
338+
339+
return result;
340+
},
267341
},
268342
};

src/typeDefs/event.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,13 +445,47 @@ input RemoveAssigneeInput {
445445
eventId: ID!
446446
}
447447
448+
input BulkUpdateAssigneeInput {
449+
"""
450+
ID of project event is related to
451+
"""
452+
projectId: ID!
453+
454+
"""
455+
Original event ids to update
456+
"""
457+
eventIds: [ID!]!
458+
459+
"""
460+
Assignee id to set. Pass null to clear assignee.
461+
"""
462+
assignee: ID
463+
}
464+
448465
type RemoveAssigneeResponse {
449466
"""
450467
Response status
451468
"""
452469
success: Boolean!
453470
}
454471
472+
type BulkUpdateAssigneeResponse {
473+
"""
474+
Number of events updated in the database
475+
"""
476+
updatedCount: Int!
477+
478+
"""
479+
Original event ids actually updated in this operation
480+
"""
481+
updatedEventIds: [ID!]!
482+
483+
"""
484+
Event ids that were not updated (invalid id or not found)
485+
"""
486+
failedEventIds: [ID!]!
487+
}
488+
455489
"""
456490
Result of bulk toggling event marks (resolve / ignore)
457491
"""
@@ -486,6 +520,13 @@ type EventsMutations {
486520
removeAssignee(
487521
input: RemoveAssigneeInput!
488522
): RemoveAssigneeResponse! @requireUserInWorkspace
523+
524+
"""
525+
Bulk set/clear assignee on many original events
526+
"""
527+
bulkUpdateAssignee(
528+
input: BulkUpdateAssigneeInput!
529+
): BulkUpdateAssigneeResponse! @requireUserInWorkspace
489530
}
490531
491532
extend type Mutation {

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

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,6 @@ describe('EventsFactory.bulkToggleEventMark', () => {
4949
});
5050
});
5151

52-
it('should throw when mark is unsupported', async () => {
53-
const factory = new EventsFactory(projectId);
54-
55-
await expect(factory.bulkToggleEventMark([], 'some-unknown-mark' as any)).rejects.toThrow(
56-
'bulkToggleEventMark: mark must be resolved, ignored or starred'
57-
);
58-
});
59-
6052
it('should support starred mark', async () => {
6153
const factory = new EventsFactory(projectId);
6254
const id = new ObjectId();
@@ -89,16 +81,6 @@ describe('EventsFactory.bulkToggleEventMark', () => {
8981
);
9082
});
9183

92-
it('should reject more than BULK_TOGGLE_EVENT_MARK_MAX unique ids', async () => {
93-
const factory = new EventsFactory(projectId);
94-
const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX;
95-
const ids = Array.from({ length: max + 1 }, (_, i) => `id-${i}`);
96-
97-
await expect(factory.bulkToggleEventMark(ids, 'ignored')).rejects.toThrow(
98-
`bulkToggleEventMark: at most ${max} event ids allowed`
99-
);
100-
});
101-
10284
it('should deduplicate duplicate event ids before applying', async () => {
10385
const factory = new EventsFactory(projectId);
10486
const id = new ObjectId();

0 commit comments

Comments
 (0)