1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

Add suggest-change-store.ts and db migration (#2201)

* Add suggest-change-store.ts and db migration

* Add suggest-change-store.ts and db migration

* change payload and event data type

* Update src/lib/db/suggest-change-store.ts

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>

* split to 3 tables, create event on every change

* split to 3 tables, create event on every change

* Move service to enterprise
PR Comments

* PR Comments

* replacy createdBy string with User

* replace createdBy string with User

* added event to docs

* return entire changeset after adding a change

* bug fix

* bug fix

* move add change return to service layer

* PR comments

* added user id to user objects

* added user id to user objects

* added user id to user objects

* bug fix

* Rework

* Remove event and fix queries

* Update snapshot

* Remove console logs

* Fix

Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
Co-authored-by: sjaanus <sellinjaanus@gmail.com>
This commit is contained in:
andreas-unleash 2022-10-24 20:07:29 +03:00 committed by GitHub
parent 8270166286
commit 85631b9951
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 492 additions and 5 deletions

View File

@ -32,6 +32,7 @@ import SegmentStore from './segment-store';
import GroupStore from './group-store';
import PatStore from './pat-store';
import { PublicSignupTokenStore } from './public-signup-token-store';
import { SuggestChangeStore } from './suggest-change-store';
export const createStores = (
config: IUnleashConfig,
@ -92,6 +93,7 @@ export const createStores = (
getLogger,
),
patStore: new PatStore(db, getLogger),
suggestChangeStore: new SuggestChangeStore(db, eventBus, getLogger),
};
};

View File

@ -0,0 +1,244 @@
import { ISuggestChangeStore } from '../types/stores/suggest-change-store';
import { Logger, LogProvider } from '../logger';
import EventEmitter from 'events';
import { Knex } from 'knex';
import { PartialSome } from '../types/partial';
import {
ISuggestChange,
ISuggestChangeset,
SuggestChangeAction,
} from '../types/model';
import User from '../types/user';
const T = {
SUGGEST_CHANGE: 'suggest_change',
SUGGEST_CHANGE_SET: 'suggest_change_set',
};
interface ISuggestChangesetInsert {
id: number;
environment: string;
state?: string;
project?: string;
created_by?: number;
created_at?: Date;
}
interface ISuggestChangeInsert {
id: number;
action: SuggestChangeAction;
feature: string;
payload?: unknown;
created_by?: number;
created_at?: Date;
}
interface ISuggestChangesetRow extends ISuggestChangesetInsert {
changes?: ISuggestChange[];
}
const suggestChangeRowReducer = (acc, suggestChangeRow) => {
const {
changeId,
changeAction,
changePayload,
changeFeature,
changeCreatedByUsername,
changeCreatedByAvatar,
changeCreatedAt,
changeCreatedBy,
...suggestChangeSet
} = suggestChangeRow;
if (!acc[suggestChangeRow.id]) {
acc[suggestChangeRow.id] = {
id: suggestChangeSet.id,
environment: suggestChangeSet.environment,
state: suggestChangeSet.state,
project: suggestChangeSet.project,
createdBy: {
id: suggestChangeSet.created_by,
username: suggestChangeSet.changeSetUsername,
imageUrl: suggestChangeSet.changeSetAvatar,
},
createdAt: suggestChangeSet.created_at,
changes: [],
};
}
const currentSuggestChangeSet = acc[suggestChangeSet.id];
if (changeId) {
currentSuggestChangeSet.changes.push({
id: changeId,
feature: changeFeature,
action: changeAction,
payload: changePayload,
createdAt: changeCreatedAt,
createdBy: {
id: changeCreatedBy,
username: changeCreatedByUsername,
imageUrl: changeCreatedByAvatar,
},
});
}
return acc;
};
export class SuggestChangeStore implements ISuggestChangeStore {
private logger: Logger;
private eventBus: EventEmitter;
private db: Knex;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.eventBus = eventBus;
this.logger = getLogger('lib/db/suggest-change-store.ts');
}
private buildSuggestChangeSetChangesQuery = () => {
return this.db<ISuggestChangesetRow>(
`${T.SUGGEST_CHANGE_SET} as changeSet`,
)
.leftJoin(
`users as changeSetUser`,
'changeSet.created_by',
'changeSetUser.id',
)
.leftJoin(`projects`, 'projects.id', 'changeSet.project')
.leftJoin(
`${T.SUGGEST_CHANGE} as change`,
'changeSet.id',
'change.suggest_change_set_id',
)
.leftJoin(
`users as changeUser`,
'change.created_by',
'changeUser.id',
)
.select(
'changeSet.environment',
'projects.name as project',
'changeSet.created_at',
'changeSet.created_by',
'changeSetUser.username as changeSetUsername',
'changeSetUser.image_url as changeSetAvatar',
'change.id as changeId',
'change.feature as changeFeature',
'change.action as changeAction',
'change.payload as changePayload',
'change.created_at as changeCreatedAt',
'change.created_by as changeCreatedBy',
'changeUser.username as changeCreatedByUsername',
'changeUser.image_url as changeCreatedByAvatar',
);
};
getAll = async (): Promise<ISuggestChangeset[]> => {
const rows = await this.buildSuggestChangeSetChangesQuery();
return this.mapRows(rows);
};
getForProject = async (project: string): Promise<ISuggestChangeset[]> => {
const rows = await this.buildSuggestChangeSetChangesQuery().where({
project,
});
return this.mapRows(rows);
};
getDraftForUser = async (
user: User,
project: string,
environment: string,
): Promise<ISuggestChangeset> => {
const rows = await this.buildSuggestChangeSetChangesQuery().where({
'changeSet.created_by': user.id,
state: 'Draft',
project: project,
environment: environment,
});
const change = this.mapRows(rows)[0];
return change;
};
getForEnvironment = async (
environment: string,
): Promise<ISuggestChangeset[]> => {
const rows = await this.buildSuggestChangeSetChangesQuery().where({
environment,
});
return this.mapRows(rows);
};
get = async (id: number): Promise<ISuggestChangeset> => {
const rows = await this.buildSuggestChangeSetChangesQuery().where({
'changeSet.id': id,
});
return this.mapRows(rows)[0];
};
create = async (
suggestChangeSet: PartialSome<
ISuggestChangeset,
'id' | 'createdBy' | 'createdAt'
>,
user: User,
): Promise<ISuggestChangeset> => {
const [{ id }] = await this.db(T.SUGGEST_CHANGE_SET)
.insert<ISuggestChangesetInsert>({
environment: suggestChangeSet.environment,
state: suggestChangeSet.state,
project: suggestChangeSet.project,
created_by: user.id,
})
.returning('id');
suggestChangeSet.changes.forEach((change) => {
this.addChangeToSet(change, id, user);
});
return this.get(id);
};
addChangeToSet = async (
change: PartialSome<ISuggestChange, 'id' | 'createdBy' | 'createdAt'>,
changeSetID: number,
user: User,
): Promise<void> => {
await this.db(T.SUGGEST_CHANGE)
.insert<ISuggestChangeInsert>({
action: change.action,
feature: change.feature,
payload: change.payload,
suggest_change_set_id: changeSetID,
created_by: user.id,
})
.returning('id');
};
delete = (id: number): Promise<void> => {
return this.db(T.SUGGEST_CHANGE_SET).where({ id }).del();
};
deleteAll = (): Promise<void> => {
return this.db(T.SUGGEST_CHANGE_SET).del();
};
exists = async (id: number): Promise<boolean> => {
const result = await this.db.raw(
`SELECT EXISTS(SELECT 1 FROM ${T.SUGGEST_CHANGE_SET} WHERE id = ?) AS present`,
[id],
);
return result.rows[0].present;
};
mapRows = (rows?: any[]): ISuggestChangeset[] => {
const suggestChangeSets = rows.reduce(suggestChangeRowReducer, {});
return Object.values(suggestChangeSets);
};
destroy(): void {}
}

View File

@ -82,6 +82,8 @@ export const PUBLIC_SIGNUP_TOKEN_CREATED = 'public-signup-token-created';
export const PUBLIC_SIGNUP_TOKEN_USER_ADDED = 'public-signup-token-user-added';
export const PUBLIC_SIGNUP_TOKEN_TOKEN_UPDATED = 'public-signup-token-updated';
export const SUGGEST_CHANGE_CREATED = 'suggest-change-created';
export interface IBaseEvent {
type: string;
createdBy: string;

View File

@ -14,14 +14,14 @@ export const defaultExperimentalOptions = {
process.env.UNLEASH_EXPERIMENTAL_PERSONAL_ACCESS_TOKENS,
false,
),
syncSSOGroups: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SYNC_SSO_GROUPS,
false,
),
suggestChanges: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SUGGEST_CHANGES,
false,
),
syncSSOGroups: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SYNC_SSO_GROUPS,
false,
),
embedProxyFrontend: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY_FRONTEND,
false,

View File

@ -1,7 +1,7 @@
import { ITagType } from './stores/tag-type-store';
import { LogProvider } from '../logger';
import { IRole } from './stores/access-store';
import { IUser } from './user';
import User, { IUser } from './user';
import { ALL_OPERATORS } from '../util/constants';
export type Operator = typeof ALL_OPERATORS[number];
@ -365,3 +365,54 @@ export interface IFeatureStrategySegment {
featureStrategyId: string;
segmentId: number;
}
export interface ISuggestChangeset {
id: number;
state: string;
project: string;
environment: string;
createdBy: Pick<User, 'id' | 'username' | 'imageUrl'>;
createdAt: Date;
changes: ISuggestChange[];
}
export interface ISuggestChangePayload {
environment: string;
data: unknown;
}
export interface ISuggestChange {
id?: number;
action: SuggestChangeAction;
feature: string;
payload: ISuggestChangePayload;
createdBy?: Pick<User, 'id' | 'username' | 'imageUrl'>;
createdAt?: Date;
}
export enum SuggestChangesetEvent {
CREATED = 'CREATED',
UPDATED = 'UPDATED',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
CLOSED = 'CLOSED',
}
export enum SuggestChangeAction {
UPDATE_ENABLED = 'updateEnabled',
ADD_STRATEGY = 'strategyAdd',
UPDATE_STRATEGY = 'strategyUpdate',
DELETE_STRATEGY = 'strategyDelete',
}
export enum SuggestChangeEvent {
UPDATE_ENABLED = 'updateFeatureEnabledEvent',
ADD_STRATEGY = 'addStrategyEvent',
UPDATE_STRATEGY = 'updateStrategyEvent',
DELETE_STRATEGY = 'deleteStrategyEvent',
}
export interface ISuggestChangeEventData {
feature: string;
data: unknown;
}

View File

@ -37,3 +37,4 @@ export const MOVE_FEATURE_TOGGLE = 'MOVE_FEATURE_TOGGLE';
export const CREATE_SEGMENT = 'CREATE_SEGMENT';
export const UPDATE_SEGMENT = 'UPDATE_SEGMENT';
export const DELETE_SEGMENT = 'DELETE_SEGMENT';
export const SUGGEST_CHANGE = 'SUGGEST_CHANGE';

View File

@ -28,6 +28,7 @@ import { ISegmentStore } from './stores/segment-store';
import { IGroupStore } from './stores/group-store';
import { IPatStore } from './stores/pat-store';
import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
import { ISuggestChangeStore } from './stores/suggest-change-store';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -60,4 +61,5 @@ export interface IUnleashStores {
segmentStore: ISegmentStore;
patStore: IPatStore;
publicSignupTokenStore: IPublicSignupTokenStore;
suggestChangeStore: ISuggestChangeStore;
}

View File

@ -0,0 +1,34 @@
import { Store } from './store';
import { ISuggestChange, ISuggestChangeset } from '../model';
import { PartialSome } from '../partial';
import User from '../user';
export interface ISuggestChangeStore extends Store<ISuggestChangeset, number> {
create(
suggestChangeSet: PartialSome<
ISuggestChangeset,
'id' | 'createdBy' | 'createdAt'
>,
user: Partial<Pick<User, 'username' | 'email'>>,
): Promise<ISuggestChangeset>;
addChangeToSet(
change: PartialSome<ISuggestChange, 'id' | 'createdBy' | 'createdAt'>,
changeSetID: number,
user: User,
): Promise<void>;
get(id: number): Promise<ISuggestChangeset>;
getAll(): Promise<ISuggestChangeset[]>;
getForProject(project: string): Promise<ISuggestChangeset[]>;
getDraftForUser(
user: User,
project: string,
environment: string,
): Promise<ISuggestChangeset>;
getForEnvironment(environment: string): Promise<ISuggestChangeset[]>;
}

View File

@ -0,0 +1,37 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
CREATE TABLE IF NOT EXISTS suggest_change_set (
id serial primary key,
environment varchar(100) REFERENCES environments(name) ON DELETE CASCADE,
state varchar(255) NOT NULL,
project varchar(255) REFERENCES projects(id) ON DELETE CASCADE,
created_by integer not null references users (id) ON DELETE CASCADE,
created_at timestamp default now()
);
CREATE TABLE IF NOT EXISTS suggest_change (
id serial primary key,
feature varchar(255) NOT NULL references features(name) on delete cascade,
action varchar(255) NOT NULL,
payload jsonb not null default '[]'::jsonb,
created_by integer not null references users (id) ON DELETE CASCADE,
created_at timestamp default now(),
suggest_change_set_id integer NOT NULL REFERENCES suggest_change_set(id) ON DELETE CASCADE
);
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
DROP TABLE IF EXISTS suggest_change;
DROP TABLE IF EXISTS suggest_change_set;
`,
callback,
);
};

View File

@ -29,6 +29,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
batchMetrics: true,
personalAccessTokens: true,
syncSSOGroups: true,
suggestChanges: true,
cloneEnvironment: true,
},
},

View File

@ -0,0 +1,90 @@
import { ISuggestChangeStore } from '../../lib/types/stores/suggest-change-store';
import { ISuggestChange, ISuggestChangeset } from '../../lib/types/model';
import { PartialSome } from '../../lib/types/partial';
import User from '../../lib/types/user';
export default class FakeSuggestChangeStore implements ISuggestChangeStore {
suggestChanges: ISuggestChangeset[] = [];
async get(id: number): Promise<ISuggestChangeset> {
const change = this.suggestChanges.find((c) => c.id === id);
return Promise.resolve(change);
}
async count(): Promise<number> {
return Promise.resolve(0);
}
// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars
async delete(id: number): Promise<void> {
return Promise.resolve(undefined);
}
addChangeToSet(
change: PartialSome<ISuggestChange, 'id' | 'createdBy' | 'createdAt'>,
changeSetID: number,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
user: User,
): Promise<void> {
const changeSet = this.suggestChanges.find((s) => s.id === changeSetID);
changeSet.changes.push(change);
return Promise.resolve();
}
getForEnvironment(environment: string): Promise<ISuggestChangeset[]> {
return Promise.resolve(
this.suggestChanges.filter(
(changeSet) => changeSet.environment === environment,
),
);
}
getDraftForUser(
user: User,
project: string,
environment: string,
): Promise<ISuggestChangeset> {
return Promise.resolve(
this.suggestChanges.find(
(changeSet) =>
changeSet.project === project &&
changeSet.environment === environment &&
changeSet.createdBy.id === user.id,
),
);
}
getForProject(project: string): Promise<ISuggestChangeset[]> {
return Promise.resolve(
this.suggestChanges.filter(
(changeSet) => changeSet.project === project,
),
);
}
create(
suggestChangeSet: PartialSome<ISuggestChangeset, 'id'>,
user: Partial<Pick<User, 'id' | 'username' | 'email'>>,
): Promise<ISuggestChangeset> {
this.suggestChanges.push({
id: 1,
...suggestChangeSet,
createdBy: { id: user.id, username: user.email, imageUrl: '' },
});
return Promise.resolve(undefined);
}
getAll(): Promise<ISuggestChangeset[]> {
return Promise.resolve([]);
}
deleteAll(): Promise<void> {
return Promise.resolve(undefined);
}
destroy(): void {}
exists(key: number): Promise<boolean> {
return Promise.resolve(Boolean(key));
}
}

View File

@ -29,6 +29,7 @@ import FakeSegmentStore from './fake-segment-store';
import FakeGroupStore from './fake-group-store';
import FakePatStore from './fake-pat-store';
import FakePublicSignupStore from './fake-public-signup-store';
import FakeSuggestChangeStore from './fake-suggest-change-store';
const createStores: () => IUnleashStores = () => {
const db = {
@ -69,6 +70,7 @@ const createStores: () => IUnleashStores = () => {
groupStore: new FakeGroupStore(),
patStore: new FakePatStore(),
publicSignupTokenStore: new FakePublicSignupStore(),
suggestChangeStore: new FakeSuggestChangeStore(),
};
};

View File

@ -1557,3 +1557,24 @@ This event fires when you delete a segment.
"environment": null
}
```
## Suggest changes events
### 'suggest-change-created'
This event fires when you create a a suggest-changes draft.
```json title="example event: suggest-change-created"
{
"id": 971,
"type": "suggest-change-created",
"createdBy": "user@company.com",
"createdAt": "2022-06-03T10:30:08.128Z",
"data": {},
"preData": null,
"tags": [],
"featureName": null,
"project": null,
"environment": null
}
```