diff --git a/package.json b/package.json index ccec20d3..ab70df85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.1.32", + "version": "1.1.33", "main": "index.ts", "license": "UNLICENSED", "scripts": { @@ -44,8 +44,6 @@ "@types/debug": "^4.1.5", "@types/escape-html": "^1.0.0", "@types/graphql-upload": "^8.0.11", - "@types/lodash.clonedeep": "^4.5.9", - "@types/lodash.mergewith": "^4.6.9", "@types/jsonwebtoken": "^8.3.5", "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.mergewith": "^4.6.9", diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 1bac1551..7641800e 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -6,6 +6,7 @@ const Factory = require('./modelFactory'); const mongo = require('../mongo'); const Event = require('../models/event'); const { ObjectID } = require('mongodb'); +const { composeEventPayloadWithRepetition } = require('../utils/merge'); /** * @typedef {Object} RecentEventSchema @@ -287,6 +288,7 @@ class EventsFactory extends Factory { if (result && result.events) { result.events.forEach(event => { event.projectId = this.projectId; + event.firstAppearanceTimestamp = event.timestamp; }); } @@ -391,23 +393,33 @@ class EventsFactory extends Factory { /** * Returns Event repetitions * - * @param {string|ObjectID} eventId - Event's id + * @param {string|ObjectID} eventId - Event's id (may be repetition id) * @param {Number} limit - count limitations - * @param {Number} skip - selection offset + * @param {Number} cursor - pointer to the next repetition * * @return {EventRepetitionSchema[]} * * @todo move to Repetitions(?) model */ - async getEventRepetitions(eventId, limit = 10, skip = 0) { + async getEventRepetitions(eventId, limit = 10, cursor = null) { limit = this.validateLimit(limit); - skip = this.validateSkip(skip); + + cursor = cursor ? new ObjectID(cursor) : null; + + const result = { + repetitions: [], + nextCursor: null, + }; /** * Get original event * @type {EventSchema} */ - const eventOriginal = await this.findById(eventId); + const eventOriginal = await this._findOriginalEvent(eventId); + + if (!eventOriginal) { + return result; + } /** * Collect repetitions @@ -416,13 +428,26 @@ class EventsFactory extends Factory { const repetitions = await this.getCollection(this.TYPES.REPETITIONS) .find({ groupHash: eventOriginal.groupHash, + _id: cursor ? { $lte: cursor } : { $exists: true }, }) .sort({ _id: -1 }) - .limit(limit) - .skip(skip) + .limit(limit + 1) .toArray(); - const isLastPortion = repetitions.length < limit && skip === 0; + if (repetitions.length === limit + 1) { + result.nextCursor = repetitions.pop()._id; + } + + for (const repetition of repetitions) { + const event = this._composeEventWithRepetition(eventOriginal, repetition); + + result.repetitions.push({ + ...event, + projectId: this.projectId, + }); + } + + const isLastPortion = result.nextCursor === null; /** * For last portion: @@ -434,16 +459,15 @@ class EventsFactory extends Factory { * @type {EventRepetitionSchema} */ const firstRepetition = { - _id: eventOriginal._id, - payload: eventOriginal.payload, - groupHash: eventOriginal.groupHash, - timestamp: eventOriginal.timestamp, + ...eventOriginal, + firstAppearanceTimestamp: eventOriginal.timestamp, + projectId: this.projectId, }; - repetitions.push(firstRepetition); + result.repetitions.push(firstRepetition); } - return repetitions; + return result; } /** @@ -455,10 +479,33 @@ class EventsFactory extends Factory { * @todo move to Repetitions(?) model */ async getEventRepetition(repetitionId) { - return this.getCollection(this.TYPES.REPETITIONS) + const repetition = await this.getCollection(this.TYPES.REPETITIONS) .findOne({ _id: ObjectID(repetitionId), }); + + if (!repetition) { + /** + * If repetition is not found, it can mean that client is trying to get original event + */ + const event = await this.findById(repetitionId); + + return event ? { + ...event, + firstAppearanceTimestamp: event.timestamp, + } : null; + } + + const originalEvent = await this.getCollection(this.TYPES.EVENTS) + .findOne({ + groupHash: repetition.groupHash, + }); + + if (!originalEvent) { + return null; + } + + return this._composeEventWithRepetition(originalEvent, repetition); } /** @@ -483,7 +530,11 @@ class EventsFactory extends Factory { * @returns {Release|null} */ async getEventRelease(eventId) { - const eventOriginal = await this.findById(eventId); + const eventOriginal = await this._findOriginalEvent(eventId); + + if (!eventOriginal) { + return null; + } const release = await mongo.databases.events.collection(this.TYPES.RELEASES).findOne({ release: eventOriginal.payload.release, @@ -502,9 +553,15 @@ class EventsFactory extends Factory { * @return {Promise} */ async visitEvent(eventId, userId) { + const event = await this._findOriginalEvent(eventId); + + if (!event) { + return null; + } + return this.getCollection(this.TYPES.EVENTS) .updateOne( - { _id: new ObjectID(eventId) }, + { _id: new ObjectID(event._id) }, { $addToSet: { visitedBy: new ObjectID(userId) } } ); } @@ -519,9 +576,15 @@ class EventsFactory extends Factory { */ async toggleEventMark(eventId, mark) { const collection = this.getCollection(this.TYPES.EVENTS); - const query = { _id: new ObjectID(eventId) }; - const event = await collection.findOne(query); + const event = await this._findOriginalEvent(eventId); + + if (!event) { + return null; + } + + const query = { _id: new ObjectID(event._id) }; + const markKey = `marks.${mark}`; let update; @@ -571,13 +634,74 @@ class EventsFactory extends Factory { */ async updateAssignee(eventId, assignee) { const collection = this.getCollection(this.TYPES.EVENTS); - const query = { _id: new ObjectID(eventId) }; + + const event = await this._findOriginalEvent(eventId); + + if (!event) { + return null; + } + + const query = { _id: new ObjectID(event._id) }; + const update = { $set: { assignee: assignee }, }; return collection.updateOne(query, update); } + + /** + * Find original event by eventId. If event is not found directly, + * try to find it as repetition and get original event by groupHash + * + * @param {string|ObjectID} eventId - event's id, may be repetition id + * @returns {Promise} original event or null if not found + */ + async _findOriginalEvent(eventId) { + let originalEvent; + + /** + * Try to find it by repetitionId + */ + const repetition = await this.getCollection(this.TYPES.REPETITIONS) + .findOne({ + _id: new ObjectID(eventId), + }); + + /** + * If repetition is not found by eventId, try to find it by eventId + */ + if (!repetition) { + originalEvent = await this.getCollection(this.TYPES.EVENTS) + .findOne({ + _id: new ObjectID(eventId), + }); + } else { + originalEvent = await this.getCollection(this.TYPES.EVENTS) + .findOne({ + groupHash: repetition.groupHash, + }); + } + + return originalEvent; + } + + /** + * Compose event with repetition + * + * @param {Event} event - event + * @param {Repetition} repetition - repetition + * @returns {Event} event merged with repetition + */ + _composeEventWithRepetition(event, repetition) { + return { + ...event, + _id: repetition._id, + firstAppearanceTimestamp: event.timestamp, + timestamp: repetition.timestamp, + payload: composeEventPayloadWithRepetition(event.payload, repetition), + }; + } } module.exports = EventsFactory; diff --git a/src/resolvers/event.js b/src/resolvers/event.js index 5a2cbb51..78eea7d1 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -18,39 +18,22 @@ module.exports = { }, }, Event: { - /** - * Returns Event with concrete repetition - * - * @param {string} eventId - id of Event of which repetition requested - * @param {string} projectId - projectId of Event of which repetition requested - * @param {string|null} [repetitionId] - if not specified, last repetition will returned - * @return {Promise} - */ - async repetition({ id: eventId, projectId }, { id: repetitionId }) { - const factory = new EventsFactory(projectId); - - if (!repetitionId) { - return factory.getEventLastRepetition(eventId); - } - - return factory.getEventRepetition(repetitionId); - }, /** - * Returns repetitions list of the event + * Returns repetitions portion of the event * * @param {ResolverObj} _obj * @param {String} eventId * @param {String} projectId * @param {Number} limit - * @param {Number} skip + * @param {Number} cursor * - * @return {EventRepetitionSchema[]} + * @return {RepetitionsPortion} */ - async repetitions({ _id: eventId, projectId }, { limit, skip }) { + async repetitionsPortion({ _id: eventId, projectId }, { limit, cursor }) { const factory = new EventsFactory(projectId); - return factory.getEventRepetitions(eventId, limit, skip); + return factory.getEventRepetitions(eventId, limit, cursor); }, /** diff --git a/src/resolvers/project.js b/src/resolvers/project.js index 3849787e..1408fc22 100644 --- a/src/resolvers/project.js +++ b/src/resolvers/project.js @@ -287,17 +287,17 @@ module.exports = { * * @returns {Event} */ - async event(project, { id: eventId }) { + async event(project, { id: repetitionId }) { const factory = new EventsFactory(project._id); - const event = await factory.findById(eventId); + const repetition = await factory.getEventRepetition(repetitionId); - if (!event) { + if (!repetition) { return null; } - event.projectId = project._id; + repetition.projectId = project._id; - return event; + return repetition; }, /** diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index a7990679..b4e91181 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -176,95 +176,6 @@ type EventPayload { addons: EncodedJSON } -""" -Type representing Event payload. All fields can be omitted if there are no difference with the original -""" -type RepetitionPayload { - """ - Event title - """ - title: String - - """ - Event type: TypeError, ReferenceError etc. - """ - type: String - - """ - Event severity level - """ - level: Int - - """ - Event stack array from the latest call to the earliest - """ - backtrace: [EventBacktraceFrame!] - - """ - Additional data about GET request - """ - get: JSONObject - - """ - Additional data about POST request - """ - post: JSONObject - - """ - HTTP headers - """ - headers: JSONObject - - """ - Source code version identifier - """ - release: String - - """ - Current authenticated user - """ - user: EventUser - - """ - Any additional data of Event - """ - context: EncodedJSON - - """ - Custom data provided by project users - """ - addons: EncodedJSON -} - -""" -Repetition of the event -""" -type Repetition { - """ - Standalone repetition ID - """ - id: ID! @renameFrom(name: "_id") - - """ - Event's hash - """ - groupHash: String! - - """ - Event's payload patch - """ - payload: RepetitionPayload - - """ - Delta of the event's payload, stringified JSON - """ - delta: String - - """ - Event timestamp - """ - timestamp: Float! -} """ Possible event marks @@ -284,6 +195,14 @@ type EventMarks { ignored: Boolean! } +""" +Object returned in repetitions property of event object +""" +type RepetitionsPortion { + repetitions: [Event!] + nextCursor: String +} + """ Type representing Hawk single Event """ @@ -324,19 +243,19 @@ type Event { timestamp: Float! """ - Release data + Event first appearance timestamp """ - release: Release + firstAppearanceTimestamp: Float! """ - Event concrete repetition + Release data """ - repetition(id: ID): Repetition + release: Release """ - Event repetitions + Event repetitions portion """ - repetitions(skip: Int = 0, limit: Int = 10): [Repetition!] + repetitionsPortion(cursor: String = null, limit: Int = 10): RepetitionsPortion! """ Array of users who visited event @@ -495,8 +414,15 @@ extend type Mutation { Mutation marks event as visited for current user """ visitEvent( - project: ID! - id: ID! + """ + ID of project event is related to + """ + projectId: ID! + + """ + ID of the event to visit + """ + eventId: ID! ): Boolean! @requireAuth """ diff --git a/src/utils/merge.ts b/src/utils/merge.ts index 090ada79..cc8d4d2e 100644 --- a/src/utils/merge.ts +++ b/src/utils/merge.ts @@ -71,22 +71,22 @@ function stringifyPayloadField(payload: GroupedEventDBScheme['payload'], field: } /** - * Helps to merge original event and repetition due to delta format, + * Helps to merge original event payload and repetition due to delta format, * in case of old delta format, we need to patch the payload * in case of new delta format, we need to assemble the payload * - * @param originalEvent {HawkEvent} - The original event - * @param repetition {HawkEventRepetition} - The repetition to process - * @returns {HawkEvent} Updated event with processed repetition payload + * @param originalEventPayload {GroupedEventDBScheme['payload']} - The original event payload + * @param repetition {RepetitionDBScheme} - The repetition to process + * @returns {GroupedEventDBScheme['payload']} Updated event with processed repetition payload */ -export function composeFullRepetitionEvent(originalEvent: GroupedEventDBScheme, repetition: RepetitionDBScheme | undefined): GroupedEventDBScheme { +export function composeEventPayloadWithRepetition(originalEventPayload: GroupedEventDBScheme['payload'], repetition: RepetitionDBScheme | undefined): GroupedEventDBScheme['payload'] { /** * Make a deep copy of the original event, because we need to avoid mutating the original event */ - const event = cloneDeep(originalEvent); + let result = cloneDeep(originalEventPayload); if (!repetition) { - return event; + return result; } /** @@ -96,35 +96,35 @@ export function composeFullRepetitionEvent(originalEvent: GroupedEventDBScheme, /** * Parse addons and context fields from string to object before patching */ - event.payload = parsePayloadField(event.payload, 'addons'); - event.payload = parsePayloadField(event.payload, 'context'); + result = parsePayloadField(result, 'addons'); + result = parsePayloadField(result, 'context'); - event.payload = patch({ - left: event.payload, + result = patch({ + left: result, delta: JSON.parse(repetition.delta), }); /** * Stringify addons and context fields from object to string after patching */ - event.payload = stringifyPayloadField(event.payload, 'addons'); - event.payload = stringifyPayloadField(event.payload, 'context'); + result = stringifyPayloadField(result, 'addons'); + result = stringifyPayloadField(result, 'context'); - return event; + return result; } /** * New delta format (repetition.payload is null) and repetition.delta is null (there is no delta between original and repetition) */ if (!repetition.payload) { - return event; + return result; } /** * Old delta format (repetition.payload is not null) * @todo remove after 6 september 2025 */ - event.payload = repetitionAssembler(event.payload, repetition.payload); + result = repetitionAssembler(result, repetition.payload); - return event; + return result; } diff --git a/test/utils/merge.test.ts b/test/utils/merge.test.ts index c065d338..0c30f31c 100644 --- a/test/utils/merge.test.ts +++ b/test/utils/merge.test.ts @@ -1,9 +1,9 @@ -import { composeFullRepetitionEvent } from '../../src/utils/merge'; +import { composeEventPayloadWithRepetition } from '../../src/utils/merge'; import { GroupedEventDBScheme, RepetitionDBScheme } from '@hawk.so/types'; import { diff } from '@n1ru4l/json-patch-plus'; -describe('composeFullRepetitionEvent', () => { +describe('composeEventPayloadWithRepetition', () => { const mockOriginalEvent: GroupedEventDBScheme = { groupHash: 'original-event-1', totalCount: 1, @@ -33,14 +33,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadWithRepetition(mockOriginalEvent.payload, repetition); /** * Assert */ - expect(result).toEqual(mockOriginalEvent); - expect(result).toMatchObject(mockOriginalEvent); - expect(result.payload).toMatchObject(mockOriginalEvent.payload); + expect(result).toMatchObject(mockOriginalEvent.payload); }); }); @@ -67,12 +65,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadWithRepetition(mockOriginalEvent.payload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Updated message', type: 'warning', addons: JSON.stringify({ userId: 123 }), @@ -81,13 +79,20 @@ describe('composeFullRepetitionEvent', () => { }); it('should handle delta with new fields', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 123 }), + context: JSON.stringify({ sessionId: 'abc' }), + }; /** * Arrange */ const delta = diff({ - left: mockOriginalEvent.payload, + left: originalEventPayload, right: { - ...mockOriginalEvent.payload, + ...originalEventPayload, release: 'v1.0.0', catcherVersion: '2.0.0', }, @@ -102,12 +107,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadWithRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Original message', type: 'error', release: 'v1.0.0', @@ -133,17 +138,24 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadWithRepetition(mockOriginalEvent.payload, repetition); /** * Assert */ - expect(result).toEqual(mockOriginalEvent); - expect(result).not.toBe(mockOriginalEvent); // Должна быть глубокая копия + expect(result).toEqual(mockOriginalEvent.payload); }); }); describe('when repetition.delta is undefined and repetition.payload is provided (old delta format)', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 123 }), + context: JSON.stringify({ sessionId: 'abc' }), + }; + it('should use repetitionAssembler to merge payloads', () => { /** * Arrange @@ -162,12 +174,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadWithRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Updated message', type: 'warning', release: 'v1.0.0', @@ -178,6 +190,13 @@ describe('composeFullRepetitionEvent', () => { }); it('should handle null values in repetition payload', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 123 }), + context: JSON.stringify({ sessionId: 'abc' }), + }; /** * Arrange */ @@ -194,12 +213,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadWithRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Updated title', // repetition value replaces original type: 'info', // Addons and context should be, because old format doesn't remove fields @@ -209,6 +228,14 @@ describe('composeFullRepetitionEvent', () => { }); it('should preserve original value when repetition payload has null', () => { + + const originalEventPayload = { + title: 'Original message', + type: 'error', + addons: JSON.stringify({ userId: 123 }), + context: JSON.stringify({ sessionId: 'abc' }), + }; + /** * Arrange */ @@ -225,12 +252,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(mockOriginalEvent, repetition); + const result = composeEventPayloadWithRepetition(originalEventPayload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'Original message', // null в repetition должно сохранить оригинальное значение type: 'info', addons: JSON.stringify({ userId: 123 }), @@ -273,12 +300,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(eventWithEmptyPayload, repetition); + const result = composeEventPayloadWithRepetition(eventWithEmptyPayload.payload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'New message', }); }); @@ -313,12 +340,12 @@ describe('composeFullRepetitionEvent', () => { /** * Act */ - const result = composeFullRepetitionEvent(eventWithNullPayload, repetition); + const result = composeEventPayloadWithRepetition(eventWithNullPayload.payload, repetition); /** * Assert */ - expect(result.payload).toEqual({ + expect(result).toEqual({ title: 'New message', }); }); @@ -359,7 +386,7 @@ describe('composeFullRepetitionEvent', () => { * Act & Assert */ expect(() => { - composeFullRepetitionEvent(eventWithInvalidJSON, repetition); + composeEventPayloadWithRepetition(eventWithInvalidJSON.payload, repetition); }).toThrow(); // Должно выбросить ошибку при парсинге невалидного JSON }); });