diff --git a/package.json b/package.json index 6db19b0b..2eb5eb56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.12", + "version": "1.5.3", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index d76c49bd..fccae66d 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -882,6 +882,48 @@ class EventsFactory extends Factory { return result; } + /** + * Mark many original events as visited for passed user + * + * @param {string[]} eventIds - original event ids + * @param {string|ObjectId} userId - id of the user who is visiting events + * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + */ + async bulkVisitEvent(eventIds, userId) { + const { + collection, + found, + failedEventIds, + } = await this._resolveBulkEventsByIds(eventIds); + const userIdStr = String(userId); + + const docsToUpdate = found.filter((doc) => { + const visitedBy = Array.isArray(doc.visitedBy) ? doc.visitedBy : []; + + return !visitedBy.some((visitedUserId) => String(visitedUserId) === userIdStr); + }); + const updatedEventIds = docsToUpdate.map(doc => doc._id.toString()); + + if (docsToUpdate.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds, + }; + } + + const updateManyResult = await collection.updateMany( + { _id: { $in: docsToUpdate.map(doc => doc._id) } }, + { $addToSet: { visitedBy: new ObjectId(userId) } } + ); + + return { + updatedCount: updateManyResult.modifiedCount, + updatedEventIds, + failedEventIds, + }; + } + /** * Mark or unmark event as Resolved, Ignored or Starred * @@ -918,6 +960,102 @@ class EventsFactory extends Factory { return collection.updateOne(query, update); } + /** + * Bulk toggle mark for original events. + * + * @param {string[]} eventIds - original event ids + * @param {string} mark - 'resolved' | 'ignored' | 'starred' + * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + */ + async bulkToggleEventMark(eventIds, mark) { + const { + collection, + found, + failedEventIds, + } = await this._resolveBulkEventsByIds(eventIds); + + const nowSec = Math.floor(Date.now() / 1000); + const markKey = `marks.${mark}`; + const allHaveMark = found.length > 0 && found.every(doc => doc.marks && doc.marks[mark]); + const ops = []; + const updatedEventIds = []; + + for (const doc of found) { + const hasMark = doc.marks && doc.marks[mark]; + let update; + + if (allHaveMark) { + update = { $unset: { [markKey]: '' } }; + } else if (!hasMark) { + update = { $set: { [markKey]: nowSec } }; + } else { + continue; + } + + ops.push({ + updateOne: { + filter: { _id: doc._id }, + update, + }, + }); + updatedEventIds.push(doc._id.toString()); + } + + if (ops.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds, + }; + } + + const bulkResult = await collection.bulkWrite(ops, { ordered: false }); + + return { + updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount, + updatedEventIds, + failedEventIds, + }; + } + + /** + * Bulk set/clear assignee for many original events. + * + * @param {string[]} eventIds - original event ids + * @param {string|null|undefined} assignee - target assignee id, null/undefined to clear + * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + */ + async bulkUpdateAssignee(eventIds, assignee) { + const { + collection, + found, + failedEventIds, + } = await this._resolveBulkEventsByIds(eventIds); + + const normalizedAssignee = assignee ? String(assignee) : ''; + const docsToUpdate = found.filter(doc => String(doc.assignee || '') !== normalizedAssignee); + const updatedEventIds = docsToUpdate.map(doc => doc._id.toString()); + + if (docsToUpdate.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds, + }; + } + + const updateManyResult = await collection.updateMany( + { _id: { $in: docsToUpdate.map(doc => doc._id) } }, + { $set: { assignee: normalizedAssignee } } + ); + + return { + updatedCount: updateManyResult.modifiedCount, + updatedEventIds, + failedEventIds, + }; + } + /** * Remove all project events * @@ -966,6 +1104,29 @@ class EventsFactory extends Factory { return result; } + /** + * Resolve original events for bulk operations and collect not found ids. + * + * @param {string[]} eventIds - original event ids + * @returns {Promise<{ collection: any, found: any[], failedEventIds: string[] }>} + */ + async _resolveBulkEventsByIds(eventIds) { + const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const objectIds = unique.map(id => new ObjectId(id)); + const collection = this.getCollection(this.TYPES.EVENTS); + const found = await collection.find({ _id: { $in: objectIds } }).toArray(); + const foundByIdStr = new Set(found.map(doc => doc._id.toString())); + const failedEventIds = objectIds + .map(id => id.toString()) + .filter(id => !foundByIdStr.has(id)); + + return { + collection, + found, + failedEventIds, + }; + } + /** * Compose event with repetition * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index c3c44971..af1d6f70 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,6 +1,11 @@ const getEventsFactory = require('./helpers/eventsFactory').default; -const sendPersonalNotification = require('../utils/personalNotifications').default; +const { + fireAndForgetAssigneeNotifications, + parseBulkEventIds, + mergeFailedEventIds, +} = require('./helpers/bulkEvents'); const { aiService } = require('../services/ai'); +const { UserInputError } = require('apollo-server-express'); /** * See all types and fields here {@see ../typeDefs/event.graphql} @@ -135,6 +140,34 @@ module.exports = { return !!result.acknowledged; }, + /** + * Mark many original events as visited for current user + * + * @param {ResolverObj} _obj - resolver context + * @param {string} projectId - project id + * @param {string[]} eventIds - original event ids + * @param {UserInContext} user - user context + * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + */ + async bulkVisitEvents(_obj, { projectId, eventIds }, { user, ...context }) { + const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); + + if (validEventIds.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds: invalidEventIds, + }; + } + + const factory = getEventsFactory(context, projectId); + const result = await factory.bulkVisitEvent(validEventIds, user.id); + + return { + ...result, + failedEventIds: mergeFailedEventIds(result, invalidEventIds), + }; + }, /** * Mark event with one of the event marks @@ -153,6 +186,37 @@ module.exports = { return !!result.acknowledged; }, + /** + * Bulk set resolved/ignored: always set mark on events that lack it, unless all selected + * already have the mark — then remove from all. + * + * @param {ResolverObj} _obj - resolver context + * @param {string} projectId - project id + * @param {string[]} eventIds - original event ids + * @param {string} mark - EventMark enum value + * @param {object} context - gql context + * @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + */ + async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { + const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); + + if (validEventIds.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds: invalidEventIds, + }; + } + + const factory = getEventsFactory(context, projectId); + const result = await factory.bulkToggleEventMark(validEventIds, mark); + + return { + ...result, + failedEventIds: mergeFailedEventIds(result, invalidEventIds), + }; + }, + /** * Mutations namespace * @@ -196,14 +260,12 @@ module.exports = { const assigneeData = await factories.usersFactory.dataLoaders.userById.load(assignee); - await sendPersonalNotification(assigneeData, { - type: 'assignee', - payload: { - assigneeId: assignee, - projectId, - whoAssignedId: user.id, - eventId, - }, + fireAndForgetAssigneeNotifications({ + assigneeData, + eventIds: [ eventId ], + projectId, + assigneeId: assignee, + whoAssignedId: user.id, }); return { @@ -230,5 +292,65 @@ module.exports = { success: !!result.acknowledged, }; }, + + /** + * Bulk set/clear assignee for selected original events + * + * @param {ResolverObj} _obj - resolver context + * @param {BulkUpdateAssigneeInput} input - object of arguments + * @param factories - factories for working with models + * @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + */ + async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) { + const { projectId, eventIds, assignee } = input; + const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); + let assigneeData = null; + + if (validEventIds.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds: invalidEventIds, + }; + } + + const factory = getEventsFactory(context, projectId); + + if (assignee) { + const userExists = await factories.usersFactory.findById(assignee); + + if (!userExists) { + throw new UserInputError('assignee not found'); + } + + assigneeData = userExists; + + const project = await factories.projectsFactory.findById(projectId); + const workspace = await factories.workspacesFactory.findById(project.workspaceId); + const assigneeExistsInWorkspace = await workspace.getMemberInfo(assignee); + + if (!assigneeExistsInWorkspace) { + throw new UserInputError('assignee is not a workspace member'); + } + } + + const result = await factory.bulkUpdateAssignee(validEventIds, assignee); + const resultWithInvalid = { + ...result, + failedEventIds: mergeFailedEventIds(result, invalidEventIds), + }; + + if (assignee && resultWithInvalid.updatedEventIds.length > 0) { + fireAndForgetAssigneeNotifications({ + assigneeData, + eventIds: resultWithInvalid.updatedEventIds, + projectId, + assigneeId: assignee, + whoAssignedId: user.id, + }); + } + + return resultWithInvalid; + }, }, }; diff --git a/src/resolvers/helpers/bulkEvents.js b/src/resolvers/helpers/bulkEvents.js new file mode 100644 index 00000000..4d0238a5 --- /dev/null +++ b/src/resolvers/helpers/bulkEvents.js @@ -0,0 +1,94 @@ +const sendPersonalNotification = require('../../utils/personalNotifications').default; +const { UserInputError } = require('apollo-server-express'); +const { ObjectId } = require('mongodb'); + +/** + * Enqueue assignee notifications in background (do not block resolver response) + * + * @param {object} args - notification args + * @param {object} args.assigneeData - assigned user data + * @param {string[]} args.eventIds - original event ids + * @param {string} args.projectId - project id + * @param {string} args.assigneeId - assignee id + * @param {string} args.whoAssignedId - user id who performed assignment + * @returns {void} + */ +function fireAndForgetAssigneeNotifications({ + assigneeData, + eventIds, + projectId, + assigneeId, + whoAssignedId, +}) { + if (!assigneeData) { + console.error('Failed to enqueue assignee notifications: assignee data is empty'); + + return; + } + + Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, { + type: 'assignee', + payload: { + assigneeId, + projectId, + whoAssignedId, + eventId, + }, + }))) + .then((results) => { + const failedResults = results.filter(result => result.status === 'rejected'); + + if (failedResults.length > 0) { + console.error('Failed to enqueue assignee notifications', failedResults); + } + }) + .catch((error) => { + console.error('Failed to enqueue assignee notifications', error); + }); +} + +/** + * Validate and normalize bulk event ids from resolver input. + * + * @param {string[]} eventIds - raw event ids from GraphQL input + * @returns {{ validEventIds: string[], invalidEventIds: string[] }} + */ +function parseBulkEventIds(eventIds) { + if (!eventIds || !eventIds.length) { + throw new UserInputError('eventIds must contain at least one id'); + } + + const uniqueEventIds = [ ...new Set(eventIds.map(id => String(id))) ]; + const invalidEventIds = []; + const validEventIds = []; + + uniqueEventIds.forEach((id) => { + if (ObjectId.isValid(id)) { + validEventIds.push(id); + } else { + invalidEventIds.push(id); + } + }); + + return { + validEventIds, + invalidEventIds, + }; +} + +/** + * Merge failed ids returned by factory with invalid ids from resolver validation. + * + * @param {{ failedEventIds?: string[] }} result - factory response + * @param {string[]} invalidEventIds - invalid ids detected on resolver level + * @returns {string[]} + */ +function mergeFailedEventIds(result, invalidEventIds) { + return [ ...new Set([...(result.failedEventIds || []), ...invalidEventIds]) ]; +} + +module.exports = { + fireAndForgetAssigneeNotifications, + parseBulkEventIds, + mergeFailedEventIds, +}; diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c200de96..849c708a 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -445,6 +445,23 @@ input RemoveAssigneeInput { eventId: ID! } +input BulkUpdateAssigneeInput { + """ + ID of project event is related to + """ + projectId: ID! + + """ + Original event ids to update + """ + eventIds: [ID!]! + + """ + Assignee id to set. Pass null to clear assignee. + """ + assignee: ID +} + type RemoveAssigneeResponse { """ Response status @@ -452,6 +469,63 @@ type RemoveAssigneeResponse { success: Boolean! } +type BulkUpdateAssigneeResponse { + """ + Number of events updated in the database + """ + updatedCount: Int! + + """ + Original event ids actually updated in this operation + """ + updatedEventIds: [ID!]! + + """ + Event ids that were not updated (invalid id or not found) + """ + failedEventIds: [ID!]! +} + +""" +Result of bulk toggling event marks (resolve / ignore) +""" +type BulkToggleEventMarksResult { + """ + Number of events updated in the database + """ + updatedCount: Int! + + """ + Original event ids actually toggled in this operation + """ + updatedEventIds: [ID!]! + + """ + Event ids that were not updated (invalid id or not found) + """ + failedEventIds: [ID!]! +} + +""" +Result of bulk marking events as viewed +""" +type BulkVisitEventsResult { + """ + Number of events updated in the database + """ + updatedCount: Int! + + """ + Original event ids actually updated in this operation + """ + updatedEventIds: [ID!]! + + """ + Event ids that were not updated (invalid id or not found) + """ + failedEventIds: [ID!]! +} + type EventsMutations { """ Set an assignee for the selected event @@ -466,6 +540,13 @@ type EventsMutations { removeAssignee( input: RemoveAssigneeInput! ): RemoveAssigneeResponse! @requireUserInWorkspace + + """ + Bulk set/clear assignee on many original events + """ + bulkUpdateAssignee( + input: BulkUpdateAssigneeInput! + ): BulkUpdateAssigneeResponse! @requireUserInWorkspace } extend type Mutation { @@ -484,6 +565,21 @@ extend type Mutation { eventId: ID! ): Boolean! + """ + Mark many original events as visited for current user + """ + bulkVisitEvents( + """ + ID of project event is related to + """ + projectId: ID! + + """ + Original event ids + """ + eventIds: [ID!]! + ): BulkVisitEventsResult! @requireUserInWorkspace + """ Mutation sets or unsets passed mark to event """ @@ -504,6 +600,28 @@ extend type Mutation { mark: EventMark! ): Boolean! + """ + Toggle the same mark on many original events at once (resolved, ignored or starred). + Same toggle semantics as toggleEventMark per event. + """ + bulkToggleEventMarks( + """ + Project id + """ + projectId: ID! + + """ + Original event ids (grouped event keys in Hawk) + """ + eventIds: [ID!]! + + """ + Mark (resolved, ignored or starred): if every selected event already has it, clear it for all; + otherwise set it on every selected event that does not have it yet. + """ + mark: EventMark! + ): BulkToggleEventMarksResult! @requireUserInWorkspace + """ Namespace that contains only mutations related to the events """ diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts new file mode 100644 index 00000000..6fa85a1c --- /dev/null +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -0,0 +1,214 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + find: jest.fn(), + bulkWrite: jest.fn(), +}; + +jest.mock('../../src/redisHelper', () => ({ + __esModule: true, + default: { + getInstance: () => ({}), + }, +})); + +jest.mock('../../src/services/chartDataService', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(function () { + return {}; + }), +})); + +jest.mock('../../src/dataLoaders', () => ({ + createProjectEventsByIdLoader: () => ({}), +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + events: { + collection: jest.fn(() => collectionMock), + }, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class +const EventsFactory = require('../../src/models/eventsFactory') as any; + +describe('EventsFactory.bulkToggleEventMark', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 0, + upsertedCount: 0, + insertedCount: 0, + matchedCount: 0, + deletedCount: 0, + }); + }); + + it('should support starred mark', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { + _id: id, + marks: {}, + }, + ]), + }); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ id.toString() ], 'starred'); + + expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ id.toString() ]); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + expect(ops[0].updateOne.update).toEqual( + expect.objectContaining({ + $set: { 'marks.starred': expect.any(Number) }, + }) + ); + }); + + it('should deduplicate duplicate event ids before applying', async () => { + const factory = new EventsFactory(projectId); + const id = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { + _id: id, + marks: {}, + }, + ]), + }); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + await factory.bulkToggleEventMark([ id.toString(), id.toString(), id.toString() ], 'ignored'); + + expect(collectionMock.bulkWrite).toHaveBeenCalledTimes(1); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + }); + + it('should list valid but missing document ids in failedEventIds', async () => { + const factory = new EventsFactory(projectId); + const missing = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => Promise.resolve([]), + }); + + const result = await factory.bulkToggleEventMark([ missing.toString() ], 'ignored'); + + expect(result.updatedCount).toBe(0); + expect(result.updatedEventIds).toEqual([]); + expect(result.failedEventIds).toContain(missing.toString()); + expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); + }); + + it('should set mark only on events that do not have it when selection is mixed', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { _id: a, marks: { ignored: 1 } }, + { _id: b, marks: {} }, + ]), + }); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); + + expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ b.toString() ]); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + expect(ops[0].updateOne.filter._id).toEqual(b); + expect(ops[0].updateOne.update).toEqual( + expect.objectContaining({ + $set: { 'marks.ignored': expect.any(Number) }, + }) + ); + }); + + it('should remove mark from all when every selected event already has the mark', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { _id: a, marks: { resolved: 1 } }, + { _id: b, marks: { resolved: 2 } }, + ]), + }); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 2, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'resolved'); + + expect(result.updatedCount).toBe(2); + expect(result.updatedEventIds).toEqual([ a.toString(), b.toString() ]); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(2); + expect(ops[0].updateOne.update).toEqual({ $unset: { 'marks.resolved': '' } }); + expect(ops[1].updateOne.update).toEqual({ $unset: { 'marks.resolved': '' } }); + }); + + it('should not remove mark from a subset when only some of the found events have the mark', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => + Promise.resolve([ + { _id: a, marks: { ignored: 1 } }, + { _id: b, marks: {} }, + ]), + }); + collectionMock.bulkWrite.mockResolvedValue({ + modifiedCount: 1, + upsertedCount: 0, + }); + + const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); + + expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ b.toString() ]); + const ops = collectionMock.bulkWrite.mock.calls[0][0]; + + expect(ops).toHaveLength(1); + expect(ops[0].updateOne.update).toEqual( + expect.objectContaining({ $set: { 'marks.ignored': expect.any(Number) } }) + ); + }); +}); diff --git a/test/models/eventsFactory-bulk-update-assignee.test.ts b/test/models/eventsFactory-bulk-update-assignee.test.ts new file mode 100644 index 00000000..cce853bb --- /dev/null +++ b/test/models/eventsFactory-bulk-update-assignee.test.ts @@ -0,0 +1,85 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + find: jest.fn(), + updateMany: jest.fn(), +}; + +jest.mock('../../src/redisHelper', () => ({ + __esModule: true, + default: { + getInstance: () => ({}), + }, +})); + +jest.mock('../../src/services/chartDataService', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(function () { + return {}; + }), +})); + +jest.mock('../../src/dataLoaders', () => ({ + createProjectEventsByIdLoader: () => ({}), +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + events: { + collection: jest.fn(() => collectionMock), + }, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class +const EventsFactory = require('../../src/models/eventsFactory') as any; + +describe('EventsFactory.bulkUpdateAssignee', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.updateMany.mockResolvedValue({ modifiedCount: 0 }); + }); + + it('should update only events with changed assignee', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => Promise.resolve([ + { _id: a, assignee: 'user-1' }, + { _id: b, assignee: '' }, + ]), + }); + collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 }); + + const result = await factory.bulkUpdateAssignee([ a.toString(), b.toString() ], 'user-1'); + + expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ b.toString() ]); + expect(result.failedEventIds).toEqual([]); + expect(collectionMock.updateMany).toHaveBeenCalledTimes(1); + }); + + it('should clear assignee with null value', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => Promise.resolve([{ _id: a, assignee: 'user-1' }]), + }); + collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 }); + + const result = await factory.bulkUpdateAssignee([ a.toString() ], null); + + expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ a.toString() ]); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { _id: { $in: [ a ] } }, + { $set: { assignee: '' } } + ); + }); +}); diff --git a/test/models/eventsFactory-bulk-visit.test.ts b/test/models/eventsFactory-bulk-visit.test.ts new file mode 100644 index 00000000..d5b19feb --- /dev/null +++ b/test/models/eventsFactory-bulk-visit.test.ts @@ -0,0 +1,82 @@ +import '../../src/env-test'; +import { ObjectId } from 'mongodb'; + +const collectionMock = { + find: jest.fn(), + updateMany: jest.fn(), +}; + +jest.mock('../../src/redisHelper', () => ({ + __esModule: true, + default: { + getInstance: () => ({}), + }, +})); + +jest.mock('../../src/services/chartDataService', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(function () { + return {}; + }), +})); + +jest.mock('../../src/dataLoaders', () => ({ + createProjectEventsByIdLoader: () => ({}), +})); + +jest.mock('../../src/mongo', () => ({ + databases: { + events: { + collection: jest.fn(() => collectionMock), + }, + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class +const EventsFactory = require('../../src/models/eventsFactory') as any; + +describe('EventsFactory.bulkVisitEvent', () => { + const projectId = '507f1f77bcf86cd799439011'; + + beforeEach(() => { + jest.clearAllMocks(); + collectionMock.updateMany.mockResolvedValue({ modifiedCount: 0 }); + }); + + it('should mark only not-yet-visited events', async () => { + const factory = new EventsFactory(projectId); + const a = new ObjectId(); + const b = new ObjectId(); + const userId = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => Promise.resolve([ + { _id: a, visitedBy: [ userId ] }, + { _id: b, visitedBy: [] }, + ]), + }); + collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 }); + + const result = await factory.bulkVisitEvent([ a.toString(), b.toString() ], userId.toString()); + + expect(result.updatedCount).toBe(1); + expect(result.updatedEventIds).toEqual([ b.toString() ]); + expect(result.failedEventIds).toEqual([]); + }); + + it('should add not found ids to failedEventIds', async () => { + const factory = new EventsFactory(projectId); + const missing = new ObjectId(); + + collectionMock.find.mockReturnValue({ + toArray: () => Promise.resolve([]), + }); + + const result = await factory.bulkVisitEvent([ missing.toString() ], new ObjectId().toString()); + + expect(result.updatedCount).toBe(0); + expect(result.updatedEventIds).toEqual([]); + expect(result.failedEventIds).toEqual([ missing.toString() ]); + expect(collectionMock.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/test/resolvers/bulk-events-helper.test.ts b/test/resolvers/bulk-events-helper.test.ts new file mode 100644 index 00000000..a9394f7f --- /dev/null +++ b/test/resolvers/bulk-events-helper.test.ts @@ -0,0 +1,86 @@ +import '../../src/env-test'; + +jest.mock('../../src/utils/personalNotifications', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); + +import sendPersonalNotification from '../../src/utils/personalNotifications'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fireAndForgetAssigneeNotifications } = require('../../src/resolvers/helpers/bulkEvents') as { + fireAndForgetAssigneeNotifications: (args: { + assigneeData: Record | null; + eventIds: string[]; + projectId: string; + assigneeId: string; + whoAssignedId: string; + }) => void; +}; + +describe('fireAndForgetAssigneeNotifications', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should enqueue personal notification for each event id', async () => { + fireAndForgetAssigneeNotifications({ + assigneeData: { id: 'assignee-1', email: 'assignee@hawk.so' }, + eventIds: [ 'e-1', 'e-2' ], + projectId: 'p-1', + assigneeId: 'assignee-1', + whoAssignedId: 'u-1', + }); + + await Promise.resolve(); + + expect(sendPersonalNotification).toHaveBeenCalledTimes(2); + expect(sendPersonalNotification).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: 'assignee-1' }), + { + type: 'assignee', + payload: { + assigneeId: 'assignee-1', + projectId: 'p-1', + whoAssignedId: 'u-1', + eventId: 'e-1', + }, + } + ); + expect(sendPersonalNotification).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: 'assignee-1' }), + { + type: 'assignee', + payload: { + assigneeId: 'assignee-1', + projectId: 'p-1', + whoAssignedId: 'u-1', + eventId: 'e-2', + }, + } + ); + }); + + it('should not call personal notifications when assignee data is empty', () => { + fireAndForgetAssigneeNotifications({ + assigneeData: null, + eventIds: [ 'e-1' ], + projectId: 'p-1', + assigneeId: 'assignee-1', + whoAssignedId: 'u-1', + }); + + expect(sendPersonalNotification).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to enqueue assignee notifications: assignee data is empty' + ); + }); +}); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts new file mode 100644 index 00000000..1b66daff --- /dev/null +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -0,0 +1,136 @@ +import '../../src/env-test'; + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ + __esModule: true, + default: jest.fn(), +})); + +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const eventResolvers = require('../../src/resolvers/event') as { + Mutation: { + bulkToggleEventMarks: ( + o: unknown, + args: { projectId: string; eventIds: string[]; mark: string }, + ctx: unknown + ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; + }; +}; + +const bulkToggleEventMark = jest.fn(); + +describe('Mutation.bulkToggleEventMarks', () => { + const ctx = {}; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + bulkToggleEventMark, + }); + }); + + it('should throw when eventIds is empty', async () => { + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { projectId: 'p1', eventIds: [], mark: 'ignored' }, + ctx + ) + ).rejects.toThrow('eventIds must contain at least one id'); + + expect(bulkToggleEventMark).not.toHaveBeenCalled(); + }); + + it('should call factory with original event ids and return its result', async () => { + const payload = { updatedCount: 2, updatedEventIds: [ 'a', 'b' ], failedEventIds: [ 'x' ] }; + + bulkToggleEventMark.mockResolvedValue(payload); + + const result = await eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012' ], + mark: 'resolved', + }, + ctx + ); + + expect(getEventsFactory).toHaveBeenCalledWith(ctx, 'p1'); + expect(bulkToggleEventMark).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012' ], + 'resolved' + ); + expect(result).toEqual(payload); + }); + + it('should allow starred mark for bulk toggle', async () => { + const payload = { updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [] }; + + bulkToggleEventMark.mockResolvedValue(payload); + + const result = await eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + mark: 'starred', + }, + ctx + ); + + expect(bulkToggleEventMark).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + 'starred' + ); + expect(result).toEqual(payload); + }); + + it('should validate ids on resolver level and merge invalid ids into failedEventIds', async () => { + bulkToggleEventMark.mockResolvedValue({ + updatedCount: 1, + updatedEventIds: [ '507f1f77bcf86cd799439011' ], + failedEventIds: [ '507f1f77bcf86cd799439099' ], + }); + + const result = await eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011', 'invalid-id' ], + mark: 'ignored', + }, + ctx + ); + + expect(bulkToggleEventMark).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + 'ignored' + ); + expect(result).toEqual({ + updatedCount: 1, + updatedEventIds: [ '507f1f77bcf86cd799439011' ], + failedEventIds: [ '507f1f77bcf86cd799439099', 'invalid-id' ], + }); + }); + + it('should return early when all ids are invalid', async () => { + const result = await eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { + projectId: 'p1', + eventIds: [ 'bad-1', 'bad-2' ], + mark: 'ignored', + }, + ctx + ); + + expect(bulkToggleEventMark).not.toHaveBeenCalled(); + expect(result).toEqual({ + updatedCount: 0, + updatedEventIds: [], + failedEventIds: [ 'bad-1', 'bad-2' ], + }); + }); + +}); diff --git a/test/resolvers/event-bulk-update-assignee.test.ts b/test/resolvers/event-bulk-update-assignee.test.ts new file mode 100644 index 00000000..3ab7ce76 --- /dev/null +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -0,0 +1,178 @@ +import '../../src/env-test'; + +import { UserInputError } from 'apollo-server-express'; + +jest.mock('../../src/utils/personalNotifications', () => ({ + __esModule: true, + default: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ + __esModule: true, + default: jest.fn(), +})); + +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +import sendPersonalNotification from '../../src/utils/personalNotifications'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const eventResolvers = require('../../src/resolvers/event') as { + EventsMutations: { + bulkUpdateAssignee: ( + o: unknown, + args: { input: { projectId: string; eventIds: string[]; assignee?: string | null } }, + ctx: any + ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; + }; +}; + +const bulkUpdateAssignee = jest.fn(); + +describe('EventsMutations.bulkUpdateAssignee', () => { + const ctx = { + user: { id: 'u1' }, + factories: { + usersFactory: { + findById: jest.fn().mockResolvedValue({ id: 'assignee-1' }), + dataLoaders: { + userById: { + load: jest.fn().mockResolvedValue({ id: 'assignee-1', email: 'a@a.a' }), + }, + }, + }, + projectsFactory: { + findById: jest.fn().mockResolvedValue({ workspaceId: 'w1' }), + }, + workspacesFactory: { + findById: jest.fn().mockResolvedValue({ + getMemberInfo: jest.fn().mockResolvedValue({ userId: 'assignee-1' }), + }), + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + bulkUpdateAssignee, + }); + bulkUpdateAssignee.mockResolvedValue({ + updatedCount: 1, + updatedEventIds: [ '507f1f77bcf86cd799439011' ], + failedEventIds: [], + }); + }); + + it('should throw when eventIds is empty', async () => { + await expect( + eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { input: { projectId: 'p1', eventIds: [], assignee: 'assignee-1' } }, + ctx + ) + ).rejects.toThrow(UserInputError); + expect(bulkUpdateAssignee).not.toHaveBeenCalled(); + }); + + it('should call factory for bulk assign', async () => { + const result = await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: 'assignee-1', + }, + }, + ctx + ); + + expect(result.updatedCount).toBe(1); + expect(bulkUpdateAssignee).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + 'assignee-1' + ); + expect(sendPersonalNotification).toHaveBeenCalledTimes(1); + expect(sendPersonalNotification).toHaveBeenCalledWith( + expect.objectContaining({ id: 'assignee-1' }), + expect.objectContaining({ + type: 'assignee', + payload: expect.objectContaining({ + assigneeId: 'assignee-1', + projectId: 'p1', + whoAssignedId: 'u1', + eventId: '507f1f77bcf86cd799439011', + }), + }) + ); + }); + + it('should validate ids on resolver level and merge invalid ids into failedEventIds', async () => { + bulkUpdateAssignee.mockResolvedValue({ + updatedCount: 1, + updatedEventIds: [ '507f1f77bcf86cd799439011' ], + failedEventIds: [ '507f1f77bcf86cd799439099' ], + }); + + const result = await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011', 'invalid-id' ], + assignee: 'assignee-1', + }, + }, + ctx + ); + + expect(bulkUpdateAssignee).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + 'assignee-1' + ); + expect(result).toEqual({ + updatedCount: 1, + updatedEventIds: [ '507f1f77bcf86cd799439011' ], + failedEventIds: [ '507f1f77bcf86cd799439099', 'invalid-id' ], + }); + }); + + it('should return early when all ids are invalid', async () => { + const result = await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ 'bad-1', 'bad-2' ], + assignee: 'assignee-1', + }, + }, + ctx + ); + + expect(bulkUpdateAssignee).not.toHaveBeenCalled(); + expect(result).toEqual({ + updatedCount: 0, + updatedEventIds: [], + failedEventIds: [ 'bad-1', 'bad-2' ], + }); + }); + + it('should call factory for bulk clear assignee', async () => { + await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: null, + }, + }, + ctx + ); + + expect(bulkUpdateAssignee).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439011' ], + null + ); + }); +}); diff --git a/test/resolvers/event-bulk-visit.test.ts b/test/resolvers/event-bulk-visit.test.ts new file mode 100644 index 00000000..632d4a71 --- /dev/null +++ b/test/resolvers/event-bulk-visit.test.ts @@ -0,0 +1,70 @@ +import '../../src/env-test'; + +jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ + __esModule: true, + default: jest.fn(), +})); + +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const eventResolvers = require('../../src/resolvers/event') as { + Mutation: { + bulkVisitEvents: ( + o: unknown, + args: { projectId: string; eventIds: string[] }, + ctx: any + ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; + }; +}; + +const bulkVisitEvent = jest.fn(); + +describe('Mutation.bulkVisitEvents', () => { + const ctx = { + user: { id: '507f1f77bcf86cd799439011' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvent }); + }); + + it('should call factory with valid ids only and merge invalid ids', async () => { + bulkVisitEvent.mockResolvedValue({ + updatedCount: 1, + updatedEventIds: [ '507f1f77bcf86cd799439012' ], + failedEventIds: [ '507f1f77bcf86cd799439099' ], + }); + + const result = await eventResolvers.Mutation.bulkVisitEvents( + {}, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012', 'bad-id' ] }, + ctx + ); + + expect(bulkVisitEvent).toHaveBeenCalledWith( + [ '507f1f77bcf86cd799439012' ], + '507f1f77bcf86cd799439011' + ); + expect(result).toEqual({ + updatedCount: 1, + updatedEventIds: [ '507f1f77bcf86cd799439012' ], + failedEventIds: [ '507f1f77bcf86cd799439099', 'bad-id' ], + }); + }); + + it('should return early when all ids are invalid', async () => { + const result = await eventResolvers.Mutation.bulkVisitEvents( + {}, + { projectId: 'p1', eventIds: [ 'bad-1', 'bad-2' ] }, + ctx + ); + + expect(bulkVisitEvent).not.toHaveBeenCalled(); + expect(result).toEqual({ + updatedCount: 0, + updatedEventIds: [], + failedEventIds: [ 'bad-1', 'bad-2' ], + }); + }); +});