1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

feat: enterprise project settings (#4844)

This commit is contained in:
Jaanus Sellin 2023-09-27 13:10:10 +03:00 committed by GitHub
parent ebb76a5354
commit 960bc110ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 165 additions and 48 deletions

View File

@ -14,6 +14,7 @@ import {
IProjectInsert, IProjectInsert,
IProjectQuery, IProjectQuery,
IProjectSettings, IProjectSettings,
IProjectEnterpriseSettingsUpdate,
IProjectStore, IProjectStore,
ProjectEnvironment, ProjectEnvironment,
} from '../types/stores/project-store'; } from '../types/stores/project-store';
@ -244,10 +245,6 @@ class ProjectStore implements IProjectStore {
project: project.id, project: project.id,
project_mode: project.mode, project_mode: project.mode,
default_stickiness: project.defaultStickiness, default_stickiness: project.defaultStickiness,
feature_limit: project.featureLimit,
feature_naming_pattern: project.featureNaming?.pattern,
feature_naming_example: project.featureNaming?.example,
feature_naming_description: project.featureNaming?.description,
}) })
.returning('*'); .returning('*');
return this.mapRow({ ...row[0], ...settingsRow[0] }); return this.mapRow({ ...row[0], ...settingsRow[0] });
@ -272,9 +269,30 @@ class ProjectStore implements IProjectStore {
await this.db(SETTINGS_TABLE) await this.db(SETTINGS_TABLE)
.where({ project: data.id }) .where({ project: data.id })
.update({ .update({
project_mode: data.mode,
default_stickiness: data.defaultStickiness, default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit, feature_limit: data.featureLimit,
});
} else {
await this.db(SETTINGS_TABLE).insert({
project: data.id,
default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit,
});
}
} catch (err) {
this.logger.error('Could not update project, error: ', err);
}
}
async updateProjectEnterpriseSettings(
data: IProjectEnterpriseSettingsUpdate,
): Promise<void> {
try {
if (await this.hasProjectSettings(data.id)) {
await this.db(SETTINGS_TABLE)
.where({ project: data.id })
.update({
project_mode: data.mode,
feature_naming_pattern: data.featureNaming?.pattern, feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example, feature_naming_example: data.featureNaming?.example,
feature_naming_description: feature_naming_description:
@ -284,15 +302,16 @@ class ProjectStore implements IProjectStore {
await this.db(SETTINGS_TABLE).insert({ await this.db(SETTINGS_TABLE).insert({
project: data.id, project: data.id,
project_mode: data.mode, project_mode: data.mode,
default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit,
feature_naming_pattern: data.featureNaming?.pattern, feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example, feature_naming_example: data.featureNaming?.example,
feature_naming_description: data.featureNaming?.description, feature_naming_description: data.featureNaming?.description,
}); });
} }
} catch (err) { } catch (err) {
this.logger.error('Could not update project, error: ', err); this.logger.error(
'Could not update project settings, error: ',
err,
);
} }
} }

View File

@ -113,13 +113,16 @@ const createProjects = async (
type: 'production', type: 'production',
}); });
for (const project of projects) { for (const project of projects) {
await db.stores.projectStore.create({ const storedProject = {
name: project, name: project,
description: '', description: '',
id: project, id: project,
mode: 'open' as const, mode: 'open' as const,
featureLimit, featureLimit,
}); };
await db.stores.projectStore.create(storedProject);
await db.stores.projectStore.update(storedProject);
await app.linkProjectToEnvironment(project, DEFAULT_ENV); await app.linkProjectToEnvironment(project, DEFAULT_ENV);
} }
}; };
@ -884,10 +887,8 @@ test('validate import data', async () => {
// note: this must be done after creating the feature on the earlier lines, // note: this must be done after creating the feature on the earlier lines,
// to prevent the pattern from blocking the creation. // to prevent the pattern from blocking the creation.
await projectStore.update({ await projectStore.updateProjectEnterpriseSettings({
id: DEFAULT_PROJECT, id: DEFAULT_PROJECT,
name: 'default',
description: '',
mode: 'open', mode: 'open',
featureNaming: { pattern: 'testpattern.+' }, featureNaming: { pattern: 'testpattern.+' },
}); });
@ -996,6 +997,9 @@ test(`should give errors with flag names if the flags don't match the project pa
description: '', description: '',
id: project, id: project,
mode: 'open' as const, mode: 'open' as const,
});
await db.stores.projectStore.updateProjectEnterpriseSettings({
id: project,
featureNaming: { pattern }, featureNaming: { pattern },
}); });
await app.linkProjectToEnvironment(project, DEFAULT_ENV); await app.linkProjectToEnvironment(project, DEFAULT_ENV);

View File

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`updateProjectEnterpriseSettings schema 1`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'version'",
"params": {
"missingProperty": "version",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/projectOverviewSchema",
}
`;

View File

@ -0,0 +1,22 @@
import { ProjectOverviewSchema } from './project-overview-schema';
import { validateSchema } from '../validate';
test('updateProjectEnterpriseSettings schema', () => {
const data: ProjectOverviewSchema = {
name: 'project',
version: 3,
featureNaming: {
description: 'naming description',
example: 'a',
pattern: '[aZ]',
},
};
expect(
validateSchema('#/components/schemas/projectOverviewSchema', data),
).toBeUndefined();
expect(
validateSchema('#/components/schemas/projectOverviewSchema', {}),
).toMatchSnapshot();
});

View File

@ -41,7 +41,11 @@ import {
IFeatureNaming, IFeatureNaming,
CreateProject, CreateProject,
} from '../types'; } from '../types';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; import {
IProjectQuery,
IProjectEnterpriseSettingsUpdate,
IProjectStore,
} from '../types/stores/project-store';
import { import {
IProjectAccessModel, IProjectAccessModel,
IRoleDescriptor, IRoleDescriptor,
@ -83,7 +87,7 @@ interface ICalculateStatus {
} }
export default class ProjectService { export default class ProjectService {
private store: IProjectStore; private projectStore: IProjectStore;
private accessService: AccessService; private accessService: AccessService;
@ -113,6 +117,8 @@ export default class ProjectService {
private flagResolver: IFlagResolver; private flagResolver: IFlagResolver;
private isEnterprise: boolean;
constructor( constructor(
{ {
projectStore, projectStore,
@ -141,7 +147,7 @@ export default class ProjectService {
favoriteService: FavoritesService, favoriteService: FavoritesService,
privateProjectChecker: IPrivateProjectChecker, privateProjectChecker: IPrivateProjectChecker,
) { ) {
this.store = projectStore; this.projectStore = projectStore;
this.environmentStore = environmentStore; this.environmentStore = environmentStore;
this.featureEnvironmentStore = featureEnvironmentStore; this.featureEnvironmentStore = featureEnvironmentStore;
this.accessService = accessService; this.accessService = accessService;
@ -156,13 +162,17 @@ export default class ProjectService {
this.projectStatsStore = projectStatsStore; this.projectStatsStore = projectStatsStore;
this.logger = config.getLogger('services/project-service.js'); this.logger = config.getLogger('services/project-service.js');
this.flagResolver = config.flagResolver; this.flagResolver = config.flagResolver;
this.isEnterprise = config.isEnterprise;
} }
async getProjects( async getProjects(
query?: IProjectQuery, query?: IProjectQuery,
userId?: number, userId?: number,
): Promise<IProjectWithCount[]> { ): Promise<IProjectWithCount[]> {
const projects = await this.store.getProjectsWithCounts(query, userId); const projects = await this.projectStore.getProjectsWithCounts(
query,
userId,
);
if (this.flagResolver.isEnabled('privateProjects') && userId) { if (this.flagResolver.isEnabled('privateProjects') && userId) {
const projectAccess = const projectAccess =
await this.privateProjectChecker.getUserAccessibleProjects( await this.privateProjectChecker.getUserAccessibleProjects(
@ -181,7 +191,7 @@ export default class ProjectService {
} }
async getProject(id: string): Promise<IProject> { async getProject(id: string): Promise<IProject> {
return this.store.get(id); return this.projectStore.get(id);
} }
private validateAndProcessFeatureNamingPattern = ( private validateAndProcessFeatureNamingPattern = (
@ -214,14 +224,11 @@ export default class ProjectService {
newProject: CreateProject, newProject: CreateProject,
user: IUser, user: IUser,
): Promise<IProject> { ): Promise<IProject> {
const data = await projectSchema.validateAsync(newProject); const validatedData = await projectSchema.validateAsync(newProject);
const data = this.removeModeForNonEnterprise(validatedData);
await this.validateUniqueId(data.id); await this.validateUniqueId(data.id);
if (data.featureNaming) { await this.projectStore.create(data);
this.validateAndProcessFeatureNamingPattern(data.featureNaming);
}
await this.store.create(data);
const enabledEnvironments = await this.environmentStore.getAll({ const enabledEnvironments = await this.environmentStore.getAll({
enabled: true, enabled: true,
@ -250,7 +257,24 @@ export default class ProjectService {
} }
async updateProject(updatedProject: IProject, user: User): Promise<void> { async updateProject(updatedProject: IProject, user: User): Promise<void> {
const preData = await this.store.get(updatedProject.id); const preData = await this.projectStore.get(updatedProject.id);
await this.projectStore.update(updatedProject);
await this.eventStore.store({
type: PROJECT_UPDATED,
project: updatedProject.id,
createdBy: getCreatedBy(user),
data: updatedProject,
preData,
});
}
async updateProjectEnterpriseSettings(
updatedProject: IProjectEnterpriseSettingsUpdate,
user: User,
): Promise<void> {
const preData = await this.projectStore.get(updatedProject.id);
if (updatedProject.featureNaming) { if (updatedProject.featureNaming) {
this.validateAndProcessFeatureNamingPattern( this.validateAndProcessFeatureNamingPattern(
@ -258,7 +282,7 @@ export default class ProjectService {
); );
} }
await this.store.update(updatedProject); await this.projectStore.updateProjectEnterpriseSettings(updatedProject);
await this.eventStore.store({ await this.eventStore.store({
type: PROJECT_UPDATED, type: PROJECT_UPDATED,
@ -276,7 +300,7 @@ export default class ProjectService {
const featureEnvs = await this.featureEnvironmentStore.getAll({ const featureEnvs = await this.featureEnvironmentStore.getAll({
feature_name: feature.name, feature_name: feature.name,
}); });
const newEnvs = await this.store.getEnvironmentsForProject( const newEnvs = await this.projectStore.getEnvironmentsForProject(
newProjectId, newProjectId,
); );
return arraysHaveSameItems( return arraysHaveSameItems(
@ -289,7 +313,7 @@ export default class ProjectService {
project: string, project: string,
environment: string, environment: string,
): Promise<void> { ): Promise<void> {
await this.store.addEnvironmentToProject(project, environment); await this.projectStore.addEnvironmentToProject(project, environment);
} }
async changeProject( async changeProject(
@ -355,7 +379,7 @@ export default class ProjectService {
); );
} }
await this.store.delete(id); await this.projectStore.delete(id);
await this.eventStore.store({ await this.eventStore.store({
type: PROJECT_DELETED, type: PROJECT_DELETED,
@ -373,7 +397,7 @@ export default class ProjectService {
} }
async validateUniqueId(id: string): Promise<void> { async validateUniqueId(id: string): Promise<void> {
const exists = await this.store.hasProject(id); const exists = await this.projectStore.hasProject(id);
if (exists) { if (exists) {
throw new NameExistsError('A project with this id already exists.'); throw new NameExistsError('A project with this id already exists.');
} }
@ -872,7 +896,7 @@ export default class ProjectService {
} }
async getMembers(projectId: string): Promise<number> { async getMembers(projectId: string): Promise<number> {
return this.store.getMembersCountByProject(projectId); return this.projectStore.getMembersCountByProject(projectId);
} }
async getProjectUsers( async getProjectUsers(
@ -903,7 +927,7 @@ export default class ProjectService {
} }
async getProjectsByUser(userId: number): Promise<string[]> { async getProjectsByUser(userId: number): Promise<string[]> {
return this.store.getProjectsByUser(userId); return this.projectStore.getProjectsByUser(userId);
} }
async getProjectRoleUsage(roleId: number): Promise<IProjectRoleUsage[]> { async getProjectRoleUsage(roleId: number): Promise<IProjectRoleUsage[]> {
@ -911,7 +935,7 @@ export default class ProjectService {
} }
async statusJob(): Promise<void> { async statusJob(): Promise<void> {
const projects = await this.store.getAll(); const projects = await this.projectStore.getAll();
const statusUpdates = await Promise.all( const statusUpdates = await Promise.all(
projects.map((project) => this.getStatusUpdates(project.id)), projects.map((project) => this.getStatusUpdates(project.id)),
@ -990,7 +1014,7 @@ export default class ProjectService {
); );
const projectMembersAddedCurrentWindow = const projectMembersAddedCurrentWindow =
await this.store.getMembersCountByProjectAfterDate( await this.projectStore.getMembersCountByProjectAfterDate(
projectId, projectId,
dateMinusThirtyDays, dateMinusThirtyDays,
); );
@ -1023,14 +1047,14 @@ export default class ProjectService {
favorite, favorite,
projectStats, projectStats,
] = await Promise.all([ ] = await Promise.all([
this.store.get(projectId), this.projectStore.get(projectId),
this.store.getEnvironmentsForProject(projectId), this.projectStore.getEnvironmentsForProject(projectId),
this.featureToggleService.getFeatureOverview({ this.featureToggleService.getFeatureOverview({
projectId, projectId,
archived, archived,
userId, userId,
}), }),
this.store.getMembersCountByProject(projectId), this.projectStore.getMembersCountByProject(projectId),
userId userId
? this.favoritesService.isFavoriteProject({ ? this.favoritesService.isFavoriteProject({
project: projectId, project: projectId,
@ -1058,4 +1082,13 @@ export default class ProjectService {
version: 1, version: 1,
}; };
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
removeModeForNonEnterprise(data): any {
if (this.isEnterprise) {
return data;
}
const { mode, ...proData } = data;
return proData;
}
} }

View File

@ -420,7 +420,7 @@ export type CreateProject = Pick<IProject, 'id' | 'name'> & {
export interface IProject { export interface IProject {
id: string; id: string;
name: string; name: string;
description: string; description?: string;
health?: number; health?: number;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;

View File

@ -16,14 +16,20 @@ import { CreateFeatureStrategySchema } from '../../openapi';
export interface IProjectInsert { export interface IProjectInsert {
id: string; id: string;
name: string; name: string;
description: string; description?: string;
updatedAt?: Date; updatedAt?: Date;
changeRequestsEnabled?: boolean; changeRequestsEnabled?: boolean;
mode: ProjectMode; mode?: ProjectMode;
featureLimit?: number; featureLimit?: number;
featureNaming?: IFeatureNaming; featureNaming?: IFeatureNaming;
} }
export interface IProjectEnterpriseSettingsUpdate {
id: string;
mode?: ProjectMode;
featureNaming?: IFeatureNaming;
}
export interface IProjectSettings { export interface IProjectSettings {
mode: ProjectMode; mode: ProjectMode;
defaultStickiness: string; defaultStickiness: string;
@ -57,6 +63,10 @@ export interface IProjectStore extends Store<IProject, string> {
update(update: IProjectInsert): Promise<void>; update(update: IProjectInsert): Promise<void>;
updateProjectEnterpriseSettings(
update: IProjectEnterpriseSettingsUpdate,
): Promise<void>;
importProjects( importProjects(
projects: IProjectInsert[], projects: IProjectInsert[],
environments?: IEnvironment[], environments?: IEnvironment[],

View File

@ -9,7 +9,12 @@ import {
} from '../../../lib/services'; } from '../../../lib/services';
import { FeatureStrategySchema } from '../../../lib/openapi'; import { FeatureStrategySchema } from '../../../lib/openapi';
import User from '../../../lib/types/user'; import User from '../../../lib/types/user';
import { IConstraint, IVariant, SKIP_CHANGE_REQUEST } from '../../../lib/types'; import {
IConstraint,
IUnleashStores,
IVariant,
SKIP_CHANGE_REQUEST,
} from '../../../lib/types';
import EnvironmentService from '../../../lib/services/environment-service'; import EnvironmentService from '../../../lib/services/environment-service';
import { import {
ForbiddenError, ForbiddenError,
@ -20,7 +25,7 @@ import { ISegmentService } from '../../../lib/segments/segment-service-interface
import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model'; import { ChangeRequestAccessReadModel } from '../../../lib/features/change-request-access-service/sql-change-request-access-read-model';
import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let stores; let stores: IUnleashStores;
let db; let db;
let service: FeatureToggleService; let service: FeatureToggleService;
let segmentService: ISegmentService; let segmentService: ISegmentService;
@ -677,10 +682,13 @@ describe('flag name validation', () => {
name: projectId, name: projectId,
mode: 'open' as const, mode: 'open' as const,
defaultStickiness: 'default', defaultStickiness: 'default',
featureNaming,
}; };
await stores.projectStore.create(project); await stores.projectStore.create(project);
await stores.projectStore.updateProjectEnterpriseSettings({
id: projectId,
featureNaming,
});
const validFeatures = ['testpattern-feature', 'testpattern-feature2']; const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
const invalidFeatures = ['a', 'b', 'c']; const invalidFeatures = ['a', 'b', 'c'];

View File

@ -135,7 +135,6 @@ test('should create new project', async () => {
id: 'test', id: 'test',
name: 'New project', name: 'New project',
description: 'Blah', description: 'Blah',
mode: 'protected' as const,
defaultStickiness: 'default', defaultStickiness: 'default',
}; };
@ -145,7 +144,6 @@ test('should create new project', async () => {
expect(project.name).toEqual(ret.name); expect(project.name).toEqual(ret.name);
expect(project.description).toEqual(ret.description); expect(project.description).toEqual(ret.description);
expect(ret.createdAt).toBeTruthy(); expect(ret.createdAt).toBeTruthy();
expect(ret.mode).toEqual('protected');
}); });
test('should create new private project', async () => { test('should create new private project', async () => {
@ -153,7 +151,6 @@ test('should create new private project', async () => {
id: 'testPrivate', id: 'testPrivate',
name: 'New private project', name: 'New private project',
description: 'Blah', description: 'Blah',
mode: 'private' as const,
defaultStickiness: 'default', defaultStickiness: 'default',
}; };
@ -163,7 +160,6 @@ test('should create new private project', async () => {
expect(project.name).toEqual(ret.name); expect(project.name).toEqual(ret.name);
expect(project.description).toEqual(ret.description); expect(project.description).toEqual(ret.description);
expect(ret.createdAt).toBeTruthy(); expect(ret.createdAt).toBeTruthy();
expect(ret.mode).toEqual('private');
}); });
test('should delete project', async () => { test('should delete project', async () => {
@ -1829,12 +1825,14 @@ describe('feature flag naming patterns', () => {
await projectService.createProject(project, user.id); await projectService.createProject(project, user.id);
await projectService.updateProjectEnterpriseSettings(project, user);
expect( expect(
(await projectService.getProject(project.id)).featureNaming, (await projectService.getProject(project.id)).featureNaming,
).toMatchObject(featureNaming); ).toMatchObject(featureNaming);
const newPattern = 'new-pattern.+'; const newPattern = 'new-pattern.+';
await projectService.updateProject( await projectService.updateProjectEnterpriseSettings(
{ {
...project, ...project,
featureNaming: { pattern: newPattern }, featureNaming: { pattern: newPattern },

View File

@ -190,4 +190,9 @@ export default class FakeProjectStore implements IProjectStore {
getProjectModeCounts(): Promise<ProjectModeCount[]> { getProjectModeCounts(): Promise<ProjectModeCount[]> {
return Promise.resolve([]); return Promise.resolve([]);
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateProjectEnterpriseSettings(update: IProjectInsert): Promise<void> {
throw new Error('Method not implemented.');
}
} }