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-01-13 11:14:17 +01:00
import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store' ;
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' ;
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
2022-01-13 11:14:17 +01:00
private tagStore : IFeatureTagStore ;
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 ,
2022-01-13 11:14:17 +01:00
featureTagStore ,
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'
2022-01-13 11:14:17 +01:00
| 'featureTagStore'
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 ,
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 ;
2022-01-13 11:14:17 +01:00
this . tagStore = featureTagStore ;
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 [ ] > {
return this . store . getProjectsWithCounts ( query , userId ) ;
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-04 13:53:33 +02:00
private validateFlagNaming = ( naming? : IFeatureNaming ) = > {
if ( naming ) {
const { pattern , example } = naming ;
if (
pattern != null &&
example &&
! example . match ( new RegExp ( pattern ) )
) {
throw new BadDataError (
` You've provided a feature flag naming example (" ${ example } ") that doesn't match your feature flag naming pattern (" ${ pattern } "). Please provide an example that matches your supplied pattern. ` ,
) ;
}
2023-09-05 11:09:55 +02:00
if ( ! pattern && example ) {
throw new BadDataError (
"You've provided a feature flag naming example, but no feature flag naming pattern. You must specify a pattern to use an example." ,
) ;
}
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-04 13:53:33 +02:00
this . validateFlagNaming ( data . featureNaming ) ;
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 ) {
this . validateFlagNaming ( updatedProject . featureNaming ) ;
}
if (
updatedProject . featureNaming ? . pattern &&
! updatedProject . featureNaming ? . example
) {
updatedProject . featureNaming . example = null ;
}
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
}