diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 693b77a071..4d2e3d1bc7 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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), }; }; diff --git a/src/lib/db/suggest-change-store.ts b/src/lib/db/suggest-change-store.ts new file mode 100644 index 0000000000..b088cd4931 --- /dev/null +++ b/src/lib/db/suggest-change-store.ts @@ -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( + `${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 => { + const rows = await this.buildSuggestChangeSetChangesQuery(); + return this.mapRows(rows); + }; + + getForProject = async (project: string): Promise => { + const rows = await this.buildSuggestChangeSetChangesQuery().where({ + project, + }); + return this.mapRows(rows); + }; + + getDraftForUser = async ( + user: User, + project: string, + environment: string, + ): Promise => { + 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 => { + const rows = await this.buildSuggestChangeSetChangesQuery().where({ + environment, + }); + + return this.mapRows(rows); + }; + + get = async (id: number): Promise => { + 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 => { + const [{ id }] = await this.db(T.SUGGEST_CHANGE_SET) + .insert({ + 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, + changeSetID: number, + user: User, + ): Promise => { + await this.db(T.SUGGEST_CHANGE) + .insert({ + action: change.action, + feature: change.feature, + payload: change.payload, + suggest_change_set_id: changeSetID, + created_by: user.id, + }) + .returning('id'); + }; + + delete = (id: number): Promise => { + return this.db(T.SUGGEST_CHANGE_SET).where({ id }).del(); + }; + + deleteAll = (): Promise => { + return this.db(T.SUGGEST_CHANGE_SET).del(); + }; + + exists = async (id: number): Promise => { + 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 {} +} diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 79ea3f0259..426699d5bd 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -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; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index d095328b41..066f1cebcf 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 06d46371c4..1512edb64b 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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; + createdAt: Date; + changes: ISuggestChange[]; +} + +export interface ISuggestChangePayload { + environment: string; + data: unknown; +} + +export interface ISuggestChange { + id?: number; + action: SuggestChangeAction; + feature: string; + payload: ISuggestChangePayload; + createdBy?: Pick; + 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; +} diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index 24c471ac9c..de02ab600b 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -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'; diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 0d49d40af6..45237b2587 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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; } diff --git a/src/lib/types/stores/suggest-change-store.ts b/src/lib/types/stores/suggest-change-store.ts new file mode 100644 index 0000000000..604287e3ce --- /dev/null +++ b/src/lib/types/stores/suggest-change-store.ts @@ -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 { + create( + suggestChangeSet: PartialSome< + ISuggestChangeset, + 'id' | 'createdBy' | 'createdAt' + >, + user: Partial>, + ): Promise; + + addChangeToSet( + change: PartialSome, + changeSetID: number, + user: User, + ): Promise; + + get(id: number): Promise; + + getAll(): Promise; + + getForProject(project: string): Promise; + + getDraftForUser( + user: User, + project: string, + environment: string, + ): Promise; + + getForEnvironment(environment: string): Promise; +} diff --git a/src/migrations/20221810114644-add-suggest-changes-table.js b/src/migrations/20221810114644-add-suggest-changes-table.js new file mode 100644 index 0000000000..058e71e8f3 --- /dev/null +++ b/src/migrations/20221810114644-add-suggest-changes-table.js @@ -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, + ); +}; diff --git a/src/test/config/test-config.ts b/src/test/config/test-config.ts index b2618d634a..ef2496a59e 100644 --- a/src/test/config/test-config.ts +++ b/src/test/config/test-config.ts @@ -29,6 +29,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig { batchMetrics: true, personalAccessTokens: true, syncSSOGroups: true, + suggestChanges: true, cloneEnvironment: true, }, }, diff --git a/src/test/fixtures/fake-suggest-change-store.ts b/src/test/fixtures/fake-suggest-change-store.ts new file mode 100644 index 0000000000..28b9983630 --- /dev/null +++ b/src/test/fixtures/fake-suggest-change-store.ts @@ -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 { + const change = this.suggestChanges.find((c) => c.id === id); + return Promise.resolve(change); + } + + async count(): Promise { + return Promise.resolve(0); + } + + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + async delete(id: number): Promise { + return Promise.resolve(undefined); + } + + addChangeToSet( + change: PartialSome, + changeSetID: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + user: User, + ): Promise { + const changeSet = this.suggestChanges.find((s) => s.id === changeSetID); + changeSet.changes.push(change); + return Promise.resolve(); + } + + getForEnvironment(environment: string): Promise { + return Promise.resolve( + this.suggestChanges.filter( + (changeSet) => changeSet.environment === environment, + ), + ); + } + + getDraftForUser( + user: User, + project: string, + environment: string, + ): Promise { + return Promise.resolve( + this.suggestChanges.find( + (changeSet) => + changeSet.project === project && + changeSet.environment === environment && + changeSet.createdBy.id === user.id, + ), + ); + } + + getForProject(project: string): Promise { + return Promise.resolve( + this.suggestChanges.filter( + (changeSet) => changeSet.project === project, + ), + ); + } + + create( + suggestChangeSet: PartialSome, + user: Partial>, + ): Promise { + this.suggestChanges.push({ + id: 1, + ...suggestChangeSet, + createdBy: { id: user.id, username: user.email, imageUrl: '' }, + }); + return Promise.resolve(undefined); + } + + getAll(): Promise { + return Promise.resolve([]); + } + + deleteAll(): Promise { + return Promise.resolve(undefined); + } + + destroy(): void {} + + exists(key: number): Promise { + return Promise.resolve(Boolean(key)); + } +} diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index e5506cabdf..17f5c4b80f 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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(), }; }; diff --git a/website/docs/api/admin/events-api.md b/website/docs/api/admin/events-api.md index 05ef65b5ef..e890ba0d07 100644 --- a/website/docs/api/admin/events-api.md +++ b/website/docs/api/admin/events-api.md @@ -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 +} +```