diff --git a/src/lib/features/events/event-service.ts b/src/lib/features/events/event-service.ts index 8ec37385e0..b55ddf0bd5 100644 --- a/src/lib/features/events/event-service.ts +++ b/src/lib/features/events/event-service.ts @@ -222,7 +222,7 @@ export default class EventService { if (parsed) queryParams.push(parsed); } - ['project', 'type', 'environment'].forEach((field) => { + ['project', 'type', 'environment', 'id'].forEach((field) => { if (params[field]) { const parsed = parseSearchOperatorValue(field, params[field]); if (parsed) queryParams.push(parsed); diff --git a/src/lib/openapi/spec/event-search-query-parameters.ts b/src/lib/openapi/spec/event-search-query-parameters.ts index d778aa738c..a36fc971c8 100644 --- a/src/lib/openapi/spec/event-search-query-parameters.ts +++ b/src/lib/openapi/spec/event-search-query-parameters.ts @@ -11,6 +11,17 @@ export const eventSearchQueryParameters = [ 'Find events by a free-text search query. The query will be matched against the event data payload (if any).', in: 'query', }, + { + name: 'id', + schema: { + type: 'string', + example: 'IS:123', + pattern: '^(IS|IS_ANY_OF):(.*?)(,([0-9]+))*$', + }, + description: + 'Filter by event ID using supported operators: IS, IS_ANY_OF.', + in: 'query', + }, { name: 'feature', schema: { diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 0ee004b0e8..581cd03316 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -6,6 +6,7 @@ import type { IQueryOperations } from '../../features/events/event-store.js'; import type { IQueryParam } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type.js'; export interface IEventSearchParams { + id?: string; project?: string; query?: string; feature?: string; diff --git a/src/test/e2e/api/admin/event-search.e2e.test.ts b/src/test/e2e/api/admin/event-search.e2e.test.ts index 8cfae7f7b8..778ab0c9c1 100644 --- a/src/test/e2e/api/admin/event-search.e2e.test.ts +++ b/src/test/e2e/api/admin/event-search.e2e.test.ts @@ -618,3 +618,103 @@ test('should filter events by environment using IS_ANY_OF', async () => { total: 2, }); }); + +test('should filter events by ID', async () => { + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'default', + data: { name: 'feature1' }, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'default', + data: { name: 'feature2' }, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'default', + data: { name: 'feature3' }, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + const { body: allEventsResponse } = await searchEvents({}); + const targetEvent = allEventsResponse.events.find( + (e: any) => e.data.name === 'feature2', + ); + + const { body } = await searchEvents({ id: `IS:${targetEvent.id}` }); + + expect(body).toMatchObject({ + events: [ + { + id: targetEvent.id, + type: 'feature-created', + data: { name: 'feature2' }, + }, + ], + total: 1, + }); +}); + +test('should filter events by multiple IDs using IS_ANY_OF', async () => { + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'default', + data: { name: 'feature1' }, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'default', + data: { name: 'feature2' }, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'default', + data: { name: 'feature3' }, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + const { body: allEventsResponse } = await searchEvents({}); + const targetEvent1 = allEventsResponse.events.find( + (e: any) => e.data.name === 'feature1', + ); + const targetEvent3 = allEventsResponse.events.find( + (e: any) => e.data.name === 'feature3', + ); + + const { body } = await searchEvents({ + id: `IS_ANY_OF:${targetEvent1.id},${targetEvent3.id}`, + }); + + expect(body.total).toBe(2); + expect(body.events).toHaveLength(2); + + const returnedIds = body.events.map((e: any) => e.id); + expect(returnedIds).toContain(targetEvent1.id); + expect(returnedIds).toContain(targetEvent3.id); + + const feature2Event = allEventsResponse.events.find( + (e: any) => e.data.name === 'feature2', + ); + expect(returnedIds).not.toContain(feature2Event.id); +}); diff --git a/src/test/e2e/stores/event-store.e2e.test.ts b/src/test/e2e/stores/event-store.e2e.test.ts index 1b1b507dbd..81a0def9a2 100644 --- a/src/test/e2e/stores/event-store.e2e.test.ts +++ b/src/test/e2e/stores/event-store.e2e.test.ts @@ -338,3 +338,137 @@ test('getMaxRevisionId should exclude FEATURE_CREATED and FEATURE_TAGGED events' expect(updatedEvent!.id).toBeGreaterThan(taggedEvent!.id); expect(segmentEvent!.id).toBeGreaterThan(updatedEvent!.id); }); + +test('Should filter events by ID using IS operator', async () => { + const event1 = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + data: { name: 'feature1' }, + }; + const event2 = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + data: { name: 'feature2' }, + }; + const event3 = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + data: { name: 'feature3' }, + }; + + await eventStore.store(event1); + await eventStore.store(event2); + await eventStore.store(event3); + + const allEvents = await eventStore.getAll(); + const targetEvent = allEvents.find((e) => e.data.name === 'feature2'); + + const filteredEvents = await eventStore.searchEvents( + { + offset: 0, + limit: 10, + }, + [ + { + field: 'id', + operator: 'IS', + values: [targetEvent!.id.toString()], + }, + ], + ); + + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].id).toBe(targetEvent!.id); + expect(filteredEvents[0].data.name).toBe('feature2'); +}); + +test('Should filter events by ID using IS_ANY_OF operator', async () => { + const event1 = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + data: { name: 'feature1' }, + }; + const event2 = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + data: { name: 'feature2' }, + }; + const event3 = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + data: { name: 'feature3' }, + }; + + await eventStore.store(event1); + await eventStore.store(event2); + await eventStore.store(event3); + + const allEvents = await eventStore.getAll(); + const targetEvent1 = allEvents.find((e) => e.data.name === 'feature1'); + const targetEvent3 = allEvents.find((e) => e.data.name === 'feature3'); + + const filteredEvents = await eventStore.searchEvents( + { + offset: 0, + limit: 10, + }, + [ + { + field: 'id', + operator: 'IS_ANY_OF', + values: [ + targetEvent1!.id.toString(), + targetEvent3!.id.toString(), + ], + }, + ], + ); + + expect(filteredEvents).toHaveLength(2); + const eventIds = filteredEvents.map((e) => e.id); + expect(eventIds).toContain(targetEvent1!.id); + expect(eventIds).toContain(targetEvent3!.id); + expect(eventIds).not.toContain( + allEvents.find((e) => e.data.name === 'feature2')!.id, + ); +}); + +test('Should return empty result when filtering by non-existent ID', async () => { + const event = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + data: { name: 'feature1' }, + }; + + await eventStore.store(event); + + const filteredEvents = await eventStore.searchEvents( + { + offset: 0, + limit: 10, + }, + [ + { + field: 'id', + operator: 'IS', + values: ['999999'], + }, + ], + ); + + expect(filteredEvents).toHaveLength(0); +});