1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

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.
This commit is contained in:
Jaanus Sellin 2024-08-09 10:32:31 +03:00 committed by GitHub
parent bde81b940c
commit 2f92dac14e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 175 additions and 5 deletions

View File

@ -200,4 +200,8 @@ export default class EventService {
return queryParams;
};
async getEventCreators() {
return this.eventStore.getEventCreators();
}
}

View File

@ -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<Array<{ id: number; name: string }>> {
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<IEvent[]> {

View File

@ -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<typeof eventCreatorsSchema>;

View File

@ -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';

View File

@ -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<ProjectFlagCreatorsSchema>,
): Promise<void> {
const flagCreators = await this.eventService.getEventCreators();
this.openApiService.respondWithValidation(
200,
res,
eventCreatorsSchema.$id,
serializeDates(flagCreators),
);
}
}

View File

@ -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,

View File

@ -20,5 +20,10 @@ export class SearchApi extends Controller {
'/events',
new EventSearchController(config, services).router,
);
this.app.use(
'/events',
new EventSearchController(config, services).router,
);
}
}

View File

@ -43,4 +43,5 @@ export interface IEventStore
query(operations: IQueryOperations[]): Promise<IEvent[]>;
queryCount(operations: IQueryOperations[]): Promise<number>;
setCreatedByUserId(batchSize: number): Promise<number | undefined>;
getEventCreators(): Promise<Array<{ id: number; name: string }>>;
}

View File

@ -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',
},
]);
});

View File

@ -15,6 +15,10 @@ class FakeEventStore implements IEventStore {
this.events = [];
}
getEventCreators(): Promise<{ id: number; name: string }[]> {
throw new Error('Method not implemented.');
}
getMaxRevisionId(): Promise<number> {
return Promise.resolve(1);
}