Skip to content

Commit ec4350a

Browse files
committed
Multi-select and bulk actions on the events list
1 parent afaf103 commit ec4350a

11 files changed

Lines changed: 189 additions & 16 deletions

File tree

jest.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ module.exports = {
3737
moduleNameMapper: {
3838
'^node:crypto$': '<rootDir>/test/__mocks__/node_crypto.js',
3939
'^node:util$': '<rootDir>/test/__mocks__/node_util.js',
40+
/**
41+
* demoWorkspace is TypeScript; CommonJS resolvers use require() without extension
42+
*/
43+
'^.+/constants/demoWorkspace$': '<rootDir>/src/constants/demoWorkspace.ts',
4044
},
4145

4246
/**

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.4.12",
3+
"version": "1.5.0",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/constants/demoWorkspace.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Mongo ObjectId string of the public "Join Demo Workspace" (Garage landing).
3+
* Keep in sync with operations that seed demo data in Mongo.
4+
*/
5+
export const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a';

src/integrations/github/routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ProjectModel from '../../models/project';
1111
import WorkspaceModel from '../../models/workspace';
1212
import { sgr, Effect } from '../../utils/ansi';
1313
import { databases } from '../../mongo';
14+
import { DEMO_WORKSPACE_ID } from '../../constants/demoWorkspace';
1415

1516
/**
1617
* Default task threshold for automatic task creation
@@ -108,7 +109,7 @@ export function createGitHubRouter(factories: ContextFactories): express.Router
108109
/**
109110
* Check if project is demo project (cannot be modified)
110111
*/
111-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
112+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
112113
res.status(400).json({ error: 'Unable to update demo project' });
113114

114115
return null;

src/models/eventsFactory.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,100 @@ class EventsFactory extends Factory {
918918
return collection.updateOne(query, update);
919919
}
920920

921+
/**
922+
* Max original event ids per bulkToggleEventMark request
923+
*/
924+
static get BULK_TOGGLE_EVENT_MARK_MAX() {
925+
return 100;
926+
}
927+
928+
/**
929+
* Bulk mark for resolved / ignored (not the same as per-event toggleEventMark).
930+
* - If every found event already has the mark: remove it from all (bulk "undo").
931+
* - Otherwise: set the mark on every found event that does not have it yet (never remove
932+
* from a subset when the selection is mixed).
933+
* Only 'resolved' and 'ignored' are allowed for bulk.
934+
*
935+
* @param {string[]} eventIds - original event ids
936+
* @param {string} mark - 'resolved' | 'ignored'
937+
* @returns {Promise<{ updatedCount: number, failedEventIds: string[] }>}
938+
*/
939+
async bulkToggleEventMark(eventIds, mark) {
940+
if (mark !== 'resolved' && mark !== 'ignored') {
941+
throw new Error(`bulkToggleEventMark: mark must be resolved or ignored, got ${mark}`);
942+
}
943+
944+
const max = EventsFactory.BULK_TOGGLE_EVENT_MARK_MAX;
945+
const unique = [ ...new Set((eventIds || []).map(id => String(id))) ];
946+
947+
if (unique.length > max) {
948+
throw new Error(`bulkToggleEventMark: at most ${max} event ids allowed`);
949+
}
950+
951+
const failedEventIds = [];
952+
const validObjectIds = [];
953+
954+
for (const id of unique) {
955+
if (!ObjectId.isValid(id)) {
956+
failedEventIds.push(id);
957+
} else {
958+
validObjectIds.push(new ObjectId(id));
959+
}
960+
}
961+
962+
if (validObjectIds.length === 0) {
963+
return { updatedCount: 0, failedEventIds };
964+
}
965+
966+
const collection = this.getCollection(this.TYPES.EVENTS);
967+
const found = await collection.find({ _id: { $in: validObjectIds } }).toArray();
968+
const foundByIdStr = new Map(found.map(doc => [ doc._id.toString(), doc ]));
969+
970+
for (const oid of validObjectIds) {
971+
const idStr = oid.toString();
972+
973+
if (!foundByIdStr.has(idStr)) {
974+
failedEventIds.push(idStr);
975+
}
976+
}
977+
978+
const nowSec = Math.floor(Date.now() / 1000);
979+
const markKey = `marks.${mark}`;
980+
const allHaveMark = found.length > 0 && found.every(doc => doc.marks && doc.marks[mark]);
981+
const ops = [];
982+
983+
for (const doc of found) {
984+
const hasMark = doc.marks && doc.marks[mark];
985+
let update;
986+
987+
if (allHaveMark) {
988+
update = { $unset: { [markKey]: '' } };
989+
} else if (!hasMark) {
990+
update = { $set: { [markKey]: nowSec } };
991+
} else {
992+
continue;
993+
}
994+
995+
ops.push({
996+
updateOne: {
997+
filter: { _id: doc._id },
998+
update,
999+
},
1000+
});
1001+
}
1002+
1003+
if (ops.length === 0) {
1004+
return { updatedCount: 0, failedEventIds };
1005+
}
1006+
1007+
const bulkResult = await collection.bulkWrite(ops, { ordered: false });
1008+
1009+
return {
1010+
updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount,
1011+
failedEventIds,
1012+
};
1013+
}
1014+
9211015
/**
9221016
* Remove all project events
9231017
*

src/resolvers/event.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const getEventsFactory = require('./helpers/eventsFactory').default;
22
const sendPersonalNotification = require('../utils/personalNotifications').default;
33
const { aiService } = require('../services/ai');
4+
const { DEMO_WORKSPACE_ID } = require('../constants/demoWorkspace');
5+
const { UserInputError } = require('apollo-server-express');
46

57
/**
68
* See all types and fields here {@see ../typeDefs/event.graphql}
@@ -48,7 +50,7 @@ module.exports = {
4850
*/
4951
const project = await factories.projectsFactory.findById(projectId);
5052

51-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
53+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
5254
return [ await factories.usersFactory.findById(user.id) ];
5355
}
5456

@@ -153,6 +155,39 @@ module.exports = {
153155
return !!result.acknowledged;
154156
},
155157

158+
/**
159+
* Bulk set resolved/ignored: always set mark on events that lack it, unless all selected
160+
* already have the mark — then remove from all.
161+
*
162+
* @param {ResolverObj} _obj - resolver context
163+
* @param {string} projectId - project id
164+
* @param {string[]} eventIds - original event ids
165+
* @param {string} mark - EventMark enum value
166+
* @param {object} context - gql context
167+
* @return {Promise<{ updatedCount: number, failedEventIds: string[] }>}
168+
*/
169+
async bulkToggleEventMarks(_obj, { projectId, eventIds, mark }, context) {
170+
if (mark !== 'resolved' && mark !== 'ignored') {
171+
throw new UserInputError('bulkToggleEventMarks supports only resolved and ignored marks');
172+
}
173+
174+
if (!eventIds || !eventIds.length) {
175+
throw new UserInputError('eventIds must contain at least one id');
176+
}
177+
178+
const factory = getEventsFactory(context, projectId);
179+
180+
try {
181+
return await factory.bulkToggleEventMark(eventIds, mark);
182+
} catch (err) {
183+
if (err.message && err.message.includes('bulkToggleEventMark: at most')) {
184+
throw new UserInputError(err.message);
185+
}
186+
187+
throw err;
188+
}
189+
},
190+
156191
/**
157192
* Mutations namespace
158193
*

src/resolvers/project.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ReceiveTypes } from '@hawk.so/types';
22
import * as telegram from '../utils/telegram';
3+
import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace';
34
const mongo = require('../mongo');
45
const { ObjectId } = require('mongodb');
56
const { ApolloError, UserInputError } = require('apollo-server-express');
@@ -253,7 +254,7 @@ module.exports = {
253254
throw new ApolloError('There is no project with that id');
254255
}
255256

256-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
257+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
257258
throw new ApolloError('Unable to update demo project');
258259
}
259260

@@ -291,7 +292,7 @@ module.exports = {
291292
throw new ApolloError('There is no project with that id');
292293
}
293294

294-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
295+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
295296
throw new ApolloError('Unable to update demo project');
296297
}
297298

@@ -399,7 +400,7 @@ module.exports = {
399400
throw new ApolloError('There is no project with that id');
400401
}
401402

402-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
403+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
403404
throw new ApolloError('Unable to remove demo project');
404405
}
405406

@@ -458,7 +459,7 @@ module.exports = {
458459
throw new ApolloError('There is no project with that id');
459460
}
460461

461-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
462+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
462463
throw new ApolloError('Unable to update demo project');
463464
}
464465

@@ -509,7 +510,7 @@ module.exports = {
509510
throw new ApolloError('There is no project with that id');
510511
}
511512

512-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
513+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
513514
throw new ApolloError('Unable to update demo project');
514515
}
515516

src/resolvers/workspace.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Validator from '../utils/validator';
1010
import { dateFromObjectId } from '../utils/dates';
1111
import cloudPaymentsApi from '../utils/cloudPaymentsApi';
1212
import { publish } from '../rabbitmq';
13+
import { DEMO_WORKSPACE_ID } from '../constants/demoWorkspace';
1314

1415
const { ApolloError, UserInputError, ForbiddenError } = require('apollo-server-express');
1516
const crypto = require('crypto');
@@ -551,7 +552,7 @@ module.exports = {
551552
/**
552553
* Crutch for Demo Workspace
553554
*/
554-
if (workspaceData._id.toString() === '6213b6a01e6281087467cc7a') {
555+
if (workspaceData._id.toString() === DEMO_WORKSPACE_ID) {
555556
return [
556557
{
557558
_id: user.id,

src/typeDefs/event.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,21 @@ type RemoveAssigneeResponse {
452452
success: Boolean!
453453
}
454454
455+
"""
456+
Result of bulk toggling event marks (resolve / ignore)
457+
"""
458+
type BulkToggleEventMarksResult {
459+
"""
460+
Number of events updated in the database
461+
"""
462+
updatedCount: Int!
463+
464+
"""
465+
Event ids that were not updated (invalid id or not found)
466+
"""
467+
failedEventIds: [ID!]!
468+
}
469+
455470
type EventsMutations {
456471
"""
457472
Set an assignee for the selected event
@@ -504,6 +519,28 @@ extend type Mutation {
504519
mark: EventMark!
505520
): Boolean!
506521
522+
"""
523+
Toggle the same mark on many original events at once (only resolved or ignored).
524+
Same toggle semantics as toggleEventMark per event.
525+
"""
526+
bulkToggleEventMarks(
527+
"""
528+
Project id
529+
"""
530+
projectId: ID!
531+
532+
"""
533+
Original event ids (grouped event keys in Hawk)
534+
"""
535+
eventIds: [ID!]!
536+
537+
"""
538+
Mark (resolved or ignored only): if every selected event already has it, clear it for all;
539+
otherwise set it on every selected event that does not have it yet.
540+
"""
541+
mark: EventMark!
542+
): BulkToggleEventMarksResult! @requireUserInWorkspace
543+
507544
"""
508545
Namespace that contains only mutations related to the events
509546
"""

test/integrations/github-routes.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ObjectId } from 'mongodb';
33
import express from 'express';
44
import { createGitHubRouter } from '../../src/integrations/github/routes';
55
import { ContextFactories } from '../../src/types/graphql';
6+
import { DEMO_WORKSPACE_ID } from '../../src/constants/demoWorkspace';
67

78
/**
89
* Mock GitHubService
@@ -32,8 +33,6 @@ jest.mock('../../src/integrations/github/store/install-state.redis.store', () =>
3233
})),
3334
}));
3435

35-
const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a';
36-
3736
function createMockProject(options: {
3837
projectId?: string;
3938
workspaceId?: string;

0 commit comments

Comments
 (0)