Skip to content
Open
Show file tree
Hide file tree
Changes from 20 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
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.0",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
161 changes: 161 additions & 0 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,48 @@ class EventsFactory extends Factory {
return result;
}

/**
* Mark many original events as visited for passed user
*
* @param {string[]} eventIds - original event ids
* @param {string|ObjectId} userId - id of the user who is visiting events
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
*/
async bulkVisitEvents(eventIds, userId) {
const {
collection,
found,
failedEventIds,
} = await this._resolveBulkEventsByIds(eventIds);
const userIdStr = String(userId);

const docsToUpdate = found.filter((doc) => {
const visitedBy = Array.isArray(doc.visitedBy) ? doc.visitedBy : [];

return !visitedBy.some((visitedUserId) => String(visitedUserId) === userIdStr);
});
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
*
Expand Down Expand Up @@ -918,6 +960,102 @@ class EventsFactory extends Factory {
return collection.updateOne(query, update);
}

/**
* Bulk toggle mark for original events.
*
* @param {string[]} eventIds - original event ids
* @param {string} mark - 'resolved' | 'ignored' | 'starred'
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
*/
async bulkToggleEventMark(eventIds, mark) {
const {
collection,
found,
failedEventIds,
} = await this._resolveBulkEventsByIds(eventIds);

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

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

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

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

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

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

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

/**
* Bulk set/clear assignee for many original events.
*
* @param {string[]} eventIds - original event ids
* @param {string|null|undefined} assignee - target assignee id, null/undefined to clear
* @returns {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
*/
async bulkUpdateAssignee(eventIds, assignee) {
const {
collection,
found,
failedEventIds,
} = await this._resolveBulkEventsByIds(eventIds);

const normalizedAssignee = assignee ? String(assignee) : '';
const docsToUpdate = found.filter(doc => String(doc.assignee || '') !== normalizedAssignee);
const updatedEventIds = docsToUpdate.map(doc => doc._id.toString());

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

const updateManyResult = await collection.updateMany(
{ _id: { $in: docsToUpdate.map(doc => doc._id) } },
{ $set: { assignee: normalizedAssignee } }
);

return {
updatedCount: updateManyResult.modifiedCount,
updatedEventIds,
failedEventIds,
};
}

/**
* Remove all project events
*
Expand Down Expand Up @@ -966,6 +1104,29 @@ class EventsFactory extends Factory {
return result;
}

/**
* Resolve original events for bulk operations and collect not found ids.
*
* @param {string[]} eventIds - original event ids
* @returns {Promise<{ collection: any, found: any[], failedEventIds: string[] }>}
*/
async _resolveBulkEventsByIds(eventIds) {
const unique = [ ...new Set((eventIds || []).map(id => String(id))) ];
const objectIds = unique.map(id => new ObjectId(id));
const collection = this.getCollection(this.TYPES.EVENTS);
const found = await collection.find({ _id: { $in: objectIds } }).toArray();
const foundByIdStr = new Set(found.map(doc => doc._id.toString()));
const failedEventIds = objectIds
.map(id => id.toString())
.filter(id => !foundByIdStr.has(id));

return {
collection,
found,
failedEventIds,
};
}

/**
* Compose event with repetition
*
Expand Down
145 changes: 136 additions & 9 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
@@ -1,6 +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');
const { ObjectId } = require('mongodb');

/**
* See all types and fields here {@see ../typeDefs/event.graphql}
Expand Down Expand Up @@ -135,6 +141,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.bulkVisitEvents(validEventIds, user.id);

return {
...result,
failedEventIds: mergeFailedEventIds(result, invalidEventIds),
};
},

/**
* Mark event with one of the event marks
Expand All @@ -153,6 +187,37 @@ module.exports = {
return !!result.acknowledged;
},

/**
* Bulk set resolved/ignored: always set mark on events that lack it, unless all selected
* already have the mark — then remove from all.
*
* @param {ResolverObj} _obj - resolver context
* @param {string} projectId - project id
* @param {string[]} eventIds - original event ids
* @param {string} mark - EventMark enum value
* @param {object} context - gql context
* @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
*/
async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) {
const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds);

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

const factory = getEventsFactory(context, projectId);
const result = await factory.bulkToggleEventMark(validEventIds, mark);

return {
...result,
failedEventIds: mergeFailedEventIds(result, invalidEventIds),
};
},

/**
* Mutations namespace
*
Expand Down Expand Up @@ -196,14 +261,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 {
Expand All @@ -230,5 +293,69 @@ module.exports = {
success: !!result.acknowledged,
};
},

/**
* Bulk set/clear assignee for selected original events
*
* @param {ResolverObj} _obj - resolver context
* @param {BulkUpdateAssigneeInput} input - object of arguments
* @param factories - factories for working with models
* @return {Promise<{ updatedCount: number, updatedEventIds: string[], failedEventIds: string[] }>}
*/
async bulkUpdateAssignee(_obj, { input }, { factories, user, ...context }) {
const { projectId, eventIds, assignee } = input;
const { validEventIds, invalidEventIds } = parseBulkEventIds(eventIds);
let assigneeData = null;

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

const factory = getEventsFactory(context, projectId);

if (assignee) {
if (!ObjectId.isValid(String(assignee))) {
throw new UserInputError('assignee must be a valid id or null');
}

const userExists = await factories.usersFactory.findById(assignee);

if (!userExists) {
throw new UserInputError('assignee not found');
}
Comment on lines +295 to +304
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.

assignee is only checked for truthiness. If the client passes an invalid ID (or an empty string), usersFactory.findById() will throw from new ObjectId(id) inside the dataloader instead of returning a controlled UserInputError. Validate assignee explicitly (e.g., ObjectId.isValid) and treat empty string as invalid since the API contract says “pass null to clear”.

Copilot uses AI. Check for mistakes.

assigneeData = userExists;

const project = await factories.projectsFactory.findById(projectId);
const workspace = await factories.workspacesFactory.findById(project.workspaceId);
const assigneeExistsInWorkspace = await workspace.getMemberInfo(assignee);

if (!assigneeExistsInWorkspace) {
throw new UserInputError('assignee is not a workspace member');
}
}

const result = await factory.bulkUpdateAssignee(validEventIds, assignee);
const resultWithInvalid = {
...result,
failedEventIds: mergeFailedEventIds(result, invalidEventIds),
};

if (assignee && resultWithInvalid.updatedEventIds.length > 0) {
fireAndForgetAssigneeNotifications({
assigneeData,
eventIds: resultWithInvalid.updatedEventIds,
projectId,
assigneeId: assignee,
whoAssignedId: user.id,
});
}

return resultWithInvalid;
},
},
};
Loading
Loading