Skip to content

Commit 29a4b17

Browse files
authored
Merge pull request #646 from codex-team/feat/events-multiselect-bulk-actions
Feat/events multiselect bulk actions
2 parents 7f46377 + 81a682f commit 29a4b17

8 files changed

Lines changed: 83 additions & 36 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.5.3",
3+
"version": "1.5.0",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/models/eventsFactory.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,7 +889,7 @@ class EventsFactory extends Factory {
889889
* @param {string|ObjectId} userId - id of the user who is visiting events
890890
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
891891
*/
892-
async bulkVisitEvent(eventIds, userId) {
892+
async bulkVisitEvents(eventIds, userId) {
893893
const {
894894
collection,
895895
found,

src/resolvers/event.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const {
66
} = require('./helpers/bulkEvents');
77
const { aiService } = require('../services/ai');
88
const { UserInputError } = require('apollo-server-express');
9+
const { ObjectId } = require('mongodb');
910

1011
/**
1112
* See all types and fields here {@see ../typeDefs/event.graphql}
@@ -161,7 +162,7 @@ module.exports = {
161162
}
162163

163164
const factory = getEventsFactory(context, projectId);
164-
const result = await factory.bulkVisitEvent(validEventIds, user.id);
165+
const result = await factory.bulkVisitEvents(validEventIds, user.id);
165166

166167
return {
167168
...result,
@@ -317,6 +318,10 @@ module.exports = {
317318
const factory = getEventsFactory(context, projectId);
318319

319320
if (assignee) {
321+
if (!ObjectId.isValid(String(assignee))) {
322+
throw new UserInputError('assignee must be a valid id or null');
323+
}
324+
320325
const userExists = await factories.usersFactory.findById(assignee);
321326

322327
if (!userExists) {

src/resolvers/helpers/bulkEvents.js

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ const sendPersonalNotification = require('../../utils/personalNotifications').de
22
const { UserInputError } = require('apollo-server-express');
33
const { ObjectId } = require('mongodb');
44

5+
const ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE = 25;
6+
57
/**
68
* Enqueue assignee notifications in background (do not block resolver response)
79
*
@@ -26,20 +28,40 @@ function fireAndForgetAssigneeNotifications({
2628
return;
2729
}
2830

29-
Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, {
30-
type: 'assignee',
31-
payload: {
32-
assigneeId,
33-
projectId,
34-
whoAssignedId,
35-
eventId,
36-
},
37-
})))
38-
.then((results) => {
39-
const failedResults = results.filter(result => result.status === 'rejected');
31+
Promise.resolve()
32+
.then(async () => {
33+
const failedResults = [];
34+
35+
for (let i = 0; i < eventIds.length; i += ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE) {
36+
const chunk = eventIds.slice(i, i + ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE);
37+
const results = await Promise.allSettled(chunk.map(eventId => sendPersonalNotification(assigneeData, {
38+
type: 'assignee',
39+
payload: {
40+
assigneeId,
41+
projectId,
42+
whoAssignedId,
43+
eventId,
44+
},
45+
})));
46+
47+
failedResults.push(...results.filter(result => result.status === 'rejected'));
48+
}
4049

4150
if (failedResults.length > 0) {
42-
console.error('Failed to enqueue assignee notifications', failedResults);
51+
const failedMessages = failedResults.map((result) => {
52+
const reason = result && result.reason;
53+
54+
if (reason && typeof reason.message === 'string') {
55+
return reason.message;
56+
}
57+
58+
return String(reason || 'Unknown error');
59+
});
60+
61+
console.error('Failed to enqueue assignee notifications', {
62+
failedCount: failedResults.length,
63+
errors: failedMessages,
64+
});
4365
}
4466
})
4567
.catch((error) => {

src/typeDefs/event.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ type BulkUpdateAssigneeResponse {
487487
}
488488
489489
"""
490-
Result of bulk toggling event marks (resolve / ignore)
490+
Result of bulk toggling event marks (resolve / ignore / starred)
491491
"""
492492
type BulkToggleEventMarksResult {
493493
"""
@@ -602,7 +602,8 @@ extend type Mutation {
602602
603603
"""
604604
Toggle the same mark on many original events at once (resolved, ignored or starred).
605-
Same toggle semantics as toggleEventMark per event.
605+
Uses bulk semantics: if every selected event already has the mark, clear it for all;
606+
otherwise set it on each selected event that does not have it yet.
606607
"""
607608
bulkToggleEventMarks(
608609
"""

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jest.mock('../../src/mongo', () => ({
3535
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class
3636
const EventsFactory = require('../../src/models/eventsFactory') as any;
3737

38-
describe('EventsFactory.bulkVisitEvent', () => {
38+
describe('EventsFactory.bulkVisitEvents', () => {
3939
const projectId = '507f1f77bcf86cd799439011';
4040

4141
beforeEach(() => {
@@ -57,7 +57,7 @@ describe('EventsFactory.bulkVisitEvent', () => {
5757
});
5858
collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 });
5959

60-
const result = await factory.bulkVisitEvent([ a.toString(), b.toString() ], userId.toString());
60+
const result = await factory.bulkVisitEvents([ a.toString(), b.toString() ], userId.toString());
6161

6262
expect(result.updatedCount).toBe(1);
6363
expect(result.updatedEventIds).toEqual([ b.toString() ]);
@@ -72,7 +72,7 @@ describe('EventsFactory.bulkVisitEvent', () => {
7272
toArray: () => Promise.resolve([]),
7373
});
7474

75-
const result = await factory.bulkVisitEvent([ missing.toString() ], new ObjectId().toString());
75+
const result = await factory.bulkVisitEvents([ missing.toString() ], new ObjectId().toString());
7676

7777
expect(result.updatedCount).toBe(0);
7878
expect(result.updatedEventIds).toEqual([]);

test/resolvers/event-bulk-update-assignee.test.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,17 @@ const eventResolvers = require('../../src/resolvers/event') as {
2626
};
2727

2828
const bulkUpdateAssignee = jest.fn();
29+
const ASSIGNEE_ID = '507f1f77bcf86cd799439012';
2930

3031
describe('EventsMutations.bulkUpdateAssignee', () => {
3132
const ctx = {
3233
user: { id: 'u1' },
3334
factories: {
3435
usersFactory: {
35-
findById: jest.fn().mockResolvedValue({ id: 'assignee-1' }),
36+
findById: jest.fn().mockResolvedValue({ id: ASSIGNEE_ID }),
3637
dataLoaders: {
3738
userById: {
38-
load: jest.fn().mockResolvedValue({ id: 'assignee-1', email: 'a@a.a' }),
39+
load: jest.fn().mockResolvedValue({ id: ASSIGNEE_ID, email: 'a@a.a' }),
3940
},
4041
},
4142
},
@@ -44,7 +45,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => {
4445
},
4546
workspacesFactory: {
4647
findById: jest.fn().mockResolvedValue({
47-
getMemberInfo: jest.fn().mockResolvedValue({ userId: 'assignee-1' }),
48+
getMemberInfo: jest.fn().mockResolvedValue({ userId: ASSIGNEE_ID }),
4849
}),
4950
},
5051
},
@@ -66,21 +67,39 @@ describe('EventsMutations.bulkUpdateAssignee', () => {
6667
await expect(
6768
eventResolvers.EventsMutations.bulkUpdateAssignee(
6869
{},
69-
{ input: { projectId: 'p1', eventIds: [], assignee: 'assignee-1' } },
70+
{ input: { projectId: 'p1', eventIds: [], assignee: ASSIGNEE_ID } },
7071
ctx
7172
)
7273
).rejects.toThrow(UserInputError);
7374
expect(bulkUpdateAssignee).not.toHaveBeenCalled();
7475
});
7576

77+
it('should throw when assignee id is invalid', async () => {
78+
await expect(
79+
eventResolvers.EventsMutations.bulkUpdateAssignee(
80+
{},
81+
{
82+
input: {
83+
projectId: 'p1',
84+
eventIds: [ '507f1f77bcf86cd799439011' ],
85+
assignee: 'not-an-object-id',
86+
},
87+
},
88+
ctx
89+
)
90+
).rejects.toThrow(UserInputError);
91+
92+
expect(bulkUpdateAssignee).not.toHaveBeenCalled();
93+
});
94+
7695
it('should call factory for bulk assign', async () => {
7796
const result = await eventResolvers.EventsMutations.bulkUpdateAssignee(
7897
{},
7998
{
8099
input: {
81100
projectId: 'p1',
82101
eventIds: [ '507f1f77bcf86cd799439011' ],
83-
assignee: 'assignee-1',
102+
assignee: ASSIGNEE_ID,
84103
},
85104
},
86105
ctx
@@ -89,15 +108,15 @@ describe('EventsMutations.bulkUpdateAssignee', () => {
89108
expect(result.updatedCount).toBe(1);
90109
expect(bulkUpdateAssignee).toHaveBeenCalledWith(
91110
[ '507f1f77bcf86cd799439011' ],
92-
'assignee-1'
111+
ASSIGNEE_ID
93112
);
94113
expect(sendPersonalNotification).toHaveBeenCalledTimes(1);
95114
expect(sendPersonalNotification).toHaveBeenCalledWith(
96-
expect.objectContaining({ id: 'assignee-1' }),
115+
expect.objectContaining({ id: ASSIGNEE_ID }),
97116
expect.objectContaining({
98117
type: 'assignee',
99118
payload: expect.objectContaining({
100-
assigneeId: 'assignee-1',
119+
assigneeId: ASSIGNEE_ID,
101120
projectId: 'p1',
102121
whoAssignedId: 'u1',
103122
eventId: '507f1f77bcf86cd799439011',
@@ -119,15 +138,15 @@ describe('EventsMutations.bulkUpdateAssignee', () => {
119138
input: {
120139
projectId: 'p1',
121140
eventIds: [ '507f1f77bcf86cd799439011', 'invalid-id' ],
122-
assignee: 'assignee-1',
141+
assignee: ASSIGNEE_ID,
123142
},
124143
},
125144
ctx
126145
);
127146

128147
expect(bulkUpdateAssignee).toHaveBeenCalledWith(
129148
[ '507f1f77bcf86cd799439011' ],
130-
'assignee-1'
149+
ASSIGNEE_ID
131150
);
132151
expect(result).toEqual({
133152
updatedCount: 1,
@@ -143,7 +162,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => {
143162
input: {
144163
projectId: 'p1',
145164
eventIds: [ 'bad-1', 'bad-2' ],
146-
assignee: 'assignee-1',
165+
assignee: ASSIGNEE_ID,
147166
},
148167
},
149168
ctx

test/resolvers/event-bulk-visit.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const eventResolvers = require('../../src/resolvers/event') as {
1717
};
1818
};
1919

20-
const bulkVisitEvent = jest.fn();
20+
const bulkVisitEvents = jest.fn();
2121

2222
describe('Mutation.bulkVisitEvents', () => {
2323
const ctx = {
@@ -26,11 +26,11 @@ describe('Mutation.bulkVisitEvents', () => {
2626

2727
beforeEach(() => {
2828
jest.clearAllMocks();
29-
(getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvent });
29+
(getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvents });
3030
});
3131

3232
it('should call factory with valid ids only and merge invalid ids', async () => {
33-
bulkVisitEvent.mockResolvedValue({
33+
bulkVisitEvents.mockResolvedValue({
3434
updatedCount: 1,
3535
updatedEventIds: [ '507f1f77bcf86cd799439012' ],
3636
failedEventIds: [ '507f1f77bcf86cd799439099' ],
@@ -42,7 +42,7 @@ describe('Mutation.bulkVisitEvents', () => {
4242
ctx
4343
);
4444

45-
expect(bulkVisitEvent).toHaveBeenCalledWith(
45+
expect(bulkVisitEvents).toHaveBeenCalledWith(
4646
[ '507f1f77bcf86cd799439012' ],
4747
'507f1f77bcf86cd799439011'
4848
);
@@ -60,7 +60,7 @@ describe('Mutation.bulkVisitEvents', () => {
6060
ctx
6161
);
6262

63-
expect(bulkVisitEvent).not.toHaveBeenCalled();
63+
expect(bulkVisitEvents).not.toHaveBeenCalled();
6464
expect(result).toEqual({
6565
updatedCount: 0,
6666
updatedEventIds: [],

0 commit comments

Comments
 (0)