diff --git a/package.json b/package.json index 00cd7e34..8a2c557b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.11", + "version": "1.4.13", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -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", diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index d76c49bd..3cf4d9ef 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -1,8 +1,8 @@ -import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates'; import safe from 'safe-regex'; import { createProjectEventsByIdLoader } from '../dataLoaders'; import RedisHelper from '../redisHelper'; import ChartDataService from '../services/chartDataService'; +import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates'; const Factory = require('./modelFactory'); const mongo = require('../mongo'); @@ -918,6 +918,40 @@ class EventsFactory extends Factory { return collection.updateOne(query, update); } + /** + * Remove a single event and all related data (repetitions, daily events) + * + * @param {string|ObjectId} eventId - id of the original event to remove + * @return {Promise} + */ + async removeEvent(eventId) { + const eventsCollection = this.getCollection(this.TYPES.EVENTS); + + const event = await eventsCollection.findOne({ _id: new ObjectId(eventId) }); + + // If event is not found, throw error + if (!event) { + throw new Error(`Event not found for eventId: ${eventId}`); + } + + const { groupHash } = event; + + // Delete original event + const result = await eventsCollection.deleteOne({ _id: new ObjectId(eventId) }); + + // Delete all repetitions with same groupHash + if (await this.isCollectionExists(this.TYPES.REPETITIONS)) { + await this.getCollection(this.TYPES.REPETITIONS).deleteMany({ groupHash }); + } + + // Delete all daily event records with same groupHash + if (await this.isCollectionExists(this.TYPES.DAILY_EVENTS)) { + await this.getCollection(this.TYPES.DAILY_EVENTS).deleteMany({ groupHash }); + } + + return result.acknowledged && result.deletedCount > 0; + } + /** * Remove all project events * diff --git a/src/resolvers/event.js b/src/resolvers/event.js index c3c44971..c90bf9a2 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -153,6 +153,22 @@ module.exports = { return !!result.acknowledged; }, + /** + * Remove event and all related data (repetitions, daily events) + * + * @param {ResolverObj} _obj - resolver context + * @param {string} projectId - project id + * @param {string} eventId - event id to remove + * @return {Promise} + */ + async removeEvent(_obj, { projectId, eventId }, context) { + const factory = getEventsFactory(context, projectId); + + const result = await factory.removeEvent(eventId); + + return result; + }, + /** * Mutations namespace * diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 65e17130..65fc2cdc 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -20,6 +20,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} @@ -604,6 +652,8 @@ module.exports = { assignee ); + normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id); + return dailyEventsPortion; }, diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c200de96..fb510c1e 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -504,6 +504,21 @@ extend type Mutation { mark: EventMark! ): Boolean! + """ + Remove event and all related data (repetitions, daily events) + """ + removeEvent( + """ + ID of project event is related to + """ + projectId: ID! + + """ + ID of the event to remove + """ + eventId: ID! + ): Boolean! @requireAdmin + """ Namespace that contains only mutations related to the events """ diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index e465242b..ba9d61c9 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -118,4 +118,106 @@ describe('Project resolver dailyEventsPortion', () => { undefined ); }); + + it('should apply fallback title for null, empty and blank payload titles', async () => { + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: null, + dailyEvents: [ + { + id: 'daily-1', + groupHash: 'group-1', + event: { + _id: 'repetition-1', + originalEventId: 'event-1', + payload: { + title: null, + }, + }, + }, + { + id: 'daily-2', + groupHash: 'group-2', + event: { + _id: 'repetition-2', + originalEventId: 'event-2', + payload: { + title: '', + }, + }, + }, + { + id: 'daily-3', + groupHash: 'group-3', + event: { + _id: 'repetition-3', + originalEventId: 'event-3', + payload: { + title: ' ', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const project = { _id: 'project-1' }; + const args = { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }; + + const result = await projectResolver.Project.dailyEventsPortion(project, args, {}) as { + dailyEvents: Array<{ event: { payload: { title: string } } }>; + }; + + expect(result.dailyEvents[0].event.payload.title).toBe('Unknown'); + expect(result.dailyEvents[1].event.payload.title).toBe('Unknown'); + expect(result.dailyEvents[2].event.payload.title).toBe('Unknown'); + }); + + it('should keep payload title when it is valid', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const findDailyEventsPortion = jest.fn().mockResolvedValue({ + nextCursor: null, + dailyEvents: [ + { + id: 'daily-1', + groupHash: 'group-1', + event: { + _id: 'repetition-1', + originalEventId: 'event-1', + payload: { + title: 'TypeError', + }, + }, + }, + ], + }); + (getEventsFactory as unknown as jest.Mock).mockReturnValue({ + findDailyEventsPortion, + }); + + const project = { _id: 'project-1' }; + const args = { + limit: 10, + nextCursor: null, + sort: 'BY_DATE', + filters: {}, + search: '', + }; + + const result = await projectResolver.Project.dailyEventsPortion(project, args, {}) as { + dailyEvents: Array<{ event: { payload: { title: string } } }>; + }; + + expect(result.dailyEvents[0].event.payload.title).toBe('TypeError'); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); });