Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.4.11",
"version": "1.5.0",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -41,7 +41,7 @@
"@graphql-tools/merge": "^8.3.1",
"@graphql-tools/schema": "^8.5.1",
"@graphql-tools/utils": "^8.9.0",
"@hawk.so/nodejs": "^3.3.1",
"@hawk.so/nodejs": "^3.3.2",
"@hawk.so/types": "^0.5.9",
"@n1ru4l/json-patch-plus": "^0.2.0",
"@node-saml/node-saml": "^5.0.1",
Expand Down
5 changes: 5 additions & 0 deletions src/constants/demoWorkspace.ts
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
107 changes: 104 additions & 3 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,11 @@ class EventsFactory extends Factory {
*
* @param {Number} limit - events count limitations
* @param {DailyEventsCursor} paginationCursor - object that contains boundary values of the last event in the previous portion
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
* @param {EventsFilters} filters - marks by which events should be filtered
* @param {'BY_DATE' | 'BY_COUNT' | 'BY_AFFECTED_USERS'} sort - events sort order
* @param {EventsFilters} filters - marks by which events should be filtered (resolved, starred, ignored only; assignee is separate)
* @param {String} search - Search query
* @param {String} release - release name
* @param {String|undefined} release - release name
* @param {String|undefined} assignee - user id or __filter_unassigned__ / __filter_any_assignee__
*
* @return {DaylyEventsPortionSchema}
*/
Expand Down Expand Up @@ -917,6 +918,106 @@ 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;
}

/**
* Bulk mark for resolved / ignored (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.
*
* @param {string[]} eventIds - original event ids
* @param {string} mark - 'resolved' | 'ignored'
* @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}`);
}

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,
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 = [];

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,
},
});
}

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

const bulkResult = await collection.bulkWrite(ops, { ordered: false });

return {
updatedCount: bulkResult.modifiedCount + bulkResult.upsertedCount,
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') {
throw new UserInputError('bulkToggleEventMarks supports only resolved and ignored 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
83 changes: 68 additions & 15 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 All @@ -20,6 +21,54 @@ const GROUPING_TIMESTAMP_INDEX_NAME = 'groupingTimestamp';
const GROUPING_TIMESTAMP_AND_LAST_REPETITION_TIME_AND_ID_INDEX_NAME = 'groupingTimestampAndLastRepetitionTimeAndId';
const GROUPING_TIMESTAMP_AND_GROUP_HASH_INDEX_NAME = 'groupingTimestampAndGroupHash';
const MAX_SEARCH_QUERY_LENGTH = 50;
const FALLBACK_EVENT_TITLE = 'Unknown';

/**
* Ensures each daily event has non-empty payload title
* and writes warning log with identifiers when fallback is used.
*
* @param {object} dailyEventsPortion - portion returned by events factory
* @param {string|ObjectId} projectId - project id for logs
* @returns {object}
*/
function normalizeDailyEventsPayloadTitle(dailyEventsPortion, projectId) {
if (!dailyEventsPortion || !Array.isArray(dailyEventsPortion.dailyEvents)) {
return dailyEventsPortion;
}

dailyEventsPortion.dailyEvents = dailyEventsPortion.dailyEvents.map((dailyEvent) => {
const event = dailyEvent && dailyEvent.event ? dailyEvent.event : null;
const payload = event && event.payload ? event.payload : null;
const hasValidTitle = payload &&
typeof payload.title === 'string' &&
payload.title.trim().length > 0;

if (hasValidTitle) {
return dailyEvent;
}

console.warn('🔴🔴🔴 [ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', {
projectId: projectId ? projectId.toString() : null,
dailyEventId: dailyEvent && dailyEvent.id ? dailyEvent.id.toString() : null,
dailyEventGroupHash: dailyEvent && dailyEvent.groupHash ? dailyEvent.groupHash.toString() : null,
eventOriginalId: event && event.originalEventId ? event.originalEventId.toString() : null,
eventId: event && event._id ? event._id.toString() : null,
});

return {
...dailyEvent,
event: {
...(event || {}),
payload: {
...(payload || {}),
title: FALLBACK_EVENT_TITLE,
},
},
};
});

return dailyEventsPortion;
}

/**
* See all types and fields here {@see ../typeDefs/project.graphql}
Expand Down Expand Up @@ -205,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 @@ -243,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 @@ -351,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 @@ -410,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 @@ -461,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 Expand Up @@ -571,17 +620,19 @@ module.exports = {
},

/**
* Returns recent Events grouped by day
*
* @param {ProjectDBScheme} project - result of parent resolver
* @param {Number} limit - limit for events count
* @param {DailyEventsCursor} cursor - object with boundary values of the first event in the next portion
* @param {'BY_DATE' | 'BY_COUNT'} sort - events sort order
* @param {EventsFilters} filters - marks by which events should be filtered
* @param {String} release - release name
* @param {String} search - search query
* Returns a paginated portion of daily-grouped events
*
* @return {Promise<RecentEventSchema[]>}
* @param {ProjectDBScheme} project - parent resolver result
* @param {object} args - GraphQL arguments
* @param {number} args.limit - max rows in portion
* @param {object|null} args.nextCursor - pagination cursor
* @param {string} args.sort - BY_DATE | BY_COUNT | BY_AFFECTED_USERS (mapped in factory)
* @param {object} args.filters - mark filters only: resolved, starred, ignored (assignee uses args.assignee)
* @param {string} args.search - search query
* @param {string|undefined} args.release - optional release label filter
* @param {string|undefined} args.assignee - user id or __filter_unassigned__ / __filter_any_assignee__
* @param {object} context - GraphQL context
* @returns {Promise<object>} dailyEventsPortion payload from factory
*/
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search, release, assignee }, context) {
if (search) {
Expand All @@ -602,6 +653,8 @@ module.exports = {
assignee
);

normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id);

return dailyEventsPortion;
},

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
Loading
Loading