From ec4350a2feaa49ed513826740fe153853d2ee4f6 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:11:43 +0300 Subject: [PATCH 01/28] Multi-select and bulk actions on the events list --- jest.config.js | 4 ++ package.json | 2 +- src/constants/demoWorkspace.ts | 5 ++ src/integrations/github/routes.ts | 3 +- src/models/eventsFactory.js | 94 +++++++++++++++++++++++++ src/resolvers/event.js | 37 +++++++++- src/resolvers/project.js | 11 +-- src/resolvers/workspace.js | 3 +- src/typeDefs/event.ts | 37 ++++++++++ test/integrations/github-routes.test.ts | 3 +- test/resolvers/project.test.ts | 6 +- 11 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 src/constants/demoWorkspace.ts diff --git a/jest.config.js b/jest.config.js index 4c2cc568..133d852f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -37,6 +37,10 @@ module.exports = { moduleNameMapper: { '^node:crypto$': '/test/__mocks__/node_crypto.js', '^node:util$': '/test/__mocks__/node_util.js', + /** + * demoWorkspace is TypeScript; CommonJS resolvers use require() without extension + */ + '^.+/constants/demoWorkspace$': '/src/constants/demoWorkspace.ts', }, /** diff --git a/package.json b/package.json index 6db19b0b..b9aaa8e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.12", + "version": "1.5.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/constants/demoWorkspace.ts b/src/constants/demoWorkspace.ts new file mode 100644 index 00000000..0aae0497 --- /dev/null +++ b/src/constants/demoWorkspace.ts @@ -0,0 +1,5 @@ +/** + * Mongo ObjectId string of the public "Join Demo Workspace" (Garage landing). + * Keep in sync with operations that seed demo data in Mongo. + */ +export const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; diff --git a/src/integrations/github/routes.ts b/src/integrations/github/routes.ts index 8144ec34..3acfc72f 100644 --- a/src/integrations/github/routes.ts +++ b/src/integrations/github/routes.ts @@ -11,6 +11,7 @@ import ProjectModel from '../../models/project'; import WorkspaceModel from '../../models/workspace'; import { sgr, Effect } from '../../utils/ansi'; import { databases } from '../../mongo'; +import { DEMO_WORKSPACE_ID } from '../../constants/demoWorkspace'; /** * Default task threshold for automatic task creation @@ -108,7 +109,7 @@ export function createGitHubRouter(factories: ContextFactories): express.Router /** * Check if project is demo project (cannot be modified) */ - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { res.status(400).json({ error: 'Unable to update demo project' }); return null; diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index d76c49bd..02eb5470 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -918,6 +918,100 @@ class EventsFactory extends Factory { return collection.updateOne(query, update); } + /** + * Max original event ids per bulkToggleEventMark request + */ + static get BULK_TOGGLE_EVENT_MARK_MAX() { + return 100; + } + + /** + * Bulk mark for resolved / ignored (not the same as per-event toggleEventMark). + * - If every found event already has the mark: remove it from all (bulk "undo"). + * - Otherwise: set the mark on every found event that does not have it yet (never remove + * from a subset when the selection is mixed). + * Only 'resolved' and 'ignored' are allowed for bulk. + * + * @param {string[]} eventIds - original event ids + * @param {string} mark - 'resolved' | 'ignored' + * @returns {Promise<{ updatedCount: number, failedEventIds: string[] }>} + */ + async bulkToggleEventMark(eventIds, mark) { + if (mark !== 'resolved' && mark !== 'ignored') { + throw new Error(`bulkToggleEventMark: mark must be resolved or ignored, got ${mark}`); + } + + const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; + const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; + + if (unique.length > max) { + throw new Error(`bulkToggleEventMark: at most ${max} event ids allowed`); + } + + const failedEventIds = []; + const validObjectIds = []; + + for (const id of unique) { + if (!ObjectId.isValid(id)) { + failedEventIds.push(id); + } else { + validObjectIds.push(new ObjectId(id)); + } + } + + if (validObjectIds.length === 0) { + return { updatedCount: 0, failedEventIds }; + } + + const collection = this.getCollection(this.TYPES.EVENTS); + const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); + const foundByIdStr = new Map(found.map(doc => [ doc._id.toString(), doc ])); + + for (const oid of validObjectIds) { + const idStr = oid.toString(); + + if (!foundByIdStr.has(idStr)) { + failedEventIds.push(idStr); + } + } + + 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 = []; + + 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, + }, + }); + } + + if (ops.length === 0) { + return { updatedCount: 0, failedEventIds }; + } + + const bulkResult = await collection.bulkWrite(ops, { ordered: false }); + + return { + updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount, + failedEventIds, + }; + } + /** * Remove all project events * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index c3c44971..49e30730 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,6 +1,8 @@ const getEventsFactory = require('./helpers/eventsFactory').default; const sendPersonalNotification = require('../utils/personalNotifications').default; const { aiService } = require('../services/ai'); +const { DEMO_WORKSPACE_ID } = require('../constants/demoWorkspace'); +const { UserInputError } = require('apollo-server-express'); /** * See all types and fields here {@see ../typeDefs/event.graphql} @@ -48,7 +50,7 @@ module.exports = { */ const project = await factories.projectsFactory.findById(projectId); - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { return [ await factories.usersFactory.findById(user.id) ]; } @@ -153,6 +155,39 @@ 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, failedEventIds: string[] }>} + */ + async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { + if (mark !== 'resolved' && mark !== 'ignored') { + throw new UserInputError('bulkToggleEventMarks supports only resolved and ignored marks'); + } + + if (!eventIds || !eventIds.length) { + throw new UserInputError('eventIds must contain at least one id'); + } + + const factory = getEventsFactory(context, projectId); + + try { + return await factory.bulkToggleEventMark(eventIds, mark); + } catch (err) { + if (err.message && err.message.includes('bulkToggleEventMark: at most')) { + throw new UserInputError(err.message); + } + + throw err; + } + }, + /** * Mutations namespace * diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 65fc2cdc..716a9f78 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -1,5 +1,6 @@ import { ReceiveTypes } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; +import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace'; const mongo = require('../mongo'); const { ObjectId } = require('mongodb'); const { ApolloError, UserInputError } = require('apollo-server-express'); @@ -253,7 +254,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } @@ -291,7 +292,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } @@ -399,7 +400,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to remove demo project'); } @@ -458,7 +459,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } @@ -509,7 +510,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { + if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { throw new ApolloError('Unable to update demo project'); } diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 83ef92ae..7a0a9e5d 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -10,6 +10,7 @@ import Validator from '../utils/validator'; import { dateFromObjectId } from '../utils/dates'; import cloudPaymentsApi from '../utils/cloudPaymentsApi'; import { publish } from '../rabbitmq'; +import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace'; const { ApolloError, UserInputError, ForbiddenError } = require('apollo-server-express'); const crypto = require('crypto'); @@ -551,7 +552,7 @@ module.exports = { /** * Crutch for Demo Workspace */ - if (workspaceData._id.toString() === '6213b6a01e6281087467cc7a') { + if (workspaceData._id.toString() === DEMO_WORKSPACE_ID) { return [ { _id: user.id, diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c200de96..cab56838 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -452,6 +452,21 @@ type RemoveAssigneeResponse { success: Boolean! } +""" +Result of bulk toggling event marks (resolve / ignore) +""" +type BulkToggleEventMarksResult { + """ + Number of events updated in the database + """ + updatedCount: Int! + + """ + Event ids that were not updated (invalid id or not found) + """ + failedEventIds: [ID!]! +} + type EventsMutations { """ Set an assignee for the selected event @@ -504,6 +519,28 @@ extend type Mutation { mark: EventMark! ): Boolean! + """ + Toggle the same mark on many original events at once (only resolved or ignored). + Same toggle semantics as toggleEventMark per event. + """ + bulkToggleEventMarks( + """ + Project id + """ + projectId: ID! + + """ + Original event ids (grouped event keys in Hawk) + """ + eventIds: [ID!]! + + """ + Mark (resolved or ignored only): 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/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 03eacc94..607c6cdb 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -3,6 +3,7 @@ import { ObjectId } from 'mongodb'; import express from 'express'; import { createGitHubRouter } from '../../src/integrations/github/routes'; import { ContextFactories } from '../../src/types/graphql'; +import { DEMO_WORKSPACE_ID } from '../../src/constants/demoWorkspace'; /** * Mock GitHubService @@ -32,8 +33,6 @@ jest.mock('../../src/integrations/github/store/install-state.redis.store', () => })), })); -const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; - function createMockProject(options: { projectId?: string; workspaceId?: string; diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index 8f50acbc..ed73687c 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -3,6 +3,7 @@ import { ObjectId } from 'mongodb'; import { ProjectDBScheme, ProjectTaskManagerConfig } from '@hawk.so/types'; import { ResolverContextWithUser } from '../../src/types/graphql'; import { ApolloError, UserInputError } from 'apollo-server-express'; +import { DEMO_WORKSPACE_ID } from '../../src/constants/demoWorkspace'; jest.mock('../../src/integrations/github/service', () => require('../__mocks__/github-service')); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -29,11 +30,6 @@ process.env.JWT_SECRET_ACCESS_TOKEN = 'belarus'; process.env.JWT_SECRET_REFRESH_TOKEN = 'abacaba'; process.env.JWT_SECRET_PROJECT_TOKEN = 'qwerty'; -/** - * Demo workspace ID (projects in this workspace cannot be updated) - */ -const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; - /** * Creates mock project with optional taskManager configuration */ From 42287da409c11e97152720b8ff27e4ba228e7213 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:23:25 +0300 Subject: [PATCH 02/28] chore: add tests --- test/models/eventsFactory-bulk-toggle.test.ts | 206 ++++++++++++++++++ .../resolvers/event-bulk-toggle-marks.test.ts | 100 +++++++++ 2 files changed, 306 insertions(+) create mode 100644 test/models/eventsFactory-bulk-toggle.test.ts create mode 100644 test/resolvers/event-bulk-toggle-marks.test.ts diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts new file mode 100644 index 00000000..61305030 --- /dev/null +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -0,0 +1,206 @@ +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 throw when mark is not resolved or ignored', async () => { + const factory = new EventsFactory(projectId); + + await expect(factory.bulkToggleEventMark([], 'starred' as any)).rejects.toThrow( + 'bulkToggleEventMark: mark must be resolved or ignored' + ); + }); + + it('should reject more than BULK_TOGGLE_EVENT_MARK_MAX unique ids', async () => { + const factory = new EventsFactory(projectId); + const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; + const ids = Array.from({ length: max + 1 }, (_, i) => `id-${i}`); + + await expect(factory.bulkToggleEventMark(ids, 'ignored')).rejects.toThrow( + `bulkToggleEventMark: at most ${max} event ids allowed` + ); + }); + + 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 return failedEventIds for invalid ObjectIds and skip bulkWrite', async () => { + const factory = new EventsFactory(projectId); + + const result = await factory.bulkToggleEventMark([ 'not-a-valid-id' ], 'resolved'); + + expect(result.updatedCount).toBe(0); + expect(result.failedEventIds).toContain('not-a-valid-id'); + expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); + }); + + 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.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); + 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); + 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); + 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/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts new file mode 100644 index 00000000..5b9dec1c --- /dev/null +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -0,0 +1,100 @@ +import '../../src/env-test'; + +import { UserInputError } from 'apollo-server-express'; + +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; 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 mark is not resolved or ignored', async () => { + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + ctx + ) + ).rejects.toThrow(UserInputError); + + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + ctx + ) + ).rejects.toThrow('bulkToggleEventMarks supports only resolved and ignored marks'); + + expect(bulkToggleEventMark).not.toHaveBeenCalled(); + }); + + 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, 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 map factory max-length error to UserInputError', async () => { + bulkToggleEventMark.mockRejectedValue( + new Error('bulkToggleEventMark: at most 100 event ids allowed') + ); + + await expect( + eventResolvers.Mutation.bulkToggleEventMarks( + {}, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439011' ], mark: 'ignored' }, + ctx + ) + ).rejects.toThrow(UserInputError); + }); +}); From 40ca14d09afd96fce804286eeced69785a5c569c Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:53:08 +0300 Subject: [PATCH 03/28] fix: lint --- src/models/eventsFactory.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 02eb5470..1c620d7f 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -960,12 +960,15 @@ class EventsFactory extends Factory { } if (validObjectIds.length === 0) { - return { updatedCount: 0, failedEventIds }; + return { + updatedCount: 0, + failedEventIds, + }; } const collection = this.getCollection(this.TYPES.EVENTS); const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); - const foundByIdStr = new Map(found.map(doc => [ doc._id.toString(), doc ])); + const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc])); for (const oid of validObjectIds) { const idStr = oid.toString(); @@ -1001,7 +1004,10 @@ class EventsFactory extends Factory { } if (ops.length === 0) { - return { updatedCount: 0, failedEventIds }; + return { + updatedCount: 0, + failedEventIds, + }; } const bulkResult = await collection.bulkWrite(ops, { ordered: false }); From 0593ed3a9def6365e7f8e97cd0650e3b322d490f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:59:46 +0300 Subject: [PATCH 04/28] feat(events): extend bulk toggle functionality to support starred marks --- src/models/eventsFactory.js | 10 ++--- src/resolvers/event.js | 4 +- src/typeDefs/event.ts | 4 +- test/models/eventsFactory-bulk-toggle.test.ts | 37 +++++++++++++++++-- .../resolvers/event-bulk-toggle-marks.test.ts | 30 +++++++++++++-- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 1c620d7f..56dfeb1f 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -926,19 +926,19 @@ class EventsFactory extends Factory { } /** - * Bulk mark for resolved / ignored (not the same as per-event toggleEventMark). + * Bulk mark for resolved / ignored / starred (not the same as per-event toggleEventMark). * - If every found event already has the mark: remove it from all (bulk "undo"). * - Otherwise: set the mark on every found event that does not have it yet (never remove * from a subset when the selection is mixed). - * Only 'resolved' and 'ignored' are allowed for bulk. + * Only 'resolved', 'ignored' and 'starred' are allowed for bulk. * * @param {string[]} eventIds - original event ids - * @param {string} mark - 'resolved' | 'ignored' + * @param {string} mark - 'resolved' | 'ignored' | 'starred' * @returns {Promise<{ updatedCount: number, failedEventIds: string[] }>} */ async bulkToggleEventMark(eventIds, mark) { - if (mark !== 'resolved' && mark !== 'ignored') { - throw new Error(`bulkToggleEventMark: mark must be resolved or ignored, got ${mark}`); + if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { + throw new Error(`bulkToggleEventMark: mark must be resolved, ignored or starred, got ${mark}`); } const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 49e30730..b28c56ad 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -167,8 +167,8 @@ module.exports = { * @return {Promise<{ updatedCount: number, failedEventIds: string[] }>} */ async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { - if (mark !== 'resolved' && mark !== 'ignored') { - throw new UserInputError('bulkToggleEventMarks supports only resolved and ignored marks'); + if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { + throw new UserInputError('bulkToggleEventMarks supports only resolved, ignored and starred marks'); } if (!eventIds || !eventIds.length) { diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index cab56838..4dcb3e20 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -520,7 +520,7 @@ extend type Mutation { ): Boolean! """ - Toggle the same mark on many original events at once (only resolved or ignored). + Toggle the same mark on many original events at once (resolved, ignored or starred). Same toggle semantics as toggleEventMark per event. """ bulkToggleEventMarks( @@ -535,7 +535,7 @@ extend type Mutation { eventIds: [ID!]! """ - Mark (resolved or ignored only): if every selected event already has it, clear it for all; + 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! diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts index 61305030..78cd1ba0 100644 --- a/test/models/eventsFactory-bulk-toggle.test.ts +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -49,11 +49,42 @@ describe('EventsFactory.bulkToggleEventMark', () => { }); }); - it('should throw when mark is not resolved or ignored', async () => { + it('should throw when mark is unsupported', async () => { const factory = new EventsFactory(projectId); - await expect(factory.bulkToggleEventMark([], 'starred' as any)).rejects.toThrow( - 'bulkToggleEventMark: mark must be resolved or ignored' + await expect(factory.bulkToggleEventMark([], 'some-unknown-mark' as any)).rejects.toThrow( + 'bulkToggleEventMark: mark must be resolved, ignored or starred' + ); + }); + + 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); + 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) }, + }) ); }); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index 5b9dec1c..c113a325 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -29,11 +29,11 @@ describe('Mutation.bulkToggleEventMarks', () => { (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkToggleEventMark }); }); - it('should throw when mark is not resolved or ignored', async () => { + it('should throw when mark is not supported', async () => { await expect( eventResolvers.Mutation.bulkToggleEventMarks( {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'some-unknown-mark' }, ctx ) ).rejects.toThrow(UserInputError); @@ -41,10 +41,10 @@ describe('Mutation.bulkToggleEventMarks', () => { await expect( eventResolvers.Mutation.bulkToggleEventMarks( {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'starred' }, + { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'some-unknown-mark' }, ctx ) - ).rejects.toThrow('bulkToggleEventMarks supports only resolved and ignored marks'); + ).rejects.toThrow('bulkToggleEventMarks supports only resolved, ignored and starred marks'); expect(bulkToggleEventMark).not.toHaveBeenCalled(); }); @@ -84,6 +84,28 @@ describe('Mutation.bulkToggleEventMarks', () => { expect(result).toEqual(payload); }); + it('should allow starred mark for bulk toggle', async () => { + const payload = { updatedCount: 1, 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 map factory max-length error to UserInputError', async () => { bulkToggleEventMark.mockRejectedValue( new Error('bulkToggleEventMark: at most 100 event ids allowed') From dd309a5cbb9830e60737dd7ad383708b64f3ab8e Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:10:24 +0300 Subject: [PATCH 05/28] feat(events): update bulkToggleEventMark to return updatedEventIds --- src/models/eventsFactory.js | 7 ++++++- src/typeDefs/event.ts | 5 +++++ test/models/eventsFactory-bulk-toggle.test.ts | 6 ++++++ test/resolvers/event-bulk-toggle-marks.test.ts | 6 +++--- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 56dfeb1f..0dd0e16b 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -934,7 +934,7 @@ class EventsFactory extends Factory { * * @param {string[]} eventIds - original event ids * @param {string} mark - 'resolved' | 'ignored' | 'starred' - * @returns {Promise<{ updatedCount: number, failedEventIds: string[] }>} + * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkToggleEventMark(eventIds, mark) { if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { @@ -962,6 +962,7 @@ class EventsFactory extends Factory { if (validObjectIds.length === 0) { return { updatedCount: 0, + updatedEventIds: [], failedEventIds, }; } @@ -982,6 +983,7 @@ class EventsFactory extends Factory { 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]; @@ -1001,11 +1003,13 @@ class EventsFactory extends Factory { update, }, }); + updatedEventIds.push(doc._id.toString()); } if (ops.length === 0) { return { updatedCount: 0, + updatedEventIds: [], failedEventIds, }; } @@ -1014,6 +1018,7 @@ class EventsFactory extends Factory { return { updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount, + updatedEventIds, failedEventIds, }; } diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index 4dcb3e20..e47e82b0 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -461,6 +461,11 @@ type BulkToggleEventMarksResult { """ updatedCount: Int! + """ + Original event ids actually toggled in this operation + """ + updatedEventIds: [ID!]! + """ Event ids that were not updated (invalid id or not found) """ diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts index 78cd1ba0..35657678 100644 --- a/test/models/eventsFactory-bulk-toggle.test.ts +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -78,6 +78,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { 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); @@ -130,6 +131,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { const result = await factory.bulkToggleEventMark([ 'not-a-valid-id' ], 'resolved'); expect(result.updatedCount).toBe(0); + expect(result.updatedEventIds).toEqual([]); expect(result.failedEventIds).toContain('not-a-valid-id'); expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); }); @@ -145,6 +147,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { 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(); }); @@ -169,6 +172,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { 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); @@ -200,6 +204,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { 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); @@ -227,6 +232,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { 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); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index c113a325..eeb22819 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -15,7 +15,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { projectId: string; eventIds: string[]; mark: string }, ctx: unknown - ) => Promise<{ updatedCount: number; failedEventIds: string[] }>; + ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; }; }; @@ -62,7 +62,7 @@ describe('Mutation.bulkToggleEventMarks', () => { }); it('should call factory with original event ids and return its result', async () => { - const payload = { updatedCount: 2, failedEventIds: [ 'x' ] }; + const payload = { updatedCount: 2, updatedEventIds: [ 'a', 'b' ], failedEventIds: [ 'x' ] }; bulkToggleEventMark.mockResolvedValue(payload); @@ -85,7 +85,7 @@ describe('Mutation.bulkToggleEventMarks', () => { }); it('should allow starred mark for bulk toggle', async () => { - const payload = { updatedCount: 1, failedEventIds: [] }; + const payload = { updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [] }; bulkToggleEventMark.mockResolvedValue(payload); From 9a0fc08ab3e7337b3abc037eaf884b30cf3cc68c Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:11:41 +0000 Subject: [PATCH 06/28] Bump version up to 1.5.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b9aaa8e8..d484a908 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.0", + "version": "1.5.1", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 7ed3481ebcbd70c8b19f71e0bf0c872a87fa9820 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:44:19 +0300 Subject: [PATCH 07/28] remove --- jest.config.js | 4 ---- src/constants/demoWorkspace.ts | 5 ----- src/integrations/github/routes.ts | 3 +-- src/resolvers/event.js | 3 +-- src/resolvers/project.js | 11 +++++------ src/resolvers/workspace.js | 3 +-- test/integrations/github-routes.test.ts | 3 ++- test/resolvers/project.test.ts | 6 +++++- 8 files changed, 15 insertions(+), 23 deletions(-) delete mode 100644 src/constants/demoWorkspace.ts diff --git a/jest.config.js b/jest.config.js index 133d852f..4c2cc568 100644 --- a/jest.config.js +++ b/jest.config.js @@ -37,10 +37,6 @@ module.exports = { moduleNameMapper: { '^node:crypto$': '/test/__mocks__/node_crypto.js', '^node:util$': '/test/__mocks__/node_util.js', - /** - * demoWorkspace is TypeScript; CommonJS resolvers use require() without extension - */ - '^.+/constants/demoWorkspace$': '/src/constants/demoWorkspace.ts', }, /** diff --git a/src/constants/demoWorkspace.ts b/src/constants/demoWorkspace.ts deleted file mode 100644 index 0aae0497..00000000 --- a/src/constants/demoWorkspace.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Mongo ObjectId string of the public "Join Demo Workspace" (Garage landing). - * Keep in sync with operations that seed demo data in Mongo. - */ -export const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; diff --git a/src/integrations/github/routes.ts b/src/integrations/github/routes.ts index 3acfc72f..8144ec34 100644 --- a/src/integrations/github/routes.ts +++ b/src/integrations/github/routes.ts @@ -11,7 +11,6 @@ import ProjectModel from '../../models/project'; import WorkspaceModel from '../../models/workspace'; import { sgr, Effect } from '../../utils/ansi'; import { databases } from '../../mongo'; -import { DEMO_WORKSPACE_ID } from '../../constants/demoWorkspace'; /** * Default task threshold for automatic task creation @@ -109,7 +108,7 @@ export function createGitHubRouter(factories: ContextFactories): express.Router /** * Check if project is demo project (cannot be modified) */ - if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { + if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { res.status(400).json({ error: 'Unable to update demo project' }); return null; diff --git a/src/resolvers/event.js b/src/resolvers/event.js index b28c56ad..d107814c 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,7 +1,6 @@ const getEventsFactory = require('./helpers/eventsFactory').default; const sendPersonalNotification = require('../utils/personalNotifications').default; const { aiService } = require('../services/ai'); -const { DEMO_WORKSPACE_ID } = require('../constants/demoWorkspace'); const { UserInputError } = require('apollo-server-express'); /** @@ -50,7 +49,7 @@ module.exports = { */ const project = await factories.projectsFactory.findById(projectId); - if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { + if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { return [ await factories.usersFactory.findById(user.id) ]; } diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 716a9f78..65fc2cdc 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -1,6 +1,5 @@ import { ReceiveTypes } from '@hawk.so/types'; import * as telegram from '../utils/telegram'; -import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace'; const mongo = require('../mongo'); const { ObjectId } = require('mongodb'); const { ApolloError, UserInputError } = require('apollo-server-express'); @@ -254,7 +253,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { + if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { throw new ApolloError('Unable to update demo project'); } @@ -292,7 +291,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { + if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { throw new ApolloError('Unable to update demo project'); } @@ -400,7 +399,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { + if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { throw new ApolloError('Unable to remove demo project'); } @@ -459,7 +458,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { + if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { throw new ApolloError('Unable to update demo project'); } @@ -510,7 +509,7 @@ module.exports = { throw new ApolloError('There is no project with that id'); } - if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) { + if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') { throw new ApolloError('Unable to update demo project'); } diff --git a/src/resolvers/workspace.js b/src/resolvers/workspace.js index 7a0a9e5d..83ef92ae 100644 --- a/src/resolvers/workspace.js +++ b/src/resolvers/workspace.js @@ -10,7 +10,6 @@ import Validator from '../utils/validator'; import { dateFromObjectId } from '../utils/dates'; import cloudPaymentsApi from '../utils/cloudPaymentsApi'; import { publish } from '../rabbitmq'; -import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace'; const { ApolloError, UserInputError, ForbiddenError } = require('apollo-server-express'); const crypto = require('crypto'); @@ -552,7 +551,7 @@ module.exports = { /** * Crutch for Demo Workspace */ - if (workspaceData._id.toString() === DEMO_WORKSPACE_ID) { + if (workspaceData._id.toString() === '6213b6a01e6281087467cc7a') { return [ { _id: user.id, diff --git a/test/integrations/github-routes.test.ts b/test/integrations/github-routes.test.ts index 607c6cdb..03eacc94 100644 --- a/test/integrations/github-routes.test.ts +++ b/test/integrations/github-routes.test.ts @@ -3,7 +3,6 @@ import { ObjectId } from 'mongodb'; import express from 'express'; import { createGitHubRouter } from '../../src/integrations/github/routes'; import { ContextFactories } from '../../src/types/graphql'; -import { DEMO_WORKSPACE_ID } from '../../src/constants/demoWorkspace'; /** * Mock GitHubService @@ -33,6 +32,8 @@ jest.mock('../../src/integrations/github/store/install-state.redis.store', () => })), })); +const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; + function createMockProject(options: { projectId?: string; workspaceId?: string; diff --git a/test/resolvers/project.test.ts b/test/resolvers/project.test.ts index ed73687c..8f50acbc 100644 --- a/test/resolvers/project.test.ts +++ b/test/resolvers/project.test.ts @@ -3,7 +3,6 @@ import { ObjectId } from 'mongodb'; import { ProjectDBScheme, ProjectTaskManagerConfig } from '@hawk.so/types'; import { ResolverContextWithUser } from '../../src/types/graphql'; import { ApolloError, UserInputError } from 'apollo-server-express'; -import { DEMO_WORKSPACE_ID } from '../../src/constants/demoWorkspace'; jest.mock('../../src/integrations/github/service', () => require('../__mocks__/github-service')); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -30,6 +29,11 @@ process.env.JWT_SECRET_ACCESS_TOKEN = 'belarus'; process.env.JWT_SECRET_REFRESH_TOKEN = 'abacaba'; process.env.JWT_SECRET_PROJECT_TOKEN = 'qwerty'; +/** + * Demo workspace ID (projects in this workspace cannot be updated) + */ +const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; + /** * Creates mock project with optional taskManager configuration */ From d497afdf4d83baea24b0b99f6973e93f7c209368 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:21:48 +0300 Subject: [PATCH 08/28] feat(events): add bulkUpdateAssignee functionality to manage event assignees --- src/models/eventsFactory.js | 80 ++++++++++--- src/resolvers/event.js | 110 ++++++++++++++--- src/typeDefs/event.ts | 41 +++++++ test/models/eventsFactory-bulk-toggle.test.ts | 18 --- ...eventsFactory-bulk-update-assignee.test.ts | 95 +++++++++++++++ .../resolvers/event-bulk-toggle-marks.test.ts | 17 +-- .../event-bulk-update-assignee.test.ts | 113 ++++++++++++++++++ 7 files changed, 408 insertions(+), 66 deletions(-) create mode 100644 test/models/eventsFactory-bulk-update-assignee.test.ts create mode 100644 test/resolvers/event-bulk-update-assignee.test.ts diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 0dd0e16b..2d572748 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -918,13 +918,6 @@ class EventsFactory extends Factory { return collection.updateOne(query, update); } - /** - * Max original event ids per bulkToggleEventMark request - */ - static get BULK_TOGGLE_EVENT_MARK_MAX() { - return 100; - } - /** * Bulk mark for resolved / ignored / starred (not the same as per-event toggleEventMark). * - If every found event already has the mark: remove it from all (bulk "undo"). @@ -937,17 +930,8 @@ class EventsFactory extends Factory { * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkToggleEventMark(eventIds, mark) { - if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { - throw new Error(`bulkToggleEventMark: mark must be resolved, ignored or starred, got ${mark}`); - } - - const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; - if (unique.length > max) { - throw new Error(`bulkToggleEventMark: at most ${max} event ids allowed`); - } - const failedEventIds = []; const validObjectIds = []; @@ -1023,6 +1007,70 @@ class EventsFactory extends Factory { }; } + /** + * 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 unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const failedEventIds = []; + const validObjectIds = []; + + for (const id of unique) { + if (!ObjectId.isValid(id)) { + failedEventIds.push(id); + } else { + validObjectIds.push(new ObjectId(id)); + } + } + + if (validObjectIds.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds, + }; + } + + const collection = this.getCollection(this.TYPES.EVENTS); + const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); + const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc])); + + for (const oid of validObjectIds) { + const idStr = oid.toString(); + + if (!foundByIdStr.has(idStr)) { + failedEventIds.push(idStr); + } + } + + 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 * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index d107814c..dec7134a 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -3,6 +3,37 @@ const sendPersonalNotification = require('../utils/personalNotifications').defau const { aiService } = require('../services/ai'); const { UserInputError } = require('apollo-server-express'); +/** + * 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, +}) { + void Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, { + type: 'assignee', + payload: { + assigneeId, + projectId, + whoAssignedId, + eventId, + }, + }))).catch((error) => { + console.error('Failed to enqueue assignee notifications', error); + }); +} + /** * See all types and fields here {@see ../typeDefs/event.graphql} */ @@ -163,7 +194,7 @@ module.exports = { * @param {string[]} eventIds - original event ids * @param {string} mark - EventMark enum value * @param {object} context - gql context - * @return {Promise<{ updatedCount: number, failedEventIds: string[] }>} + * @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { @@ -176,15 +207,7 @@ module.exports = { const factory = getEventsFactory(context, projectId); - try { - return await factory.bulkToggleEventMark(eventIds, mark); - } catch (err) { - if (err.message && err.message.includes('bulkToggleEventMark: at most')) { - throw new UserInputError(err.message); - } - - throw err; - } + return factory.bulkToggleEventMark(eventIds, mark); }, /** @@ -230,14 +253,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 { @@ -264,5 +285,58 @@ 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 factory = getEventsFactory(context, projectId); + + if (!eventIds || !eventIds.length) { + throw new UserInputError('eventIds must contain at least one id'); + } + + if (assignee) { + const userExists = await factories.usersFactory.findById(assignee); + + if (!userExists) { + throw new UserInputError('assignee not found'); + } + + 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(eventIds, assignee); + + if (assignee && result.updatedEventIds.length > 0) { + void factories.usersFactory.dataLoaders.userById.load(assignee) + .then((assigneeData) => { + fireAndForgetAssigneeNotifications({ + assigneeData, + eventIds: result.updatedEventIds, + projectId, + assigneeId: assignee, + whoAssignedId: user.id, + }); + }) + .catch((error) => { + console.error('Failed to load assignee data for bulk notifications', error); + }); + } + + return result; + }, }, }; diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index e47e82b0..b824a97e 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,23 @@ 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) """ @@ -486,6 +520,13 @@ type EventsMutations { removeAssignee( input: RemoveAssigneeInput! ): RemoveAssigneeResponse! @requireUserInWorkspace + + """ + Bulk set/clear assignee on many original events + """ + bulkUpdateAssignee( + input: BulkUpdateAssigneeInput! + ): BulkUpdateAssigneeResponse! @requireUserInWorkspace } extend type Mutation { diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts index 35657678..c195d77e 100644 --- a/test/models/eventsFactory-bulk-toggle.test.ts +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -49,14 +49,6 @@ describe('EventsFactory.bulkToggleEventMark', () => { }); }); - it('should throw when mark is unsupported', async () => { - const factory = new EventsFactory(projectId); - - await expect(factory.bulkToggleEventMark([], 'some-unknown-mark' as any)).rejects.toThrow( - 'bulkToggleEventMark: mark must be resolved, ignored or starred' - ); - }); - it('should support starred mark', async () => { const factory = new EventsFactory(projectId); const id = new ObjectId(); @@ -89,16 +81,6 @@ describe('EventsFactory.bulkToggleEventMark', () => { ); }); - it('should reject more than BULK_TOGGLE_EVENT_MARK_MAX unique ids', async () => { - const factory = new EventsFactory(projectId); - const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX; - const ids = Array.from({ length: max + 1 }, (_, i) => `id-${i}`); - - await expect(factory.bulkToggleEventMark(ids, 'ignored')).rejects.toThrow( - `bulkToggleEventMark: at most ${max} event ids allowed` - ); - }); - it('should deduplicate duplicate event ids before applying', async () => { const factory = new EventsFactory(projectId); const id = new ObjectId(); 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..dcfc5bc4 --- /dev/null +++ b/test/models/eventsFactory-bulk-update-assignee.test.ts @@ -0,0 +1,95 @@ +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 return failed ids for invalid ObjectIds and skip updateMany', async () => { + const factory = new EventsFactory(projectId); + const result = await factory.bulkUpdateAssignee([ 'bad-id' ], 'user-1'); + + expect(result.updatedCount).toBe(0); + expect(result.updatedEventIds).toEqual([]); + expect(result.failedEventIds).toEqual([ 'bad-id' ]); + expect(collectionMock.updateMany).not.toHaveBeenCalled(); + }); + + 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/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index eeb22819..6ff43fa5 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -26,7 +26,9 @@ describe('Mutation.bulkToggleEventMarks', () => { beforeEach(() => { jest.clearAllMocks(); - (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkToggleEventMark }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + bulkToggleEventMark, + }); }); it('should throw when mark is not supported', async () => { @@ -106,17 +108,4 @@ describe('Mutation.bulkToggleEventMarks', () => { expect(result).toEqual(payload); }); - it('should map factory max-length error to UserInputError', async () => { - bulkToggleEventMark.mockRejectedValue( - new Error('bulkToggleEventMark: at most 100 event ids allowed') - ); - - await expect( - eventResolvers.Mutation.bulkToggleEventMarks( - {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439011' ], mark: 'ignored' }, - ctx - ) - ).rejects.toThrow(UserInputError); - }); }); 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..ef6ee10c --- /dev/null +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -0,0 +1,113 @@ +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'; +// 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' + ); + }); + + 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 + ); + }); +}); From dd84f03da8a0d1bf2afc3dd253a7ace5f13437eb Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:39:26 +0300 Subject: [PATCH 09/28] refactor(events): streamline bulk event ID validation and enhance error handling in bulkToggleEventMark and bulkUpdateAssignee methods --- src/models/eventsFactory.js | 43 +--------- src/resolvers/event.js | 77 ++++++++---------- src/resolvers/helpers/bulkEvents.js | 80 +++++++++++++++++++ test/models/eventsFactory-bulk-toggle.test.ts | 11 --- ...eventsFactory-bulk-update-assignee.test.ts | 10 --- .../resolvers/event-bulk-toggle-marks.test.ts | 47 +++++++++++ .../event-bulk-update-assignee.test.ts | 51 ++++++++++++ 7 files changed, 216 insertions(+), 103 deletions(-) create mode 100644 src/resolvers/helpers/bulkEvents.js diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 2d572748..05447db8 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -919,11 +919,7 @@ class EventsFactory extends Factory { } /** - * Bulk mark for resolved / ignored / starred (not the same as per-event toggleEventMark). - * - If every found event already has the mark: remove it from all (bulk "undo"). - * - Otherwise: set the mark on every found event that does not have it yet (never remove - * from a subset when the selection is mixed). - * Only 'resolved', 'ignored' and 'starred' are allowed for bulk. + * Bulk toggle mark for original events. * * @param {string[]} eventIds - original event ids * @param {string} mark - 'resolved' | 'ignored' | 'starred' @@ -931,25 +927,8 @@ class EventsFactory extends Factory { */ async bulkToggleEventMark(eventIds, mark) { const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; - const failedEventIds = []; - const validObjectIds = []; - - for (const id of unique) { - if (!ObjectId.isValid(id)) { - failedEventIds.push(id); - } else { - validObjectIds.push(new ObjectId(id)); - } - } - - if (validObjectIds.length === 0) { - return { - updatedCount: 0, - updatedEventIds: [], - failedEventIds, - }; - } + const validObjectIds = unique.map(id => new ObjectId(id)); const collection = this.getCollection(this.TYPES.EVENTS); const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); @@ -1017,23 +996,7 @@ class EventsFactory extends Factory { async bulkUpdateAssignee(eventIds, assignee) { const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; const failedEventIds = []; - const validObjectIds = []; - - for (const id of unique) { - if (!ObjectId.isValid(id)) { - failedEventIds.push(id); - } else { - validObjectIds.push(new ObjectId(id)); - } - } - - if (validObjectIds.length === 0) { - return { - updatedCount: 0, - updatedEventIds: [], - failedEventIds, - }; - } + const validObjectIds = unique.map(id => new ObjectId(id)); const collection = this.getCollection(this.TYPES.EVENTS); const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); diff --git a/src/resolvers/event.js b/src/resolvers/event.js index dec7134a..0ab4edc1 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,39 +1,12 @@ 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'); -/** - * 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, -}) { - void Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, { - type: 'assignee', - payload: { - assigneeId, - projectId, - whoAssignedId, - eventId, - }, - }))).catch((error) => { - console.error('Failed to enqueue assignee notifications', error); - }); -} - /** * See all types and fields here {@see ../typeDefs/event.graphql} */ @@ -201,13 +174,23 @@ module.exports = { throw new UserInputError('bulkToggleEventMarks supports only resolved, ignored and starred marks'); } - if (!eventIds || !eventIds.length) { - throw new UserInputError('eventIds must contain at least one id'); + 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 factory.bulkToggleEventMark(eventIds, mark); + return { + ...result, + failedEventIds: mergeFailedEventIds(result, invalidEventIds), + }; }, /** @@ -296,12 +279,18 @@ module.exports = { */ async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) { const { projectId, eventIds, assignee } = input; - const factory = getEventsFactory(context, projectId); + const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); - if (!eventIds || !eventIds.length) { - throw new UserInputError('eventIds must contain at least one id'); + if (validEventIds.length === 0) { + return { + updatedCount: 0, + updatedEventIds: [], + failedEventIds: invalidEventIds, + }; } + const factory = getEventsFactory(context, projectId); + if (assignee) { const userExists = await factories.usersFactory.findById(assignee); @@ -318,14 +307,18 @@ module.exports = { } } - const result = await factory.bulkUpdateAssignee(eventIds, assignee); + const result = await factory.bulkUpdateAssignee(validEventIds, assignee); + const resultWithInvalid = { + ...result, + failedEventIds: mergeFailedEventIds(result, invalidEventIds), + }; - if (assignee && result.updatedEventIds.length > 0) { + if (assignee && resultWithInvalid.updatedEventIds.length > 0) { void factories.usersFactory.dataLoaders.userById.load(assignee) .then((assigneeData) => { fireAndForgetAssigneeNotifications({ assigneeData, - eventIds: result.updatedEventIds, + eventIds: resultWithInvalid.updatedEventIds, projectId, assigneeId: assignee, whoAssignedId: user.id, @@ -336,7 +329,7 @@ module.exports = { }); } - return result; + return resultWithInvalid; }, }, }; diff --git a/src/resolvers/helpers/bulkEvents.js b/src/resolvers/helpers/bulkEvents.js new file mode 100644 index 00000000..ca6fc854 --- /dev/null +++ b/src/resolvers/helpers/bulkEvents.js @@ -0,0 +1,80 @@ +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, +}) { + void Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, { + type: 'assignee', + payload: { + assigneeId, + projectId, + whoAssignedId, + eventId, + }, + }))).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/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts index c195d77e..6fa85a1c 100644 --- a/test/models/eventsFactory-bulk-toggle.test.ts +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -107,17 +107,6 @@ describe('EventsFactory.bulkToggleEventMark', () => { expect(ops).toHaveLength(1); }); - it('should return failedEventIds for invalid ObjectIds and skip bulkWrite', async () => { - const factory = new EventsFactory(projectId); - - const result = await factory.bulkToggleEventMark([ 'not-a-valid-id' ], 'resolved'); - - expect(result.updatedCount).toBe(0); - expect(result.updatedEventIds).toEqual([]); - expect(result.failedEventIds).toContain('not-a-valid-id'); - expect(collectionMock.bulkWrite).not.toHaveBeenCalled(); - }); - it('should list valid but missing document ids in failedEventIds', async () => { const factory = new EventsFactory(projectId); const missing = new ObjectId(); diff --git a/test/models/eventsFactory-bulk-update-assignee.test.ts b/test/models/eventsFactory-bulk-update-assignee.test.ts index dcfc5bc4..cce853bb 100644 --- a/test/models/eventsFactory-bulk-update-assignee.test.ts +++ b/test/models/eventsFactory-bulk-update-assignee.test.ts @@ -43,16 +43,6 @@ describe('EventsFactory.bulkUpdateAssignee', () => { collectionMock.updateMany.mockResolvedValue({ modifiedCount: 0 }); }); - it('should return failed ids for invalid ObjectIds and skip updateMany', async () => { - const factory = new EventsFactory(projectId); - const result = await factory.bulkUpdateAssignee([ 'bad-id' ], 'user-1'); - - expect(result.updatedCount).toBe(0); - expect(result.updatedEventIds).toEqual([]); - expect(result.failedEventIds).toEqual([ 'bad-id' ]); - expect(collectionMock.updateMany).not.toHaveBeenCalled(); - }); - it('should update only events with changed assignee', async () => { const factory = new EventsFactory(projectId); const a = new ObjectId(); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index 6ff43fa5..23c8a933 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -108,4 +108,51 @@ describe('Mutation.bulkToggleEventMarks', () => { 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 index ef6ee10c..7a370c69 100644 --- a/test/resolvers/event-bulk-update-assignee.test.ts +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -92,6 +92,57 @@ describe('EventsMutations.bulkUpdateAssignee', () => { ); }); + 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( {}, From ace53d0374b8c078c8d5b238b0ee243d2665a6d5 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:49:04 +0300 Subject: [PATCH 10/28] refactor(events): remove unsupported mark validation from bulkToggleEventMarks and clean up related tests --- src/resolvers/event.js | 4 ---- .../resolvers/event-bulk-toggle-marks.test.ts | 22 ------------------- 2 files changed, 26 deletions(-) diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 0ab4edc1..11b91773 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -170,10 +170,6 @@ module.exports = { * @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { - if (mark !== 'resolved' && mark !== 'ignored' && mark !== 'starred') { - throw new UserInputError('bulkToggleEventMarks supports only resolved, ignored and starred marks'); - } - const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); if (validEventIds.length === 0) { diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index 23c8a933..1b66daff 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -1,7 +1,5 @@ import '../../src/env-test'; -import { UserInputError } from 'apollo-server-express'; - jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ __esModule: true, default: jest.fn(), @@ -31,26 +29,6 @@ describe('Mutation.bulkToggleEventMarks', () => { }); }); - it('should throw when mark is not supported', async () => { - await expect( - eventResolvers.Mutation.bulkToggleEventMarks( - {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'some-unknown-mark' }, - ctx - ) - ).rejects.toThrow(UserInputError); - - await expect( - eventResolvers.Mutation.bulkToggleEventMarks( - {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012' ], mark: 'some-unknown-mark' }, - ctx - ) - ).rejects.toThrow('bulkToggleEventMarks supports only resolved, ignored and starred marks'); - - expect(bulkToggleEventMark).not.toHaveBeenCalled(); - }); - it('should throw when eventIds is empty', async () => { await expect( eventResolvers.Mutation.bulkToggleEventMarks( From 530adaae38e6898d0fa119825f99eba2320a2a4d Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:09:48 +0300 Subject: [PATCH 11/28] feat(events): implement bulkVisitEvents functionality to mark multiple events as visited for a user --- src/models/eventsFactory.js | 51 ++++++++++++ src/resolvers/event.js | 28 +++++++ src/typeDefs/event.ts | 35 +++++++++ test/models/eventsFactory-bulk-visit.test.ts | 82 ++++++++++++++++++++ test/resolvers/event-bulk-visit.test.ts | 70 +++++++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 test/models/eventsFactory-bulk-visit.test.ts create mode 100644 test/resolvers/event-bulk-visit.test.ts diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 05447db8..202fe766 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -882,6 +882,57 @@ 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 unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const failedEventIds = []; + const validObjectIds = unique.map(id => new ObjectId(id)); + const userIdStr = String(userId); + + const collection = this.getCollection(this.TYPES.EVENTS); + const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); + const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc])); + + for (const oid of validObjectIds) { + const idStr = oid.toString(); + + if (!foundByIdStr.has(idStr)) { + failedEventIds.push(idStr); + } + } + + 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 * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 11b91773..91c1c4f8 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -140,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 diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index b824a97e..849c708a 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -506,6 +506,26 @@ type BulkToggleEventMarksResult { 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 @@ -545,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 """ 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/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' ], + }); + }); +}); From c212292461969d800296be881596ee01cd4c57df Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:22:19 +0300 Subject: [PATCH 12/28] refactor(events): consolidate bulk event ID resolution into a new method for improved code reuse and clarity --- src/models/eventsFactory.js | 83 +++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 202fe766..5a946a0e 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -890,23 +890,13 @@ class EventsFactory extends Factory { * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkVisitEvent(eventIds, userId) { - const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; - const failedEventIds = []; - const validObjectIds = unique.map(id => new ObjectId(id)); + const { + collection, + found, + failedEventIds, + } = await this._resolveBulkEventsByIds(eventIds); const userIdStr = String(userId); - const collection = this.getCollection(this.TYPES.EVENTS); - const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); - const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc])); - - for (const oid of validObjectIds) { - const idStr = oid.toString(); - - if (!foundByIdStr.has(idStr)) { - failedEventIds.push(idStr); - } - } - const docsToUpdate = found.filter((doc) => { const visitedBy = Array.isArray(doc.visitedBy) ? doc.visitedBy : []; return !visitedBy.some((visitedUserId) => String(visitedUserId) === userIdStr); @@ -977,21 +967,11 @@ class EventsFactory extends Factory { * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkToggleEventMark(eventIds, mark) { - const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; - const failedEventIds = []; - const validObjectIds = unique.map(id => new ObjectId(id)); - - const collection = this.getCollection(this.TYPES.EVENTS); - const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); - const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc])); - - for (const oid of validObjectIds) { - const idStr = oid.toString(); - - if (!foundByIdStr.has(idStr)) { - failedEventIds.push(idStr); - } - } + const { + collection, + found, + failedEventIds, + } = await this._resolveBulkEventsByIds(eventIds); const nowSec = Math.floor(Date.now() / 1000); const markKey = `marks.${mark}`; @@ -1045,21 +1025,11 @@ class EventsFactory extends Factory { * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkUpdateAssignee(eventIds, assignee) { - const unique = [ ...new Set((eventIds || []).map(id => String(id))) ]; - const failedEventIds = []; - const validObjectIds = unique.map(id => new ObjectId(id)); - - const collection = this.getCollection(this.TYPES.EVENTS); - const found = await collection.find({ _id: { $in: validObjectIds } }).toArray(); - const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc])); - - for (const oid of validObjectIds) { - const idStr = oid.toString(); - - if (!foundByIdStr.has(idStr)) { - failedEventIds.push(idStr); - } - } + const { + collection, + found, + failedEventIds, + } = await this._resolveBulkEventsByIds(eventIds); const normalizedAssignee = assignee ? String(assignee) : ''; const docsToUpdate = found.filter(doc => String(doc.assignee || '') !== normalizedAssignee); @@ -1133,6 +1103,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 * From 049bacec4211b1c369a41efc57613f08bc84e1d3 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:28:57 +0300 Subject: [PATCH 13/28] fix: lint --- src/models/eventsFactory.js | 1 + src/resolvers/helpers/bulkEvents.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 5a946a0e..fccae66d 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -899,6 +899,7 @@ class EventsFactory extends Factory { 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()); diff --git a/src/resolvers/helpers/bulkEvents.js b/src/resolvers/helpers/bulkEvents.js index ca6fc854..29413b0e 100644 --- a/src/resolvers/helpers/bulkEvents.js +++ b/src/resolvers/helpers/bulkEvents.js @@ -70,7 +70,7 @@ function parseBulkEventIds(eventIds) { * @returns {string[]} */ function mergeFailedEventIds(result, invalidEventIds) { - return [ ...new Set([ ...(result.failedEventIds || []), ...invalidEventIds ]) ]; + return [ ...new Set([...(result.failedEventIds || []), ...invalidEventIds]) ]; } module.exports = { From f250de5a76c4ca38f223cbd6704b746694ab7d65 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:30:11 +0300 Subject: [PATCH 14/28] fix: lint --- src/resolvers/event.js | 2 +- src/resolvers/helpers/bulkEvents.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 91c1c4f8..971b80a5 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -338,7 +338,7 @@ module.exports = { }; if (assignee && resultWithInvalid.updatedEventIds.length > 0) { - void factories.usersFactory.dataLoaders.userById.load(assignee) + factories.usersFactory.dataLoaders.userById.load(assignee) .then((assigneeData) => { fireAndForgetAssigneeNotifications({ assigneeData, diff --git a/src/resolvers/helpers/bulkEvents.js b/src/resolvers/helpers/bulkEvents.js index 29413b0e..75cb7cc2 100644 --- a/src/resolvers/helpers/bulkEvents.js +++ b/src/resolvers/helpers/bulkEvents.js @@ -20,7 +20,7 @@ function fireAndForgetAssigneeNotifications({ assigneeId, whoAssignedId, }) { - void Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, { + Promise.allSettled(eventIds.map(eventId => sendPersonalNotification(assigneeData, { type: 'assignee', payload: { assigneeId, From ccd4a7e3a0e211571b8687e41110324151569f1a Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:05:31 +0000 Subject: [PATCH 15/28] Bump version up to 1.5.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d484a908..5a468592 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.1", + "version": "1.5.2", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 6535527f5be5521db3c7727798d71b9565d54304 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:20:49 +0300 Subject: [PATCH 16/28] fix: notif --- src/resolvers/event.js | 23 +++-- src/resolvers/helpers/bulkEvents.js | 20 ++++- test/resolvers/bulk-events-helper.test.ts | 86 +++++++++++++++++++ .../event-bulk-update-assignee.test.ts | 14 +++ 4 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 test/resolvers/bulk-events-helper.test.ts diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 971b80a5..af1d6f70 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -304,6 +304,7 @@ module.exports = { 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 { @@ -322,6 +323,8 @@ module.exports = { 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); @@ -338,19 +341,13 @@ module.exports = { }; if (assignee && resultWithInvalid.updatedEventIds.length > 0) { - factories.usersFactory.dataLoaders.userById.load(assignee) - .then((assigneeData) => { - fireAndForgetAssigneeNotifications({ - assigneeData, - eventIds: resultWithInvalid.updatedEventIds, - projectId, - assigneeId: assignee, - whoAssignedId: user.id, - }); - }) - .catch((error) => { - console.error('Failed to load assignee data for bulk notifications', error); - }); + 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 index 75cb7cc2..4d0238a5 100644 --- a/src/resolvers/helpers/bulkEvents.js +++ b/src/resolvers/helpers/bulkEvents.js @@ -20,6 +20,12 @@ function fireAndForgetAssigneeNotifications({ 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: { @@ -28,9 +34,17 @@ function fireAndForgetAssigneeNotifications({ whoAssignedId, eventId, }, - }))).catch((error) => { - console.error('Failed to enqueue assignee notifications', error); - }); + }))) + .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); + }); } /** 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-update-assignee.test.ts b/test/resolvers/event-bulk-update-assignee.test.ts index 7a370c69..3ab7ce76 100644 --- a/test/resolvers/event-bulk-update-assignee.test.ts +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -13,6 +13,7 @@ jest.mock('../../src/resolvers/helpers/eventsFactory', () => ({ })); 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: { @@ -90,6 +91,19 @@ describe('EventsMutations.bulkUpdateAssignee', () => { [ '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 () => { From 93fd72839e83b89f79b9d123a3d30ebb8e747a79 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:22:33 +0000 Subject: [PATCH 17/28] Bump version up to 1.5.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5a468592..2eb5eb56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.2", + "version": "1.5.3", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 94d4e84a11edd57d631ea30764c8ed775f66a3dc Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:38:31 +0300 Subject: [PATCH 18/28] chore: optimize assignee notification handling with chunked processing --- package.json | 2 +- src/resolvers/event.js | 5 ++ src/resolvers/helpers/bulkEvents.js | 46 ++++++++++++++----- src/typeDefs/event.ts | 3 +- .../event-bulk-update-assignee.test.ts | 41 ++++++++++++----- 5 files changed, 72 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 2eb5eb56..b9aaa8e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.3", + "version": "1.5.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/resolvers/event.js b/src/resolvers/event.js index af1d6f70..fdb74117 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -6,6 +6,7 @@ const { } = require('./helpers/bulkEvents'); const { aiService } = require('../services/ai'); const { UserInputError } = require('apollo-server-express'); +const { ObjectId } = require('mongodb'); /** * See all types and fields here {@see ../typeDefs/event.graphql} @@ -317,6 +318,10 @@ module.exports = { const factory = getEventsFactory(context, projectId); if (assignee) { + if (!ObjectId.isValid(String(assignee))) { + throw new UserInputError('assignee must be a valid id or null'); + } + const userExists = await factories.usersFactory.findById(assignee); if (!userExists) { diff --git a/src/resolvers/helpers/bulkEvents.js b/src/resolvers/helpers/bulkEvents.js index 4d0238a5..50dedb6f 100644 --- a/src/resolvers/helpers/bulkEvents.js +++ b/src/resolvers/helpers/bulkEvents.js @@ -2,6 +2,8 @@ const sendPersonalNotification = require('../../utils/personalNotifications').de const { UserInputError } = require('apollo-server-express'); const { ObjectId } = require('mongodb'); +const ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE = 25; + /** * Enqueue assignee notifications in background (do not block resolver response) * @@ -26,20 +28,40 @@ function fireAndForgetAssigneeNotifications({ 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'); + Promise.resolve() + .then(async () => { + const failedResults = []; + + for (let i = 0; i < eventIds.length; i += ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE) { + const chunk = eventIds.slice(i, i + ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE); + const results = await Promise.allSettled(chunk.map(eventId => sendPersonalNotification(assigneeData, { + type: 'assignee', + payload: { + assigneeId, + projectId, + whoAssignedId, + eventId, + }, + }))); + + failedResults.push(...results.filter(result => result.status === 'rejected')); + } if (failedResults.length > 0) { - console.error('Failed to enqueue assignee notifications', failedResults); + const failedMessages = failedResults.map((result) => { + const reason = result && result.reason; + + if (reason && typeof reason.message === 'string') { + return reason.message; + } + + return String(reason || 'Unknown error'); + }); + + console.error('Failed to enqueue assignee notifications', { + failedCount: failedResults.length, + errors: failedMessages, + }); } }) .catch((error) => { diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index 849c708a..6cdf43ca 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -602,7 +602,8 @@ extend type Mutation { """ Toggle the same mark on many original events at once (resolved, ignored or starred). - Same toggle semantics as toggleEventMark per event. + Uses bulk semantics: if every selected event already has the mark, clear it for all; + otherwise set it on each selected event that does not have it yet. """ bulkToggleEventMarks( """ diff --git a/test/resolvers/event-bulk-update-assignee.test.ts b/test/resolvers/event-bulk-update-assignee.test.ts index 3ab7ce76..9fcc093d 100644 --- a/test/resolvers/event-bulk-update-assignee.test.ts +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -26,16 +26,17 @@ const eventResolvers = require('../../src/resolvers/event') as { }; const bulkUpdateAssignee = jest.fn(); +const ASSIGNEE_ID = '507f1f77bcf86cd799439012'; describe('EventsMutations.bulkUpdateAssignee', () => { const ctx = { user: { id: 'u1' }, factories: { usersFactory: { - findById: jest.fn().mockResolvedValue({ id: 'assignee-1' }), + findById: jest.fn().mockResolvedValue({ id: ASSIGNEE_ID }), dataLoaders: { userById: { - load: jest.fn().mockResolvedValue({ id: 'assignee-1', email: 'a@a.a' }), + load: jest.fn().mockResolvedValue({ id: ASSIGNEE_ID, email: 'a@a.a' }), }, }, }, @@ -44,7 +45,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => { }, workspacesFactory: { findById: jest.fn().mockResolvedValue({ - getMemberInfo: jest.fn().mockResolvedValue({ userId: 'assignee-1' }), + getMemberInfo: jest.fn().mockResolvedValue({ userId: ASSIGNEE_ID }), }), }, }, @@ -66,13 +67,31 @@ describe('EventsMutations.bulkUpdateAssignee', () => { await expect( eventResolvers.EventsMutations.bulkUpdateAssignee( {}, - { input: { projectId: 'p1', eventIds: [], assignee: 'assignee-1' } }, + { input: { projectId: 'p1', eventIds: [], assignee: ASSIGNEE_ID } }, ctx ) ).rejects.toThrow(UserInputError); expect(bulkUpdateAssignee).not.toHaveBeenCalled(); }); + it('should throw when assignee id is invalid', async () => { + await expect( + eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: 'not-an-object-id', + }, + }, + ctx + ) + ).rejects.toThrow(UserInputError); + + expect(bulkUpdateAssignee).not.toHaveBeenCalled(); + }); + it('should call factory for bulk assign', async () => { const result = await eventResolvers.EventsMutations.bulkUpdateAssignee( {}, @@ -80,7 +99,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => { input: { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439011' ], - assignee: 'assignee-1', + assignee: ASSIGNEE_ID, }, }, ctx @@ -89,15 +108,15 @@ describe('EventsMutations.bulkUpdateAssignee', () => { expect(result.updatedCount).toBe(1); expect(bulkUpdateAssignee).toHaveBeenCalledWith( [ '507f1f77bcf86cd799439011' ], - 'assignee-1' + ASSIGNEE_ID ); expect(sendPersonalNotification).toHaveBeenCalledTimes(1); expect(sendPersonalNotification).toHaveBeenCalledWith( - expect.objectContaining({ id: 'assignee-1' }), + expect.objectContaining({ id: ASSIGNEE_ID }), expect.objectContaining({ type: 'assignee', payload: expect.objectContaining({ - assigneeId: 'assignee-1', + assigneeId: ASSIGNEE_ID, projectId: 'p1', whoAssignedId: 'u1', eventId: '507f1f77bcf86cd799439011', @@ -119,7 +138,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => { input: { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439011', 'invalid-id' ], - assignee: 'assignee-1', + assignee: ASSIGNEE_ID, }, }, ctx @@ -127,7 +146,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => { expect(bulkUpdateAssignee).toHaveBeenCalledWith( [ '507f1f77bcf86cd799439011' ], - 'assignee-1' + ASSIGNEE_ID ); expect(result).toEqual({ updatedCount: 1, @@ -143,7 +162,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => { input: { projectId: 'p1', eventIds: [ 'bad-1', 'bad-2' ], - assignee: 'assignee-1', + assignee: ASSIGNEE_ID, }, }, ctx From f2ab3382cdcb773e14f19fdaca3de9e4a893da1f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:38:54 +0300 Subject: [PATCH 19/28] Update src/typeDefs/event.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/typeDefs/event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index 6cdf43ca..1d792aff 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -487,7 +487,7 @@ type BulkUpdateAssigneeResponse { } """ -Result of bulk toggling event marks (resolve / ignore) +Result of bulk toggling event marks (resolve / ignore / starred) """ type BulkToggleEventMarksResult { """ From 81a682ff80f790a02ee39277ab370a893e60dc23 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:40:38 +0300 Subject: [PATCH 20/28] refactor(events): rename bulkVisitEvent to bulkVisitEvents for consistency across the codebase --- src/models/eventsFactory.js | 2 +- src/resolvers/event.js | 2 +- test/models/eventsFactory-bulk-visit.test.ts | 6 +++--- test/resolvers/event-bulk-visit.test.ts | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index fccae66d..e7234566 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -889,7 +889,7 @@ class EventsFactory extends Factory { * @param {string|ObjectId} userId - id of the user who is visiting events * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} */ - async bulkVisitEvent(eventIds, userId) { + async bulkVisitEvents(eventIds, userId) { const { collection, found, diff --git a/src/resolvers/event.js b/src/resolvers/event.js index fdb74117..dd6f12b6 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -162,7 +162,7 @@ module.exports = { } const factory = getEventsFactory(context, projectId); - const result = await factory.bulkVisitEvent(validEventIds, user.id); + const result = await factory.bulkVisitEvents(validEventIds, user.id); return { ...result, diff --git a/test/models/eventsFactory-bulk-visit.test.ts b/test/models/eventsFactory-bulk-visit.test.ts index d5b19feb..7a1b1627 100644 --- a/test/models/eventsFactory-bulk-visit.test.ts +++ b/test/models/eventsFactory-bulk-visit.test.ts @@ -35,7 +35,7 @@ jest.mock('../../src/mongo', () => ({ // 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', () => { +describe('EventsFactory.bulkVisitEvents', () => { const projectId = '507f1f77bcf86cd799439011'; beforeEach(() => { @@ -57,7 +57,7 @@ describe('EventsFactory.bulkVisitEvent', () => { }); collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 }); - const result = await factory.bulkVisitEvent([ a.toString(), b.toString() ], userId.toString()); + const result = await factory.bulkVisitEvents([ a.toString(), b.toString() ], userId.toString()); expect(result.updatedCount).toBe(1); expect(result.updatedEventIds).toEqual([ b.toString() ]); @@ -72,7 +72,7 @@ describe('EventsFactory.bulkVisitEvent', () => { toArray: () => Promise.resolve([]), }); - const result = await factory.bulkVisitEvent([ missing.toString() ], new ObjectId().toString()); + const result = await factory.bulkVisitEvents([ missing.toString() ], new ObjectId().toString()); expect(result.updatedCount).toBe(0); expect(result.updatedEventIds).toEqual([]); diff --git a/test/resolvers/event-bulk-visit.test.ts b/test/resolvers/event-bulk-visit.test.ts index 632d4a71..e99ce988 100644 --- a/test/resolvers/event-bulk-visit.test.ts +++ b/test/resolvers/event-bulk-visit.test.ts @@ -17,7 +17,7 @@ const eventResolvers = require('../../src/resolvers/event') as { }; }; -const bulkVisitEvent = jest.fn(); +const bulkVisitEvents = jest.fn(); describe('Mutation.bulkVisitEvents', () => { const ctx = { @@ -26,11 +26,11 @@ describe('Mutation.bulkVisitEvents', () => { beforeEach(() => { jest.clearAllMocks(); - (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvent }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvents }); }); it('should call factory with valid ids only and merge invalid ids', async () => { - bulkVisitEvent.mockResolvedValue({ + bulkVisitEvents.mockResolvedValue({ updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439012' ], failedEventIds: [ '507f1f77bcf86cd799439099' ], @@ -42,7 +42,7 @@ describe('Mutation.bulkVisitEvents', () => { ctx ); - expect(bulkVisitEvent).toHaveBeenCalledWith( + expect(bulkVisitEvents).toHaveBeenCalledWith( [ '507f1f77bcf86cd799439012' ], '507f1f77bcf86cd799439011' ); @@ -60,7 +60,7 @@ describe('Mutation.bulkVisitEvents', () => { ctx ); - expect(bulkVisitEvent).not.toHaveBeenCalled(); + expect(bulkVisitEvents).not.toHaveBeenCalled(); expect(result).toEqual({ updatedCount: 0, updatedEventIds: [], From c27156313246eb50faca52208f5781d2369d391b Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:31:17 +0300 Subject: [PATCH 21/28] refactor(events): simplify bulk event operations by removing updatedCount from responses and enhancing error handling --- src/models/eventsFactory.js | 128 ++++++++++++++---- src/resolvers/event.js | 43 +++--- src/resolvers/helpers/bulkEventUtils.ts | 109 +++++++++++++++ src/resolvers/helpers/bulkEvents.js | 116 ---------------- src/typeDefs/event.ts | 15 -- test/models/eventsFactory-bulk-toggle.test.ts | 76 +++-------- ...eventsFactory-bulk-update-assignee.test.ts | 19 +-- test/models/eventsFactory-bulk-visit.test.ts | 10 +- test/resolvers/bulk-events-helper.test.ts | 98 ++++---------- .../resolvers/event-bulk-toggle-marks.test.ts | 9 +- .../event-bulk-update-assignee.test.ts | 7 +- test/resolvers/event-bulk-visit.test.ts | 5 +- 12 files changed, 292 insertions(+), 343 deletions(-) create mode 100644 src/resolvers/helpers/bulkEventUtils.ts delete mode 100644 src/resolvers/helpers/bulkEvents.js diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index e7234566..27b0ca85 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -887,7 +887,7 @@ class EventsFactory extends Factory { * * @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[] }>} + * @returns {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkVisitEvents(eventIds, userId) { const { @@ -902,25 +902,53 @@ class EventsFactory extends Factory { 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) } } + const userObjectId = new ObjectId(userId); + const settled = await Promise.allSettled( + docsToUpdate.map(async (doc) => { + const eventId = doc._id.toString(); + const updateResult = await collection.updateOne( + { + _id: doc._id, + visitedBy: { $ne: userObjectId }, + }, + { $addToSet: { visitedBy: userObjectId } } + ); + + return { + eventId, + updated: updateResult.modifiedCount > 0, + }; + }) ); + const updatedEventIds = []; + const failedByUpdate = []; + + settled.forEach((result, index) => { + const fallbackEventId = docsToUpdate[index]._id.toString(); + + if (result.status === 'fulfilled') { + if (result.value.updated) { + updatedEventIds.push(result.value.eventId); + } else { + failedByUpdate.push(result.value.eventId); + } + } else { + failedByUpdate.push(fallbackEventId); + } + }); + return { - updatedCount: updateManyResult.modifiedCount, updatedEventIds, - failedEventIds, + failedEventIds: [ ...new Set([ ...failedEventIds, ...failedByUpdate ]) ], }; } @@ -965,7 +993,7 @@ class EventsFactory extends Factory { * * @param {string[]} eventIds - original event ids * @param {string} mark - 'resolved' | 'ignored' | 'starred' - * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + * @returns {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkToggleEventMark(eventIds, mark) { const { @@ -978,7 +1006,6 @@ class EventsFactory extends Factory { 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]; @@ -998,23 +1025,50 @@ class EventsFactory extends Factory { update, }, }); - updatedEventIds.push(doc._id.toString()); } if (ops.length === 0) { return { - updatedCount: 0, updatedEventIds: [], failedEventIds, }; } - const bulkResult = await collection.bulkWrite(ops, { ordered: false }); + const settled = await Promise.allSettled( + ops.map(async ({ updateOne }) => { + const eventId = updateOne.filter._id.toString(); + const updateResult = await collection.updateOne( + updateOne.filter, + updateOne.update + ); + + return { + eventId, + updated: updateResult.modifiedCount > 0, + }; + }) + ); + + const updatedEventIds = []; + const failedByUpdate = []; + + settled.forEach((result, index) => { + const fallbackEventId = ops[index].updateOne.filter._id.toString(); + + if (result.status === 'fulfilled') { + if (result.value.updated) { + updatedEventIds.push(result.value.eventId); + } else { + failedByUpdate.push(result.value.eventId); + } + } else { + failedByUpdate.push(fallbackEventId); + } + }); return { - updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount, updatedEventIds, - failedEventIds, + failedEventIds: [ ...new Set([ ...failedEventIds, ...failedByUpdate ]) ], }; } @@ -1023,7 +1077,7 @@ class EventsFactory extends Factory { * * @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[] }>} + * @returns {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkUpdateAssignee(eventIds, assignee) { const { @@ -1034,25 +1088,51 @@ class EventsFactory extends Factory { 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 } } + const settled = await Promise.allSettled( + docsToUpdate.map(async (doc) => { + const eventId = doc._id.toString(); + const updateResult = await collection.updateOne( + { + _id: doc._id, + assignee: { $ne: normalizedAssignee }, + }, + { $set: { assignee: normalizedAssignee } } + ); + + return { + eventId, + updated: updateResult.modifiedCount > 0, + }; + }) ); + const updatedEventIds = []; + const failedByUpdate = []; + + settled.forEach((result, index) => { + const fallbackEventId = docsToUpdate[index]._id.toString(); + + if (result.status === 'fulfilled') { + if (result.value.updated) { + updatedEventIds.push(result.value.eventId); + } else { + failedByUpdate.push(result.value.eventId); + } + } else { + failedByUpdate.push(fallbackEventId); + } + }); + return { - updatedCount: updateManyResult.modifiedCount, updatedEventIds, - failedEventIds, + failedEventIds: [ ...new Set([ ...failedEventIds, ...failedByUpdate ]) ], }; } diff --git a/src/resolvers/event.js b/src/resolvers/event.js index dd6f12b6..a517b562 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,9 +1,10 @@ const getEventsFactory = require('./helpers/eventsFactory').default; const { - fireAndForgetAssigneeNotifications, parseBulkEventIds, - mergeFailedEventIds, -} = require('./helpers/bulkEvents'); + withMergedInvalidEventIds, + enqueueAssigneeNotification, + enqueueBulkAssigneeNotifications, +} = require('./helpers/bulkEventUtils'); const { aiService } = require('../services/ai'); const { UserInputError } = require('apollo-server-express'); const { ObjectId } = require('mongodb'); @@ -148,14 +149,13 @@ module.exports = { * @param {string} projectId - project id * @param {string[]} eventIds - original event ids * @param {UserInContext} user - user context - * @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>} + * @returns {Promise<{ 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, }; @@ -164,10 +164,7 @@ module.exports = { const factory = getEventsFactory(context, projectId); const result = await factory.bulkVisitEvents(validEventIds, user.id); - return { - ...result, - failedEventIds: mergeFailedEventIds(result, invalidEventIds), - }; + return withMergedInvalidEventIds(result, invalidEventIds); }, /** @@ -196,14 +193,13 @@ module.exports = { * @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[] }>} + * @return {Promise<{ 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, }; @@ -212,10 +208,7 @@ module.exports = { const factory = getEventsFactory(context, projectId); const result = await factory.bulkToggleEventMark(validEventIds, mark); - return { - ...result, - failedEventIds: mergeFailedEventIds(result, invalidEventIds), - }; + return withMergedInvalidEventIds(result, invalidEventIds); }, /** @@ -261,12 +254,12 @@ module.exports = { const assigneeData = await factories.usersFactory.dataLoaders.userById.load(assignee); - fireAndForgetAssigneeNotifications({ + enqueueAssigneeNotification({ assigneeData, - eventIds: [ eventId ], - projectId, assigneeId: assignee, + projectId, whoAssignedId: user.id, + eventId, }); return { @@ -300,7 +293,7 @@ module.exports = { * @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[] }>} + * @return {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} */ async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) { const { projectId, eventIds, assignee } = input; @@ -309,7 +302,6 @@ module.exports = { if (validEventIds.length === 0) { return { - updatedCount: 0, updatedEventIds: [], failedEventIds: invalidEventIds, }; @@ -340,18 +332,15 @@ module.exports = { } const result = await factory.bulkUpdateAssignee(validEventIds, assignee); - const resultWithInvalid = { - ...result, - failedEventIds: mergeFailedEventIds(result, invalidEventIds), - }; + const resultWithInvalid = withMergedInvalidEventIds(result, invalidEventIds); if (assignee && resultWithInvalid.updatedEventIds.length > 0) { - fireAndForgetAssigneeNotifications({ + enqueueBulkAssigneeNotifications({ assigneeData, - eventIds: resultWithInvalid.updatedEventIds, - projectId, assigneeId: assignee, + projectId, whoAssignedId: user.id, + eventIds: resultWithInvalid.updatedEventIds, }); } diff --git a/src/resolvers/helpers/bulkEventUtils.ts b/src/resolvers/helpers/bulkEventUtils.ts new file mode 100644 index 00000000..99356a22 --- /dev/null +++ b/src/resolvers/helpers/bulkEventUtils.ts @@ -0,0 +1,109 @@ +import { UserInputError } from 'apollo-server-express'; +import { ObjectId } from 'mongodb'; +import sendPersonalNotification from '../../utils/personalNotifications'; +import type { UserDBScheme } from '@hawk.so/types'; +import { SenderWorkerTaskType } from '../../types/userNotifications/task-type'; + +/** + * Validate and normalize bulk event ids from resolver input. + * + * @param eventIds - raw event ids from GraphQL input + */ +export function parseBulkEventIds(eventIds: string[]): { validEventIds: string[]; invalidEventIds: string[] } { + 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: string[] = []; + const validEventIds: string[] = []; + + 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. + */ +export function mergeFailedEventIds(result: { failedEventIds?: string[] }, invalidEventIds: string[]): string[] { + return [ ...new Set([ ...(result.failedEventIds || []), ...invalidEventIds ]) ]; +} + +/** + * Merge resolver-level invalid ids into factory-level failed ids. + */ +export function withMergedInvalidEventIds( + result: T, + invalidEventIds: string[] +): T & { failedEventIds: string[] } { + return { + ...result, + failedEventIds: mergeFailedEventIds(result, invalidEventIds), + }; +} + +type AssigneeNotificationParams = { + assigneeData: UserDBScheme | null; + assigneeId: string; + projectId: string; + whoAssignedId: string; + eventId: string; +}; + +/** + * Enqueue one assignee notification without blocking resolver response. + */ +export function enqueueAssigneeNotification({ + assigneeData, + assigneeId, + projectId, + whoAssignedId, + eventId, +}: AssigneeNotificationParams): void { + if (!assigneeData) { + return; + } + + void sendPersonalNotification(assigneeData, { + type: SenderWorkerTaskType.Assignee, + payload: { + assigneeId, + projectId, + whoAssignedId, + eventId, + }, + }).catch((error: unknown) => { + console.error('Failed to enqueue assignee notification', error); + }); +} + +/** + * Enqueue assignee notifications for all updated original events. + */ +export function enqueueBulkAssigneeNotifications({ + assigneeData, + assigneeId, + projectId, + whoAssignedId, + eventIds, +}: Omit & { eventIds: string[] }): void { + eventIds.forEach((eventId) => { + enqueueAssigneeNotification({ + assigneeData, + assigneeId, + projectId, + whoAssignedId, + eventId, + }); + }); +} diff --git a/src/resolvers/helpers/bulkEvents.js b/src/resolvers/helpers/bulkEvents.js deleted file mode 100644 index 50dedb6f..00000000 --- a/src/resolvers/helpers/bulkEvents.js +++ /dev/null @@ -1,116 +0,0 @@ -const sendPersonalNotification = require('../../utils/personalNotifications').default; -const { UserInputError } = require('apollo-server-express'); -const { ObjectId } = require('mongodb'); - -const ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE = 25; - -/** - * 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.resolve() - .then(async () => { - const failedResults = []; - - for (let i = 0; i < eventIds.length; i += ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE) { - const chunk = eventIds.slice(i, i + ASSIGNEE_NOTIFICATIONS_CHUNK_SIZE); - const results = await Promise.allSettled(chunk.map(eventId => sendPersonalNotification(assigneeData, { - type: 'assignee', - payload: { - assigneeId, - projectId, - whoAssignedId, - eventId, - }, - }))); - - failedResults.push(...results.filter(result => result.status === 'rejected')); - } - - if (failedResults.length > 0) { - const failedMessages = failedResults.map((result) => { - const reason = result && result.reason; - - if (reason && typeof reason.message === 'string') { - return reason.message; - } - - return String(reason || 'Unknown error'); - }); - - console.error('Failed to enqueue assignee notifications', { - failedCount: failedResults.length, - errors: failedMessages, - }); - } - }) - .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 1d792aff..1a3de27c 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -470,11 +470,6 @@ type RemoveAssigneeResponse { } type BulkUpdateAssigneeResponse { - """ - Number of events updated in the database - """ - updatedCount: Int! - """ Original event ids actually updated in this operation """ @@ -490,11 +485,6 @@ type BulkUpdateAssigneeResponse { Result of bulk toggling event marks (resolve / ignore / starred) """ type BulkToggleEventMarksResult { - """ - Number of events updated in the database - """ - updatedCount: Int! - """ Original event ids actually toggled in this operation """ @@ -510,11 +500,6 @@ type BulkToggleEventMarksResult { 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 """ diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts index 6fa85a1c..0da1a7ca 100644 --- a/test/models/eventsFactory-bulk-toggle.test.ts +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -3,7 +3,7 @@ import { ObjectId } from 'mongodb'; const collectionMock = { find: jest.fn(), - bulkWrite: jest.fn(), + updateOne: jest.fn(), }; jest.mock('../../src/redisHelper', () => ({ @@ -40,13 +40,7 @@ describe('EventsFactory.bulkToggleEventMark', () => { beforeEach(() => { jest.clearAllMocks(); - collectionMock.bulkWrite.mockResolvedValue({ - modifiedCount: 0, - upsertedCount: 0, - insertedCount: 0, - matchedCount: 0, - deletedCount: 0, - }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 0 }); }); it('should support starred mark', async () => { @@ -62,19 +56,13 @@ describe('EventsFactory.bulkToggleEventMark', () => { }, ]), }); - collectionMock.bulkWrite.mockResolvedValue({ - modifiedCount: 1, - upsertedCount: 0, - }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); 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(collectionMock.updateOne).toHaveBeenCalledWith( + { _id: id }, expect.objectContaining({ $set: { 'marks.starred': expect.any(Number) }, }) @@ -94,17 +82,11 @@ describe('EventsFactory.bulkToggleEventMark', () => { }, ]), }); - collectionMock.bulkWrite.mockResolvedValue({ - modifiedCount: 1, - upsertedCount: 0, - }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); 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); + expect(collectionMock.updateOne).toHaveBeenCalledTimes(1); }); it('should list valid but missing document ids in failedEventIds', async () => { @@ -117,10 +99,9 @@ describe('EventsFactory.bulkToggleEventMark', () => { 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(); + expect(collectionMock.updateOne).not.toHaveBeenCalled(); }); it('should set mark only on events that do not have it when selection is mixed', async () => { @@ -135,20 +116,14 @@ describe('EventsFactory.bulkToggleEventMark', () => { { _id: b, marks: {} }, ]), }); - collectionMock.bulkWrite.mockResolvedValue({ - modifiedCount: 1, - upsertedCount: 0, - }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); 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(collectionMock.updateOne).toHaveBeenCalledTimes(1); + expect(collectionMock.updateOne).toHaveBeenCalledWith( + { _id: b }, expect.objectContaining({ $set: { 'marks.ignored': expect.any(Number) }, }) @@ -167,20 +142,14 @@ describe('EventsFactory.bulkToggleEventMark', () => { { _id: b, marks: { resolved: 2 } }, ]), }); - collectionMock.bulkWrite.mockResolvedValue({ - modifiedCount: 2, - upsertedCount: 0, - }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); 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': '' } }); + expect(collectionMock.updateOne).toHaveBeenCalledTimes(2); + expect(collectionMock.updateOne).toHaveBeenNthCalledWith(1, { _id: a }, { $unset: { 'marks.resolved': '' } }); + expect(collectionMock.updateOne).toHaveBeenNthCalledWith(2, { _id: b }, { $unset: { 'marks.resolved': '' } }); }); it('should not remove mark from a subset when only some of the found events have the mark', async () => { @@ -195,19 +164,14 @@ describe('EventsFactory.bulkToggleEventMark', () => { { _id: b, marks: {} }, ]), }); - collectionMock.bulkWrite.mockResolvedValue({ - modifiedCount: 1, - upsertedCount: 0, - }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); 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(collectionMock.updateOne).toHaveBeenCalledTimes(1); + expect(collectionMock.updateOne).toHaveBeenCalledWith( + { _id: b }, 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 index cce853bb..5a85f0c5 100644 --- a/test/models/eventsFactory-bulk-update-assignee.test.ts +++ b/test/models/eventsFactory-bulk-update-assignee.test.ts @@ -3,7 +3,7 @@ import { ObjectId } from 'mongodb'; const collectionMock = { find: jest.fn(), - updateMany: jest.fn(), + updateOne: jest.fn(), }; jest.mock('../../src/redisHelper', () => ({ @@ -40,7 +40,7 @@ describe('EventsFactory.bulkUpdateAssignee', () => { beforeEach(() => { jest.clearAllMocks(); - collectionMock.updateMany.mockResolvedValue({ modifiedCount: 0 }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 0 }); }); it('should update only events with changed assignee', async () => { @@ -54,14 +54,13 @@ describe('EventsFactory.bulkUpdateAssignee', () => { { _id: b, assignee: '' }, ]), }); - collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateOne.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); + expect(collectionMock.updateOne).toHaveBeenCalledTimes(1); }); it('should clear assignee with null value', async () => { @@ -71,14 +70,16 @@ describe('EventsFactory.bulkUpdateAssignee', () => { collectionMock.find.mockReturnValue({ toArray: () => Promise.resolve([{ _id: a, assignee: 'user-1' }]), }); - collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateOne.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 ] } }, + expect(collectionMock.updateOne).toHaveBeenCalledWith( + { + _id: a, + assignee: { $ne: '' }, + }, { $set: { assignee: '' } } ); }); diff --git a/test/models/eventsFactory-bulk-visit.test.ts b/test/models/eventsFactory-bulk-visit.test.ts index 7a1b1627..bb4bb35c 100644 --- a/test/models/eventsFactory-bulk-visit.test.ts +++ b/test/models/eventsFactory-bulk-visit.test.ts @@ -3,7 +3,7 @@ import { ObjectId } from 'mongodb'; const collectionMock = { find: jest.fn(), - updateMany: jest.fn(), + updateOne: jest.fn(), }; jest.mock('../../src/redisHelper', () => ({ @@ -40,7 +40,7 @@ describe('EventsFactory.bulkVisitEvents', () => { beforeEach(() => { jest.clearAllMocks(); - collectionMock.updateMany.mockResolvedValue({ modifiedCount: 0 }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 0 }); }); it('should mark only not-yet-visited events', async () => { @@ -55,11 +55,10 @@ describe('EventsFactory.bulkVisitEvents', () => { { _id: b, visitedBy: [] }, ]), }); - collectionMock.updateMany.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); const result = await factory.bulkVisitEvents([ a.toString(), b.toString() ], userId.toString()); - expect(result.updatedCount).toBe(1); expect(result.updatedEventIds).toEqual([ b.toString() ]); expect(result.failedEventIds).toEqual([]); }); @@ -74,9 +73,8 @@ describe('EventsFactory.bulkVisitEvents', () => { const result = await factory.bulkVisitEvents([ 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(); + expect(collectionMock.updateOne).not.toHaveBeenCalled(); }); }); diff --git a/test/resolvers/bulk-events-helper.test.ts b/test/resolvers/bulk-events-helper.test.ts index a9394f7f..c29f6a25 100644 --- a/test/resolvers/bulk-events-helper.test.ts +++ b/test/resolvers/bulk-events-helper.test.ts @@ -1,86 +1,36 @@ 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; +const { parseBulkEventIds, mergeFailedEventIds } = require('../../src/resolvers/helpers/bulkEventUtils') as { + parseBulkEventIds: (eventIds: string[]) => { validEventIds: string[]; invalidEventIds: string[] }; + mergeFailedEventIds: ( + result: { failedEventIds?: string[] }, + invalidEventIds: string[] + ) => string[]; }; -describe('fireAndForgetAssigneeNotifications', () => { - let consoleErrorSpy: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - }); +describe('bulkEvents helper', () => { + it('should split valid and invalid ids and deduplicate them', () => { + const validA = '507f1f77bcf86cd799439011'; + const validB = '507f1f77bcf86cd799439012'; + const invalid = 'bad-id'; + const result = parseBulkEventIds([ validA, validA, invalid, validB ]); - 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', + expect(result).toEqual({ + validEventIds: [ validA, validB ], + invalidEventIds: [ invalid ], }); + }); - 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 merge failed ids from factory and invalid resolver ids', () => { + const result = mergeFailedEventIds( + { failedEventIds: [ '507f1f77bcf86cd799439011' ] }, + [ 'bad-id', '507f1f77bcf86cd799439011' ] ); - }); - 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(result).toEqual([ '507f1f77bcf86cd799439011', 'bad-id' ]); + }); - expect(sendPersonalNotification).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to enqueue assignee notifications: assignee data is empty' - ); + it('should throw when eventIds is empty', () => { + expect(() => parseBulkEventIds([])).toThrow('eventIds must contain at least one id'); }); }); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index 1b66daff..1e568339 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -13,7 +13,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { projectId: string; eventIds: string[]; mark: string }, ctx: unknown - ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; + ) => Promise<{ updatedEventIds: string[]; failedEventIds: string[] }>; }; }; @@ -42,7 +42,7 @@ describe('Mutation.bulkToggleEventMarks', () => { }); it('should call factory with original event ids and return its result', async () => { - const payload = { updatedCount: 2, updatedEventIds: [ 'a', 'b' ], failedEventIds: [ 'x' ] }; + const payload = { updatedEventIds: [ 'a', 'b' ], failedEventIds: [ 'x' ] }; bulkToggleEventMark.mockResolvedValue(payload); @@ -65,7 +65,7 @@ describe('Mutation.bulkToggleEventMarks', () => { }); it('should allow starred mark for bulk toggle', async () => { - const payload = { updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [] }; + const payload = { updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [] }; bulkToggleEventMark.mockResolvedValue(payload); @@ -88,7 +88,6 @@ describe('Mutation.bulkToggleEventMarks', () => { it('should validate ids on resolver level and merge invalid ids into failedEventIds', async () => { bulkToggleEventMark.mockResolvedValue({ - updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [ '507f1f77bcf86cd799439099' ], }); @@ -108,7 +107,6 @@ describe('Mutation.bulkToggleEventMarks', () => { 'ignored' ); expect(result).toEqual({ - updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [ '507f1f77bcf86cd799439099', 'invalid-id' ], }); @@ -127,7 +125,6 @@ describe('Mutation.bulkToggleEventMarks', () => { 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 index 9fcc093d..a00aa388 100644 --- a/test/resolvers/event-bulk-update-assignee.test.ts +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -21,7 +21,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { input: { projectId: string; eventIds: string[]; assignee?: string | null } }, ctx: any - ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; + ) => Promise<{ updatedEventIds: string[]; failedEventIds: string[] }>; }; }; @@ -57,7 +57,6 @@ describe('EventsMutations.bulkUpdateAssignee', () => { bulkUpdateAssignee, }); bulkUpdateAssignee.mockResolvedValue({ - updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [], }); @@ -105,7 +104,6 @@ describe('EventsMutations.bulkUpdateAssignee', () => { ctx ); - expect(result.updatedCount).toBe(1); expect(bulkUpdateAssignee).toHaveBeenCalledWith( [ '507f1f77bcf86cd799439011' ], ASSIGNEE_ID @@ -127,7 +125,6 @@ describe('EventsMutations.bulkUpdateAssignee', () => { it('should validate ids on resolver level and merge invalid ids into failedEventIds', async () => { bulkUpdateAssignee.mockResolvedValue({ - updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [ '507f1f77bcf86cd799439099' ], }); @@ -149,7 +146,6 @@ describe('EventsMutations.bulkUpdateAssignee', () => { ASSIGNEE_ID ); expect(result).toEqual({ - updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [ '507f1f77bcf86cd799439099', 'invalid-id' ], }); @@ -170,7 +166,6 @@ describe('EventsMutations.bulkUpdateAssignee', () => { expect(bulkUpdateAssignee).not.toHaveBeenCalled(); expect(result).toEqual({ - updatedCount: 0, updatedEventIds: [], failedEventIds: [ 'bad-1', 'bad-2' ], }); diff --git a/test/resolvers/event-bulk-visit.test.ts b/test/resolvers/event-bulk-visit.test.ts index e99ce988..dade08b6 100644 --- a/test/resolvers/event-bulk-visit.test.ts +++ b/test/resolvers/event-bulk-visit.test.ts @@ -13,7 +13,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { projectId: string; eventIds: string[] }, ctx: any - ) => Promise<{ updatedCount: number; updatedEventIds: string[]; failedEventIds: string[] }>; + ) => Promise<{ updatedEventIds: string[]; failedEventIds: string[] }>; }; }; @@ -31,7 +31,6 @@ describe('Mutation.bulkVisitEvents', () => { it('should call factory with valid ids only and merge invalid ids', async () => { bulkVisitEvents.mockResolvedValue({ - updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439012' ], failedEventIds: [ '507f1f77bcf86cd799439099' ], }); @@ -47,7 +46,6 @@ describe('Mutation.bulkVisitEvents', () => { '507f1f77bcf86cd799439011' ); expect(result).toEqual({ - updatedCount: 1, updatedEventIds: [ '507f1f77bcf86cd799439012' ], failedEventIds: [ '507f1f77bcf86cd799439099', 'bad-id' ], }); @@ -62,7 +60,6 @@ describe('Mutation.bulkVisitEvents', () => { expect(bulkVisitEvents).not.toHaveBeenCalled(); expect(result).toEqual({ - updatedCount: 0, updatedEventIds: [], failedEventIds: [ 'bad-1', 'bad-2' ], }); From 57582bdb3988d34f8216de6876c51293c14625cd Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:33:49 +0300 Subject: [PATCH 22/28] refactor(events): rename response types for bulk event operations to improve clarity --- src/typeDefs/event.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index 1a3de27c..e4e63f07 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -482,9 +482,9 @@ type BulkUpdateAssigneeResponse { } """ -Result of bulk toggling event marks (resolve / ignore / starred) +Response of bulk toggling event marks (resolve / ignore / starred) """ -type BulkToggleEventMarksResult { +type BulkToggleEventMarksResponse { """ Original event ids actually toggled in this operation """ @@ -497,9 +497,9 @@ type BulkToggleEventMarksResult { } """ -Result of bulk marking events as viewed +Response of bulk marking events as viewed """ -type BulkVisitEventsResult { +type BulkVisitEventsResponse { """ Original event ids actually updated in this operation """ @@ -563,7 +563,7 @@ extend type Mutation { Original event ids """ eventIds: [ID!]! - ): BulkVisitEventsResult! @requireUserInWorkspace + ): BulkVisitEventsResponse! @requireUserInWorkspace """ Mutation sets or unsets passed mark to event @@ -606,7 +606,7 @@ extend type Mutation { otherwise set it on every selected event that does not have it yet. """ mark: EventMark! - ): BulkToggleEventMarksResult! @requireUserInWorkspace + ): BulkToggleEventMarksResponse! @requireUserInWorkspace """ Namespace that contains only mutations related to the events From 1e13972419db9f27cd43449cfa2667d4da89ebed Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:48:31 +0300 Subject: [PATCH 23/28] fix: lint --- src/models/eventsFactory.js | 26 +++++++++++-- src/resolvers/event.js | 33 ++++++++++------ src/resolvers/helpers/bulkEventUtils.ts | 46 ++--------------------- test/resolvers/bulk-events-helper.test.ts | 15 +------- 4 files changed, 48 insertions(+), 72 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 27b0ca85..fdf37211 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -95,7 +95,7 @@ class EventsFactory extends Factory { /** * Creates Event instance - * @param {ObjectId} projectId + * @param {ObjectId} projectId - project id */ constructor(projectId) { super(); @@ -948,7 +948,7 @@ class EventsFactory extends Factory { return { updatedEventIds, - failedEventIds: [ ...new Set([ ...failedEventIds, ...failedByUpdate ]) ], + failedEventIds: this._mergeFailedEventIds(failedEventIds, failedByUpdate), }; } @@ -1068,7 +1068,7 @@ class EventsFactory extends Factory { return { updatedEventIds, - failedEventIds: [ ...new Set([ ...failedEventIds, ...failedByUpdate ]) ], + failedEventIds: this._mergeFailedEventIds(failedEventIds, failedByUpdate), }; } @@ -1088,6 +1088,7 @@ class EventsFactory extends Factory { const normalizedAssignee = assignee ? String(assignee) : ''; const docsToUpdate = found.filter(doc => String(doc.assignee || '') !== normalizedAssignee); + if (docsToUpdate.length === 0) { return { updatedEventIds: [], @@ -1132,7 +1133,7 @@ class EventsFactory extends Factory { return { updatedEventIds, - failedEventIds: [ ...new Set([ ...failedEventIds, ...failedByUpdate ]) ], + failedEventIds: this._mergeFailedEventIds(failedEventIds, failedByUpdate), }; } @@ -1207,6 +1208,23 @@ class EventsFactory extends Factory { }; } + /** + * Merge two failed ids collections preserving uniqueness. + * + * @param {string[]} baseFailedEventIds - existing failed ids + * @param {string[]} extraFailedEventIds - failed ids collected from update results + * @returns {string[]} + */ + _mergeFailedEventIds(baseFailedEventIds, extraFailedEventIds) { + const mergedFailedEventIds = new Set(baseFailedEventIds); + + extraFailedEventIds.forEach((eventId) => { + mergedFailedEventIds.add(eventId); + }); + + return Array.from(mergedFailedEventIds); + } + /** * Compose event with repetition * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index a517b562..fa11c888 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,9 +1,7 @@ const getEventsFactory = require('./helpers/eventsFactory').default; const { parseBulkEventIds, - withMergedInvalidEventIds, enqueueAssigneeNotification, - enqueueBulkAssigneeNotifications, } = require('./helpers/bulkEventUtils'); const { aiService } = require('../services/ai'); const { UserInputError } = require('apollo-server-express'); @@ -163,8 +161,12 @@ module.exports = { const factory = getEventsFactory(context, projectId); const result = await factory.bulkVisitEvents(validEventIds, user.id); + const failedEventIds = Array.from(new Set([...(result.failedEventIds || []), ...invalidEventIds])); - return withMergedInvalidEventIds(result, invalidEventIds); + return { + ...result, + failedEventIds, + }; }, /** @@ -207,8 +209,12 @@ module.exports = { const factory = getEventsFactory(context, projectId); const result = await factory.bulkToggleEventMark(validEventIds, mark); + const failedEventIds = Array.from(new Set([...(result.failedEventIds || []), ...invalidEventIds])); - return withMergedInvalidEventIds(result, invalidEventIds); + return { + ...result, + failedEventIds, + }; }, /** @@ -332,15 +338,20 @@ module.exports = { } const result = await factory.bulkUpdateAssignee(validEventIds, assignee); - const resultWithInvalid = withMergedInvalidEventIds(result, invalidEventIds); + const resultWithInvalid = { + ...result, + failedEventIds: Array.from(new Set([...(result.failedEventIds || []), ...invalidEventIds])), + }; if (assignee && resultWithInvalid.updatedEventIds.length > 0) { - enqueueBulkAssigneeNotifications({ - assigneeData, - assigneeId: assignee, - projectId, - whoAssignedId: user.id, - eventIds: resultWithInvalid.updatedEventIds, + resultWithInvalid.updatedEventIds.forEach((eventId) => { + enqueueAssigneeNotification({ + assigneeData, + assigneeId: assignee, + projectId, + whoAssignedId: user.id, + eventId, + }); }); } diff --git a/src/resolvers/helpers/bulkEventUtils.ts b/src/resolvers/helpers/bulkEventUtils.ts index 99356a22..b3e34c8a 100644 --- a/src/resolvers/helpers/bulkEventUtils.ts +++ b/src/resolvers/helpers/bulkEventUtils.ts @@ -7,7 +7,8 @@ import { SenderWorkerTaskType } from '../../types/userNotifications/task-type'; /** * Validate and normalize bulk event ids from resolver input. * - * @param eventIds - raw event ids from GraphQL input + * @param {string[]} eventIds - raw event ids from GraphQL input + * @returns {object} normalized ids grouped by validity */ export function parseBulkEventIds(eventIds: string[]): { validEventIds: string[]; invalidEventIds: string[] } { if (!eventIds || !eventIds.length) { @@ -32,26 +33,6 @@ export function parseBulkEventIds(eventIds: string[]): { validEventIds: string[] }; } -/** - * Merge failed ids returned by factory with invalid ids from resolver validation. - */ -export function mergeFailedEventIds(result: { failedEventIds?: string[] }, invalidEventIds: string[]): string[] { - return [ ...new Set([ ...(result.failedEventIds || []), ...invalidEventIds ]) ]; -} - -/** - * Merge resolver-level invalid ids into factory-level failed ids. - */ -export function withMergedInvalidEventIds( - result: T, - invalidEventIds: string[] -): T & { failedEventIds: string[] } { - return { - ...result, - failedEventIds: mergeFailedEventIds(result, invalidEventIds), - }; -} - type AssigneeNotificationParams = { assigneeData: UserDBScheme | null; assigneeId: string; @@ -74,7 +55,7 @@ export function enqueueAssigneeNotification({ return; } - void sendPersonalNotification(assigneeData, { + sendPersonalNotification(assigneeData, { type: SenderWorkerTaskType.Assignee, payload: { assigneeId, @@ -86,24 +67,3 @@ export function enqueueAssigneeNotification({ console.error('Failed to enqueue assignee notification', error); }); } - -/** - * Enqueue assignee notifications for all updated original events. - */ -export function enqueueBulkAssigneeNotifications({ - assigneeData, - assigneeId, - projectId, - whoAssignedId, - eventIds, -}: Omit & { eventIds: string[] }): void { - eventIds.forEach((eventId) => { - enqueueAssigneeNotification({ - assigneeData, - assigneeId, - projectId, - whoAssignedId, - eventId, - }); - }); -} diff --git a/test/resolvers/bulk-events-helper.test.ts b/test/resolvers/bulk-events-helper.test.ts index c29f6a25..5ca4dcc9 100644 --- a/test/resolvers/bulk-events-helper.test.ts +++ b/test/resolvers/bulk-events-helper.test.ts @@ -1,11 +1,7 @@ import '../../src/env-test'; // eslint-disable-next-line @typescript-eslint/no-var-requires -const { parseBulkEventIds, mergeFailedEventIds } = require('../../src/resolvers/helpers/bulkEventUtils') as { +const { parseBulkEventIds } = require('../../src/resolvers/helpers/bulkEventUtils') as { parseBulkEventIds: (eventIds: string[]) => { validEventIds: string[]; invalidEventIds: string[] }; - mergeFailedEventIds: ( - result: { failedEventIds?: string[] }, - invalidEventIds: string[] - ) => string[]; }; describe('bulkEvents helper', () => { @@ -21,15 +17,6 @@ describe('bulkEvents helper', () => { }); }); - it('should merge failed ids from factory and invalid resolver ids', () => { - const result = mergeFailedEventIds( - { failedEventIds: [ '507f1f77bcf86cd799439011' ] }, - [ 'bad-id', '507f1f77bcf86cd799439011' ] - ); - - expect(result).toEqual([ '507f1f77bcf86cd799439011', 'bad-id' ]); - }); - it('should throw when eventIds is empty', () => { expect(() => parseBulkEventIds([])).toThrow('eventIds must contain at least one id'); }); From 2e9fba0d61ff028257bb019911e48b2c7fa6db10 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:51:22 +0300 Subject: [PATCH 24/28] fix --- src/models/eventsFactory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index fdf37211..2bfb585b 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -95,7 +95,7 @@ class EventsFactory extends Factory { /** * Creates Event instance - * @param {ObjectId} projectId - project id + * @param {ObjectId} projectId */ constructor(projectId) { super(); From 0ee4684dbf4a3e3c8836212c316a090239609571 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:53:20 +0000 Subject: [PATCH 25/28] Bump version up to 1.5.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b9aaa8e8..d484a908 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.0", + "version": "1.5.1", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From c4963c4cbff7cdeda34603ef9c0ccbc7c37bd2bd Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:59:24 +0300 Subject: [PATCH 26/28] fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d484a908..b9aaa8e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.1", + "version": "1.5.0", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From bf64aded9e734cfd872cd6804f1881f03ebe28cd Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:31:05 +0300 Subject: [PATCH 27/28] refactor(events): update bulk event operations to return success status and modified count, enhancing response clarity --- src/models/eventsFactory.js | 254 +++--------------- src/resolvers/event.js | 56 ++-- src/resolvers/helpers/bulkEventUtils.ts | 21 +- src/typeDefs/event.ts | 24 +- test/models/eventsFactory-bulk-toggle.test.ts | 143 +++++++--- ...eventsFactory-bulk-update-assignee.test.ts | 52 ++-- test/models/eventsFactory-bulk-visit.test.ts | 52 ++-- test/resolvers/bulk-events-helper.test.ts | 18 +- .../resolvers/event-bulk-toggle-marks.test.ts | 72 ++--- .../event-bulk-update-assignee.test.ts | 69 ++--- test/resolvers/event-bulk-visit.test.ts | 37 +-- 11 files changed, 311 insertions(+), 487 deletions(-) diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 2bfb585b..6f09055c 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -887,69 +887,20 @@ class EventsFactory extends Factory { * * @param {string[]} eventIds - original event ids * @param {string|ObjectId} userId - id of the user who is visiting events - * @returns {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} + * @returns {Promise} */ async bulkVisitEvents(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); - }); - - if (docsToUpdate.length === 0) { - return { - updatedEventIds: [], - failedEventIds, - }; - } - + const uniqueEventIds = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const collection = this.getCollection(this.TYPES.EVENTS); const userObjectId = new ObjectId(userId); - const settled = await Promise.allSettled( - docsToUpdate.map(async (doc) => { - const eventId = doc._id.toString(); - const updateResult = await collection.updateOne( - { - _id: doc._id, - visitedBy: { $ne: userObjectId }, - }, - { $addToSet: { visitedBy: userObjectId } } - ); - return { - eventId, - updated: updateResult.modifiedCount > 0, - }; - }) + return collection.updateMany( + { + _id: { $in: uniqueEventIds.map(id => new ObjectId(id)) }, + visitedBy: { $ne: userObjectId }, + }, + { $addToSet: { visitedBy: userObjectId } } ); - - const updatedEventIds = []; - const failedByUpdate = []; - - settled.forEach((result, index) => { - const fallbackEventId = docsToUpdate[index]._id.toString(); - - if (result.status === 'fulfilled') { - if (result.value.updated) { - updatedEventIds.push(result.value.eventId); - } else { - failedByUpdate.push(result.value.eventId); - } - } else { - failedByUpdate.push(fallbackEventId); - } - }); - - return { - updatedEventIds, - failedEventIds: this._mergeFailedEventIds(failedEventIds, failedByUpdate), - }; } /** @@ -993,83 +944,34 @@ class EventsFactory extends Factory { * * @param {string[]} eventIds - original event ids * @param {string} mark - 'resolved' | 'ignored' | 'starred' - * @returns {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} + * @returns {Promise} */ async bulkToggleEventMark(eventIds, mark) { - const { - collection, - found, - failedEventIds, - } = await this._resolveBulkEventsByIds(eventIds); - + const uniqueEventIds = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const objectIds = uniqueEventIds.map(id => new ObjectId(id)); + const collection = this.getCollection(this.TYPES.EVENTS); + const found = await collection.find({ _id: { $in: objectIds } }).toArray(); 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 = []; - - 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, + if (allHaveMark) { + return collection.updateMany( + { + _id: { $in: objectIds }, + [markKey]: { $exists: true }, }, - }); - } - - if (ops.length === 0) { - return { - updatedEventIds: [], - failedEventIds, - }; + { $unset: { [markKey]: '' } } + ); } - const settled = await Promise.allSettled( - ops.map(async ({ updateOne }) => { - const eventId = updateOne.filter._id.toString(); - const updateResult = await collection.updateOne( - updateOne.filter, - updateOne.update - ); - - return { - eventId, - updated: updateResult.modifiedCount > 0, - }; - }) + return collection.updateMany( + { + _id: { $in: objectIds }, + [markKey]: { $exists: false }, + }, + { $set: { [markKey]: nowSec } } ); - - const updatedEventIds = []; - const failedByUpdate = []; - - settled.forEach((result, index) => { - const fallbackEventId = ops[index].updateOne.filter._id.toString(); - - if (result.status === 'fulfilled') { - if (result.value.updated) { - updatedEventIds.push(result.value.eventId); - } else { - failedByUpdate.push(result.value.eventId); - } - } else { - failedByUpdate.push(fallbackEventId); - } - }); - - return { - updatedEventIds, - failedEventIds: this._mergeFailedEventIds(failedEventIds, failedByUpdate), - }; } /** @@ -1077,64 +979,20 @@ class EventsFactory extends Factory { * * @param {string[]} eventIds - original event ids * @param {string|null|undefined} assignee - target assignee id, null/undefined to clear - * @returns {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} + * @returns {Promise} */ async bulkUpdateAssignee(eventIds, assignee) { - const { - collection, - found, - failedEventIds, - } = await this._resolveBulkEventsByIds(eventIds); - + const uniqueEventIds = [ ...new Set((eventIds || []).map(id => String(id))) ]; + const collection = this.getCollection(this.TYPES.EVENTS); const normalizedAssignee = assignee ? String(assignee) : ''; - const docsToUpdate = found.filter(doc => String(doc.assignee || '') !== normalizedAssignee); - - if (docsToUpdate.length === 0) { - return { - updatedEventIds: [], - failedEventIds, - }; - } - const settled = await Promise.allSettled( - docsToUpdate.map(async (doc) => { - const eventId = doc._id.toString(); - const updateResult = await collection.updateOne( - { - _id: doc._id, - assignee: { $ne: normalizedAssignee }, - }, - { $set: { assignee: normalizedAssignee } } - ); - - return { - eventId, - updated: updateResult.modifiedCount > 0, - }; - }) + return collection.updateMany( + { + _id: { $in: uniqueEventIds.map(id => new ObjectId(id)) }, + assignee: { $ne: normalizedAssignee }, + }, + { $set: { assignee: normalizedAssignee } } ); - - const updatedEventIds = []; - const failedByUpdate = []; - - settled.forEach((result, index) => { - const fallbackEventId = docsToUpdate[index]._id.toString(); - - if (result.status === 'fulfilled') { - if (result.value.updated) { - updatedEventIds.push(result.value.eventId); - } else { - failedByUpdate.push(result.value.eventId); - } - } else { - failedByUpdate.push(fallbackEventId); - } - }); - - return { - updatedEventIds, - failedEventIds: this._mergeFailedEventIds(failedEventIds, failedByUpdate), - }; } /** @@ -1185,46 +1043,6 @@ 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, - }; - } - - /** - * Merge two failed ids collections preserving uniqueness. - * - * @param {string[]} baseFailedEventIds - existing failed ids - * @param {string[]} extraFailedEventIds - failed ids collected from update results - * @returns {string[]} - */ - _mergeFailedEventIds(baseFailedEventIds, extraFailedEventIds) { - const mergedFailedEventIds = new Set(baseFailedEventIds); - - extraFailedEventIds.forEach((eventId) => { - mergedFailedEventIds.add(eventId); - }); - - return Array.from(mergedFailedEventIds); - } - /** * Compose event with repetition * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index fa11c888..c3cd298b 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -147,25 +147,17 @@ module.exports = { * @param {string} projectId - project id * @param {string[]} eventIds - original event ids * @param {UserInContext} user - user context - * @returns {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} + * @returns {Promise<{ success: boolean, modifiedCount: number }>} */ async bulkVisitEvents(_obj, { projectId, eventIds }, { user, ...context }) { - const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); - - if (validEventIds.length === 0) { - return { - updatedEventIds: [], - failedEventIds: invalidEventIds, - }; - } + const validEventIds = parseBulkEventIds(eventIds); const factory = getEventsFactory(context, projectId); const result = await factory.bulkVisitEvents(validEventIds, user.id); - const failedEventIds = Array.from(new Set([...(result.failedEventIds || []), ...invalidEventIds])); return { - ...result, - failedEventIds, + success: !!result.acknowledged, + modifiedCount: result.modifiedCount || 0, }; }, @@ -195,25 +187,17 @@ module.exports = { * @param {string[]} eventIds - original event ids * @param {string} mark - EventMark enum value * @param {object} context - gql context - * @return {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} + * @return {Promise<{ success: boolean, modifiedCount: number }>} */ async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) { - const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); - - if (validEventIds.length === 0) { - return { - updatedEventIds: [], - failedEventIds: invalidEventIds, - }; - } + const validEventIds = parseBulkEventIds(eventIds); const factory = getEventsFactory(context, projectId); const result = await factory.bulkToggleEventMark(validEventIds, mark); - const failedEventIds = Array.from(new Set([...(result.failedEventIds || []), ...invalidEventIds])); return { - ...result, - failedEventIds, + success: !!result.acknowledged, + modifiedCount: result.modifiedCount || 0, }; }, @@ -299,20 +283,13 @@ module.exports = { * @param {ResolverObj} _obj - resolver context * @param {BulkUpdateAssigneeInput} input - object of arguments * @param factories - factories for working with models - * @return {Promise<{ updatedEventIds: string[], failedEventIds: string[] }>} + * @return {Promise<{ success: boolean, modifiedCount: number }>} */ async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) { const { projectId, eventIds, assignee } = input; - const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds); + const validEventIds = parseBulkEventIds(eventIds); let assigneeData = null; - if (validEventIds.length === 0) { - return { - updatedEventIds: [], - failedEventIds: invalidEventIds, - }; - } - const factory = getEventsFactory(context, projectId); if (assignee) { @@ -338,13 +315,9 @@ module.exports = { } const result = await factory.bulkUpdateAssignee(validEventIds, assignee); - const resultWithInvalid = { - ...result, - failedEventIds: Array.from(new Set([...(result.failedEventIds || []), ...invalidEventIds])), - }; - if (assignee && resultWithInvalid.updatedEventIds.length > 0) { - resultWithInvalid.updatedEventIds.forEach((eventId) => { + if (assignee && result.modifiedCount > 0) { + validEventIds.forEach((eventId) => { enqueueAssigneeNotification({ assigneeData, assigneeId: assignee, @@ -355,7 +328,10 @@ module.exports = { }); } - return resultWithInvalid; + return { + success: !!result.acknowledged, + modifiedCount: result.modifiedCount || 0, + }; }, }, }; diff --git a/src/resolvers/helpers/bulkEventUtils.ts b/src/resolvers/helpers/bulkEventUtils.ts index b3e34c8a..36104237 100644 --- a/src/resolvers/helpers/bulkEventUtils.ts +++ b/src/resolvers/helpers/bulkEventUtils.ts @@ -8,29 +8,20 @@ import { SenderWorkerTaskType } from '../../types/userNotifications/task-type'; * Validate and normalize bulk event ids from resolver input. * * @param {string[]} eventIds - raw event ids from GraphQL input - * @returns {object} normalized ids grouped by validity + * @returns {string[]} unique valid event ids */ -export function parseBulkEventIds(eventIds: string[]): { validEventIds: string[]; invalidEventIds: string[] } { +export function parseBulkEventIds(eventIds: string[]): string[] { 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: string[] = []; - const validEventIds: string[] = []; - uniqueEventIds.forEach((id) => { - if (ObjectId.isValid(id)) { - validEventIds.push(id); - } else { - invalidEventIds.push(id); - } - }); + if (!uniqueEventIds.every((id) => ObjectId.isValid(id))) { + throw new UserInputError('eventIds must contain only valid ids'); + } - return { - validEventIds, - invalidEventIds, - }; + return uniqueEventIds; } type AssigneeNotificationParams = { diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index e4e63f07..c94d71a7 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -471,14 +471,14 @@ type RemoveAssigneeResponse { type BulkUpdateAssigneeResponse { """ - Original event ids actually updated in this operation + True when database accepted mutation """ - updatedEventIds: [ID!]! + success: Boolean! """ - Event ids that were not updated (invalid id or not found) + Number of original events actually modified """ - failedEventIds: [ID!]! + modifiedCount: Int! } """ @@ -486,14 +486,14 @@ Response of bulk toggling event marks (resolve / ignore / starred) """ type BulkToggleEventMarksResponse { """ - Original event ids actually toggled in this operation + True when database accepted mutation """ - updatedEventIds: [ID!]! + success: Boolean! """ - Event ids that were not updated (invalid id or not found) + Number of original events actually modified """ - failedEventIds: [ID!]! + modifiedCount: Int! } """ @@ -501,14 +501,14 @@ Response of bulk marking events as viewed """ type BulkVisitEventsResponse { """ - Original event ids actually updated in this operation + True when database accepted mutation """ - updatedEventIds: [ID!]! + success: Boolean! """ - Event ids that were not updated (invalid id or not found) + Number of original events actually modified """ - failedEventIds: [ID!]! + modifiedCount: Int! } type EventsMutations { diff --git a/test/models/eventsFactory-bulk-toggle.test.ts b/test/models/eventsFactory-bulk-toggle.test.ts index 0da1a7ca..0b8c9eb3 100644 --- a/test/models/eventsFactory-bulk-toggle.test.ts +++ b/test/models/eventsFactory-bulk-toggle.test.ts @@ -3,7 +3,7 @@ import { ObjectId } from 'mongodb'; const collectionMock = { find: jest.fn(), - updateOne: jest.fn(), + updateMany: jest.fn(), }; jest.mock('../../src/redisHelper', () => ({ @@ -32,7 +32,7 @@ jest.mock('../../src/mongo', () => ({ }, })); -// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any const EventsFactory = require('../../src/models/eventsFactory') as any; describe('EventsFactory.bulkToggleEventMark', () => { @@ -40,7 +40,10 @@ describe('EventsFactory.bulkToggleEventMark', () => { beforeEach(() => { jest.clearAllMocks(); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 0 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); }); it('should support starred mark', async () => { @@ -56,13 +59,22 @@ describe('EventsFactory.bulkToggleEventMark', () => { }, ]), }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); const result = await factory.bulkToggleEventMark([ id.toString() ], 'starred'); - expect(result.updatedEventIds).toEqual([ id.toString() ]); - expect(collectionMock.updateOne).toHaveBeenCalledWith( - { _id: id }, + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [ id ] }, + 'marks.starred': { $exists: false }, + }, expect.objectContaining({ $set: { 'marks.starred': expect.any(Number) }, }) @@ -82,26 +94,39 @@ describe('EventsFactory.bulkToggleEventMark', () => { }, ]), }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); - await factory.bulkToggleEventMark([ id.toString(), id.toString(), id.toString() ], 'ignored'); + await factory.bulkToggleEventMark([id.toString(), id.toString(), id.toString()], 'ignored'); - expect(collectionMock.updateOne).toHaveBeenCalledTimes(1); + const query = collectionMock.updateMany.mock.calls[0][0]; + + expect(query._id.$in).toHaveLength(1); }); - it('should list valid but missing document ids in failedEventIds', async () => { + it('should return success shape even when nothing changed', async () => { const factory = new EventsFactory(projectId); - const missing = new ObjectId(); + const id = new ObjectId(); collectionMock.find.mockReturnValue({ - toArray: () => Promise.resolve([]), + toArray: () => Promise.resolve([ { + _id: id, + marks: { ignored: 1 }, + } ]), + }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, }); - const result = await factory.bulkToggleEventMark([ missing.toString() ], 'ignored'); + const result = await factory.bulkToggleEventMark([ id.toString() ], 'ignored'); - expect(result.updatedEventIds).toEqual([]); - expect(result.failedEventIds).toContain(missing.toString()); - expect(collectionMock.updateOne).not.toHaveBeenCalled(); + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 0, + }); }); it('should set mark only on events that do not have it when selection is mixed', async () => { @@ -112,18 +137,28 @@ describe('EventsFactory.bulkToggleEventMark', () => { collectionMock.find.mockReturnValue({ toArray: () => Promise.resolve([ - { _id: a, marks: { ignored: 1 } }, - { _id: b, marks: {} }, + { + _id: a, + marks: { ignored: 1 }, + }, + { + _id: b, + marks: {}, + }, ]), }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); - const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); + await factory.bulkToggleEventMark([a.toString(), b.toString()], 'ignored'); - expect(result.updatedEventIds).toEqual([ b.toString() ]); - expect(collectionMock.updateOne).toHaveBeenCalledTimes(1); - expect(collectionMock.updateOne).toHaveBeenCalledWith( - { _id: b }, + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + 'marks.ignored': { $exists: false }, + }, expect.objectContaining({ $set: { 'marks.ignored': expect.any(Number) }, }) @@ -138,18 +173,34 @@ describe('EventsFactory.bulkToggleEventMark', () => { collectionMock.find.mockReturnValue({ toArray: () => Promise.resolve([ - { _id: a, marks: { resolved: 1 } }, - { _id: b, marks: { resolved: 2 } }, + { + _id: a, + marks: { resolved: 1 }, + }, + { + _id: b, + marks: { resolved: 2 }, + }, ]), }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 2, + }); - const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'resolved'); + const result = await factory.bulkToggleEventMark([a.toString(), b.toString()], 'resolved'); - expect(result.updatedEventIds).toEqual([ a.toString(), b.toString() ]); - expect(collectionMock.updateOne).toHaveBeenCalledTimes(2); - expect(collectionMock.updateOne).toHaveBeenNthCalledWith(1, { _id: a }, { $unset: { 'marks.resolved': '' } }); - expect(collectionMock.updateOne).toHaveBeenNthCalledWith(2, { _id: b }, { $unset: { 'marks.resolved': '' } }); + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 2, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + 'marks.resolved': { $exists: true }, + }, + { $unset: { 'marks.resolved': '' } } + ); }); it('should not remove mark from a subset when only some of the found events have the mark', async () => { @@ -160,18 +211,28 @@ describe('EventsFactory.bulkToggleEventMark', () => { collectionMock.find.mockReturnValue({ toArray: () => Promise.resolve([ - { _id: a, marks: { ignored: 1 } }, - { _id: b, marks: {} }, + { + _id: a, + marks: { ignored: 1 }, + }, + { + _id: b, + marks: {}, + }, ]), }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 1, + }); - const result = await factory.bulkToggleEventMark([ a.toString(), b.toString() ], 'ignored'); + await factory.bulkToggleEventMark([a.toString(), b.toString()], 'ignored'); - expect(result.updatedEventIds).toEqual([ b.toString() ]); - expect(collectionMock.updateOne).toHaveBeenCalledTimes(1); - expect(collectionMock.updateOne).toHaveBeenCalledWith( - { _id: b }, + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + 'marks.ignored': { $exists: false }, + }, 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 index 5a85f0c5..5c8b463a 100644 --- a/test/models/eventsFactory-bulk-update-assignee.test.ts +++ b/test/models/eventsFactory-bulk-update-assignee.test.ts @@ -2,8 +2,7 @@ import '../../src/env-test'; import { ObjectId } from 'mongodb'; const collectionMock = { - find: jest.fn(), - updateOne: jest.fn(), + updateMany: jest.fn(), }; jest.mock('../../src/redisHelper', () => ({ @@ -32,7 +31,7 @@ jest.mock('../../src/mongo', () => ({ }, })); -// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any const EventsFactory = require('../../src/models/eventsFactory') as any; describe('EventsFactory.bulkUpdateAssignee', () => { @@ -40,44 +39,55 @@ describe('EventsFactory.bulkUpdateAssignee', () => { beforeEach(() => { jest.clearAllMocks(); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 0 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); }); - it('should update only events with changed assignee', async () => { + it('should update assignee with updateMany', 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({ + acknowledged: true, + modifiedCount: 1, }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); - const result = await factory.bulkUpdateAssignee([ a.toString(), b.toString() ], 'user-1'); + const result = await factory.bulkUpdateAssignee([a.toString(), b.toString()], 'user-1'); - expect(result.updatedEventIds).toEqual([ b.toString() ]); - expect(result.failedEventIds).toEqual([]); - expect(collectionMock.updateOne).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + assignee: { $ne: 'user-1' }, + }, + { $set: { assignee: 'user-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({ + acknowledged: true, + modifiedCount: 1, }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); const result = await factory.bulkUpdateAssignee([ a.toString() ], null); - expect(result.updatedEventIds).toEqual([ a.toString() ]); - expect(collectionMock.updateOne).toHaveBeenCalledWith( + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); + expect(collectionMock.updateMany).toHaveBeenCalledWith( { - _id: a, + _id: { $in: [ a ] }, assignee: { $ne: '' }, }, { $set: { assignee: '' } } diff --git a/test/models/eventsFactory-bulk-visit.test.ts b/test/models/eventsFactory-bulk-visit.test.ts index bb4bb35c..991a7fb0 100644 --- a/test/models/eventsFactory-bulk-visit.test.ts +++ b/test/models/eventsFactory-bulk-visit.test.ts @@ -2,8 +2,7 @@ import '../../src/env-test'; import { ObjectId } from 'mongodb'; const collectionMock = { - find: jest.fn(), - updateOne: jest.fn(), + updateMany: jest.fn(), }; jest.mock('../../src/redisHelper', () => ({ @@ -32,7 +31,7 @@ jest.mock('../../src/mongo', () => ({ }, })); -// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any -- CJS class +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any const EventsFactory = require('../../src/models/eventsFactory') as any; describe('EventsFactory.bulkVisitEvents', () => { @@ -40,41 +39,46 @@ describe('EventsFactory.bulkVisitEvents', () => { beforeEach(() => { jest.clearAllMocks(); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 0 }); + collectionMock.updateMany.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); }); - it('should mark only not-yet-visited events', async () => { + it('should use updateMany with visitedBy guard', 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({ + acknowledged: true, + modifiedCount: 1, }); - collectionMock.updateOne.mockResolvedValue({ modifiedCount: 1 }); - - const result = await factory.bulkVisitEvents([ a.toString(), b.toString() ], userId.toString()); - expect(result.updatedEventIds).toEqual([ b.toString() ]); - expect(result.failedEventIds).toEqual([]); + const result = await factory.bulkVisitEvents([a.toString(), b.toString()], userId.toString()); + + expect(collectionMock.updateMany).toHaveBeenCalledWith( + { + _id: { $in: [a, b] }, + visitedBy: { $ne: userId }, + }, + { $addToSet: { visitedBy: userId } } + ); + expect(result).toEqual({ + acknowledged: true, + modifiedCount: 1, + }); }); - it('should add not found ids to failedEventIds', async () => { + it('should deduplicate ids before updateMany', async () => { const factory = new EventsFactory(projectId); - const missing = new ObjectId(); + const id = new ObjectId(); - collectionMock.find.mockReturnValue({ - toArray: () => Promise.resolve([]), - }); + await factory.bulkVisitEvents([id.toString(), id.toString()], new ObjectId().toString()); - const result = await factory.bulkVisitEvents([ missing.toString() ], new ObjectId().toString()); + const query = collectionMock.updateMany.mock.calls[0][0]; - expect(result.updatedEventIds).toEqual([]); - expect(result.failedEventIds).toEqual([ missing.toString() ]); - expect(collectionMock.updateOne).not.toHaveBeenCalled(); + expect(query._id.$in).toHaveLength(1); }); }); diff --git a/test/resolvers/bulk-events-helper.test.ts b/test/resolvers/bulk-events-helper.test.ts index 5ca4dcc9..2d27cf9a 100644 --- a/test/resolvers/bulk-events-helper.test.ts +++ b/test/resolvers/bulk-events-helper.test.ts @@ -1,23 +1,25 @@ import '../../src/env-test'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { parseBulkEventIds } = require('../../src/resolvers/helpers/bulkEventUtils') as { - parseBulkEventIds: (eventIds: string[]) => { validEventIds: string[]; invalidEventIds: string[] }; + parseBulkEventIds: (eventIds: string[]) => string[]; }; describe('bulkEvents helper', () => { - it('should split valid and invalid ids and deduplicate them', () => { + it('should deduplicate valid ids', () => { const validA = '507f1f77bcf86cd799439011'; const validB = '507f1f77bcf86cd799439012'; - const invalid = 'bad-id'; - const result = parseBulkEventIds([ validA, validA, invalid, validB ]); + const result = parseBulkEventIds([validA, validA, validB]); - expect(result).toEqual({ - validEventIds: [ validA, validB ], - invalidEventIds: [ invalid ], - }); + expect(result).toEqual([validA, validB]); }); it('should throw when eventIds is empty', () => { expect(() => parseBulkEventIds([])).toThrow('eventIds must contain at least one id'); }); + + it('should throw when at least one id is invalid', () => { + expect(() => parseBulkEventIds(['507f1f77bcf86cd799439011', 'bad-id'])).toThrow( + 'eventIds must contain only valid ids' + ); + }); }); diff --git a/test/resolvers/event-bulk-toggle-marks.test.ts b/test/resolvers/event-bulk-toggle-marks.test.ts index 1e568339..b2882f52 100644 --- a/test/resolvers/event-bulk-toggle-marks.test.ts +++ b/test/resolvers/event-bulk-toggle-marks.test.ts @@ -1,11 +1,11 @@ import '../../src/env-test'; +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; + 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: { @@ -13,7 +13,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { projectId: string; eventIds: string[]; mark: string }, ctx: unknown - ) => Promise<{ updatedEventIds: string[]; failedEventIds: string[] }>; + ) => Promise<{ success: boolean; modifiedCount: number }>; }; }; @@ -33,7 +33,11 @@ describe('Mutation.bulkToggleEventMarks', () => { await expect( eventResolvers.Mutation.bulkToggleEventMarks( {}, - { projectId: 'p1', eventIds: [], mark: 'ignored' }, + { + projectId: 'p1', + eventIds: [], + mark: 'ignored', + }, ctx ) ).rejects.toThrow('eventIds must contain at least one id'); @@ -42,7 +46,10 @@ describe('Mutation.bulkToggleEventMarks', () => { }); it('should call factory with original event ids and return its result', async () => { - const payload = { updatedEventIds: [ 'a', 'b' ], failedEventIds: [ 'x' ] }; + const payload = { + acknowledged: true, + modifiedCount: 2, + }; bulkToggleEventMark.mockResolvedValue(payload); @@ -50,7 +57,7 @@ describe('Mutation.bulkToggleEventMarks', () => { {}, { projectId: 'p1', - eventIds: [ '507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012' ], + eventIds: ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'], mark: 'resolved', }, ctx @@ -58,14 +65,20 @@ describe('Mutation.bulkToggleEventMarks', () => { expect(getEventsFactory).toHaveBeenCalledWith(ctx, 'p1'); expect(bulkToggleEventMark).toHaveBeenCalledWith( - [ '507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012' ], + ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012'], 'resolved' ); - expect(result).toEqual(payload); + expect(result).toEqual({ + success: true, + modifiedCount: 2, + }); }); it('should allow starred mark for bulk toggle', async () => { - const payload = { updatedEventIds: [ '507f1f77bcf86cd799439011' ], failedEventIds: [] }; + const payload = { + acknowledged: true, + modifiedCount: 1, + }; bulkToggleEventMark.mockResolvedValue(payload); @@ -83,51 +96,22 @@ describe('Mutation.bulkToggleEventMarks', () => { [ '507f1f77bcf86cd799439011' ], 'starred' ); - expect(result).toEqual(payload); - }); - - it('should validate ids on resolver level and merge invalid ids into failedEventIds', async () => { - bulkToggleEventMark.mockResolvedValue({ - 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({ - updatedEventIds: [ '507f1f77bcf86cd799439011' ], - failedEventIds: [ '507f1f77bcf86cd799439099', 'invalid-id' ], + success: true, + modifiedCount: 1, }); }); - it('should return early when all ids are invalid', async () => { - const result = await eventResolvers.Mutation.bulkToggleEventMarks( + it('should throw for invalid ids', async () => { + await expect(eventResolvers.Mutation.bulkToggleEventMarks( {}, { projectId: 'p1', - eventIds: [ 'bad-1', 'bad-2' ], + eventIds: ['507f1f77bcf86cd799439011', 'invalid-id'], mark: 'ignored', }, ctx - ); - + )).rejects.toThrow('eventIds must contain only valid ids'); expect(bulkToggleEventMark).not.toHaveBeenCalled(); - expect(result).toEqual({ - 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 index a00aa388..5da3307b 100644 --- a/test/resolvers/event-bulk-update-assignee.test.ts +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -2,6 +2,9 @@ import '../../src/env-test'; import { UserInputError } from 'apollo-server-express'; +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; +import sendPersonalNotification from '../../src/utils/personalNotifications'; + jest.mock('../../src/utils/personalNotifications', () => ({ __esModule: true, default: jest.fn().mockResolvedValue(undefined), @@ -11,9 +14,6 @@ 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: { @@ -21,7 +21,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { input: { projectId: string; eventIds: string[]; assignee?: string | null } }, ctx: any - ) => Promise<{ updatedEventIds: string[]; failedEventIds: string[] }>; + ) => Promise<{ success: boolean; modifiedCount: number }>; }; }; @@ -36,7 +36,10 @@ describe('EventsMutations.bulkUpdateAssignee', () => { findById: jest.fn().mockResolvedValue({ id: ASSIGNEE_ID }), dataLoaders: { userById: { - load: jest.fn().mockResolvedValue({ id: ASSIGNEE_ID, email: 'a@a.a' }), + load: jest.fn().mockResolvedValue({ + id: ASSIGNEE_ID, + email: 'a@a.a', + }), }, }, }, @@ -57,8 +60,8 @@ describe('EventsMutations.bulkUpdateAssignee', () => { bulkUpdateAssignee, }); bulkUpdateAssignee.mockResolvedValue({ - updatedEventIds: [ '507f1f77bcf86cd799439011' ], - failedEventIds: [], + acknowledged: true, + modifiedCount: 1, }); }); @@ -66,7 +69,13 @@ describe('EventsMutations.bulkUpdateAssignee', () => { await expect( eventResolvers.EventsMutations.bulkUpdateAssignee( {}, - { input: { projectId: 'p1', eventIds: [], assignee: ASSIGNEE_ID } }, + { + input: { + projectId: 'p1', + eventIds: [], + assignee: ASSIGNEE_ID, + }, + }, ctx ) ).rejects.toThrow(UserInputError); @@ -92,7 +101,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => { }); it('should call factory for bulk assign', async () => { - const result = await eventResolvers.EventsMutations.bulkUpdateAssignee( + await eventResolvers.EventsMutations.bulkUpdateAssignee( {}, { input: { @@ -112,7 +121,7 @@ describe('EventsMutations.bulkUpdateAssignee', () => { expect(sendPersonalNotification).toHaveBeenCalledWith( expect.objectContaining({ id: ASSIGNEE_ID }), expect.objectContaining({ - type: 'assignee', + type: expect.anything(), payload: expect.objectContaining({ assigneeId: ASSIGNEE_ID, projectId: 'p1', @@ -123,52 +132,20 @@ describe('EventsMutations.bulkUpdateAssignee', () => { ); }); - it('should validate ids on resolver level and merge invalid ids into failedEventIds', async () => { - bulkUpdateAssignee.mockResolvedValue({ - updatedEventIds: [ '507f1f77bcf86cd799439011' ], - failedEventIds: [ '507f1f77bcf86cd799439099' ], - }); - - const result = await eventResolvers.EventsMutations.bulkUpdateAssignee( - {}, - { - input: { - projectId: 'p1', - eventIds: [ '507f1f77bcf86cd799439011', 'invalid-id' ], - assignee: ASSIGNEE_ID, - }, - }, - ctx - ); - - expect(bulkUpdateAssignee).toHaveBeenCalledWith( - [ '507f1f77bcf86cd799439011' ], - ASSIGNEE_ID - ); - expect(result).toEqual({ - updatedEventIds: [ '507f1f77bcf86cd799439011' ], - failedEventIds: [ '507f1f77bcf86cd799439099', 'invalid-id' ], - }); - }); - - it('should return early when all ids are invalid', async () => { - const result = await eventResolvers.EventsMutations.bulkUpdateAssignee( + it('should throw for invalid event ids', async () => { + await expect(eventResolvers.EventsMutations.bulkUpdateAssignee( {}, { input: { projectId: 'p1', - eventIds: [ 'bad-1', 'bad-2' ], + eventIds: ['bad-1', 'bad-2'], assignee: ASSIGNEE_ID, }, }, ctx - ); + )).rejects.toThrow('eventIds must contain only valid ids'); expect(bulkUpdateAssignee).not.toHaveBeenCalled(); - expect(result).toEqual({ - updatedEventIds: [], - failedEventIds: [ 'bad-1', 'bad-2' ], - }); }); it('should call factory for bulk clear assignee', async () => { diff --git a/test/resolvers/event-bulk-visit.test.ts b/test/resolvers/event-bulk-visit.test.ts index dade08b6..edddc2f5 100644 --- a/test/resolvers/event-bulk-visit.test.ts +++ b/test/resolvers/event-bulk-visit.test.ts @@ -1,11 +1,11 @@ import '../../src/env-test'; +import getEventsFactory from '../../src/resolvers/helpers/eventsFactory'; + 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: { @@ -13,7 +13,7 @@ const eventResolvers = require('../../src/resolvers/event') as { o: unknown, args: { projectId: string; eventIds: string[] }, ctx: any - ) => Promise<{ updatedEventIds: string[]; failedEventIds: string[] }>; + ) => Promise<{ success: boolean; modifiedCount: number }>; }; }; @@ -29,15 +29,18 @@ describe('Mutation.bulkVisitEvents', () => { (getEventsFactory as unknown as jest.Mock).mockReturnValue({ bulkVisitEvents }); }); - it('should call factory with valid ids only and merge invalid ids', async () => { + it('should call factory and return normalized response', async () => { bulkVisitEvents.mockResolvedValue({ - updatedEventIds: [ '507f1f77bcf86cd799439012' ], - failedEventIds: [ '507f1f77bcf86cd799439099' ], + acknowledged: true, + modifiedCount: 1, }); const result = await eventResolvers.Mutation.bulkVisitEvents( {}, - { projectId: 'p1', eventIds: [ '507f1f77bcf86cd799439012', 'bad-id' ] }, + { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439012' ], + }, ctx ); @@ -46,22 +49,20 @@ describe('Mutation.bulkVisitEvents', () => { '507f1f77bcf86cd799439011' ); expect(result).toEqual({ - updatedEventIds: [ '507f1f77bcf86cd799439012' ], - failedEventIds: [ '507f1f77bcf86cd799439099', 'bad-id' ], + success: true, + modifiedCount: 1, }); }); - it('should return early when all ids are invalid', async () => { - const result = await eventResolvers.Mutation.bulkVisitEvents( + it('should throw when ids contain invalid values', async () => { + await expect(eventResolvers.Mutation.bulkVisitEvents( {}, - { projectId: 'p1', eventIds: [ 'bad-1', 'bad-2' ] }, + { + projectId: 'p1', + eventIds: ['bad-1', 'bad-2'], + }, ctx - ); - + )).rejects.toThrow('eventIds must contain only valid ids'); expect(bulkVisitEvents).not.toHaveBeenCalled(); - expect(result).toEqual({ - updatedEventIds: [], - failedEventIds: [ 'bad-1', 'bad-2' ], - }); }); }); From ae8d3dc2eed2be64ed64863a375adf897fbbdf9e Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:00:40 +0300 Subject: [PATCH 28/28] test(events): add test to ensure no notifications are sent when no changes occur during bulk assignee update --- .../event-bulk-update-assignee.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/resolvers/event-bulk-update-assignee.test.ts b/test/resolvers/event-bulk-update-assignee.test.ts index 5da3307b..c1cb7785 100644 --- a/test/resolvers/event-bulk-update-assignee.test.ts +++ b/test/resolvers/event-bulk-update-assignee.test.ts @@ -166,4 +166,25 @@ describe('EventsMutations.bulkUpdateAssignee', () => { null ); }); + + it('should not enqueue notifications when nothing changed', async () => { + bulkUpdateAssignee.mockResolvedValue({ + acknowledged: true, + modifiedCount: 0, + }); + + await eventResolvers.EventsMutations.bulkUpdateAssignee( + {}, + { + input: { + projectId: 'p1', + eventIds: [ '507f1f77bcf86cd799439011' ], + assignee: ASSIGNEE_ID, + }, + }, + ctx + ); + + expect(sendPersonalNotification).not.toHaveBeenCalled(); + }); });