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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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": {