1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +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,
IProjectQuery,
IProjectSettings,
IProjectEnterpriseSettingsUpdate,
IProjectStore,
ProjectEnvironment,
} from '../types/stores/project-store';
@ -244,10 +245,6 @@ class ProjectStore implements IProjectStore {
project: project.id,
project_mode: project.mode,
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('*');
return this.mapRow({ ...row[0], ...settingsRow[0] });
@ -272,9 +269,30 @@ class ProjectStore implements IProjectStore {
await this.db(SETTINGS_TABLE)
.where({ project: data.id })
.update({
project_mode: data.mode,
default_stickiness: data.defaultStickiness,
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_example: data.featureNaming?.example,
feature_naming_description:
@ -284,15 +302,16 @@ class ProjectStore implements IProjectStore {
await this.db(SETTINGS_TABLE).insert({
project: data.id,
project_mode: data.mode,
default_stickiness: data.defaultStickiness,
feature_limit: data.featureLimit,
feature_naming_pattern: data.featureNaming?.pattern,
feature_naming_example: data.featureNaming?.example,
feature_naming_description: data.featureNaming?.description,
});
}
} 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',
});
for (const project of projects) {
await db.stores.projectStore.create({
const storedProject = {
name: project,
description: '',
id: project,
mode: 'open' as const,
featureLimit,
});
};
await db.stores.projectStore.create(storedProject);
await db.stores.projectStore.update(storedProject);
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,
// to prevent the pattern from blocking the creation.
await projectStore.update({
await projectStore.updateProjectEnterpriseSettings({
id: DEFAULT_PROJECT,
name: 'default',
description: '',
mode: 'open',
featureNaming: { pattern: 'testpattern.+' },
});
@ -996,6 +997,9 @@ test(`should give errors with flag names if the flags don't match the project pa
description: '',
id: project,
mode: 'open' as const,
});
await db.stores.projectStore.updateProjectEnterpriseSettings({
id: project,
featureNaming: { pattern },
});
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,
CreateProject,
} from '../types';
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import {
IProjectQuery,
IProjectEnterpriseSettingsUpdate,
IProjectStore,
} from '../types/stores/project-store';
import {
IProjectAccessModel,
IRoleDescriptor,
@ -83,7 +87,7 @@ interface ICalculateStatus {
}
export default class ProjectService {
private store: IProjectStore;
private projectStore: IProjectStore;
private accessService: AccessService;
@ -113,6 +117,8 @@ export default class ProjectService {
private flagResolver: IFlagResolver;
private isEnterprise: boolean;
constructor(
{
projectStore,
@ -141,7 +147,7 @@ export default class ProjectService {
favoriteService: FavoritesService,
privateProjectChecker: IPrivateProjectChecker,
) {
this.store = projectStore;
this.projectStore = projectStore;
this.environmentStore = environmentStore;
this.featureEnvironmentStore = featureEnvironmentStore;
this.accessService = accessService;
@ -156,13 +162,17 @@ export default class ProjectService {
this.projectStatsStore = projectStatsStore;
this.logger = config.getLogger('services/project-service.js');
this.flagResolver = config.flagResolver;
this.isEnterprise = config.isEnterprise;
}
async getProjects(
query?: IProjectQuery,
userId?: number,
): Promise<IProjectWithCount[]> {
const projects = await this.store.getProjectsWithCounts(query, userId);
const projects = await this.projectStore.getProjectsWithCounts(
query,
userId,
);
if (this.flagResolver.isEnabled('privateProjects') && userId) {
const projectAccess =
await this.privateProjectChecker.getUserAccessibleProjects(
@ -181,7 +191,7 @@ export default class ProjectService {
}
async getProject(id: string): Promise<IProject> {
return this.store.get(id);
return this.projectStore.get(id);
}
private validateAndProcessFeatureNamingPattern = (
@ -214,14 +224,11 @@ export default class ProjectService {
newProject: CreateProject,
user: IUser,
): Promise<IProject> {
const data = await projectSchema.validateAsync(newProject);
const validatedData = await projectSchema.validateAsync(newProject);
const data = this.removeModeForNonEnterprise(validatedData);
await this.validateUniqueId(data.id);
if (data.featureNaming) {
this.validateAndProcessFeatureNamingPattern(data.featureNaming);
}
await this.store.create(data);
await this.projectStore.create(data);
const enabledEnvironments = await this.environmentStore.getAll({
enabled: true,
@ -250,7 +257,24 @@ export default class ProjectService {
}
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) {
this.validateAndProcessFeatureNamingPattern(
@ -258,7 +282,7 @@ export default class ProjectService {
);
}
await this.store.update(updatedProject);
await this.projectStore.updateProjectEnterpriseSettings(updatedProject);
await this.eventStore.store({
type: PROJECT_UPDATED,
@ -276,7 +300,7 @@ export default class ProjectService {
const featureEnvs = await this.featureEnvironmentStore.getAll({
feature_name: feature.name,
});
const newEnvs = await this.store.getEnvironmentsForProject(
const newEnvs = await this.projectStore.getEnvironmentsForProject(
newProjectId,
);
return arraysHaveSameItems(
@ -289,7 +313,7 @@ export default class ProjectService {
project: string,
environment: string,
): Promise<void> {
await this.store.addEnvironmentToProject(project, environment);
await this.projectStore.addEnvironmentToProject(project, environment);
}
async changeProject(
@ -355,7 +379,7 @@ export default class ProjectService {
);
}
await this.store.delete(id);
await this.projectStore.delete(id);
await this.eventStore.store({
type: PROJECT_DELETED,
@ -373,7 +397,7 @@ export default class ProjectService {
}
async validateUniqueId(id: string): Promise<void> {
const exists = await this.store.hasProject(id);
const exists = await this.projectStore.hasProject(id);
if (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> {
return this.store.getMembersCountByProject(projectId);
return this.projectStore.getMembersCountByProject(projectId);
}
async getProjectUsers(
@ -903,7 +927,7 @@ export default class ProjectService {
}
async getProjectsByUser(userId: number): Promise<string[]> {
return this.store.getProjectsByUser(userId);
return this.projectStore.getProjectsByUser(userId);
}
async getProjectRoleUsage(roleId: number): Promise<IProjectRoleUsage[]> {
@ -911,7 +935,7 @@ export default class ProjectService {
}
async statusJob(): Promise<void> {
const projects = await this.store.getAll();
const projects = await this.projectStore.getAll();
const statusUpdates = await Promise.all(
projects.map((project) => this.getStatusUpdates(project.id)),
@ -990,7 +1014,7 @@ export default class ProjectService {
);
const projectMembersAddedCurrentWindow =
await this.store.getMembersCountByProjectAfterDate(
await this.projectStore.getMembersCountByProjectAfterDate(
projectId,
dateMinusThirtyDays,
);
@ -1023,14 +1047,14 @@ export default class ProjectService {
favorite,
projectStats,
] = await Promise.all([
this.store.get(projectId),
this.store.getEnvironmentsForProject(projectId),
this.projectStore.get(projectId),
this.projectStore.getEnvironmentsForProject(projectId),
this.featureToggleService.getFeatureOverview({
projectId,
archived,
userId,
}),
this.store.getMembersCountByProject(projectId),
this.projectStore.getMembersCountByProject(projectId),
userId
? this.favoritesService.isFavoriteProject({
project: projectId,
@ -1058,4 +1082,13 @@ export default class ProjectService {
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 {
id: string;
name: string;
description: string;
description?: string;
health?: number;
createdAt?: Date;
updatedAt?: Date;

View File

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

View File

@ -9,7 +9,12 @@ import {
} from '../../../lib/services';
import { FeatureStrategySchema } from '../../../lib/openapi';
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 {
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 { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker';
let stores;
let stores: IUnleashStores;
let db;
let service: FeatureToggleService;
let segmentService: ISegmentService;
@ -677,10 +682,13 @@ describe('flag name validation', () => {
name: projectId,
mode: 'open' as const,
defaultStickiness: 'default',
featureNaming,
};
await stores.projectStore.create(project);
await stores.projectStore.updateProjectEnterpriseSettings({
id: projectId,
featureNaming,
});
const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
const invalidFeatures = ['a', 'b', 'c'];

View File

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

View File

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