Skip to content

Commit 530adaa

Browse files
committed
feat(events): implement bulkVisitEvents functionality to mark multiple events as visited for a user
1 parent ace53d0 commit 530adaa

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

src/models/eventsFactory.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,57 @@ class EventsFactory extends Factory {
882882
return result;
883883
}
884884

885+
/**
886+
* Mark many original events as visited for passed user
887+
*
888+
* @param {string[]} eventIds - original event ids
889+
* @param {string|ObjectId} userId - id of the user who is visiting events
890+
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
891+
*/
892+
async bulkVisitEvent(eventIds, userId) {
893+
const unique = [ ...new Set((eventIds || []).map(id => String(id))) ];
894+
const failedEventIds = [];
895+
const validObjectIds = unique.map(id => new ObjectId(id));
896+
const userIdStr = String(userId);
897+
898+
const collection = this.getCollection(this.TYPES.EVENTS);
899+
const found = await collection.find({ _id: { $in: validObjectIds } }).toArray();
900+
const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc]));
901+
902+
for (const oid of validObjectIds) {
903+
const idStr = oid.toString();
904+
905+
if (!foundByIdStr.has(idStr)) {
906+
failedEventIds.push(idStr);
907+
}
908+
}
909+
910+
const docsToUpdate = found.filter((doc) => {
911+
const visitedBy = Array.isArray(doc.visitedBy) ? doc.visitedBy : [];
912+
return !visitedBy.some((visitedUserId) => String(visitedUserId) === userIdStr);
913+
});
914+
const updatedEventIds = docsToUpdate.map(doc => doc._id.toString());
915+
916+
if (docsToUpdate.length === 0) {
917+
return {
918+
updatedCount: 0,
919+
updatedEventIds: [],
920+
failedEventIds,
921+
};
922+
}
923+
924+
const updateManyResult = await collection.updateMany(
925+
{ _id: { $in: docsToUpdate.map(doc => doc._id) } },
926+
{ $addToSet: { visitedBy: new ObjectId(userId) } }
927+
);
928+
929+
return {
930+
updatedCount: updateManyResult.modifiedCount,
931+
updatedEventIds,
932+
failedEventIds,
933+
};
934+
}
935+
885936
/**
886937
* Mark or unmark event as Resolved, Ignored or Starred
887938
*

src/resolvers/event.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,34 @@ module.exports = {
140140

141141
return !!result.acknowledged;
142142
},
143+
/**
144+
* Mark many original events as visited for current user
145+
*
146+
* @param {ResolverObj} _obj - resolver context
147+
* @param {string} projectId - project id
148+
* @param {string[]} eventIds - original event ids
149+
* @param {UserInContext} user - user context
150+
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
151+
*/
152+
async bulkVisitEvents(_obj, { projectId, eventIds }, { user, ...context }) {
153+
const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds);
154+
155+
if (validEventIds.length === 0) {
156+
return {
157+
updatedCount: 0,
158+
updatedEventIds: [],
159+
failedEventIds: invalidEventIds,
160+
};
161+
}
162+
163+
const factory = getEventsFactory(context, projectId);
164+
const result = await factory.bulkVisitEvent(validEventIds, user.id);
165+
166+
return {
167+
...result,
168+
failedEventIds: mergeFailedEventIds(result, invalidEventIds),
169+
};
170+
},
143171

144172
/**
145173
* Mark event with one of the event marks

src/typeDefs/event.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,26 @@ type BulkToggleEventMarksResult {
506506
failedEventIds: [ID!]!
507507
}
508508
509+
"""
510+
Result of bulk marking events as viewed
511+
"""
512+
type BulkVisitEventsResult {
513+
"""
514+
Number of events updated in the database
515+
"""
516+
updatedCount: Int!
517+
518+
"""
519+
Original event ids actually updated in this operation
520+
"""
521+
updatedEventIds: [ID!]!
522+
523+
"""
524+
Event ids that were not updated (invalid id or not found)
525+
"""
526+
failedEventIds: [ID!]!
527+
}
528+
509529
type EventsMutations {
510530
"""
511531
Set an assignee for the selected event
@@ -545,6 +565,21 @@ extend type Mutation {
545565
eventId: ID!
546566
): Boolean!
547567
568+
"""
569+
Mark many original events as visited for current user
570+
"""
571+
bulkVisitEvents(
572+
"""
573+
ID of project event is related to
574+
"""
575+
projectId: ID!
576+
577+
"""
578+
Original event ids
579+
"""
580+
eventIds: [ID!]!
581+
): BulkVisitEventsResult! @requireUserInWorkspace
582+
548583
"""
549584
Mutation sets or unsets passed mark to event
550585
"""
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import '../../src/env-test';
2+
import { ObjectId } from 'mongodb';
3+
4+
const collectionMock = {
5+
find: jest.fn(),
6+
updateMany: jest.fn(),
7+
};
8+
9+
jest.mock('../../src/redisHelper', () => ({
10+
__esModule: true,
11+
default: {
12+
getInstance: () => ({}),
13+
},
14+
}));
15+
16+
jest.mock('../../src/services/chartDataService', () => ({
17+
__esModule: true,
18+
default: jest.fn().mockImplementation(function () {
19+
return {};
20+
}),
21+
}));
22+
23+
jest.mock('../../src/dataLoaders', () => ({
24+
createProjectEventsByIdLoader: () => ({}),
25+
}));
26+
27+
jest.mock('../../src/mongo', () => ({
28+
databases: {
29+
events: {
30+
collection: jest.fn(() => collectionMock),
31+
},
32+
},
33+
}));
34+
35+
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class
36+
const EventsFactory = require('../../src/models/eventsFactory') as any;
37+
38+
describe('EventsFactory.bulkVisitEvent', () => {
39+
const projectId = '507f1f77bcf86cd799439011';
40+
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
collectionMock.updateMany.mockResolvedValue({ modifiedCount: 0 });
44+
});
45+
46+
it('should mark only not-yet-visited events', async () => {
47+
const factory = new EventsFactory(projectId);
48+
const a = new ObjectId();
49+
const b = new ObjectId();
50+
const userId = new ObjectId();
51+
52+
collectionMock.find.mockReturnValue({
53+
toArray: () => Promise.resolve([
54+
{ _id: a, visitedBy: [ userId ] },
55+
{ _id: b, visitedBy: [] },
56+
]),
57+
});
58+
collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 });
59+
60+
const result = await factory.bulkVisitEvent([ a.toString(), b.toString() ], userId.toString());
61+
62+
expect(result.updatedCount).toBe(1);
63+
expect(result.updatedEventIds).toEqual([ b.toString() ]);
64+
expect(result.failedEventIds).toEqual([]);
65+
});
66+
67+
it('should add not found ids to failedEventIds', async () => {
68+
const factory = new EventsFactory(projectId);
69+
const missing = new ObjectId();
70+
71+
collectionMock.find.mockReturnValue({
72+
toArray: () => Promise.resolve([]),
73+
});
74+
75+
const result = await factory.bulkVisitEvent([ missing.toString() ], new ObjectId().toString());
76+
77+
expect(result.updatedCount).toBe(0);
78+
expect(result.updatedEventIds).toEqual([]);
79+
expect(result.failedEventIds).toEqual([ missing.toString() ]);
80+
expect(collectionMock.updateMany).not.toHaveBeenCalled();
81+
});
82+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import '../../src/env-test';
2+
3+
jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({
4+
__esModule: true,
5+
default: jest.fn(),
6+
}));
7+
8+
import getEventsFactory from '../../src/resolvers/helpers/eventsFactory';
9+
// eslint-disable-next-line @typescript-eslint/no-var-requires
10+
const eventResolvers = require('../../src/resolvers/event') as {
11+
Mutation: {
12+
bulkVisitEvents: (
13+
o: unknown,
14+
args: { projectId: string; eventIds: string[] },
15+
ctx: any
16+
) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>;
17+
};
18+
};
19+
20+
const bulkVisitEvent = jest.fn();
21+
22+
describe('Mutation.bulkVisitEvents', () => {
23+
const ctx = {
24+
user: { id: '507f1f77bcf86cd799439011' },
25+
};
26+
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
(getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvent });
30+
});
31+
32+
it('should call factory with valid ids only and merge invalid ids', async () => {
33+
bulkVisitEvent.mockResolvedValue({
34+
updatedCount: 1,
35+
updatedEventIds: [ '507f1f77bcf86cd799439012' ],
36+
failedEventIds: [ '507f1f77bcf86cd799439099' ],
37+
});
38+
39+
const result = await eventResolvers.Mutation.bulkVisitEvents(
40+
{},
41+
{ projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012', 'bad-id' ] },
42+
ctx
43+
);
44+
45+
expect(bulkVisitEvent).toHaveBeenCalledWith(
46+
[ '507f1f77bcf86cd799439012' ],
47+
'507f1f77bcf86cd799439011'
48+
);
49+
expect(result).toEqual({
50+
updatedCount: 1,
51+
updatedEventIds: [ '507f1f77bcf86cd799439012' ],
52+
failedEventIds: [ '507f1f77bcf86cd799439099', 'bad-id' ],
53+
});
54+
});
55+
56+
it('should return early when all ids are invalid', async () => {
57+
const result = await eventResolvers.Mutation.bulkVisitEvents(
58+
{},
59+
{ projectId: 'p1', eventIds: [ 'bad-1', 'bad-2' ] },
60+
ctx
61+
);
62+
63+
expect(bulkVisitEvent).not.toHaveBeenCalled();
64+
expect(result).toEqual({
65+
updatedCount: 0,
66+
updatedEventIds: [],
67+
failedEventIds: [ 'bad-1', 'bad-2' ],
68+
});
69+
});
70+
});

0 commit comments

Comments
 (0)