Skip to content

Commit 4eb4c0f

Browse files
authored
Merge pull request #641 from codex-team/feat/events-multiselect-bulk-actions
Feat/events multiselect bulk actions
2 parents 370cd76 + 40ca14d commit 4eb4c0f

15 files changed

Lines changed: 671 additions & 35 deletions

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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.4.11",
3+
"version": "1.5.0",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {
@@ -41,7 +41,7 @@
4141
"@graphql-tools/merge": "^8.3.1",
4242
"@graphql-tools/schema": "^8.5.1",
4343
"@graphql-tools/utils": "^8.9.0",
44-
"@hawk.so/nodejs": "^3.3.1",
44+
"@hawk.so/nodejs": "^3.3.2",
4545
"@hawk.so/types": "^0.5.9",
4646
"@n1ru4l/json-patch-plus": "^0.2.0",
4747
"@node-saml/node-saml": "^5.0.1",

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: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,11 @@ class EventsFactory extends Factory {
203203
*
204204
* @param {Number} limit - events count limitations
205205
* @param {DailyEventsCursor} paginationCursor - object that contains boundary values of the last event in the previous portion
206-
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
207-
* @param {EventsFilters} filters - marks by which events should be filtered
206+
* @param {'BY_DATE' | 'BY_COUNT' | 'BY_AFFECTED_USERS'} sort - events sort order
207+
* @param {EventsFilters} filters - marks by which events should be filtered (resolved, starred, ignored only; assignee is separate)
208208
* @param {String} search - Search query
209-
* @param {String} release - release name
209+
* @param {String|undefined} release - release name
210+
* @param {String|undefined} assignee - user id or __filter_unassigned__ / __filter_any_assignee__
210211
*
211212
* @return {DaylyEventsPortionSchema}
212213
*/
@@ -917,6 +918,106 @@ class EventsFactory extends Factory {
917918
return collection.updateOne(query, update);
918919
}
919920

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 {
964+
updatedCount: 0,
965+
failedEventIds,
966+
};
967+
}
968+
969+
const collection = this.getCollection(this.TYPES.EVENTS);
970+
const found = await collection.find({ _id: { $in: validObjectIds } }).toArray();
971+
const foundByIdStr = new Map(found.map(doc => [doc._id.toString(), doc]));
972+
973+
for (const oid of validObjectIds) {
974+
const idStr = oid.toString();
975+
976+
if (!foundByIdStr.has(idStr)) {
977+
failedEventIds.push(idStr);
978+
}
979+
}
980+
981+
const nowSec = Math.floor(Date.now() / 1000);
982+
const markKey = `marks.${mark}`;
983+
const allHaveMark = found.length > 0 && found.every(doc => doc.marks && doc.marks[mark]);
984+
const ops = [];
985+
986+
for (const doc of found) {
987+
const hasMark = doc.marks && doc.marks[mark];
988+
let update;
989+
990+
if (allHaveMark) {
991+
update = { $unset: { [markKey]: '' } };
992+
} else if (!hasMark) {
993+
update = { $set: { [markKey]: nowSec } };
994+
} else {
995+
continue;
996+
}
997+
998+
ops.push({
999+
updateOne: {
1000+
filter: { _id: doc._id },
1001+
update,
1002+
},
1003+
});
1004+
}
1005+
1006+
if (ops.length === 0) {
1007+
return {
1008+
updatedCount: 0,
1009+
failedEventIds,
1010+
};
1011+
}
1012+
1013+
const bulkResult = await collection.bulkWrite(ops, { ordered: false });
1014+
1015+
return {
1016+
updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount,
1017+
failedEventIds,
1018+
};
1019+
}
1020+
9201021
/**
9211022
* Remove all project events
9221023
*

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: 68 additions & 15 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');
@@ -20,6 +21,54 @@ const GROUPING_TIMESTAMP_INDEX_NAME = 'groupingTimestamp';
2021
const GROUPING_TIMESTAMP_AND_LAST_REPETITION_TIME_AND_ID_INDEX_NAME = 'groupingTimestampAndLastRepetitionTimeAndId';
2122
const GROUPING_TIMESTAMP_AND_GROUP_HASH_INDEX_NAME = 'groupingTimestampAndGroupHash';
2223
const MAX_SEARCH_QUERY_LENGTH = 50;
24+
const FALLBACK_EVENT_TITLE = 'Unknown';
25+
26+
/**
27+
* Ensures each daily event has non-empty payload title
28+
* and writes warning log with identifiers when fallback is used.
29+
*
30+
* @param {object} dailyEventsPortion - portion returned by events factory
31+
* @param {string|ObjectId} projectId - project id for logs
32+
* @returns {object}
33+
*/
34+
function normalizeDailyEventsPayloadTitle(dailyEventsPortion, projectId) {
35+
if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) {
36+
return dailyEventsPortion;
37+
}
38+
39+
dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => {
40+
const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null;
41+
const payload = event && event.payload ? event.payload : null;
42+
const hasValidTitle = payload &&
43+
typeof payload.title === 'string' &&
44+
payload.title.trim().length > 0;
45+
46+
if (hasValidTitle) {
47+
return dailyEvent;
48+
}
49+
50+
console.warn('🔴🔴🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', {
51+
projectId: projectId ? projectId.toString() : null,
52+
dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null,
53+
dailyEventGroupHash: dailyEvent && dailyEvent.groupHash ? dailyEvent.groupHash.toString() : null,
54+
eventOriginalId: event && event.originalEventId ? event.originalEventId.toString() : null,
55+
eventId: event && event._id ? event._id.toString() : null,
56+
});
57+
58+
return {
59+
...dailyEvent,
60+
event: {
61+
...(event || {}),
62+
payload: {
63+
...(payload || {}),
64+
title: FALLBACK_EVENT_TITLE,
65+
},
66+
},
67+
};
68+
});
69+
70+
return dailyEventsPortion;
71+
}
2372

2473
/**
2574
* See all types and fields here {@see ../typeDefs/project.graphql}
@@ -205,7 +254,7 @@ module.exports = {
205254
throw new ApolloError('There is no project with that id');
206255
}
207256

208-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
257+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
209258
throw new ApolloError('Unable to update demo project');
210259
}
211260

@@ -243,7 +292,7 @@ module.exports = {
243292
throw new ApolloError('There is no project with that id');
244293
}
245294

246-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
295+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
247296
throw new ApolloError('Unable to update demo project');
248297
}
249298

@@ -351,7 +400,7 @@ module.exports = {
351400
throw new ApolloError('There is no project with that id');
352401
}
353402

354-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
403+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
355404
throw new ApolloError('Unable to remove demo project');
356405
}
357406

@@ -410,7 +459,7 @@ module.exports = {
410459
throw new ApolloError('There is no project with that id');
411460
}
412461

413-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
462+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
414463
throw new ApolloError('Unable to update demo project');
415464
}
416465

@@ -461,7 +510,7 @@ module.exports = {
461510
throw new ApolloError('There is no project with that id');
462511
}
463512

464-
if (project.workspaceId.toString() === '6213b6a01e6281087467cc7a') {
513+
if (project.workspaceId.toString() === DEMO_WORKSPACE_ID) {
465514
throw new ApolloError('Unable to update demo project');
466515
}
467516

@@ -571,17 +620,19 @@ module.exports = {
571620
},
572621

573622
/**
574-
* Returns recent Events grouped by day
575-
*
576-
* @param {ProjectDBScheme} project - result of parent resolver
577-
* @param {Number} limit - limit for events count
578-
* @param {DailyEventsCursor} cursor - object with boundary values of the first event in the next portion
579-
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
580-
* @param {EventsFilters} filters - marks by which events should be filtered
581-
* @param {String} release - release name
582-
* @param {String} search - search query
623+
* Returns a paginated portion of daily-grouped events
583624
*
584-
* @return {Promise<RecentEventSchema[]>}
625+
* @param {ProjectDBScheme} project - parent resolver result
626+
* @param {object} args - GraphQL arguments
627+
* @param {number} args.limit - max rows in portion
628+
* @param {object|null} args.nextCursor - pagination cursor
629+
* @param {string} args.sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS (mapped in factory)
630+
* @param {object} args.filters - mark filters only: resolved, starred, ignored (assignee uses args.assignee)
631+
* @param {string} args.search - search query
632+
* @param {string|undefined} args.release - optional release label filter
633+
* @param {string|undefined} args.assignee - user id or __filter_unassigned__ / __filter_any_assignee__
634+
* @param {object} context - GraphQL context
635+
* @returns {Promise<object>} dailyEventsPortion payload from factory
585636
*/
586637
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search, release, assignee }, context) {
587638
if (search) {
@@ -602,6 +653,8 @@ module.exports = {
602653
assignee
603654
);
604655

656+
normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id);
657+
605658
return dailyEventsPortion;
606659
},
607660

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,

0 commit comments

Comments
 (0)