mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +02:00
1519 lines
48 KiB
TypeScript
1519 lines
48 KiB
TypeScript
import { subDays } from 'date-fns';
|
|
import { ValidationError } from 'joi';
|
|
import createSlug from 'slug';
|
|
import type { IAuditUser, IUser } from '../../types/user';
|
|
import type {
|
|
AccessService,
|
|
AccessWithRoles,
|
|
} from '../../services/access-service';
|
|
import NameExistsError from '../../error/name-exists-error';
|
|
import InvalidOperationError from '../../error/invalid-operation-error';
|
|
import { nameType } from '../../routes/util';
|
|
import { projectSchema } from '../../services/project-schema';
|
|
import NotFoundError from '../../error/notfound-error';
|
|
import {
|
|
ADMIN,
|
|
ADMIN_TOKEN_USER,
|
|
type CreateProject,
|
|
DEFAULT_PROJECT,
|
|
type FeatureToggle,
|
|
type IAccountStore,
|
|
type IEnvironmentStore,
|
|
type IEventStore,
|
|
type IFeatureEnvironmentStore,
|
|
type IFeatureNaming,
|
|
type IFeatureToggleStore,
|
|
type IFlagResolver,
|
|
type IProject,
|
|
type IProjectApplications,
|
|
type IProjectHealth,
|
|
type IProjectOverview,
|
|
type IProjectOwnersReadModel,
|
|
type IProjectRoleUsage,
|
|
type IProjectStore,
|
|
type IProjectUpdate,
|
|
type IUnleashConfig,
|
|
type IUnleashStores,
|
|
MOVE_FEATURE_TOGGLE,
|
|
ProjectAccessAddedEvent,
|
|
ProjectAccessGroupRolesUpdated,
|
|
ProjectAccessUserRolesDeleted,
|
|
ProjectAccessUserRolesUpdated,
|
|
ProjectArchivedEvent,
|
|
type ProjectCreated,
|
|
ProjectCreatedEvent,
|
|
ProjectDeletedEvent,
|
|
ProjectGroupAddedEvent,
|
|
ProjectGroupRemovedEvent,
|
|
ProjectGroupUpdateRoleEvent,
|
|
ProjectRevivedEvent,
|
|
ProjectUpdatedEvent,
|
|
ProjectUserRemovedEvent,
|
|
ProjectUserUpdateRoleEvent,
|
|
RoleName,
|
|
SYSTEM_USER_ID,
|
|
type IProjectReadModel,
|
|
type IOnboardingReadModel,
|
|
} from '../../types';
|
|
import type {
|
|
IProjectAccessModel,
|
|
IRoleDescriptor,
|
|
IRoleWithProject,
|
|
} from '../../types/stores/access-store';
|
|
import type FeatureToggleService from '../feature-toggle/feature-toggle-service';
|
|
import IncompatibleProjectError from '../../error/incompatible-project-error';
|
|
import { arraysHaveSameItems } from '../../util';
|
|
import type { GroupService } from '../../services/group-service';
|
|
import type { IGroupRole } from '../../types/group';
|
|
import type { FavoritesService } from '../../services/favorites-service';
|
|
import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production/time-to-production';
|
|
import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type';
|
|
import { uniqueByKey } from '../../util/unique';
|
|
import { BadDataError, PermissionError } from '../../error';
|
|
import type {
|
|
ProjectDoraMetricsSchema,
|
|
ResourceLimitsSchema,
|
|
} from '../../openapi';
|
|
import { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming-validation';
|
|
import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
|
|
import type EventService from '../events/event-service';
|
|
import type {
|
|
IProjectApplicationsSearchParams,
|
|
IProjectEnterpriseSettingsUpdate,
|
|
IProjectQuery,
|
|
IProjectsQuery,
|
|
} from './project-store-type';
|
|
import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read-model.type';
|
|
import { throwExceedsLimitError } from '../../error/exceeds-limit-error';
|
|
import type EventEmitter from 'events';
|
|
import type { ApiTokenService } from '../../services/api-token-service';
|
|
import type { ProjectForUi } from './project-read-model-type';
|
|
import { canGrantProjectRole } from './can-grant-project-role';
|
|
import { batchExecute } from '../../util/batchExecute';
|
|
import metricsHelper from '../../util/metrics-helper';
|
|
import { FUNCTION_TIME } from '../../metric-events';
|
|
|
|
type Days = number;
|
|
type Count = number;
|
|
|
|
export interface IProjectStats {
|
|
avgTimeToProdCurrentWindow: Days;
|
|
createdCurrentWindow: Count;
|
|
createdPastWindow: Count;
|
|
archivedCurrentWindow: Count;
|
|
archivedPastWindow: Count;
|
|
projectActivityCurrentWindow: Count;
|
|
projectActivityPastWindow: Count;
|
|
projectMembersAddedCurrentWindow: Count;
|
|
}
|
|
|
|
interface ICalculateStatus {
|
|
projectId: string;
|
|
updates: IProjectStats;
|
|
}
|
|
|
|
function includes(
|
|
list: number[],
|
|
{
|
|
id,
|
|
}: {
|
|
id: number;
|
|
},
|
|
): boolean {
|
|
return list.some((l) => l === id);
|
|
}
|
|
|
|
export default class ProjectService {
|
|
private projectStore: IProjectStore;
|
|
|
|
private projectOwnersReadModel: IProjectOwnersReadModel;
|
|
|
|
private projectFlagCreatorsReadModel: IProjectFlagCreatorsReadModel;
|
|
|
|
private accessService: AccessService;
|
|
|
|
private eventStore: IEventStore;
|
|
|
|
private featureToggleStore: IFeatureToggleStore;
|
|
|
|
private featureEnvironmentStore: IFeatureEnvironmentStore;
|
|
|
|
private environmentStore: IEnvironmentStore;
|
|
|
|
private groupService: GroupService;
|
|
|
|
private logger: any;
|
|
|
|
private featureToggleService: FeatureToggleService;
|
|
|
|
private privateProjectChecker: IPrivateProjectChecker;
|
|
|
|
private accountStore: IAccountStore;
|
|
|
|
private apiTokenService: ApiTokenService;
|
|
|
|
private favoritesService: FavoritesService;
|
|
|
|
private eventService: EventService;
|
|
|
|
private projectStatsStore: IProjectStatsStore;
|
|
|
|
private flagResolver: IFlagResolver;
|
|
|
|
private isEnterprise: boolean;
|
|
|
|
private resourceLimits: ResourceLimitsSchema;
|
|
|
|
private eventBus: EventEmitter;
|
|
|
|
private projectReadModel: IProjectReadModel;
|
|
|
|
private onboardingReadModel: IOnboardingReadModel;
|
|
|
|
private timer: Function;
|
|
|
|
constructor(
|
|
{
|
|
projectStore,
|
|
projectOwnersReadModel,
|
|
projectFlagCreatorsReadModel,
|
|
eventStore,
|
|
featureToggleStore,
|
|
environmentStore,
|
|
featureEnvironmentStore,
|
|
accountStore,
|
|
projectStatsStore,
|
|
projectReadModel,
|
|
onboardingReadModel,
|
|
}: Pick<
|
|
IUnleashStores,
|
|
| 'projectStore'
|
|
| 'projectOwnersReadModel'
|
|
| 'projectFlagCreatorsReadModel'
|
|
| 'eventStore'
|
|
| 'featureToggleStore'
|
|
| 'environmentStore'
|
|
| 'featureEnvironmentStore'
|
|
| 'accountStore'
|
|
| 'projectStatsStore'
|
|
| 'projectReadModel'
|
|
| 'onboardingReadModel'
|
|
>,
|
|
config: IUnleashConfig,
|
|
accessService: AccessService,
|
|
featureToggleService: FeatureToggleService,
|
|
groupService: GroupService,
|
|
favoriteService: FavoritesService,
|
|
eventService: EventService,
|
|
privateProjectChecker: IPrivateProjectChecker,
|
|
apiTokenService: ApiTokenService,
|
|
) {
|
|
this.projectStore = projectStore;
|
|
this.projectOwnersReadModel = projectOwnersReadModel;
|
|
this.projectFlagCreatorsReadModel = projectFlagCreatorsReadModel;
|
|
this.environmentStore = environmentStore;
|
|
this.featureEnvironmentStore = featureEnvironmentStore;
|
|
this.accessService = accessService;
|
|
this.eventStore = eventStore;
|
|
this.featureToggleStore = featureToggleStore;
|
|
this.apiTokenService = apiTokenService;
|
|
this.featureToggleService = featureToggleService;
|
|
this.favoritesService = favoriteService;
|
|
this.privateProjectChecker = privateProjectChecker;
|
|
this.accountStore = accountStore;
|
|
this.groupService = groupService;
|
|
this.eventService = eventService;
|
|
this.projectStatsStore = projectStatsStore;
|
|
this.logger = config.getLogger('services/project-service.js');
|
|
this.flagResolver = config.flagResolver;
|
|
this.isEnterprise = config.isEnterprise;
|
|
this.resourceLimits = config.resourceLimits;
|
|
this.eventBus = config.eventBus;
|
|
this.projectReadModel = projectReadModel;
|
|
this.onboardingReadModel = onboardingReadModel;
|
|
this.timer = (functionName: string) =>
|
|
metricsHelper.wrapTimer(config.eventBus, FUNCTION_TIME, {
|
|
className: 'ProjectService',
|
|
functionName,
|
|
});
|
|
}
|
|
|
|
async getProjects(
|
|
query?: IProjectQuery & IProjectsQuery,
|
|
userId?: number,
|
|
): Promise<ProjectForUi[]> {
|
|
const projects = await this.projectReadModel.getProjectsForAdminUi(
|
|
query,
|
|
userId,
|
|
);
|
|
|
|
if (userId) {
|
|
const projectAccess =
|
|
await this.privateProjectChecker.getUserAccessibleProjects(
|
|
userId,
|
|
);
|
|
|
|
if (projectAccess.mode === 'all') {
|
|
return projects;
|
|
} else {
|
|
return projects.filter((project) =>
|
|
projectAccess.projects.includes(project.id),
|
|
);
|
|
}
|
|
}
|
|
return projects;
|
|
}
|
|
|
|
async addOwnersToProjects(
|
|
projects: ProjectForUi[],
|
|
): Promise<ProjectForUi[]> {
|
|
return this.projectOwnersReadModel.addOwners(projects);
|
|
}
|
|
|
|
async getProject(id: string): Promise<IProject> {
|
|
const project = await this.projectStore.get(id);
|
|
if (project === undefined) {
|
|
throw new NotFoundError(`Could not find project with id ${id}`);
|
|
}
|
|
return Promise.resolve(project);
|
|
}
|
|
|
|
private validateAndProcessFeatureNamingPattern = (
|
|
featureNaming: IFeatureNaming,
|
|
): IFeatureNaming => {
|
|
const validationResult = checkFeatureNamingData(featureNaming);
|
|
|
|
if (validationResult.state === 'invalid') {
|
|
const [firstReason, ...remainingReasons] =
|
|
validationResult.reasons.map((message) => ({
|
|
message,
|
|
}));
|
|
throw new BadDataError(
|
|
'The feature naming pattern data you provided was invalid.',
|
|
[firstReason, ...remainingReasons],
|
|
);
|
|
}
|
|
|
|
if (featureNaming.pattern && !featureNaming.example) {
|
|
featureNaming.example = null;
|
|
}
|
|
if (featureNaming.pattern && !featureNaming.description) {
|
|
featureNaming.description = null;
|
|
}
|
|
|
|
return featureNaming;
|
|
};
|
|
|
|
private async validateEnvironmentsExist(environments: string[]) {
|
|
const projectsAndExistence = await Promise.all(
|
|
environments.map(async (env) => [
|
|
env,
|
|
await this.environmentStore.exists(env),
|
|
]),
|
|
);
|
|
|
|
const invalidEnvs = projectsAndExistence
|
|
.filter(([_, exists]) => !exists)
|
|
.map(([env]) => env);
|
|
|
|
if (invalidEnvs.length > 0) {
|
|
throw new BadDataError(
|
|
`These environments do not exist: ${invalidEnvs
|
|
.map((env) => `'${env}'`)
|
|
.join(', ')}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async validateProjectEnvironments(environments: string[] | undefined) {
|
|
if (environments) {
|
|
await this.validateEnvironmentsExist(environments);
|
|
}
|
|
}
|
|
|
|
async validateProjectLimit() {
|
|
const limit = Math.max(this.resourceLimits.projects, 1);
|
|
const projectCount = await this.projectStore.count();
|
|
|
|
if (projectCount >= limit) {
|
|
throwExceedsLimitError(this.eventBus, {
|
|
resource: 'project',
|
|
limit,
|
|
});
|
|
}
|
|
}
|
|
|
|
async generateProjectId(name: string): Promise<string> {
|
|
const slug = createSlug(name).slice(0, 90);
|
|
const generateUniqueId = async (suffix?: number) => {
|
|
const id = suffix ? `${slug}-${suffix}` : slug;
|
|
if (await this.projectStore.hasProject(id)) {
|
|
return await generateUniqueId((suffix ?? 0) + 1);
|
|
} else {
|
|
return id;
|
|
}
|
|
};
|
|
return generateUniqueId();
|
|
}
|
|
|
|
async getAllChangeRequestEnvironments(
|
|
newProject: CreateProject,
|
|
): Promise<CreateProject['changeRequestEnvironments']> {
|
|
const predefinedChangeRequestEnvironments =
|
|
await this.environmentStore.getChangeRequestEnvironments(
|
|
newProject.environments || [],
|
|
);
|
|
const userSelectedChangeRequestEnvironments =
|
|
newProject.changeRequestEnvironments || [];
|
|
const allChangeRequestEnvironments = [
|
|
...userSelectedChangeRequestEnvironments.filter(
|
|
(userEnv) =>
|
|
!predefinedChangeRequestEnvironments.find(
|
|
(predefinedEnv) => predefinedEnv.name === userEnv.name,
|
|
),
|
|
),
|
|
...predefinedChangeRequestEnvironments,
|
|
];
|
|
return allChangeRequestEnvironments;
|
|
}
|
|
|
|
async createProject(
|
|
newProject: CreateProject,
|
|
user: IUser,
|
|
auditUser: IAuditUser,
|
|
enableChangeRequestsForSpecifiedEnvironments: (
|
|
environments: CreateProject['changeRequestEnvironments'],
|
|
) => Promise<
|
|
ProjectCreated['changeRequestEnvironments']
|
|
> = async () => {
|
|
return [];
|
|
},
|
|
): Promise<ProjectCreated> {
|
|
await this.validateProjectLimit();
|
|
|
|
const validateData = async () => {
|
|
await this.validateProjectEnvironments(newProject.environments);
|
|
|
|
if (!newProject.id?.trim()) {
|
|
newProject.id = await this.generateProjectId(newProject.name);
|
|
return await projectSchema.validateAsync(newProject);
|
|
} else {
|
|
const validatedData =
|
|
await projectSchema.validateAsync(newProject);
|
|
await this.validateUniqueId(validatedData.id);
|
|
return validatedData;
|
|
}
|
|
};
|
|
|
|
const validatedData = await validateData();
|
|
const data = this.removePropertiesForNonEnterprise(validatedData);
|
|
|
|
await this.projectStore.create(data);
|
|
|
|
const envsToEnable = newProject.environments
|
|
? newProject.environments
|
|
: (
|
|
await this.environmentStore.getAll({
|
|
enabled: true,
|
|
})
|
|
).map((env) => env.name);
|
|
|
|
await Promise.all(
|
|
envsToEnable.map(async (env) => {
|
|
await this.featureEnvironmentStore.connectProject(env, data.id);
|
|
}),
|
|
);
|
|
|
|
if (this.isEnterprise) {
|
|
if (newProject.changeRequestEnvironments) {
|
|
await this.validateEnvironmentsExist(
|
|
newProject.changeRequestEnvironments.map((env) => env.name),
|
|
);
|
|
const globalChangeRequestConfigEnabled =
|
|
this.flagResolver.isEnabled('globalChangeRequestConfig');
|
|
if (globalChangeRequestConfigEnabled) {
|
|
const allChangeRequestEnvironments =
|
|
await this.getAllChangeRequestEnvironments(newProject);
|
|
const changeRequestEnvironments =
|
|
await enableChangeRequestsForSpecifiedEnvironments(
|
|
allChangeRequestEnvironments,
|
|
);
|
|
|
|
data.changeRequestEnvironments = changeRequestEnvironments;
|
|
} else {
|
|
const changeRequestEnvironments =
|
|
await enableChangeRequestsForSpecifiedEnvironments(
|
|
newProject.changeRequestEnvironments,
|
|
);
|
|
|
|
data.changeRequestEnvironments = changeRequestEnvironments;
|
|
}
|
|
} else {
|
|
data.changeRequestEnvironments = [];
|
|
}
|
|
}
|
|
|
|
await this.accessService.createDefaultProjectRoles(user, data.id);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectCreatedEvent({
|
|
data,
|
|
project: data.id,
|
|
auditUser,
|
|
}),
|
|
);
|
|
|
|
return { ...data, environments: envsToEnable };
|
|
}
|
|
|
|
async updateProject(
|
|
updatedProject: IProjectUpdate,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const preData = await this.projectStore.get(updatedProject.id);
|
|
|
|
await this.projectStore.update(updatedProject);
|
|
|
|
// updated project contains instructions to update the project but it may not represent a whole project
|
|
const afterData = await this.projectStore.get(updatedProject.id);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectUpdatedEvent({
|
|
project: updatedProject.id,
|
|
data: afterData,
|
|
preData,
|
|
auditUser,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async updateProjectEnterpriseSettings(
|
|
updatedProject: IProjectEnterpriseSettingsUpdate,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const preData = await this.projectStore.get(updatedProject.id);
|
|
|
|
if (updatedProject.featureNaming) {
|
|
this.validateAndProcessFeatureNamingPattern(
|
|
updatedProject.featureNaming,
|
|
);
|
|
}
|
|
|
|
await this.projectStore.updateProjectEnterpriseSettings(updatedProject);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectUpdatedEvent({
|
|
project: updatedProject.id,
|
|
data: { ...preData, ...updatedProject },
|
|
preData,
|
|
auditUser,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async checkProjectsCompatibility(
|
|
feature: FeatureToggle,
|
|
newProjectId: string,
|
|
): Promise<boolean> {
|
|
const featureEnvs = await this.featureEnvironmentStore.getAll({
|
|
feature_name: feature.name,
|
|
});
|
|
const newEnvs =
|
|
await this.projectStore.getEnvironmentsForProject(newProjectId);
|
|
return arraysHaveSameItems(
|
|
featureEnvs.map((env) => env.environment),
|
|
newEnvs.map((projectEnv) => projectEnv.environment),
|
|
);
|
|
}
|
|
|
|
async addEnvironmentToProject(
|
|
project: string,
|
|
environment: string,
|
|
): Promise<void> {
|
|
await this.projectStore.addEnvironmentToProject(project, environment);
|
|
}
|
|
|
|
private async validateActiveProject(projectId: string) {
|
|
const hasActiveProject =
|
|
await this.projectStore.hasActiveProject(projectId);
|
|
if (!hasActiveProject) {
|
|
throw new NotFoundError(
|
|
`Active project with id ${projectId} does not exist`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async changeProject(
|
|
newProjectId: string,
|
|
featureName: string,
|
|
user: IUser,
|
|
currentProjectId: string,
|
|
auditUser: IAuditUser,
|
|
): Promise<any> {
|
|
const feature = await this.featureToggleStore.get(featureName);
|
|
if (feature === undefined) {
|
|
throw new NotFoundError(`Could not find feature ${featureName}`);
|
|
}
|
|
if (feature.project !== currentProjectId) {
|
|
throw new PermissionError(MOVE_FEATURE_TOGGLE);
|
|
}
|
|
|
|
await this.validateActiveProject(newProjectId);
|
|
|
|
const authorized = await this.accessService.hasPermission(
|
|
user,
|
|
MOVE_FEATURE_TOGGLE,
|
|
newProjectId,
|
|
);
|
|
|
|
if (!authorized) {
|
|
throw new PermissionError(MOVE_FEATURE_TOGGLE);
|
|
}
|
|
|
|
const isCompatibleWithTargetProject =
|
|
await this.checkProjectsCompatibility(feature, newProjectId);
|
|
if (!isCompatibleWithTargetProject) {
|
|
throw new IncompatibleProjectError(newProjectId);
|
|
}
|
|
const updatedFeature = await this.featureToggleService.changeProject(
|
|
featureName,
|
|
newProjectId,
|
|
auditUser,
|
|
);
|
|
await this.featureToggleService.updateFeatureStrategyProject(
|
|
featureName,
|
|
newProjectId,
|
|
);
|
|
|
|
return updatedFeature;
|
|
}
|
|
|
|
async deleteProject(
|
|
id: string,
|
|
user: IUser,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
if (id === DEFAULT_PROJECT) {
|
|
throw new InvalidOperationError(
|
|
'You can not delete the default project!',
|
|
);
|
|
}
|
|
|
|
const flags = await this.featureToggleStore.getAll({
|
|
project: id,
|
|
archived: false,
|
|
});
|
|
|
|
if (flags.length > 0) {
|
|
throw new InvalidOperationError(
|
|
'You can not delete a project with active feature flags',
|
|
);
|
|
}
|
|
|
|
const archivedFlags = await this.featureToggleStore.getAll({
|
|
project: id,
|
|
archived: true,
|
|
});
|
|
|
|
await this.featureToggleService.deleteFeatures(
|
|
archivedFlags.map((flag) => flag.name),
|
|
id,
|
|
auditUser,
|
|
);
|
|
|
|
const allTokens = await this.apiTokenService.getAllTokens();
|
|
const projectTokens = allTokens.filter(
|
|
(token) =>
|
|
(token.projects &&
|
|
token.projects.length === 1 &&
|
|
token.projects[0] === id) ||
|
|
token.project === id,
|
|
);
|
|
|
|
await this.projectStore.delete(id);
|
|
|
|
await Promise.all(
|
|
projectTokens.map((token) =>
|
|
this.apiTokenService.delete(token.secret, auditUser),
|
|
),
|
|
);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectDeletedEvent({
|
|
project: id,
|
|
auditUser,
|
|
}),
|
|
);
|
|
|
|
await this.accessService.removeDefaultProjectRoles(user, id);
|
|
}
|
|
|
|
async archiveProject(id: string, auditUser: IAuditUser): Promise<void> {
|
|
const flags = await this.featureToggleStore.getAll({
|
|
project: id,
|
|
archived: false,
|
|
});
|
|
|
|
// TODO: allow archiving project with unused flags
|
|
|
|
if (flags.length > 0) {
|
|
throw new InvalidOperationError(
|
|
'You can not archive a project with active feature flags',
|
|
);
|
|
}
|
|
|
|
await this.projectStore.archive(id);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectArchivedEvent({
|
|
project: id,
|
|
auditUser,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async reviveProject(id: string, auditUser: IAuditUser): Promise<void> {
|
|
await this.validateProjectLimit();
|
|
|
|
await this.projectStore.revive(id);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectRevivedEvent({
|
|
project: id,
|
|
auditUser,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async validateId(id: string): Promise<boolean> {
|
|
await nameType.validateAsync(id);
|
|
await this.validateUniqueId(id);
|
|
return true;
|
|
}
|
|
|
|
async validateUniqueId(id: string): Promise<void> {
|
|
const exists = await this.projectStore.hasProject(id);
|
|
if (exists) {
|
|
throw new NameExistsError('A project with this id already exists.');
|
|
}
|
|
}
|
|
|
|
// RBAC methods
|
|
async getAccessToProject(projectId: string): Promise<AccessWithRoles> {
|
|
return this.accessService.getProjectRoleAccess(projectId);
|
|
}
|
|
|
|
/**
|
|
* @deprecated use removeUserAccess
|
|
*/
|
|
async removeUser(
|
|
projectId: string,
|
|
roleId: number,
|
|
userId: number,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const role = await this.findProjectRole(projectId, roleId);
|
|
|
|
await this.accessService.removeUserFromRole(userId, role.id, projectId);
|
|
|
|
const user = await this.accountStore.get(userId);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectUserRemovedEvent({
|
|
project: projectId,
|
|
auditUser,
|
|
preData: {
|
|
roleId,
|
|
userId,
|
|
roleName: role.name,
|
|
email: user?.email,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async removeUserAccess(
|
|
projectId: string,
|
|
userId: number,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const existingRoles = await this.accessService.getProjectRolesForUser(
|
|
projectId,
|
|
userId,
|
|
);
|
|
|
|
await this.accessService.removeUserAccess(projectId, userId);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectAccessUserRolesDeleted({
|
|
project: projectId,
|
|
auditUser,
|
|
preData: {
|
|
roles: existingRoles,
|
|
userId,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async removeGroupAccess(
|
|
projectId: string,
|
|
groupId: number,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const existingRoles = await this.accessService.getProjectRolesForGroup(
|
|
projectId,
|
|
groupId,
|
|
);
|
|
|
|
await this.accessService.removeGroupAccess(projectId, groupId);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectAccessUserRolesDeleted({
|
|
project: projectId,
|
|
auditUser,
|
|
preData: {
|
|
roles: existingRoles,
|
|
groupId,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async addGroup(
|
|
projectId: string,
|
|
roleId: number,
|
|
groupId: number,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const role = await this.accessService.getRole(roleId);
|
|
const group = await this.groupService.getGroup(groupId);
|
|
const project = await this.getProject(projectId);
|
|
if (group.id == null)
|
|
throw new ValidationError(
|
|
'Unexpected empty group id',
|
|
[],
|
|
undefined,
|
|
);
|
|
|
|
await this.accessService.addGroupToRole(
|
|
group.id,
|
|
role.id,
|
|
auditUser.username,
|
|
project.id,
|
|
);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectGroupAddedEvent({
|
|
project: project.id,
|
|
auditUser,
|
|
data: {
|
|
groupId: group.id,
|
|
projectId: project.id,
|
|
roleName: role.name,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @deprecated use removeGroupAccess
|
|
*/
|
|
async removeGroup(
|
|
projectId: string,
|
|
roleId: number,
|
|
groupId: number,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const group = await this.groupService.getGroup(groupId);
|
|
const role = await this.accessService.getRole(roleId);
|
|
const project = await this.getProject(projectId);
|
|
if (group.id == null)
|
|
throw new ValidationError(
|
|
'Unexpected empty group id',
|
|
[],
|
|
undefined,
|
|
);
|
|
|
|
await this.accessService.removeGroupFromRole(
|
|
group.id,
|
|
role.id,
|
|
project.id,
|
|
);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectGroupRemovedEvent({
|
|
project: projectId,
|
|
auditUser,
|
|
preData: {
|
|
groupId: group.id,
|
|
projectId: project.id,
|
|
roleName: role.name,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async addRoleAccess(
|
|
projectId: string,
|
|
roleId: number,
|
|
usersAndGroups: IProjectAccessModel,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
await this.accessService.addRoleAccessToProject(
|
|
usersAndGroups.users,
|
|
usersAndGroups.groups,
|
|
projectId,
|
|
roleId,
|
|
auditUser,
|
|
);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectAccessAddedEvent({
|
|
project: projectId,
|
|
auditUser,
|
|
data: {
|
|
roles: {
|
|
roleId,
|
|
groupIds: usersAndGroups.groups.map(({ id }) => id),
|
|
userIds: usersAndGroups.users.map(({ id }) => id),
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
private isAdmin(userId: number, roles: IRoleWithProject[]): boolean {
|
|
return (
|
|
userId === SYSTEM_USER_ID ||
|
|
userId === ADMIN_TOKEN_USER.id ||
|
|
roles.some((r) => r.name === RoleName.ADMIN)
|
|
);
|
|
}
|
|
|
|
private isProjectOwner(
|
|
roles: IRoleWithProject[],
|
|
project: string,
|
|
): boolean {
|
|
return roles.some(
|
|
(r) => r.project === project && r.name === RoleName.OWNER,
|
|
);
|
|
}
|
|
|
|
private async isAllowedToAddAccess(
|
|
userAddingAccess: IAuditUser,
|
|
projectId: string,
|
|
rolesBeingAdded: number[],
|
|
): Promise<boolean> {
|
|
const userPermissions =
|
|
await this.accessService.getPermissionsForUser(userAddingAccess);
|
|
if (userPermissions.some(({ permission }) => permission === ADMIN)) {
|
|
return true;
|
|
}
|
|
const userRoles = await this.accessService.getAllProjectRolesForUser(
|
|
userAddingAccess.id,
|
|
projectId,
|
|
);
|
|
|
|
if (
|
|
this.isAdmin(userAddingAccess.id, userRoles) ||
|
|
this.isProjectOwner(userRoles, projectId)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Users may have access to multiple projects, so we need to filter out the permissions based on this project.
|
|
// Since the project roles are just collections of permissions that are not tied to a project in the database
|
|
// not filtering here might lead to false positives as they may have the permission in another project.
|
|
if (this.flagResolver.isEnabled('projectRoleAssignment')) {
|
|
const filteredUserPermissions = userPermissions.filter(
|
|
(permission) => permission.project === projectId,
|
|
);
|
|
|
|
const rolesToBeAssignedData = await Promise.all(
|
|
rolesBeingAdded.map((role) => this.accessService.getRole(role)),
|
|
);
|
|
const rolesToBeAssignedPermissions = rolesToBeAssignedData.flatMap(
|
|
(role) => role.permissions,
|
|
);
|
|
|
|
return canGrantProjectRole(
|
|
filteredUserPermissions,
|
|
rolesToBeAssignedPermissions,
|
|
);
|
|
} else {
|
|
return rolesBeingAdded.every((roleId) =>
|
|
userRoles.some((userRole) => userRole.id === roleId),
|
|
);
|
|
}
|
|
}
|
|
|
|
async addAccess(
|
|
projectId: string,
|
|
roles: number[],
|
|
groups: number[],
|
|
users: number[],
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
if (await this.isAllowedToAddAccess(auditUser, projectId, roles)) {
|
|
await this.accessService.addAccessToProject(
|
|
roles,
|
|
groups,
|
|
users,
|
|
projectId,
|
|
auditUser.username,
|
|
);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectAccessAddedEvent({
|
|
project: projectId,
|
|
auditUser,
|
|
data: {
|
|
roles: roles.map((roleId) => {
|
|
return {
|
|
roleId,
|
|
groupIds: groups,
|
|
userIds: users,
|
|
};
|
|
}),
|
|
},
|
|
}),
|
|
);
|
|
} else {
|
|
throw new InvalidOperationError(
|
|
'User tried to grant role they did not have access to',
|
|
);
|
|
}
|
|
}
|
|
|
|
async setRolesForUser(
|
|
projectId: string,
|
|
userId: number,
|
|
newRoles: number[],
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const currentRoles = await this.accessService.getProjectRolesForUser(
|
|
projectId,
|
|
userId,
|
|
);
|
|
const isAllowedToAssignRoles = await this.isAllowedToAddAccess(
|
|
auditUser,
|
|
projectId,
|
|
newRoles,
|
|
);
|
|
if (isAllowedToAssignRoles) {
|
|
await this.accessService.setProjectRolesForUser(
|
|
projectId,
|
|
userId,
|
|
newRoles,
|
|
);
|
|
await this.eventService.storeEvent(
|
|
new ProjectAccessUserRolesUpdated({
|
|
project: projectId,
|
|
auditUser,
|
|
data: {
|
|
roles: newRoles,
|
|
userId,
|
|
},
|
|
preData: {
|
|
roles: currentRoles,
|
|
userId,
|
|
},
|
|
}),
|
|
);
|
|
} else {
|
|
throw new InvalidOperationError(
|
|
'User tried to assign a role they did not have access to',
|
|
);
|
|
}
|
|
}
|
|
|
|
async setRolesForGroup(
|
|
projectId: string,
|
|
groupId: number,
|
|
newRoles: number[],
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const currentRoles = await this.accessService.getProjectRolesForGroup(
|
|
projectId,
|
|
groupId,
|
|
);
|
|
|
|
const isAllowedToAssignRoles = await this.isAllowedToAddAccess(
|
|
auditUser,
|
|
projectId,
|
|
newRoles,
|
|
);
|
|
if (isAllowedToAssignRoles) {
|
|
await this.accessService.setProjectRolesForGroup(
|
|
projectId,
|
|
groupId,
|
|
newRoles,
|
|
auditUser.username,
|
|
);
|
|
await this.eventService.storeEvent(
|
|
new ProjectAccessGroupRolesUpdated({
|
|
project: projectId,
|
|
auditUser,
|
|
data: {
|
|
roles: newRoles,
|
|
groupId,
|
|
},
|
|
preData: {
|
|
roles: currentRoles,
|
|
groupId,
|
|
},
|
|
}),
|
|
);
|
|
} else {
|
|
throw new InvalidOperationError(
|
|
'User tried to assign a role they did not have access to',
|
|
);
|
|
}
|
|
}
|
|
|
|
async findProjectGroupRole(
|
|
projectId: string,
|
|
roleId: number,
|
|
): Promise<IGroupRole> {
|
|
const roles = await this.groupService.getRolesForProject(projectId);
|
|
const role = roles.find((r) => r.roleId === roleId);
|
|
if (!role) {
|
|
throw new NotFoundError(
|
|
`Couldn't find roleId=${roleId} on project=${projectId}`,
|
|
);
|
|
}
|
|
return role;
|
|
}
|
|
|
|
async findProjectRole(
|
|
projectId: string,
|
|
roleId: number,
|
|
): Promise<IRoleDescriptor> {
|
|
const roles = await this.accessService.getRolesForProject(projectId);
|
|
const role = roles.find((r) => r.id === roleId);
|
|
if (!role) {
|
|
throw new NotFoundError(
|
|
`Couldn't find roleId=${roleId} on project=${projectId}`,
|
|
);
|
|
}
|
|
return role;
|
|
}
|
|
|
|
/** @deprecated use projectInsightsService instead */
|
|
async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
|
|
const activeFeatureFlags = (
|
|
await this.featureToggleStore.getAll({ project: projectId })
|
|
).map((feature) => feature.name);
|
|
|
|
const archivedFeatureFlags = (
|
|
await this.featureToggleStore.getAll({
|
|
project: projectId,
|
|
archived: true,
|
|
})
|
|
).map((feature) => feature.name);
|
|
|
|
const featureFlagNames = [
|
|
...activeFeatureFlags,
|
|
...archivedFeatureFlags,
|
|
];
|
|
|
|
const projectAverage = calculateAverageTimeToProd(
|
|
await this.projectStatsStore.getTimeToProdDates(projectId),
|
|
);
|
|
|
|
const flagAverage =
|
|
await this.projectStatsStore.getTimeToProdDatesForFeatureToggles(
|
|
projectId,
|
|
featureFlagNames,
|
|
);
|
|
|
|
return {
|
|
features: flagAverage,
|
|
projectAverage: projectAverage,
|
|
};
|
|
}
|
|
|
|
async getApplications(
|
|
searchParams: IProjectApplicationsSearchParams,
|
|
): Promise<IProjectApplications> {
|
|
const applications = await this.projectStore.getApplicationsByProject({
|
|
...searchParams,
|
|
sortBy: searchParams.sortBy || 'appName',
|
|
});
|
|
return applications;
|
|
}
|
|
|
|
async getProjectFlagCreators(projectId: string) {
|
|
return this.projectFlagCreatorsReadModel.getFlagCreators(projectId);
|
|
}
|
|
|
|
async changeRole(
|
|
projectId: string,
|
|
roleId: number,
|
|
userId: number,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const usersWithRoles = await this.getAccessToProject(projectId);
|
|
const user = usersWithRoles.users.find((u) => u.id === userId);
|
|
if (!user)
|
|
throw new ValidationError('Unexpected empty user', [], undefined);
|
|
|
|
const currentRole = usersWithRoles.roles.find(
|
|
(r) => r.id === user.roleId,
|
|
);
|
|
if (!currentRole)
|
|
throw new ValidationError(
|
|
'Unexpected empty current role',
|
|
[],
|
|
undefined,
|
|
);
|
|
|
|
if (currentRole.id === roleId) {
|
|
// Nothing to do....
|
|
return;
|
|
}
|
|
|
|
await this.accessService.updateUserProjectRole(
|
|
userId,
|
|
roleId,
|
|
projectId,
|
|
);
|
|
const role = await this.findProjectRole(projectId, roleId);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectUserUpdateRoleEvent({
|
|
project: projectId,
|
|
auditUser,
|
|
preData: {
|
|
userId,
|
|
roleId: currentRole.id,
|
|
roleName: currentRole.name,
|
|
email: user.email,
|
|
},
|
|
data: {
|
|
userId,
|
|
roleId,
|
|
roleName: role.name,
|
|
email: user.email,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async changeGroupRole(
|
|
projectId: string,
|
|
roleId: number,
|
|
userId: number,
|
|
auditUser: IAuditUser,
|
|
): Promise<void> {
|
|
const usersWithRoles = await this.getAccessToProject(projectId);
|
|
const userGroup = usersWithRoles.groups.find((u) => u.id === userId);
|
|
if (!userGroup)
|
|
throw new ValidationError('Unexpected empty user', [], undefined);
|
|
const currentRole = usersWithRoles.roles.find((r) =>
|
|
userGroup.roles?.includes(r.id),
|
|
);
|
|
if (!currentRole)
|
|
throw new ValidationError(
|
|
'Unexpected empty current role',
|
|
[],
|
|
undefined,
|
|
);
|
|
|
|
if (currentRole.id === roleId) {
|
|
// Nothing to do....
|
|
return;
|
|
}
|
|
|
|
await this.accessService.updateGroupProjectRole(
|
|
userId,
|
|
roleId,
|
|
projectId,
|
|
);
|
|
const role = await this.findProjectGroupRole(projectId, roleId);
|
|
|
|
await this.eventService.storeEvent(
|
|
new ProjectGroupUpdateRoleEvent({
|
|
project: projectId,
|
|
auditUser,
|
|
preData: {
|
|
userId,
|
|
roleId: currentRole.id,
|
|
roleName: currentRole.name,
|
|
},
|
|
data: {
|
|
userId,
|
|
roleId,
|
|
roleName: role.name,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async getMembers(projectId: string): Promise<number> {
|
|
return this.projectStore.getMembersCountByProject(projectId);
|
|
}
|
|
|
|
async getProjectUsers(
|
|
projectId: string,
|
|
): Promise<Array<Pick<IUser, 'id' | 'email' | 'username'>>> {
|
|
const { groups, users } =
|
|
await this.accessService.getProjectRoleAccess(projectId);
|
|
const actualUsers = users.map((user) => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
}));
|
|
const actualGroupUsers = groups
|
|
.flatMap((group) => group.users)
|
|
.map((user) => user.user)
|
|
.map((user) => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
}));
|
|
return uniqueByKey([...actualUsers, ...actualGroupUsers], 'id');
|
|
}
|
|
|
|
async isProjectUser(userId: number, projectId: string): Promise<boolean> {
|
|
const users = await this.getProjectUsers(projectId);
|
|
return Boolean(users.find((user) => user.id === userId));
|
|
}
|
|
|
|
async getProjectsByUser(userId: number): Promise<string[]> {
|
|
return this.projectReadModel.getProjectsByUser(userId);
|
|
}
|
|
|
|
async getProjectRoleUsage(roleId: number): Promise<IProjectRoleUsage[]> {
|
|
return this.accessService.getProjectRoleUsage(roleId);
|
|
}
|
|
|
|
async statusJob(): Promise<void> {
|
|
const projects = await this.projectStore.getAll();
|
|
|
|
// run one project status update at a time every
|
|
void batchExecute(projects, 1, 30_000, async (project) => {
|
|
const statusUpdate = await this.getStatusUpdates(project.id);
|
|
await this.projectStatsStore.updateProjectStats(
|
|
statusUpdate.projectId,
|
|
statusUpdate.updates,
|
|
);
|
|
});
|
|
}
|
|
|
|
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
|
|
const stopTimer = this.timer('getStatusUpdates');
|
|
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
|
|
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
|
|
|
|
const [
|
|
createdCurrentWindow,
|
|
createdPastWindow,
|
|
archivedCurrentWindow,
|
|
archivedPastWindow,
|
|
] = await Promise.all([
|
|
await this.featureToggleStore.countByDate({
|
|
project: projectId,
|
|
dateAccessor: 'created_at',
|
|
date: dateMinusThirtyDays,
|
|
}),
|
|
await this.featureToggleStore.countByDate({
|
|
project: projectId,
|
|
dateAccessor: 'created_at',
|
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
|
}),
|
|
await this.featureToggleStore.countByDate({
|
|
project: projectId,
|
|
archived: true,
|
|
dateAccessor: 'archived_at',
|
|
date: dateMinusThirtyDays,
|
|
}),
|
|
await this.featureToggleStore.countByDate({
|
|
project: projectId,
|
|
archived: true,
|
|
dateAccessor: 'archived_at',
|
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
|
}),
|
|
]);
|
|
|
|
const [projectActivityCurrentWindow, projectActivityPastWindow] =
|
|
await Promise.all([
|
|
this.eventStore.queryCount([
|
|
{
|
|
op: 'where',
|
|
parameters: { project: projectId },
|
|
},
|
|
{
|
|
op: 'beforeDate',
|
|
parameters: {
|
|
dateAccessor: 'created_at',
|
|
date: dateMinusThirtyDays,
|
|
},
|
|
},
|
|
]),
|
|
this.eventStore.queryCount([
|
|
{
|
|
op: 'where',
|
|
parameters: { project: projectId },
|
|
},
|
|
{
|
|
op: 'betweenDate',
|
|
parameters: {
|
|
dateAccessor: 'created_at',
|
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
|
},
|
|
},
|
|
]),
|
|
]);
|
|
|
|
const avgTimeToProdCurrentWindow = calculateAverageTimeToProd(
|
|
await this.projectStatsStore.getTimeToProdDates(projectId),
|
|
);
|
|
|
|
const projectMembersAddedCurrentWindow =
|
|
await this.projectStore.getMembersCountByProjectAfterDate(
|
|
projectId,
|
|
dateMinusThirtyDays,
|
|
);
|
|
|
|
stopTimer();
|
|
return {
|
|
projectId,
|
|
updates: {
|
|
avgTimeToProdCurrentWindow,
|
|
createdCurrentWindow,
|
|
createdPastWindow,
|
|
archivedCurrentWindow,
|
|
archivedPastWindow,
|
|
projectActivityCurrentWindow,
|
|
projectActivityPastWindow,
|
|
projectMembersAddedCurrentWindow,
|
|
},
|
|
};
|
|
}
|
|
|
|
async getProjectHealth(
|
|
projectId: string,
|
|
archived: boolean = false,
|
|
userId?: number,
|
|
): Promise<IProjectHealth> {
|
|
const [
|
|
project,
|
|
environments,
|
|
features,
|
|
members,
|
|
favorite,
|
|
projectStats,
|
|
] = await Promise.all([
|
|
this.projectStore.get(projectId),
|
|
this.projectStore.getEnvironmentsForProject(projectId),
|
|
this.featureToggleService.getFeatureOverview({
|
|
projectId,
|
|
archived,
|
|
userId,
|
|
}),
|
|
this.projectStore.getMembersCountByProject(projectId),
|
|
userId
|
|
? this.favoritesService.isFavoriteProject({
|
|
project: projectId,
|
|
userId,
|
|
})
|
|
: Promise.resolve(false),
|
|
this.projectStatsStore.getProjectStats(projectId),
|
|
]);
|
|
if (project === undefined) {
|
|
throw new NotFoundError(
|
|
`Could not find project with id ${projectId}`,
|
|
);
|
|
}
|
|
return {
|
|
stats: projectStats,
|
|
name: project.name,
|
|
description: project.description!,
|
|
mode: project.mode,
|
|
featureLimit: project.featureLimit,
|
|
featureNaming: project.featureNaming,
|
|
defaultStickiness: project.defaultStickiness,
|
|
health: project.health || 0,
|
|
favorite: favorite,
|
|
updatedAt: project.updatedAt,
|
|
createdAt: project.createdAt,
|
|
environments,
|
|
features: features,
|
|
members,
|
|
version: 1,
|
|
};
|
|
}
|
|
|
|
async getProjectOverview(
|
|
projectId: string,
|
|
archived: boolean = false,
|
|
userId?: number,
|
|
): Promise<IProjectOverview> {
|
|
const [
|
|
project,
|
|
environments,
|
|
featureTypeCounts,
|
|
members,
|
|
favorite,
|
|
projectStats,
|
|
onboardingStatus,
|
|
] = await Promise.all([
|
|
this.projectStore.get(projectId),
|
|
this.projectStore.getEnvironmentsForProject(projectId),
|
|
this.featureToggleService.getFeatureTypeCounts({
|
|
projectId,
|
|
archived,
|
|
userId,
|
|
}),
|
|
this.projectStore.getMembersCountByProject(projectId),
|
|
userId
|
|
? this.favoritesService.isFavoriteProject({
|
|
project: projectId,
|
|
userId,
|
|
})
|
|
: Promise.resolve(false),
|
|
this.projectStatsStore.getProjectStats(projectId),
|
|
this.onboardingReadModel.getOnboardingStatusForProject(projectId),
|
|
]);
|
|
|
|
if (project === undefined) {
|
|
throw new NotFoundError(
|
|
`Could not find project with id: ${projectId}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
stats: projectStats,
|
|
name: project.name,
|
|
description: project.description!,
|
|
mode: project.mode,
|
|
featureLimit: project.featureLimit,
|
|
featureNaming: project.featureNaming,
|
|
defaultStickiness: project.defaultStickiness,
|
|
health: project.health || 0,
|
|
favorite: favorite,
|
|
updatedAt: project.updatedAt,
|
|
archivedAt: project.archivedAt,
|
|
createdAt: project.createdAt,
|
|
onboardingStatus: onboardingStatus ?? {
|
|
status: 'onboarding-started',
|
|
},
|
|
environments,
|
|
featureTypeCounts,
|
|
members,
|
|
version: 1,
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
|
removePropertiesForNonEnterprise(data): any {
|
|
if (this.isEnterprise) {
|
|
return data;
|
|
}
|
|
const { mode, changeRequestEnvironments, ...proData } = data;
|
|
return proData;
|
|
}
|
|
}
|