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 1/3] 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 2/3] 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 3/3] 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": {