2023-01-26 16:13:15 +01:00
|
|
|
import { subDays } from 'date-fns';
|
chore: remove uses of type errors from user-facing code (#3553)
BREAKING CHANGE: This changes the `name` property of a small number of error responses that we return. The property would have been `TypeError`, but is now `ValidationError` instead. It's a grey area, but I'd rather be strict.
---
This change removes uses of the `TypeError` type from user-facing code.
Type errors are used by typescript when you provide it the wrong type.
This is a valid concern. However, in the API, they're usually a signal
that **we've** done something wrong rather than the user having done
something wrong. As such, it makes more sense to return them as
validation errors or bad request errors.
## Breaking changes
Note that because of the way we handle errors, some of these changes
will be made visible to the end user, but only in the response body.
```ts
{ "name": "TypeError", "message": "Something is wrong", "isJoi": true }
```
will become
```ts
{ "name": "ValidationError", "message": "Something is wrong", "isJoi": true }
```
Technically, this could be considered a breaking change. However, as
we're gearing up for v5, this might be a good time to merge that?
## A return to 500
This PR also makes TypeErrors a 500-type error again because they should
never be caused by invalid data provided by the user
2023-04-18 13:42:07 +02:00
|
|
|
import { ValidationError } from 'joi';
|
2022-08-16 15:33:33 +02:00
|
|
|
import User, { IUser } from '../types/user';
|
2023-08-25 10:31:37 +02:00
|
|
|
import { AccessService, AccessWithRoles } from './access-service';
|
2021-04-20 12:32:02 +02:00
|
|
|
import NameExistsError from '../error/name-exists-error';
|
|
|
|
import InvalidOperationError from '../error/invalid-operation-error';
|
2021-08-13 10:36:19 +02:00
|
|
|
import { nameType } from '../routes/util';
|
2021-09-14 20:43:05 +02:00
|
|
|
import { projectSchema } from './project-schema';
|
2021-04-20 12:32:02 +02:00
|
|
|
import NotFoundError from '../error/notfound-error';
|
2021-04-29 10:21:29 +02:00
|
|
|
import {
|
2023-03-17 13:41:59 +01:00
|
|
|
DEFAULT_PROJECT,
|
|
|
|
FeatureToggle,
|
|
|
|
IAccountStore,
|
|
|
|
IEnvironmentStore,
|
|
|
|
IEventStore,
|
|
|
|
IFeatureEnvironmentStore,
|
|
|
|
IFeatureToggleStore,
|
|
|
|
IFeatureTypeStore,
|
|
|
|
IProject,
|
|
|
|
IProjectOverview,
|
|
|
|
IProjectWithCount,
|
|
|
|
IUnleashConfig,
|
|
|
|
IUnleashStores,
|
|
|
|
MOVE_FEATURE_TOGGLE,
|
2021-04-29 10:21:29 +02:00
|
|
|
PROJECT_CREATED,
|
|
|
|
PROJECT_DELETED,
|
|
|
|
PROJECT_UPDATED,
|
2022-07-21 16:23:56 +02:00
|
|
|
ProjectGroupAddedEvent,
|
|
|
|
ProjectGroupRemovedEvent,
|
2022-07-25 12:11:16 +02:00
|
|
|
ProjectGroupUpdateRoleEvent,
|
2023-03-14 10:32:00 +01:00
|
|
|
ProjectUserAddedEvent,
|
|
|
|
ProjectUserRemovedEvent,
|
|
|
|
ProjectUserUpdateRoleEvent,
|
2021-08-19 13:25:36 +02:00
|
|
|
RoleName,
|
2023-03-27 11:24:01 +02:00
|
|
|
IFlagResolver,
|
2023-05-09 11:13:38 +02:00
|
|
|
ProjectAccessAddedEvent,
|
2023-08-25 10:31:37 +02:00
|
|
|
ProjectAccessUserRolesUpdated,
|
|
|
|
ProjectAccessGroupRolesUpdated,
|
2023-08-17 09:43:43 +02:00
|
|
|
IProjectRoleUsage,
|
2023-08-25 10:31:37 +02:00
|
|
|
ProjectAccessUserRolesDeleted,
|
2023-09-04 13:53:33 +02:00
|
|
|
IFeatureNaming,
|
2023-09-12 15:40:57 +02:00
|
|
|
CreateProject,
|
2023-03-17 13:41:59 +01:00
|
|
|
} from '../types';
|
2023-07-13 13:02:35 +02:00
|
|
|
import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
|
2022-07-21 16:23:56 +02:00
|
|
|
import {
|
|
|
|
IProjectAccessModel,
|
|
|
|
IRoleDescriptor,
|
|
|
|
} from '../types/stores/access-store';
|
2021-11-12 13:15:51 +01:00
|
|
|
import FeatureToggleService from './feature-toggle-service';
|
2021-10-21 10:29:09 +02:00
|
|
|
import IncompatibleProjectError from '../error/incompatible-project-error';
|
2022-03-03 14:25:14 +01:00
|
|
|
import ProjectWithoutOwnerError from '../error/project-without-owner-error';
|
2023-03-17 13:41:59 +01:00
|
|
|
import { arraysHaveSameItems } from '../util';
|
2022-07-21 16:23:56 +02:00
|
|
|
import { GroupService } from './group-service';
|
2023-08-25 10:31:37 +02:00
|
|
|
import { IGroupRole } from 'lib/types/group';
|
2023-01-18 13:22:58 +01:00
|
|
|
import { FavoritesService } from './favorites-service';
|
2023-04-10 09:50:39 +02:00
|
|
|
import { calculateAverageTimeToProd } from '../features/feature-toggle/time-to-production/time-to-production';
|
2023-01-26 16:13:15 +01:00
|
|
|
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
2023-03-15 14:44:08 +01:00
|
|
|
import { uniqueByKey } from '../util/unique';
|
2023-09-04 13:53:33 +02:00
|
|
|
import { BadDataError, PermissionError } from '../error';
|
2023-08-30 14:39:43 +02:00
|
|
|
import { ProjectDoraMetricsSchema } from 'lib/openapi';
|
2023-09-13 10:50:37 +02:00
|
|
|
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
|
2023-09-18 10:06:26 +02:00
|
|
|
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2023-03-14 10:32:00 +01:00
|
|
|
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2023-01-26 16:13:15 +01:00
|
|
|
type Days = number;
|
|
|
|
type Count = number;
|
|
|
|
|
|
|
|
export interface IProjectStats {
|
|
|
|
avgTimeToProdCurrentWindow: Days;
|
|
|
|
createdCurrentWindow: Count;
|
|
|
|
createdPastWindow: Count;
|
|
|
|
archivedCurrentWindow: Count;
|
|
|
|
archivedPastWindow: Count;
|
|
|
|
projectActivityCurrentWindow: Count;
|
|
|
|
projectActivityPastWindow: Count;
|
2023-01-27 13:13:41 +01:00
|
|
|
projectMembersAddedCurrentWindow: Count;
|
2023-01-26 16:13:15 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
interface ICalculateStatus {
|
|
|
|
projectId: string;
|
|
|
|
updates: IProjectStats;
|
|
|
|
}
|
|
|
|
|
2021-04-20 12:32:02 +02:00
|
|
|
export default class ProjectService {
|
2021-08-19 13:25:36 +02:00
|
|
|
private store: IProjectStore;
|
2021-03-11 22:51:58 +01:00
|
|
|
|
|
|
|
private accessService: AccessService;
|
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
private eventStore: IEventStore;
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
private featureToggleStore: IFeatureToggleStore;
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
private featureTypeStore: IFeatureTypeStore;
|
2021-07-07 10:46:50 +02:00
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
private featureEnvironmentStore: IFeatureEnvironmentStore;
|
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
private environmentStore: IEnvironmentStore;
|
2021-07-14 13:20:36 +02:00
|
|
|
|
2022-07-21 16:23:56 +02:00
|
|
|
private groupService: GroupService;
|
|
|
|
|
2021-03-11 22:51:58 +01:00
|
|
|
private logger: any;
|
|
|
|
|
2021-11-12 13:15:51 +01:00
|
|
|
private featureToggleService: FeatureToggleService;
|
2021-08-19 13:25:36 +02:00
|
|
|
|
2023-09-18 10:06:26 +02:00
|
|
|
private privateProjectChecker: IPrivateProjectChecker;
|
2022-01-13 11:14:17 +01:00
|
|
|
|
2023-01-18 17:08:07 +01:00
|
|
|
private accountStore: IAccountStore;
|
2022-03-16 08:44:30 +01:00
|
|
|
|
2023-01-18 13:22:58 +01:00
|
|
|
private favoritesService: FavoritesService;
|
|
|
|
|
2023-01-26 16:13:15 +01:00
|
|
|
private projectStatsStore: IProjectStatsStore;
|
|
|
|
|
2023-03-27 11:24:01 +02:00
|
|
|
private flagResolver: IFlagResolver;
|
|
|
|
|
2021-03-11 22:51:58 +01:00
|
|
|
constructor(
|
2021-04-30 12:51:46 +02:00
|
|
|
{
|
|
|
|
projectStore,
|
|
|
|
eventStore,
|
|
|
|
featureToggleStore,
|
2021-07-07 10:46:50 +02:00
|
|
|
featureTypeStore,
|
2021-07-14 13:20:36 +02:00
|
|
|
environmentStore,
|
2021-09-13 10:23:57 +02:00
|
|
|
featureEnvironmentStore,
|
2023-01-18 17:08:07 +01:00
|
|
|
accountStore,
|
2023-01-26 16:13:15 +01:00
|
|
|
projectStatsStore,
|
2021-04-30 12:51:46 +02:00
|
|
|
}: Pick<
|
2021-07-07 10:46:50 +02:00
|
|
|
IUnleashStores,
|
|
|
|
| 'projectStore'
|
|
|
|
| 'eventStore'
|
|
|
|
| 'featureToggleStore'
|
|
|
|
| 'featureTypeStore'
|
2021-07-14 13:20:36 +02:00
|
|
|
| 'environmentStore'
|
2021-09-13 10:23:57 +02:00
|
|
|
| 'featureEnvironmentStore'
|
2023-01-18 17:08:07 +01:00
|
|
|
| 'accountStore'
|
2023-01-26 16:13:15 +01:00
|
|
|
| 'projectStatsStore'
|
2021-04-30 12:51:46 +02:00
|
|
|
>,
|
|
|
|
config: IUnleashConfig,
|
2021-03-11 22:51:58 +01:00
|
|
|
accessService: AccessService,
|
2021-11-12 13:15:51 +01:00
|
|
|
featureToggleService: FeatureToggleService,
|
2022-07-21 16:23:56 +02:00
|
|
|
groupService: GroupService,
|
2023-01-18 13:22:58 +01:00
|
|
|
favoriteService: FavoritesService,
|
2023-09-18 10:06:26 +02:00
|
|
|
privateProjectChecker: IPrivateProjectChecker,
|
2021-03-11 22:51:58 +01:00
|
|
|
) {
|
2021-08-19 13:25:36 +02:00
|
|
|
this.store = projectStore;
|
2021-07-14 13:20:36 +02:00
|
|
|
this.environmentStore = environmentStore;
|
2021-09-13 10:23:57 +02:00
|
|
|
this.featureEnvironmentStore = featureEnvironmentStore;
|
2021-03-11 22:51:58 +01:00
|
|
|
this.accessService = accessService;
|
|
|
|
this.eventStore = eventStore;
|
|
|
|
this.featureToggleStore = featureToggleStore;
|
2021-07-07 10:46:50 +02:00
|
|
|
this.featureTypeStore = featureTypeStore;
|
2021-08-19 13:25:36 +02:00
|
|
|
this.featureToggleService = featureToggleService;
|
2023-01-18 13:22:58 +01:00
|
|
|
this.favoritesService = favoriteService;
|
2023-09-18 10:06:26 +02:00
|
|
|
this.privateProjectChecker = privateProjectChecker;
|
2023-01-18 17:08:07 +01:00
|
|
|
this.accountStore = accountStore;
|
2022-07-21 16:23:56 +02:00
|
|
|
this.groupService = groupService;
|
2023-01-26 16:13:15 +01:00
|
|
|
this.projectStatsStore = projectStatsStore;
|
2021-03-11 22:51:58 +01:00
|
|
|
this.logger = config.getLogger('services/project-service.js');
|
2023-03-27 11:24:01 +02:00
|
|
|
this.flagResolver = config.flagResolver;
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
|
2022-11-30 12:41:53 +01:00
|
|
|
async getProjects(
|
|
|
|
query?: IProjectQuery,
|
|
|
|
userId?: number,
|
|
|
|
): Promise<IProjectWithCount[]> {
|
2023-09-18 10:06:26 +02:00
|
|
|
const projects = await this.store.getProjectsWithCounts(query, userId);
|
|
|
|
if (this.flagResolver.isEnabled('privateProjects') && userId) {
|
|
|
|
const accessibleProjects =
|
|
|
|
await this.privateProjectChecker.getUserAccessibleProjects(
|
|
|
|
userId,
|
|
|
|
);
|
|
|
|
return projects.filter((project) =>
|
|
|
|
accessibleProjects.includes(project.id),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return projects;
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
|
2021-04-30 12:51:46 +02:00
|
|
|
async getProject(id: string): Promise<IProject> {
|
2021-08-19 13:25:36 +02:00
|
|
|
return this.store.get(id);
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
|
2023-09-13 10:50:37 +02:00
|
|
|
private validateAndProcessFeatureNamingPattern = (
|
|
|
|
featureNaming: IFeatureNaming,
|
|
|
|
): IFeatureNaming => {
|
|
|
|
const validationResult = checkFeatureNamingData(featureNaming);
|
2023-09-05 11:09:55 +02:00
|
|
|
|
2023-09-13 10:50:37 +02:00
|
|
|
if (validationResult.state === 'invalid') {
|
2023-09-14 09:32:07 +02:00
|
|
|
const [firstReason, ...remainingReasons] =
|
|
|
|
validationResult.reasons.map((message) => ({
|
|
|
|
message,
|
|
|
|
}));
|
|
|
|
throw new BadDataError(
|
|
|
|
'The feature naming pattern data you provided was invalid.',
|
|
|
|
[firstReason, ...remainingReasons],
|
|
|
|
);
|
2023-09-13 10:50:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (featureNaming.pattern && !featureNaming.example) {
|
|
|
|
featureNaming.example = null;
|
2023-09-04 13:53:33 +02:00
|
|
|
}
|
2023-09-13 10:50:37 +02:00
|
|
|
if (featureNaming.pattern && !featureNaming.description) {
|
|
|
|
featureNaming.description = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return featureNaming;
|
2023-09-04 13:53:33 +02:00
|
|
|
};
|
|
|
|
|
2022-05-18 11:07:01 +02:00
|
|
|
async createProject(
|
2023-09-12 15:40:57 +02:00
|
|
|
newProject: CreateProject,
|
2022-08-16 15:33:33 +02:00
|
|
|
user: IUser,
|
2022-05-18 11:07:01 +02:00
|
|
|
): Promise<IProject> {
|
2021-09-14 20:36:40 +02:00
|
|
|
const data = await projectSchema.validateAsync(newProject);
|
2021-03-11 22:51:58 +01:00
|
|
|
await this.validateUniqueId(data.id);
|
|
|
|
|
2023-09-13 10:50:37 +02:00
|
|
|
if (data.featureNaming) {
|
|
|
|
this.validateAndProcessFeatureNamingPattern(data.featureNaming);
|
|
|
|
}
|
2023-09-04 13:53:33 +02:00
|
|
|
|
2021-08-19 13:25:36 +02:00
|
|
|
await this.store.create(data);
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2021-11-26 15:31:36 +01:00
|
|
|
const enabledEnvironments = await this.environmentStore.getAll({
|
|
|
|
enabled: true,
|
|
|
|
});
|
|
|
|
|
|
|
|
// TODO: Only if enabled!
|
|
|
|
await Promise.all(
|
|
|
|
enabledEnvironments.map(async (e) => {
|
|
|
|
await this.featureEnvironmentStore.connectProject(
|
|
|
|
e.name,
|
|
|
|
data.id,
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
);
|
2021-07-14 13:20:36 +02:00
|
|
|
|
2021-04-12 20:25:03 +02:00
|
|
|
await this.accessService.createDefaultProjectRoles(user, data.id);
|
2021-03-11 22:51:58 +01:00
|
|
|
|
|
|
|
await this.eventStore.store({
|
2021-04-29 10:21:29 +02:00
|
|
|
type: PROJECT_CREATED,
|
2021-03-11 22:51:58 +01:00
|
|
|
createdBy: getCreatedBy(user),
|
|
|
|
data,
|
2021-09-20 12:13:38 +02:00
|
|
|
project: newProject.id,
|
2021-03-11 22:51:58 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
async updateProject(updatedProject: IProject, user: User): Promise<void> {
|
2021-11-12 13:15:51 +01:00
|
|
|
const preData = await this.store.get(updatedProject.id);
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2023-09-04 13:53:33 +02:00
|
|
|
if (updatedProject.featureNaming) {
|
2023-09-13 10:50:37 +02:00
|
|
|
this.validateAndProcessFeatureNamingPattern(
|
|
|
|
updatedProject.featureNaming,
|
|
|
|
);
|
2023-09-04 13:53:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
await this.store.update(updatedProject);
|
2021-03-11 22:51:58 +01:00
|
|
|
|
|
|
|
await this.eventStore.store({
|
2021-04-29 10:21:29 +02:00
|
|
|
type: PROJECT_UPDATED,
|
2023-09-04 13:53:33 +02:00
|
|
|
project: updatedProject.id,
|
2021-03-11 22:51:58 +01:00
|
|
|
createdBy: getCreatedBy(user),
|
2023-09-04 13:53:33 +02:00
|
|
|
data: updatedProject,
|
2021-11-12 13:15:51 +01:00
|
|
|
preData,
|
2021-03-11 22:51:58 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-10-21 10:29:09 +02:00
|
|
|
async checkProjectsCompatibility(
|
|
|
|
feature: FeatureToggle,
|
|
|
|
newProjectId: string,
|
|
|
|
): Promise<boolean> {
|
|
|
|
const featureEnvs = await this.featureEnvironmentStore.getAll({
|
|
|
|
feature_name: feature.name,
|
|
|
|
});
|
|
|
|
const newEnvs = await this.store.getEnvironmentsForProject(
|
|
|
|
newProjectId,
|
|
|
|
);
|
2022-05-18 11:07:01 +02:00
|
|
|
return arraysHaveSameItems(
|
|
|
|
featureEnvs.map((env) => env.environment),
|
2023-04-28 13:59:04 +02:00
|
|
|
newEnvs.map((projectEnv) => projectEnv.environment),
|
2021-10-21 10:29:09 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-11-17 10:08:29 +01:00
|
|
|
async addEnvironmentToProject(
|
|
|
|
project: string,
|
|
|
|
environment: string,
|
|
|
|
): Promise<void> {
|
|
|
|
await this.store.addEnvironmentToProject(project, environment);
|
|
|
|
}
|
|
|
|
|
2021-08-25 13:38:00 +02:00
|
|
|
async changeProject(
|
|
|
|
newProjectId: string,
|
|
|
|
featureName: string,
|
|
|
|
user: User,
|
|
|
|
currentProjectId: string,
|
|
|
|
): Promise<any> {
|
|
|
|
const feature = await this.featureToggleStore.get(featureName);
|
|
|
|
|
|
|
|
if (feature.project !== currentProjectId) {
|
2023-07-10 12:48:13 +02:00
|
|
|
throw new PermissionError(MOVE_FEATURE_TOGGLE);
|
2021-08-25 13:38:00 +02:00
|
|
|
}
|
|
|
|
const project = await this.getProject(newProjectId);
|
|
|
|
|
|
|
|
if (!project) {
|
|
|
|
throw new NotFoundError(`Project ${newProjectId} not found`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const authorized = await this.accessService.hasPermission(
|
|
|
|
user,
|
2022-01-13 11:14:17 +01:00
|
|
|
MOVE_FEATURE_TOGGLE,
|
2021-08-25 13:38:00 +02:00
|
|
|
newProjectId,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!authorized) {
|
2023-07-10 12:48:13 +02:00
|
|
|
throw new PermissionError(MOVE_FEATURE_TOGGLE);
|
2021-08-25 13:38:00 +02:00
|
|
|
}
|
|
|
|
|
2021-10-21 10:29:09 +02:00
|
|
|
const isCompatibleWithTargetProject =
|
|
|
|
await this.checkProjectsCompatibility(feature, newProjectId);
|
|
|
|
if (!isCompatibleWithTargetProject) {
|
|
|
|
throw new IncompatibleProjectError(newProjectId);
|
|
|
|
}
|
2021-10-21 21:06:56 +02:00
|
|
|
const updatedFeature = await this.featureToggleService.changeProject(
|
2021-08-25 13:38:00 +02:00
|
|
|
featureName,
|
|
|
|
newProjectId,
|
2022-06-02 13:52:10 +02:00
|
|
|
getCreatedBy(user),
|
2021-08-25 13:38:00 +02:00
|
|
|
);
|
2021-10-19 09:49:43 +02:00
|
|
|
await this.featureToggleService.updateFeatureStrategyProject(
|
|
|
|
featureName,
|
|
|
|
newProjectId,
|
|
|
|
);
|
2021-08-25 13:38:00 +02:00
|
|
|
|
|
|
|
return updatedFeature;
|
|
|
|
}
|
|
|
|
|
2021-03-11 22:51:58 +01:00
|
|
|
async deleteProject(id: string, user: User): Promise<void> {
|
|
|
|
if (id === DEFAULT_PROJECT) {
|
|
|
|
throw new InvalidOperationError(
|
|
|
|
'You can not delete the default project!',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-09-13 10:23:57 +02:00
|
|
|
const toggles = await this.featureToggleStore.getAll({
|
2021-03-11 22:51:58 +01:00
|
|
|
project: id,
|
2021-07-07 10:46:50 +02:00
|
|
|
archived: false,
|
2021-03-11 22:51:58 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
if (toggles.length > 0) {
|
|
|
|
throw new InvalidOperationError(
|
2021-07-07 10:46:50 +02:00
|
|
|
'You can not delete a project with active feature toggles',
|
2021-03-11 22:51:58 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-08-19 13:25:36 +02:00
|
|
|
await this.store.delete(id);
|
2021-03-11 22:51:58 +01:00
|
|
|
|
|
|
|
await this.eventStore.store({
|
2021-04-29 10:21:29 +02:00
|
|
|
type: PROJECT_DELETED,
|
2021-03-11 22:51:58 +01:00
|
|
|
createdBy: getCreatedBy(user),
|
2021-09-20 12:13:38 +02:00
|
|
|
project: id,
|
2021-03-11 22:51:58 +01:00
|
|
|
});
|
|
|
|
|
2021-09-20 12:13:38 +02:00
|
|
|
await this.accessService.removeDefaultProjectRoles(user, id);
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
async validateId(id: string): Promise<boolean> {
|
|
|
|
await nameType.validateAsync(id);
|
|
|
|
await this.validateUniqueId(id);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
async validateUniqueId(id: string): Promise<void> {
|
2021-08-19 13:25:36 +02:00
|
|
|
const exists = await this.store.hasProject(id);
|
2021-08-12 15:04:37 +02:00
|
|
|
if (exists) {
|
|
|
|
throw new NameExistsError('A project with this id already exists.');
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// RBAC methods
|
2022-07-21 16:23:56 +02:00
|
|
|
async getAccessToProject(projectId: string): Promise<AccessWithRoles> {
|
2023-08-25 10:31:37 +02:00
|
|
|
return this.accessService.getProjectRoleAccess(projectId);
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
|
2023-05-09 11:13:38 +02:00
|
|
|
// Deprecated: See addAccess instead.
|
2021-03-11 22:51:58 +01:00
|
|
|
async addUser(
|
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
userId: number,
|
2022-08-25 08:39:28 +02:00
|
|
|
createdBy: string,
|
2021-03-11 22:51:58 +01:00
|
|
|
): Promise<void> {
|
2023-08-25 10:31:37 +02:00
|
|
|
const { roles, users } = await this.accessService.getProjectRoleAccess(
|
2021-03-11 22:51:58 +01:00
|
|
|
projectId,
|
|
|
|
);
|
2023-01-18 17:08:07 +01:00
|
|
|
const user = await this.accountStore.get(userId);
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
const role = roles.find((r) => r.id === roleId);
|
2021-03-11 22:51:58 +01:00
|
|
|
if (!role) {
|
|
|
|
throw new NotFoundError(
|
|
|
|
`Could not find roleId=${roleId} on project=${projectId}`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-08-12 15:04:37 +02:00
|
|
|
const alreadyHasAccess = users.some((u) => u.id === userId);
|
2021-03-11 22:51:58 +01:00
|
|
|
if (alreadyHasAccess) {
|
2022-01-13 11:14:17 +01:00
|
|
|
throw new Error(`User already has access to project=${projectId}`);
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
|
2022-01-13 11:14:17 +01:00
|
|
|
await this.accessService.addUserToRole(userId, role.id, projectId);
|
2022-01-14 12:14:02 +01:00
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectUserAddedEvent({
|
|
|
|
project: projectId,
|
2022-08-25 08:39:28 +02:00
|
|
|
createdBy: createdBy || 'system-user',
|
2022-03-16 08:44:30 +01:00
|
|
|
data: {
|
|
|
|
roleId,
|
|
|
|
userId,
|
|
|
|
roleName: role.name,
|
|
|
|
email: user.email,
|
|
|
|
},
|
2022-01-14 12:14:02 +01:00
|
|
|
}),
|
|
|
|
);
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
async removeUser(
|
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
userId: number,
|
2022-08-25 08:39:28 +02:00
|
|
|
createdBy: string,
|
2021-03-11 22:51:58 +01:00
|
|
|
): Promise<void> {
|
2022-02-21 14:39:59 +01:00
|
|
|
const role = await this.findProjectRole(projectId, roleId);
|
|
|
|
|
|
|
|
await this.validateAtLeastOneOwner(projectId, role);
|
|
|
|
|
|
|
|
await this.accessService.removeUserFromRole(userId, role.id, projectId);
|
|
|
|
|
2023-01-18 17:08:07 +01:00
|
|
|
const user = await this.accountStore.get(userId);
|
2022-03-16 08:44:30 +01:00
|
|
|
|
2022-02-21 14:39:59 +01:00
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectUserRemovedEvent({
|
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
2022-03-16 08:44:30 +01:00
|
|
|
preData: {
|
|
|
|
roleId,
|
|
|
|
userId,
|
|
|
|
roleName: role.name,
|
|
|
|
email: user.email,
|
|
|
|
},
|
2022-02-21 14:39:59 +01:00
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-08-25 10:31:37 +02:00
|
|
|
async removeUserAccess(
|
|
|
|
projectId: string,
|
|
|
|
userId: number,
|
|
|
|
createdBy: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const existingRoles = await this.accessService.getProjectRolesForUser(
|
|
|
|
projectId,
|
|
|
|
userId,
|
|
|
|
);
|
|
|
|
|
|
|
|
await this.accessService.removeUserAccess(projectId, userId);
|
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectAccessUserRolesDeleted({
|
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
|
|
|
preData: {
|
|
|
|
roles: existingRoles,
|
|
|
|
userId,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async removeGroupAccess(
|
|
|
|
projectId: string,
|
|
|
|
groupId: number,
|
|
|
|
createdBy: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const existingRoles = await this.accessService.getProjectRolesForGroup(
|
|
|
|
projectId,
|
|
|
|
groupId,
|
|
|
|
);
|
|
|
|
|
|
|
|
await this.accessService.removeGroupAccess(projectId, groupId);
|
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectAccessUserRolesDeleted({
|
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
|
|
|
preData: {
|
|
|
|
roles: existingRoles,
|
|
|
|
groupId,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-07-21 16:23:56 +02:00
|
|
|
async addGroup(
|
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
groupId: number,
|
2022-08-25 08:39:28 +02:00
|
|
|
modifiedBy: string,
|
2022-07-21 16:23:56 +02:00
|
|
|
): Promise<void> {
|
|
|
|
const role = await this.accessService.getRole(roleId);
|
|
|
|
const group = await this.groupService.getGroup(groupId);
|
|
|
|
const project = await this.getProject(projectId);
|
chore: remove uses of type errors from user-facing code (#3553)
BREAKING CHANGE: This changes the `name` property of a small number of error responses that we return. The property would have been `TypeError`, but is now `ValidationError` instead. It's a grey area, but I'd rather be strict.
---
This change removes uses of the `TypeError` type from user-facing code.
Type errors are used by typescript when you provide it the wrong type.
This is a valid concern. However, in the API, they're usually a signal
that **we've** done something wrong rather than the user having done
something wrong. As such, it makes more sense to return them as
validation errors or bad request errors.
## Breaking changes
Note that because of the way we handle errors, some of these changes
will be made visible to the end user, but only in the response body.
```ts
{ "name": "TypeError", "message": "Something is wrong", "isJoi": true }
```
will become
```ts
{ "name": "ValidationError", "message": "Something is wrong", "isJoi": true }
```
Technically, this could be considered a breaking change. However, as
we're gearing up for v5, this might be a good time to merge that?
## A return to 500
This PR also makes TypeErrors a 500-type error again because they should
never be caused by invalid data provided by the user
2023-04-18 13:42:07 +02:00
|
|
|
if (group.id == null)
|
|
|
|
throw new ValidationError(
|
|
|
|
'Unexpected empty group id',
|
|
|
|
[],
|
|
|
|
undefined,
|
|
|
|
);
|
2022-07-21 16:23:56 +02:00
|
|
|
|
|
|
|
await this.accessService.addGroupToRole(
|
|
|
|
group.id,
|
|
|
|
role.id,
|
|
|
|
modifiedBy,
|
|
|
|
project.id,
|
|
|
|
);
|
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectGroupAddedEvent({
|
|
|
|
project: project.id,
|
|
|
|
createdBy: modifiedBy,
|
|
|
|
data: {
|
|
|
|
groupId: group.id,
|
|
|
|
projectId: project.id,
|
|
|
|
roleName: role.name,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async removeGroup(
|
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
groupId: number,
|
2022-08-25 08:39:28 +02:00
|
|
|
modifiedBy: string,
|
2022-07-21 16:23:56 +02:00
|
|
|
): Promise<void> {
|
|
|
|
const group = await this.groupService.getGroup(groupId);
|
|
|
|
const role = await this.accessService.getRole(roleId);
|
|
|
|
const project = await this.getProject(projectId);
|
chore: remove uses of type errors from user-facing code (#3553)
BREAKING CHANGE: This changes the `name` property of a small number of error responses that we return. The property would have been `TypeError`, but is now `ValidationError` instead. It's a grey area, but I'd rather be strict.
---
This change removes uses of the `TypeError` type from user-facing code.
Type errors are used by typescript when you provide it the wrong type.
This is a valid concern. However, in the API, they're usually a signal
that **we've** done something wrong rather than the user having done
something wrong. As such, it makes more sense to return them as
validation errors or bad request errors.
## Breaking changes
Note that because of the way we handle errors, some of these changes
will be made visible to the end user, but only in the response body.
```ts
{ "name": "TypeError", "message": "Something is wrong", "isJoi": true }
```
will become
```ts
{ "name": "ValidationError", "message": "Something is wrong", "isJoi": true }
```
Technically, this could be considered a breaking change. However, as
we're gearing up for v5, this might be a good time to merge that?
## A return to 500
This PR also makes TypeErrors a 500-type error again because they should
never be caused by invalid data provided by the user
2023-04-18 13:42:07 +02:00
|
|
|
if (group.id == null)
|
|
|
|
throw new ValidationError(
|
|
|
|
'Unexpected empty group id',
|
|
|
|
[],
|
|
|
|
undefined,
|
|
|
|
);
|
2022-07-21 16:23:56 +02:00
|
|
|
|
|
|
|
await this.accessService.removeGroupFromRole(
|
|
|
|
group.id,
|
|
|
|
role.id,
|
|
|
|
project.id,
|
|
|
|
);
|
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectGroupRemovedEvent({
|
|
|
|
project: projectId,
|
|
|
|
createdBy: modifiedBy,
|
|
|
|
preData: {
|
|
|
|
groupId: group.id,
|
|
|
|
projectId: project.id,
|
|
|
|
roleName: role.name,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-08-25 10:31:37 +02:00
|
|
|
async addRoleAccess(
|
2022-07-21 16:23:56 +02:00
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
usersAndGroups: IProjectAccessModel,
|
|
|
|
createdBy: string,
|
|
|
|
): Promise<void> {
|
2023-08-25 10:31:37 +02:00
|
|
|
await this.accessService.addRoleAccessToProject(
|
2022-07-21 16:23:56 +02:00
|
|
|
usersAndGroups.users,
|
|
|
|
usersAndGroups.groups,
|
|
|
|
projectId,
|
|
|
|
roleId,
|
|
|
|
createdBy,
|
|
|
|
);
|
2023-05-09 11:13:38 +02:00
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectAccessAddedEvent({
|
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
|
|
|
data: {
|
|
|
|
roleId,
|
|
|
|
groups: usersAndGroups.groups.map(({ id }) => id),
|
|
|
|
users: usersAndGroups.users.map(({ id }) => id),
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
2022-07-21 16:23:56 +02:00
|
|
|
}
|
|
|
|
|
2023-08-25 10:31:37 +02:00
|
|
|
async addAccess(
|
|
|
|
projectId: string,
|
|
|
|
roles: number[],
|
|
|
|
groups: number[],
|
|
|
|
users: number[],
|
|
|
|
createdBy: string,
|
|
|
|
): Promise<void> {
|
|
|
|
await this.accessService.addAccessToProject(
|
|
|
|
roles,
|
|
|
|
groups,
|
|
|
|
users,
|
|
|
|
projectId,
|
|
|
|
createdBy,
|
|
|
|
);
|
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectAccessAddedEvent({
|
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
|
|
|
data: {
|
|
|
|
roles,
|
|
|
|
groups,
|
|
|
|
users,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async setRolesForUser(
|
|
|
|
projectId: string,
|
|
|
|
userId: number,
|
|
|
|
roles: number[],
|
|
|
|
createdByUserName: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const existingRoles = await this.accessService.getProjectRolesForUser(
|
|
|
|
projectId,
|
|
|
|
userId,
|
|
|
|
);
|
|
|
|
await this.accessService.setProjectRolesForUser(
|
|
|
|
projectId,
|
|
|
|
userId,
|
|
|
|
roles,
|
|
|
|
);
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectAccessUserRolesUpdated({
|
|
|
|
project: projectId,
|
|
|
|
createdBy: createdByUserName,
|
|
|
|
data: {
|
|
|
|
roles,
|
|
|
|
userId,
|
|
|
|
},
|
|
|
|
preData: {
|
|
|
|
roles: existingRoles,
|
|
|
|
userId,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async setRolesForGroup(
|
|
|
|
projectId: string,
|
|
|
|
groupId: number,
|
|
|
|
roles: number[],
|
|
|
|
createdBy: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const existingRoles = await this.accessService.getProjectRolesForGroup(
|
|
|
|
projectId,
|
|
|
|
groupId,
|
|
|
|
);
|
|
|
|
await this.accessService.setProjectRolesForGroup(
|
|
|
|
projectId,
|
|
|
|
groupId,
|
|
|
|
roles,
|
|
|
|
createdBy,
|
|
|
|
);
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectAccessGroupRolesUpdated({
|
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
|
|
|
data: {
|
|
|
|
roles,
|
|
|
|
groupId,
|
|
|
|
},
|
|
|
|
preData: {
|
|
|
|
roles: existingRoles,
|
|
|
|
groupId,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-07-25 12:11:16 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2022-02-21 14:39:59 +01:00
|
|
|
async findProjectRole(
|
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
): Promise<IRoleDescriptor> {
|
2021-03-11 22:51:58 +01:00
|
|
|
const roles = await this.accessService.getRolesForProject(projectId);
|
2021-08-12 15:04:37 +02:00
|
|
|
const role = roles.find((r) => r.id === roleId);
|
2021-03-11 22:51:58 +01:00
|
|
|
if (!role) {
|
|
|
|
throw new NotFoundError(
|
|
|
|
`Couldn't find roleId=${roleId} on project=${projectId}`,
|
|
|
|
);
|
|
|
|
}
|
2022-02-21 14:39:59 +01:00
|
|
|
return role;
|
|
|
|
}
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2022-02-21 14:39:59 +01:00
|
|
|
async validateAtLeastOneOwner(
|
|
|
|
projectId: string,
|
|
|
|
currentRole: IRoleDescriptor,
|
|
|
|
): Promise<void> {
|
|
|
|
if (currentRole.name === RoleName.OWNER) {
|
2022-01-13 11:14:17 +01:00
|
|
|
const users = await this.accessService.getProjectUsersForRole(
|
2022-02-21 14:39:59 +01:00
|
|
|
currentRole.id,
|
2022-01-13 11:14:17 +01:00
|
|
|
projectId,
|
|
|
|
);
|
2022-07-21 16:23:56 +02:00
|
|
|
const groups = await this.groupService.getProjectGroups(projectId);
|
|
|
|
const roleGroups = groups.filter((g) => g.roleId == currentRole.id);
|
|
|
|
if (users.length + roleGroups.length < 2) {
|
2022-03-03 14:25:14 +01:00
|
|
|
throw new ProjectWithoutOwnerError();
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
|
|
|
}
|
2022-02-21 14:39:59 +01:00
|
|
|
}
|
2021-03-11 22:51:58 +01:00
|
|
|
|
2023-08-30 14:39:43 +02:00
|
|
|
async getDoraMetrics(projectId: string): Promise<ProjectDoraMetricsSchema> {
|
2023-09-08 14:18:58 +02:00
|
|
|
const activeFeatureToggles = (
|
|
|
|
await this.featureToggleStore.getAll({ project: projectId })
|
|
|
|
).map((feature) => feature.name);
|
|
|
|
|
|
|
|
const archivedFeatureToggles = (
|
|
|
|
await this.featureToggleStore.getAll({
|
|
|
|
project: projectId,
|
|
|
|
archived: true,
|
|
|
|
})
|
|
|
|
).map((feature) => feature.name);
|
|
|
|
|
|
|
|
const featureToggleNames = [
|
|
|
|
...activeFeatureToggles,
|
|
|
|
...archivedFeatureToggles,
|
|
|
|
];
|
|
|
|
|
|
|
|
const projectAverage = calculateAverageTimeToProd(
|
|
|
|
await this.projectStatsStore.getTimeToProdDates(projectId),
|
2023-08-30 14:39:43 +02:00
|
|
|
);
|
|
|
|
|
2023-09-08 14:18:58 +02:00
|
|
|
const toggleAverage =
|
2023-08-30 14:39:43 +02:00
|
|
|
await this.projectStatsStore.getTimeToProdDatesForFeatureToggles(
|
|
|
|
projectId,
|
|
|
|
featureToggleNames,
|
|
|
|
);
|
|
|
|
|
2023-09-08 14:18:58 +02:00
|
|
|
return { features: toggleAverage, projectAverage: projectAverage };
|
2023-08-30 14:39:43 +02:00
|
|
|
}
|
|
|
|
|
2022-02-21 14:39:59 +01:00
|
|
|
async changeRole(
|
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
userId: number,
|
|
|
|
createdBy: string,
|
|
|
|
): Promise<void> {
|
2022-07-21 16:23:56 +02:00
|
|
|
const usersWithRoles = await this.getAccessToProject(projectId);
|
2022-03-02 23:48:43 +01:00
|
|
|
const user = usersWithRoles.users.find((u) => u.id === userId);
|
chore: remove uses of type errors from user-facing code (#3553)
BREAKING CHANGE: This changes the `name` property of a small number of error responses that we return. The property would have been `TypeError`, but is now `ValidationError` instead. It's a grey area, but I'd rather be strict.
---
This change removes uses of the `TypeError` type from user-facing code.
Type errors are used by typescript when you provide it the wrong type.
This is a valid concern. However, in the API, they're usually a signal
that **we've** done something wrong rather than the user having done
something wrong. As such, it makes more sense to return them as
validation errors or bad request errors.
## Breaking changes
Note that because of the way we handle errors, some of these changes
will be made visible to the end user, but only in the response body.
```ts
{ "name": "TypeError", "message": "Something is wrong", "isJoi": true }
```
will become
```ts
{ "name": "ValidationError", "message": "Something is wrong", "isJoi": true }
```
Technically, this could be considered a breaking change. However, as
we're gearing up for v5, this might be a good time to merge that?
## A return to 500
This PR also makes TypeErrors a 500-type error again because they should
never be caused by invalid data provided by the user
2023-04-18 13:42:07 +02:00
|
|
|
if (!user)
|
|
|
|
throw new ValidationError('Unexpected empty user', [], undefined);
|
2023-03-14 10:32:00 +01:00
|
|
|
|
2022-02-21 14:39:59 +01:00
|
|
|
const currentRole = usersWithRoles.roles.find(
|
2022-03-02 23:48:43 +01:00
|
|
|
(r) => r.id === user.roleId,
|
2022-02-21 14:39:59 +01:00
|
|
|
);
|
chore: remove uses of type errors from user-facing code (#3553)
BREAKING CHANGE: This changes the `name` property of a small number of error responses that we return. The property would have been `TypeError`, but is now `ValidationError` instead. It's a grey area, but I'd rather be strict.
---
This change removes uses of the `TypeError` type from user-facing code.
Type errors are used by typescript when you provide it the wrong type.
This is a valid concern. However, in the API, they're usually a signal
that **we've** done something wrong rather than the user having done
something wrong. As such, it makes more sense to return them as
validation errors or bad request errors.
## Breaking changes
Note that because of the way we handle errors, some of these changes
will be made visible to the end user, but only in the response body.
```ts
{ "name": "TypeError", "message": "Something is wrong", "isJoi": true }
```
will become
```ts
{ "name": "ValidationError", "message": "Something is wrong", "isJoi": true }
```
Technically, this could be considered a breaking change. However, as
we're gearing up for v5, this might be a good time to merge that?
## A return to 500
This PR also makes TypeErrors a 500-type error again because they should
never be caused by invalid data provided by the user
2023-04-18 13:42:07 +02:00
|
|
|
if (!currentRole)
|
|
|
|
throw new ValidationError(
|
|
|
|
'Unexpected empty current role',
|
|
|
|
[],
|
|
|
|
undefined,
|
|
|
|
);
|
2022-02-21 14:39:59 +01:00
|
|
|
|
|
|
|
if (currentRole.id === roleId) {
|
|
|
|
// Nothing to do....
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.validateAtLeastOneOwner(projectId, currentRole);
|
|
|
|
|
|
|
|
await this.accessService.updateUserProjectRole(
|
|
|
|
userId,
|
|
|
|
roleId,
|
|
|
|
projectId,
|
|
|
|
);
|
2022-03-03 14:25:14 +01:00
|
|
|
const role = await this.findProjectRole(projectId, roleId);
|
2022-01-14 12:14:02 +01:00
|
|
|
|
|
|
|
await this.eventStore.store(
|
2022-02-21 14:39:59 +01:00
|
|
|
new ProjectUserUpdateRoleEvent({
|
2022-01-14 12:14:02 +01:00
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
2022-02-21 14:39:59 +01:00
|
|
|
preData: {
|
|
|
|
userId,
|
|
|
|
roleId: currentRole.id,
|
|
|
|
roleName: currentRole.name,
|
2022-03-16 08:44:30 +01:00
|
|
|
email: user.email,
|
|
|
|
},
|
|
|
|
data: {
|
|
|
|
userId,
|
|
|
|
roleId,
|
|
|
|
roleName: role.name,
|
|
|
|
email: user.email,
|
2022-02-21 14:39:59 +01:00
|
|
|
},
|
2022-01-14 12:14:02 +01:00
|
|
|
}),
|
|
|
|
);
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|
2021-07-07 10:46:50 +02:00
|
|
|
|
2022-07-25 12:11:16 +02:00
|
|
|
async changeGroupRole(
|
|
|
|
projectId: string,
|
|
|
|
roleId: number,
|
|
|
|
userId: number,
|
|
|
|
createdBy: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const usersWithRoles = await this.getAccessToProject(projectId);
|
|
|
|
const user = usersWithRoles.groups.find((u) => u.id === userId);
|
chore: remove uses of type errors from user-facing code (#3553)
BREAKING CHANGE: This changes the `name` property of a small number of error responses that we return. The property would have been `TypeError`, but is now `ValidationError` instead. It's a grey area, but I'd rather be strict.
---
This change removes uses of the `TypeError` type from user-facing code.
Type errors are used by typescript when you provide it the wrong type.
This is a valid concern. However, in the API, they're usually a signal
that **we've** done something wrong rather than the user having done
something wrong. As such, it makes more sense to return them as
validation errors or bad request errors.
## Breaking changes
Note that because of the way we handle errors, some of these changes
will be made visible to the end user, but only in the response body.
```ts
{ "name": "TypeError", "message": "Something is wrong", "isJoi": true }
```
will become
```ts
{ "name": "ValidationError", "message": "Something is wrong", "isJoi": true }
```
Technically, this could be considered a breaking change. However, as
we're gearing up for v5, this might be a good time to merge that?
## A return to 500
This PR also makes TypeErrors a 500-type error again because they should
never be caused by invalid data provided by the user
2023-04-18 13:42:07 +02:00
|
|
|
if (!user)
|
|
|
|
throw new ValidationError('Unexpected empty user', [], undefined);
|
2022-07-25 12:11:16 +02:00
|
|
|
const currentRole = usersWithRoles.roles.find(
|
|
|
|
(r) => r.id === user.roleId,
|
|
|
|
);
|
chore: remove uses of type errors from user-facing code (#3553)
BREAKING CHANGE: This changes the `name` property of a small number of error responses that we return. The property would have been `TypeError`, but is now `ValidationError` instead. It's a grey area, but I'd rather be strict.
---
This change removes uses of the `TypeError` type from user-facing code.
Type errors are used by typescript when you provide it the wrong type.
This is a valid concern. However, in the API, they're usually a signal
that **we've** done something wrong rather than the user having done
something wrong. As such, it makes more sense to return them as
validation errors or bad request errors.
## Breaking changes
Note that because of the way we handle errors, some of these changes
will be made visible to the end user, but only in the response body.
```ts
{ "name": "TypeError", "message": "Something is wrong", "isJoi": true }
```
will become
```ts
{ "name": "ValidationError", "message": "Something is wrong", "isJoi": true }
```
Technically, this could be considered a breaking change. However, as
we're gearing up for v5, this might be a good time to merge that?
## A return to 500
This PR also makes TypeErrors a 500-type error again because they should
never be caused by invalid data provided by the user
2023-04-18 13:42:07 +02:00
|
|
|
if (!currentRole)
|
|
|
|
throw new ValidationError(
|
|
|
|
'Unexpected empty current role',
|
|
|
|
[],
|
|
|
|
undefined,
|
|
|
|
);
|
2022-07-25 12:11:16 +02:00
|
|
|
|
|
|
|
if (currentRole.id === roleId) {
|
|
|
|
// Nothing to do....
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.validateAtLeastOneOwner(projectId, currentRole);
|
|
|
|
|
|
|
|
await this.accessService.updateGroupProjectRole(
|
|
|
|
userId,
|
|
|
|
roleId,
|
|
|
|
projectId,
|
|
|
|
);
|
|
|
|
const role = await this.findProjectGroupRole(projectId, roleId);
|
|
|
|
|
|
|
|
await this.eventStore.store(
|
|
|
|
new ProjectGroupUpdateRoleEvent({
|
|
|
|
project: projectId,
|
|
|
|
createdBy,
|
|
|
|
preData: {
|
|
|
|
userId,
|
|
|
|
roleId: currentRole.id,
|
|
|
|
roleName: currentRole.name,
|
|
|
|
},
|
|
|
|
data: {
|
|
|
|
userId,
|
|
|
|
roleId,
|
|
|
|
roleName: role.name,
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-07-07 10:46:50 +02:00
|
|
|
async getMembers(projectId: string): Promise<number> {
|
2022-08-17 11:05:41 +02:00
|
|
|
return this.store.getMembersCountByProject(projectId);
|
2021-07-07 10:46:50 +02:00
|
|
|
}
|
|
|
|
|
2023-03-15 14:44:08 +01:00
|
|
|
async getProjectUsers(
|
|
|
|
projectId: string,
|
|
|
|
): Promise<Array<Pick<IUser, 'id' | 'email' | 'username'>>> {
|
2023-08-25 10:31:37 +02:00
|
|
|
const { groups, users } = await this.accessService.getProjectRoleAccess(
|
2023-03-15 14:44:08 +01:00
|
|
|
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));
|
|
|
|
}
|
|
|
|
|
2022-09-29 15:27:54 +02:00
|
|
|
async getProjectsByUser(userId: number): Promise<string[]> {
|
|
|
|
return this.store.getProjectsByUser(userId);
|
|
|
|
}
|
|
|
|
|
2023-08-17 09:43:43 +02:00
|
|
|
async getProjectRoleUsage(roleId: number): Promise<IProjectRoleUsage[]> {
|
|
|
|
return this.accessService.getProjectRoleUsage(roleId);
|
|
|
|
}
|
|
|
|
|
2023-01-19 13:27:50 +01:00
|
|
|
async statusJob(): Promise<void> {
|
2023-04-17 11:21:52 +02:00
|
|
|
const projects = await this.store.getAll();
|
2023-01-19 13:27:50 +01:00
|
|
|
|
2023-04-17 11:21:52 +02:00
|
|
|
const statusUpdates = await Promise.all(
|
|
|
|
projects.map((project) => this.getStatusUpdates(project.id)),
|
|
|
|
);
|
2023-01-26 16:13:15 +01:00
|
|
|
|
2023-04-17 11:21:52 +02:00
|
|
|
await Promise.all(
|
|
|
|
statusUpdates.map((statusUpdate) => {
|
|
|
|
return this.projectStatsStore.updateProjectStats(
|
|
|
|
statusUpdate.projectId,
|
|
|
|
statusUpdate.updates,
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
);
|
2023-01-19 13:27:50 +01:00
|
|
|
}
|
|
|
|
|
2023-01-26 16:13:15 +01:00
|
|
|
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
|
|
|
|
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
|
|
|
|
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
|
|
|
|
|
2023-04-07 13:31:27 +02:00
|
|
|
const [
|
|
|
|
createdCurrentWindow,
|
|
|
|
createdPastWindow,
|
|
|
|
archivedCurrentWindow,
|
|
|
|
archivedPastWindow,
|
|
|
|
] = await Promise.all([
|
2023-04-06 15:34:08 +02:00
|
|
|
await this.featureToggleStore.countByDate({
|
2023-01-26 16:13:15 +01:00
|
|
|
project: projectId,
|
|
|
|
dateAccessor: 'created_at',
|
|
|
|
date: dateMinusThirtyDays,
|
|
|
|
}),
|
2023-04-06 15:34:08 +02:00
|
|
|
await this.featureToggleStore.countByDate({
|
2023-01-26 16:13:15 +01:00
|
|
|
project: projectId,
|
|
|
|
dateAccessor: 'created_at',
|
|
|
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
|
|
|
}),
|
2023-04-06 15:34:08 +02:00
|
|
|
await this.featureToggleStore.countByDate({
|
2023-01-26 16:13:15 +01:00
|
|
|
project: projectId,
|
|
|
|
archived: true,
|
|
|
|
dateAccessor: 'archived_at',
|
|
|
|
date: dateMinusThirtyDays,
|
|
|
|
}),
|
2023-04-06 15:34:08 +02:00
|
|
|
await this.featureToggleStore.countByDate({
|
2023-01-26 16:13:15 +01:00
|
|
|
project: projectId,
|
|
|
|
archived: true,
|
|
|
|
dateAccessor: 'archived_at',
|
|
|
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
|
|
|
}),
|
|
|
|
]);
|
|
|
|
|
|
|
|
const [projectActivityCurrentWindow, projectActivityPastWindow] =
|
|
|
|
await Promise.all([
|
2023-03-27 11:24:01 +02:00
|
|
|
this.eventStore.queryCount([
|
2023-01-26 16:13:15 +01:00
|
|
|
{ op: 'where', parameters: { project: projectId } },
|
|
|
|
{
|
|
|
|
op: 'beforeDate',
|
|
|
|
parameters: {
|
|
|
|
dateAccessor: 'created_at',
|
|
|
|
date: dateMinusThirtyDays,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
]),
|
2023-03-27 11:24:01 +02:00
|
|
|
this.eventStore.queryCount([
|
2023-01-26 16:13:15 +01:00
|
|
|
{ op: 'where', parameters: { project: projectId } },
|
|
|
|
{
|
|
|
|
op: 'betweenDate',
|
|
|
|
parameters: {
|
|
|
|
dateAccessor: 'created_at',
|
|
|
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
|
|
|
},
|
|
|
|
},
|
|
|
|
]),
|
|
|
|
]);
|
|
|
|
|
2023-04-10 09:50:39 +02:00
|
|
|
const avgTimeToProdCurrentWindow = calculateAverageTimeToProd(
|
|
|
|
await this.projectStatsStore.getTimeToProdDates(projectId),
|
2023-01-19 13:27:50 +01:00
|
|
|
);
|
2023-01-26 16:13:15 +01:00
|
|
|
|
2023-01-27 13:13:41 +01:00
|
|
|
const projectMembersAddedCurrentWindow =
|
|
|
|
await this.store.getMembersCountByProjectAfterDate(
|
|
|
|
projectId,
|
|
|
|
dateMinusThirtyDays,
|
|
|
|
);
|
|
|
|
|
2023-01-26 16:13:15 +01:00
|
|
|
return {
|
|
|
|
projectId,
|
|
|
|
updates: {
|
2023-04-07 13:31:27 +02:00
|
|
|
avgTimeToProdCurrentWindow,
|
|
|
|
createdCurrentWindow,
|
|
|
|
createdPastWindow,
|
|
|
|
archivedCurrentWindow,
|
|
|
|
archivedPastWindow,
|
|
|
|
projectActivityCurrentWindow,
|
|
|
|
projectActivityPastWindow,
|
|
|
|
projectMembersAddedCurrentWindow,
|
2023-01-26 16:13:15 +01:00
|
|
|
},
|
|
|
|
};
|
2023-01-19 13:27:50 +01:00
|
|
|
}
|
|
|
|
|
2021-07-07 10:46:50 +02:00
|
|
|
async getProjectOverview(
|
|
|
|
projectId: string,
|
|
|
|
archived: boolean = false,
|
2023-01-18 13:22:58 +01:00
|
|
|
userId?: number,
|
2021-07-07 10:46:50 +02:00
|
|
|
): Promise<IProjectOverview> {
|
2023-02-07 08:57:28 +01:00
|
|
|
const [
|
|
|
|
project,
|
|
|
|
environments,
|
|
|
|
features,
|
|
|
|
members,
|
|
|
|
favorite,
|
|
|
|
projectStats,
|
|
|
|
] = await Promise.all([
|
|
|
|
this.store.get(projectId),
|
|
|
|
this.store.getEnvironmentsForProject(projectId),
|
|
|
|
this.featureToggleService.getFeatureOverview({
|
|
|
|
projectId,
|
|
|
|
archived,
|
|
|
|
userId,
|
|
|
|
}),
|
|
|
|
this.store.getMembersCountByProject(projectId),
|
2023-03-14 10:32:00 +01:00
|
|
|
userId
|
|
|
|
? this.favoritesService.isFavoriteProject({
|
|
|
|
project: projectId,
|
|
|
|
userId,
|
|
|
|
})
|
|
|
|
: Promise.resolve(false),
|
2023-02-07 08:57:28 +01:00
|
|
|
this.projectStatsStore.getProjectStats(projectId),
|
|
|
|
]);
|
2023-08-30 14:39:43 +02:00
|
|
|
|
2021-07-07 10:46:50 +02:00
|
|
|
return {
|
2023-01-27 17:19:27 +01:00
|
|
|
stats: projectStats,
|
2021-07-07 10:46:50 +02:00
|
|
|
name: project.name,
|
|
|
|
description: project.description,
|
2023-03-16 15:29:52 +01:00
|
|
|
mode: project.mode,
|
2023-07-13 13:02:35 +02:00
|
|
|
featureLimit: project.featureLimit,
|
2023-09-04 13:53:33 +02:00
|
|
|
featureNaming: project.featureNaming,
|
2023-04-12 15:22:13 +02:00
|
|
|
defaultStickiness: project.defaultStickiness,
|
2023-03-14 10:32:00 +01:00
|
|
|
health: project.health || 0,
|
2023-01-18 13:22:58 +01:00
|
|
|
favorite: favorite,
|
|
|
|
updatedAt: project.updatedAt,
|
2023-06-09 16:18:38 +02:00
|
|
|
createdAt: project.createdAt,
|
2023-01-18 13:22:58 +01:00
|
|
|
environments,
|
2021-07-07 10:46:50 +02:00
|
|
|
features,
|
|
|
|
members,
|
|
|
|
version: 1,
|
|
|
|
};
|
|
|
|
}
|
2021-03-11 22:51:58 +01:00
|
|
|
}
|