Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ec4350a
Multi-select and bulk actions on the events list
Dobrunia Apr 22, 2026
42287da
chore: add tests
Dobrunia Apr 22, 2026
40ca14d
fix: lint
Dobrunia Apr 22, 2026
0593ed3
feat(events): extend bulk toggle functionality to support starred marks
Dobrunia Apr 22, 2026
dd309a5
feat(events): update bulkToggleEventMark to return updatedEventIds
Dobrunia Apr 22, 2026
9a0fc08
Bump version up to 1.5.1
github-actions[bot] Apr 22, 2026
7ed3481
remove
Dobrunia Apr 22, 2026
d497afd
feat(events): add bulkUpdateAssignee functionality to manage event as…
Dobrunia Apr 22, 2026
dd84f03
refactor(events): streamline bulk event ID validation and enhance err…
Dobrunia Apr 22, 2026
ace53d0
refactor(events): remove unsupported mark validation from bulkToggleE…
Dobrunia Apr 22, 2026
530adaa
feat(events): implement bulkVisitEvents functionality to mark multipl…
Dobrunia Apr 22, 2026
c212292
refactor(events): consolidate bulk event ID resolution into a new met…
Dobrunia Apr 22, 2026
049bace
fix: lint
Dobrunia Apr 22, 2026
f250de5
fix: lint
Dobrunia Apr 22, 2026
ccd4a7e
Bump version up to 1.5.2
github-actions[bot] Apr 22, 2026
6535527
fix: notif
Dobrunia Apr 22, 2026
93fd728
Bump version up to 1.5.3
github-actions[bot] Apr 22, 2026
94d4e84
chore: optimize assignee notification handling with chunked processing
Dobrunia Apr 24, 2026
f2ab338
Update src/typeDefs/event.ts
Dobrunia Apr 24, 2026
81a682f
refactor(events): rename bulkVisitEvent to bulkVisitEvents for consis…
Dobrunia Apr 24, 2026
c271563
refactor(events): simplify bulk event operations by removing updatedC…
Dobrunia Apr 28, 2026
57582bd
refactor(events): rename response types for bulk event operations to …
Dobrunia Apr 28, 2026
1e13972
fix: lint
Dobrunia Apr 28, 2026
2e9fba0
fix
Dobrunia Apr 28, 2026
0ee4684
Bump version up to 1.5.1
github-actions[bot] Apr 28, 2026
c4963c4
fix
Dobrunia Apr 28, 2026
bf64ade
refactor(events): update bulk event operations to return success stat…
Dobrunia Apr 28, 2026
ae8d3dc
test(events): add test to ensure no notifications are sent when no ch…
Dobrunia Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ module.exports = {
moduleNameMapper: {
'^node:crypto$': '<rootDir>/test/__mocks__/node_crypto.js',
'^node:util$': '<rootDir>/test/__mocks__/node_util.js',
/**
* demoWorkspace is TypeScript; CommonJS resolvers use require() without extension
*/
'^.+/constants/demoWorkspace$': '<rootDir>/src/constants/demoWorkspace.ts',
},

/**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.4.12",
"version": "1.5.1",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
5 changes: 5 additions & 0 deletions src/constants/demoWorkspace.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unexpected change

Original file line number Diff line number Diff line change
@@ -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';
3 changes: 2 additions & 1 deletion src/integrations/github/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,111 @@ 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;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

specify exact problem


/**
* 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.
*
* @param {string[]} eventIds - original event ids
* @param {string} mark - 'resolved' | 'ignored' | 'starred'
* @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}`);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validation should be on resolver level (or via graphql schema)


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,
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 nowSec = Math.floor(Date.now() / 1000);
const markKey = `marks.${mark}`;
const allHaveMark = found.length > 0 && found.every(doc => doc.marks && doc.marks[mark]);
const ops = [];
const updatedEventIds = [];

for (const doc of found) {
const hasMark = doc.marks && doc.marks[mark];
let update;

if (allHaveMark) {
update = { $unset: { [markKey]: '' } };
} else if (!hasMark) {
update = { $set: { [markKey]: nowSec } };
} else {
continue;
}

ops.push({
updateOne: {
filter: { _id: doc._id },
update,
},
});
updatedEventIds.push(doc._id.toString());
}

if (ops.length === 0) {
return {
updatedCount: 0,
updatedEventIds: [],
failedEventIds,
};
}

Comment thread
neSpecc marked this conversation as resolved.
const bulkResult = await collection.bulkWrite(ops, { ordered: false });

return {
updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount,
updatedEventIds,
failedEventIds,
};
}

/**
* Remove all project events
*
Expand Down
37 changes: 36 additions & 1 deletion src/resolvers/event.js
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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) ];
}

Expand Down Expand Up @@ -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' && mark !== 'starred') {
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 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
*
Expand Down
11 changes: 6 additions & 5 deletions src/resolvers/project.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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');
}

Expand Down
3 changes: 2 additions & 1 deletion src/resolvers/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions src/typeDefs/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,26 @@ type RemoveAssigneeResponse {
success: Boolean!
}

"""
Result of bulk toggling event marks (resolve / ignore)
Comment thread
Dobrunia marked this conversation as resolved.
Outdated
"""
type BulkToggleEventMarksResult {
"""
Number of events updated in the database
"""
updatedCount: Int!

"""
Original event ids actually toggled in this operation
"""
updatedEventIds: [ID!]!

"""
Event ids that were not updated (invalid id or not found)
"""
failedEventIds: [ID!]!
}

type EventsMutations {
"""
Set an assignee for the selected event
Expand Down Expand Up @@ -504,6 +524,28 @@ extend type Mutation {
mark: EventMark!
): Boolean!

"""
Toggle the same mark on many original events at once (resolved, ignored or starred).
Same toggle semantics as toggleEventMark per event.
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutation comment says “Same toggle semantics as toggleEventMark per event”, but this bulk operation has different semantics (it only clears when all selected already have the mark). Please adjust the docstring so clients don’t assume per-event toggling behavior.

Suggested change
Same toggle semantics as toggleEventMark per event.
Uses bulk semantics: if every selected event already has the mark, clear it for all;
otherwise set it on each selected event that does not have it yet.

Copilot uses AI. Check for mistakes.
"""
bulkToggleEventMarks(
"""
Project id
"""
projectId: ID!

"""
Original event ids (grouped event keys in Hawk)
"""
eventIds: [ID!]!

"""
Mark (resolved, ignored or starred): if every selected event already has it, clear it for all;
otherwise set it on every selected event that does not have it yet.
"""
mark: EventMark!
): BulkToggleEventMarksResult! @requireUserInWorkspace

"""
Namespace that contains only mutations related to the events
"""
Expand Down
3 changes: 1 addition & 2 deletions test/integrations/github-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading