diff --git a/src/lib/db/event-store.test.ts b/src/lib/db/event-store.test.ts index 849703b49d..72891ea9b9 100644 --- a/src/lib/db/event-store.test.ts +++ b/src/lib/db/event-store.test.ts @@ -24,7 +24,7 @@ test('Trying to get events by name if db fails should yield empty list', async ( client: 'pg', }); const store = new EventStore(db, getLogger); - const events = await store.getEventsFilterByType('application-created'); + const events = await store.searchEvents({ type: 'application-created' }); expect(events).toBeTruthy(); expect(events.length).toBe(0); }); diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 4980e19495..013aacd34e 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -1,9 +1,10 @@ import { EventEmitter } from 'events'; import { Knex } from 'knex'; -import { DROP_FEATURES, IEvent, IBaseEvent } from '../types/events'; +import { IEvent, IBaseEvent } from '../types/events'; import { LogProvider, Logger } from '../logger'; import { IEventStore } from '../types/stores/event-store'; import { ITag } from '../types/model'; +import { SearchEventsSchema } from '../openapi/spec/search-events-schema'; const EVENT_COLUMNS = [ 'id', @@ -115,50 +116,44 @@ class EventStore extends EventEmitter implements IEventStore { } } - async getEventsFilterByType(name: string): Promise { - try { - const rows = await this.db - .select(EVENT_COLUMNS) - .from(TABLE) - .limit(100) - .where('type', name) - .andWhere( - 'id', - '>=', - this.db - .select(this.db.raw('coalesce(max(id),0) as id')) - .from(TABLE) - .where({ type: DROP_FEATURES }), - ) - .orderBy('created_at', 'desc'); - return rows.map(this.rowToEvent); - } catch (err) { - this.logger.error(err); - return []; - } - } + async searchEvents(search: SearchEventsSchema = {}): Promise { + let query = this.db + .select(EVENT_COLUMNS) + .from(TABLE) + .limit(search.limit ?? 100) + .offset(search.offset ?? 0) + .orderBy('created_at', 'desc'); - async getEventsFilterByProject(project: string): Promise { - try { - const rows = await this.db - .select(EVENT_COLUMNS) - .from(TABLE) - .where({ project }) - .orderBy('created_at', 'desc'); - return rows.map(this.rowToEvent); - } catch (err) { - return []; + if (search.type) { + query = query.andWhere({ + type: search.type, + }); + } + + if (search.project) { + query = query.andWhere({ + project: search.project, + }); + } + + if (search.feature) { + query = query.andWhere({ + feature_name: search.feature, + }); + } + + if (search.query) { + query = query.where((where) => + where + .orWhereRaw('type::text ILIKE ?', `%${search.query}%`) + .orWhereRaw('created_by::text ILIKE ?', `%${search.query}%`) + .orWhereRaw('data::text ILIKE ?', `%${search.query}%`) + .orWhereRaw('pre_data::text ILIKE ?', `%${search.query}%`), + ); } - } - async getEventsForFeature(featureName: string): Promise { try { - const rows = await this.db - .select(EVENT_COLUMNS) - .from(TABLE) - .where({ feature_name: featureName }) - .orderBy('created_at', 'desc'); - return rows.map(this.rowToEvent); + return (await query).map(this.rowToEvent); } catch (err) { return []; } diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 10dcafbf73..b33801641f 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -104,6 +104,7 @@ import { groupSchema } from './spec/group-schema'; import { groupsSchema } from './spec/groups-schema'; import { groupUserModelSchema } from './spec/group-user-model-schema'; import { usersGroupsBaseSchema } from './spec/users-groups-base-schema'; +import { searchEventsSchema } from './spec/search-events-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -178,6 +179,7 @@ export const schemas = { resetPasswordSchema, roleSchema, sdkContextSchema, + searchEventsSchema, segmentSchema, setStrategySortOrderSchema, sortOrderSchema, diff --git a/src/lib/openapi/spec/feature-events-schema.ts b/src/lib/openapi/spec/feature-events-schema.ts index dca62d7e6e..912fd52fd7 100644 --- a/src/lib/openapi/spec/feature-events-schema.ts +++ b/src/lib/openapi/spec/feature-events-schema.ts @@ -6,7 +6,7 @@ export const featureEventsSchema = { $id: '#/components/schemas/featureEventsSchema', type: 'object', additionalProperties: false, - required: ['toggleName', 'events'], + required: ['events'], properties: { version: { type: 'number' }, toggleName: { diff --git a/src/lib/openapi/spec/search-events-schema.ts b/src/lib/openapi/spec/search-events-schema.ts new file mode 100644 index 0000000000..f1d187a343 --- /dev/null +++ b/src/lib/openapi/spec/search-events-schema.ts @@ -0,0 +1,47 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const searchEventsSchema = { + $id: '#/components/schemas/searchEventsSchema', + type: 'object', + description: ` + Search for events by type, project, feature, free-text query, + or a combination thereof. Pass an empty object to fetch all events. + `, + properties: { + type: { + type: 'string', + description: 'Find events by event type (case-sensitive).', + }, + project: { + type: 'string', + description: 'Find events by project ID (case-sensitive).', + }, + feature: { + type: 'string', + description: 'Find events by feature toggle name (case-sensitive).', + }, + query: { + type: 'string', + description: ` + Find events by a free-text search query. + The query will be matched against the event type, + the username or email that created the event (if any), + and the event data payload (if any). + `, + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 100, + }, + offset: { + type: 'integer', + minimum: 0, + default: 0, + }, + }, + components: {}, +} as const; + +export type SearchEventsSchema = FromSchema; diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index 4537145fe8..40805fc504 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -19,6 +19,8 @@ import { FeatureEventsSchema, } from '../../../lib/openapi/spec/feature-events-schema'; import { getStandardResponses } from '../../../lib/openapi/util/standard-responses'; +import { createRequestSchema } from '../../openapi/util/create-request-schema'; +import { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; const version = 1; export default class EventController extends Controller { @@ -86,9 +88,24 @@ export default class EventController extends Controller { }), ], }); + + this.route({ + method: 'post', + path: '/search', + handler: this.searchEvents, + permission: NONE, + middleware: [ + openApiService.validPath({ + operationId: 'searchEvents', + tags: ['admin'], + requestBody: createRequestSchema('searchEventsSchema'), + responses: { 200: createResponseSchema('eventsSchema') }, + }), + ], + }); } - fixEvents(events: IEvent[]): IEvent[] { + maybeAnonymiseEvents(events: IEvent[]): IEvent[] { if (this.anonymise) { return events.map((e: IEvent) => ({ ...e, @@ -105,15 +122,16 @@ export default class EventController extends Controller { const { project } = req.query; let events: IEvent[]; if (project) { - events = await this.eventService.getEventsForProject(project); + events = await this.eventService.searchEvents({ project }); } else { events = await this.eventService.getEvents(); } const response: EventsSchema = { version, - events: serializeDates(this.fixEvents(events)), + events: serializeDates(this.maybeAnonymiseEvents(events)), }; + this.openApiService.respondWithValidation( 200, res, @@ -126,13 +144,32 @@ export default class EventController extends Controller { req: Request<{ featureName: string }>, res: Response, ): Promise { - const toggleName = req.params.featureName; - const events = await this.eventService.getEventsForToggle(toggleName); + const feature = req.params.featureName; + const events = await this.eventService.searchEvents({ feature }); const response = { version, - toggleName, - events: serializeDates(this.fixEvents(events)), + toggleName: feature, + events: serializeDates(this.maybeAnonymiseEvents(events)), + }; + + this.openApiService.respondWithValidation( + 200, + res, + featureEventsSchema.$id, + response, + ); + } + + async searchEvents( + req: Request, + res: Response, + ): Promise { + const events = await this.eventService.searchEvents(req.body); + + const response = { + version, + events: serializeDates(this.maybeAnonymiseEvents(events)), }; this.openApiService.respondWithValidation( diff --git a/src/lib/services/event-service.ts b/src/lib/services/event-service.ts index e31456f420..0f878d3557 100644 --- a/src/lib/services/event-service.ts +++ b/src/lib/services/event-service.ts @@ -3,6 +3,7 @@ import { IUnleashStores } from '../types/stores'; import { Logger } from '../logger'; import { IEventStore } from '../types/stores/event-store'; import { IEvent } from '../types/events'; +import { SearchEventsSchema } from '../openapi/spec/search-events-schema'; export default class EventService { private logger: Logger; @@ -21,12 +22,8 @@ export default class EventService { return this.eventStore.getEvents(); } - async getEventsForToggle(name: string): Promise { - return this.eventStore.getEventsForFeature(name); - } - - async getEventsForProject(project: string): Promise { - return this.eventStore.getEventsFilterByProject(project); + async searchEvents(search: SearchEventsSchema): Promise { + return this.eventStore.searchEvents(search); } } diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 365cf89ae8..67b2e33aab 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -1,12 +1,11 @@ import EventEmitter from 'events'; import { IBaseEvent, IEvent } from '../events'; import { Store } from './store'; +import { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; export interface IEventStore extends Store, EventEmitter { store(event: IBaseEvent): Promise; batchStore(events: IBaseEvent[]): Promise; getEvents(): Promise; - getEventsFilterByType(name: string): Promise; - getEventsForFeature(featureName: string): Promise; - getEventsFilterByProject(project: string): Promise; + searchEvents(search: SearchEventsSchema): Promise; } diff --git a/src/test/e2e/api/admin/event.e2e.test.ts b/src/test/e2e/api/admin/event.e2e.test.ts index bbee66737a..055f599370 100644 --- a/src/test/e2e/api/admin/event.e2e.test.ts +++ b/src/test/e2e/api/admin/event.e2e.test.ts @@ -1,8 +1,9 @@ import { IUnleashTest, setupApp } from '../../helpers/test-helper'; import dbInit, { ITestDb } from '../../helpers/database-init'; import getLogger from '../../../fixtures/no-logger'; -import { FEATURE_CREATED } from '../../../../lib/types/events'; +import { FEATURE_CREATED, IBaseEvent } from '../../../../lib/types/events'; import { IEventStore } from '../../../../lib/types/stores/event-store'; +import { randomId } from '../../../../lib/util/random-id'; let app: IUnleashTest; let db: ITestDb; @@ -14,6 +15,10 @@ beforeAll(async () => { eventStore = db.stores.eventStore; }); +beforeEach(async () => { + await eventStore.deleteAll(); +}); + afterAll(async () => { await app.destroy(); await db.destroy(); @@ -60,3 +65,61 @@ test('Can filter by project', async () => { expect(res.body.events[0].data.id).toEqual('feature'); }); }); + +test('can search for events', async () => { + const events: IBaseEvent[] = [ + { + type: FEATURE_CREATED, + project: randomId(), + data: { id: randomId() }, + tags: [], + createdBy: randomId(), + }, + { + type: FEATURE_CREATED, + project: randomId(), + data: { id: randomId() }, + preData: { id: randomId() }, + tags: [], + createdBy: randomId(), + }, + ]; + + await Promise.all( + events.map((event) => { + return eventStore.store(event); + }), + ); + + await app.request + .post('/api/admin/events/search') + .send({}) + .expect(200) + .expect((res) => { + expect(res.body.events).toHaveLength(2); + }); + await app.request + .post('/api/admin/events/search') + .send({ limit: 1, offset: 1 }) + .expect(200) + .expect((res) => { + expect(res.body.events).toHaveLength(1); + expect(res.body.events[0].data.id).toEqual(events[0].data.id); + }); + await app.request + .post('/api/admin/events/search') + .send({ query: events[1].data.id }) + .expect(200) + .expect((res) => { + expect(res.body.events).toHaveLength(1); + expect(res.body.events[0].data.id).toEqual(events[1].data.id); + }); + await app.request + .post('/api/admin/events/search') + .send({ query: events[1].preData.id }) + .expect(200) + .expect((res) => { + expect(res.body.events).toHaveLength(1); + expect(res.body.events[0].preData.id).toEqual(events[1].preData.id); + }); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index f02e2ae777..bbdda67727 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -1010,7 +1010,6 @@ Object { }, }, "required": Array [ - "toggleName", "events", ], "type": "object", @@ -2218,6 +2217,47 @@ Object { ], "type": "object", }, + "searchEventsSchema": Object { + "description": " + Search for events by type, project, feature, free-text query, + or a combination thereof. Pass an empty object to fetch all events. + ", + "properties": Object { + "feature": Object { + "description": "Find events by feature toggle name (case-sensitive).", + "type": "string", + }, + "limit": Object { + "default": 100, + "maximum": 100, + "minimum": 1, + "type": "integer", + }, + "offset": Object { + "default": 0, + "minimum": 0, + "type": "integer", + }, + "project": Object { + "description": "Find events by project ID (case-sensitive).", + "type": "string", + }, + "query": Object { + "description": " + Find events by a free-text search query. + The query will be matched against the event type, + the username or email that created the event (if any), + and the event data payload (if any). + ", + "type": "string", + }, + "type": Object { + "description": "Find events by event type (case-sensitive).", + "type": "string", + }, + }, + "type": "object", + }, "segmentSchema": Object { "additionalProperties": false, "properties": Object { @@ -3736,6 +3776,37 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/events/search": Object { + "post": Object { + "operationId": "searchEvents", + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/searchEventsSchema", + }, + }, + }, + "description": "searchEventsSchema", + "required": true, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/eventsSchema", + }, + }, + }, + "description": "eventsSchema", + }, + }, + "tags": Array [ + "admin", + ], + }, + }, "/api/admin/events/{featureName}": Object { "get": Object { "description": "Returns all events related to the specified feature toggle. If the feature toggle does not exist, the list of events will be empty.", diff --git a/src/test/e2e/services/setting-service.test.ts b/src/test/e2e/services/setting-service.test.ts index ad0e47f860..6c6e7c503f 100644 --- a/src/test/e2e/services/setting-service.test.ts +++ b/src/test/e2e/services/setting-service.test.ts @@ -32,9 +32,9 @@ test('Can create new setting', async () => { expect(actual).toStrictEqual(someData); const { eventStore } = stores; - const createdEvents = await eventStore.getEventsFilterByType( - SETTING_CREATED, - ); + const createdEvents = await eventStore.searchEvents({ + type: SETTING_CREATED, + }); expect(createdEvents).toHaveLength(1); }); @@ -46,9 +46,9 @@ test('Can delete setting', async () => { const actual = await service.get('some-setting'); expect(actual).toBeUndefined(); const { eventStore } = stores; - const createdEvents = await eventStore.getEventsFilterByType( - SETTING_DELETED, - ); + const createdEvents = await eventStore.searchEvents({ + type: SETTING_DELETED, + }); expect(createdEvents).toHaveLength(1); }); @@ -61,8 +61,8 @@ test('Can update setting', async () => { { ...someData, test: 'fun' }, 'test-user', ); - const updatedEvents = await eventStore.getEventsFilterByType( - SETTING_UPDATED, - ); + const updatedEvents = await eventStore.searchEvents({ + type: SETTING_UPDATED, + }); expect(updatedEvents).toHaveLength(1); }); diff --git a/src/test/e2e/stores/event-store.e2e.test.ts b/src/test/e2e/stores/event-store.e2e.test.ts index 6fff806f19..5ca37bbedc 100644 --- a/src/test/e2e/stores/event-store.e2e.test.ts +++ b/src/test/e2e/stores/event-store.e2e.test.ts @@ -209,12 +209,12 @@ test('Should get all events of type', async () => { return eventStore.store(event); }), ); - const featureCreatedEvents = await eventStore.getEventsFilterByType( - FEATURE_CREATED, - ); + const featureCreatedEvents = await eventStore.searchEvents({ + type: FEATURE_CREATED, + }); expect(featureCreatedEvents).toHaveLength(3); - const featureDeletedEvents = await eventStore.getEventsFilterByType( - FEATURE_DELETED, - ); + const featureDeletedEvents = await eventStore.searchEvents({ + type: FEATURE_DELETED, + }); expect(featureDeletedEvents).toHaveLength(3); }); diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 8fb83b1243..1155bc2056 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -11,10 +11,6 @@ class FakeEventStore extends EventEmitter implements IEventStore { this.events = []; } - async getEventsForFeature(featureName: string): Promise { - return this.events.filter((e) => e.featureName === featureName); - } - store(event: IEvent): Promise { this.events.push(event); this.emit(event.type, event); @@ -58,12 +54,8 @@ class FakeEventStore extends EventEmitter implements IEventStore { return this.events; } - async getEventsFilterByType(type: string): Promise { - return this.events.filter((e) => e.type === type); - } - - async getEventsFilterByProject(project: string): Promise { - return this.events.filter((e) => e.project === project); + async searchEvents(): Promise { + throw new Error('Method not implemented.'); } }