From 2f92dac14eac4b4cc44ce9f9714c3a4b1c517331 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Fri, 9 Aug 2024 10:32:31 +0300 Subject: [PATCH] feat: event creators (#7809) Adds an endpoint to return all event creators. An interesting point is that it does not return the user object, but just created_by as a string. This is because we do not store user IDs for events, as they are not strictly bound to a user object, but rather a historical user with the name X. --- src/lib/features/events/event-service.ts | 4 ++ src/lib/features/events/event-store.ts | 28 ++++++++- src/lib/openapi/spec/event-creators-schema.ts | 31 ++++++++++ src/lib/openapi/spec/index.ts | 1 + src/lib/routes/admin-api/event.ts | 45 +++++++++++++- src/lib/routes/admin-api/index.ts | 2 +- src/lib/routes/admin-api/search/index.ts | 5 ++ src/lib/types/stores/event-store.ts | 1 + src/test/e2e/api/admin/event.e2e.test.ts | 59 +++++++++++++++++++ src/test/fixtures/fake-event-store.ts | 4 ++ 10 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 src/lib/openapi/spec/event-creators-schema.ts diff --git a/src/lib/features/events/event-service.ts b/src/lib/features/events/event-service.ts index 6ec7d4b5e2..4923d662e0 100644 --- a/src/lib/features/events/event-service.ts +++ b/src/lib/features/events/event-service.ts @@ -200,4 +200,8 @@ export default class EventService { return queryParams; }; + + async getEventCreators() { + return this.eventStore.getEventCreators(); + } } diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index 9ef38c4769..badf1a7265 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -16,7 +16,7 @@ import { sharedEventEmitter } from '../../util/anyEventEmitter'; import type { Db } from '../../db/db'; import type { Knex } from 'knex'; import type EventEmitter from 'events'; -import { ADMIN_TOKEN_USER, SYSTEM_USER_ID } from '../../types'; +import { ADMIN_TOKEN_USER, SYSTEM_USER, SYSTEM_USER_ID } from '../../types'; import type { DeprecatedSearchEventsSchema } from '../../openapi'; import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type'; import { applyGenericQueryParams } from '../feature-search/search-utils'; @@ -380,6 +380,32 @@ class EventStore implements IEventStore { return query; } + async getEventCreators(): Promise> { + const query = this.db('events') + .distinct('events.created_by_user_id') + .leftJoin('users', 'users.id', '=', 'events.created_by_user_id') + .select([ + 'events.created_by_user_id as id', + this.db.raw(` + CASE + WHEN events.created_by_user_id = -1337 THEN '${SYSTEM_USER.name}' + WHEN events.created_by_user_id = -42 THEN '${ADMIN_TOKEN_USER.name}' + ELSE COALESCE(users.name, events.created_by) + END as name + `), + 'users.username', + 'users.email', + ]); + + const result = await query; + return result + .filter((row) => row.name || row.username || row.email) + .map((row) => ({ + id: Number(row.id), + name: String(row.name || row.username || row.email), + })); + } + async deprecatedSearchEvents( search: DeprecatedSearchEventsSchema = {}, ): Promise { diff --git a/src/lib/openapi/spec/event-creators-schema.ts b/src/lib/openapi/spec/event-creators-schema.ts new file mode 100644 index 0000000000..f9101d5816 --- /dev/null +++ b/src/lib/openapi/spec/event-creators-schema.ts @@ -0,0 +1,31 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const eventCreatorsSchema = { + $id: '#/components/schemas/eventCreatorsSchema', + type: 'array', + description: 'A list of event creators', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name'], + properties: { + id: { + type: 'integer', + example: 50, + description: 'The user id.', + }, + name: { + description: + "Name of the user. If the user has no set name, the API falls back to using the user's username (if they have one) or email (if neither name or username is set).", + type: 'string', + example: 'User', + }, + }, + }, + + components: { + schemas: {}, + }, +} as const; + +export type EventCreatorsSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 75225a079e..c5a9c254eb 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -69,6 +69,7 @@ export * from './environment-project-schema'; export * from './environment-schema'; export * from './environments-project-schema'; export * from './environments-schema'; +export * from './event-creators-schema'; export * from './event-schema'; export * from './event-search-response-schema'; export * from './events-schema'; diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index 76c3146ac4..2985339e2d 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -21,6 +21,11 @@ import { getStandardResponses } from '../../../lib/openapi/util/standard-respons import { createRequestSchema } from '../../openapi/util/create-request-schema'; import type { DeprecatedSearchEventsSchema } from '../../openapi/spec/deprecated-search-events-schema'; import type { IFlagResolver } from '../../types/experimental'; +import type { IAuthRequest } from '../unleash-types'; +import { + eventCreatorsSchema, + type ProjectFlagCreatorsSchema, +} from '../../openapi'; const ANON_KEYS = ['email', 'username', 'createdBy']; const version = 1 as const; @@ -45,7 +50,7 @@ export default class EventController extends Controller { this.route({ method: 'get', - path: '', + path: '/events', handler: this.getEvents, permission: ADMIN, middleware: [ @@ -76,7 +81,7 @@ export default class EventController extends Controller { this.route({ method: 'get', - path: '/:featureName', + path: '/events/:featureName', handler: this.getEventsForToggle, permission: NONE, middleware: [ @@ -97,7 +102,7 @@ export default class EventController extends Controller { this.route({ method: 'post', - path: '/search', + path: '/events/search', handler: this.deprecatedSearchEvents, permission: NONE, middleware: [ @@ -115,6 +120,26 @@ export default class EventController extends Controller { }), ], }); + + this.route({ + method: 'get', + path: '/event-creators', + handler: this.getEventCreators, + permission: NONE, + middleware: [ + this.openApiService.validPath({ + tags: ['Events'], + operationId: 'getEventCreators', + summary: 'Get a list of all users that have created events', + description: + 'Returns a list of all users that have created events in the system.', + responses: { + 200: createResponseSchema('eventCreatorsSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); } maybeAnonymiseEvents(events: IEvent[]): IEvent[] { @@ -197,4 +222,18 @@ export default class EventController extends Controller { response, ); } + + async getEventCreators( + req: IAuthRequest, + res: Response, + ): Promise { + const flagCreators = await this.eventService.getEventCreators(); + + this.openApiService.respondWithValidation( + 200, + res, + eventCreatorsSchema.$id, + serializeDates(flagCreators), + ); + } } diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index ad9fcabf70..f645465eeb 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -61,7 +61,7 @@ export class AdminApi extends Controller { '/strategies', new StrategyController(config, services).router, ); - this.app.use('/events', new EventController(config, services).router); + this.app.use('', new EventController(config, services).router); this.app.use( '/playground', new PlaygroundController(config, services).router, diff --git a/src/lib/routes/admin-api/search/index.ts b/src/lib/routes/admin-api/search/index.ts index db5eec1f13..d2d95ba430 100644 --- a/src/lib/routes/admin-api/search/index.ts +++ b/src/lib/routes/admin-api/search/index.ts @@ -20,5 +20,10 @@ export class SearchApi extends Controller { '/events', new EventSearchController(config, services).router, ); + + this.app.use( + '/events', + new EventSearchController(config, services).router, + ); } } diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 2e905f13c7..57c36fcf0f 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -43,4 +43,5 @@ export interface IEventStore query(operations: IQueryOperations[]): Promise; queryCount(operations: IQueryOperations[]): Promise; setCreatedByUserId(batchSize: number): Promise; + getEventCreators(): Promise>; } diff --git a/src/test/e2e/api/admin/event.e2e.test.ts b/src/test/e2e/api/admin/event.e2e.test.ts index bd9ff120a9..f8a4838d6a 100644 --- a/src/test/e2e/api/admin/event.e2e.test.ts +++ b/src/test/e2e/api/admin/event.e2e.test.ts @@ -8,6 +8,7 @@ import { FEATURE_CREATED, type IBaseEvent } from '../../../../lib/types/events'; import { randomId } from '../../../../lib/util/random-id'; import { EventService } from '../../../../lib/services'; import EventEmitter from 'events'; +import { SYSTEM_USER } from '../../../../lib/types'; let app: IUnleashTest; let db: ITestDb; @@ -150,3 +151,61 @@ test('can search for events', async () => { expect(res.body.events[0].data.id).toEqual(events[1].data.id); }); }); + +test('event creators - if system user, return system name, else should return name from database if user exists, else from events table', async () => { + const user = await db.stores.userStore.insert({ name: 'database-user' }); + const events: IBaseEvent[] = [ + { + type: FEATURE_CREATED, + project: randomId(), + data: { id: randomId() }, + tags: [], + createdBy: 'should-not-use-this-name', + createdByUserId: SYSTEM_USER.id, + ip: '127.0.0.1', + }, + { + type: FEATURE_CREATED, + project: randomId(), + data: { id: randomId() }, + tags: [], + createdBy: 'test-user1', + createdByUserId: user.id, + ip: '127.0.0.1', + }, + { + type: FEATURE_CREATED, + project: randomId(), + data: { id: randomId() }, + preData: { id: randomId() }, + tags: [{ type: 'simple', value: randomId() }], + createdBy: 'test-user2', + createdByUserId: 2, + ip: '127.0.0.1', + }, + ]; + + await Promise.all( + events.map((event) => { + return eventService.storeEvent(event); + }), + ); + + const { body } = await app.request + .get('/api/admin/event-creators') + .expect(200); + expect(body).toMatchObject([ + { + id: SYSTEM_USER.id, + name: SYSTEM_USER.name, + }, + { + id: 1, + name: 'database-user', + }, + { + id: 2, + name: 'test-user2', + }, + ]); +}); diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 8787a28948..ff53577f0f 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -15,6 +15,10 @@ class FakeEventStore implements IEventStore { this.events = []; } + getEventCreators(): Promise<{ id: number; name: string }[]> { + throw new Error('Method not implemented.'); + } + getMaxRevisionId(): Promise { return Promise.resolve(1); }