From 5de9ba14e39c388766695d93e04598448bb26c95 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 9 Apr 2026 14:45:30 +0300 Subject: [PATCH 1/4] Normalize daily event payload titles Ensure daily events always have a non-empty payload.title by adding normalizeDailyEventsPayloadTitle in src/resolvers/project.js; when missing it sets a fallback title 'Unknown' and logs a warning with identifiers. Integrates the normalizer into Project.dailyEventsPortion. Adds unit tests to verify fallback behavior and that valid titles are preserved. Also bumps @hawk.so/nodejs to 3.3.2 in package.json. --- package.json | 2 +- src/resolvers/project.js | 51 ++++++++++ .../project-daily-events-portion.test.ts | 92 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 00cd7e34..758715c5 100644 --- a/package.json +++ b/package.json @@ -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/resolvers/project.js b/src/resolvers/project.js index 65e17130..54776981 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -20,6 +20,55 @@ 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 +653,8 @@ module.exports = { assignee ); + normalizeDailyEventsPayloadTitle(dailyEventsPortion, project._id); + return dailyEventsPortion; }, diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index e465242b..564b20bc 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -118,4 +118,96 @@ describe('Project resolver dailyEventsPortion', () => { undefined ); }); + + it('should apply fallback title when payload title is missing and log warning', 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: null, + }, + }, + }, + ], + }); + (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(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + '[ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', + expect.objectContaining({ + projectId: 'project-1', + dailyEventId: 'daily-1', + dailyEventGroupHash: 'group-1', + eventOriginalId: 'event-1', + eventId: 'repetition-1', + }) + ); + + warnSpy.mockRestore(); + }); + + 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(); + }); }); From b63db31226a30c9edbb82dd09768bddb15c05e69 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:49:49 +0000 Subject: [PATCH 2/4] Bump version up to 1.4.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 758715c5..6db19b0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.11", + "version": "1.4.12", "main": "index.ts", "license": "BUSL-1.1", "scripts": { From 9c765e36b515f656ee7940cb39441a7c4c5e185c Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 9 Apr 2026 16:28:14 +0300 Subject: [PATCH 3/4] cover empty and blank title with test --- .../project-daily-events-portion.test.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/test/resolvers/project-daily-events-portion.test.ts b/test/resolvers/project-daily-events-portion.test.ts index 564b20bc..ba9d61c9 100644 --- a/test/resolvers/project-daily-events-portion.test.ts +++ b/test/resolvers/project-daily-events-portion.test.ts @@ -119,8 +119,7 @@ describe('Project resolver dailyEventsPortion', () => { ); }); - it('should apply fallback title when payload title is missing and log warning', async () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + it('should apply fallback title for null, empty and blank payload titles', async () => { const findDailyEventsPortion = jest.fn().mockResolvedValue({ nextCursor: null, dailyEvents: [ @@ -135,6 +134,28 @@ describe('Project resolver dailyEventsPortion', () => { }, }, }, + { + 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({ @@ -155,19 +176,8 @@ describe('Project resolver dailyEventsPortion', () => { }; expect(result.dailyEvents[0].event.payload.title).toBe('Unknown'); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - '[ProjectResolver.dailyEventsPortion] Missing event payload title. Fallback title applied.', - expect.objectContaining({ - projectId: 'project-1', - dailyEventId: 'daily-1', - dailyEventGroupHash: 'group-1', - eventOriginalId: 'event-1', - eventId: 'repetition-1', - }) - ); - - warnSpy.mockRestore(); + 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 () => { From fe8447dc1ef200f097b8f427dac02b1fe36315fb Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 9 Apr 2026 16:30:56 +0300 Subject: [PATCH 4/4] lint --- src/resolvers/project.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 54776981..65fc2cdc 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -38,9 +38,9 @@ function normalizeDailyEventsPayloadTitle(dailyEventsPortion, projectId) { 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; + const hasValidTitle = payload && + typeof payload.title === 'string' && + payload.title.trim().length > 0; if (hasValidTitle) { return dailyEvent; @@ -69,7 +69,6 @@ function normalizeDailyEventsPayloadTitle(dailyEventsPortion, projectId) { return dailyEventsPortion; } - /** * See all types and fields here {@see ../typeDefs/project.graphql} */