diff --git a/src/lib/db/favorite-projects-store.ts b/src/lib/db/favorite-projects-store.ts new file mode 100644 index 0000000000..671f45f486 --- /dev/null +++ b/src/lib/db/favorite-projects-store.ts @@ -0,0 +1,96 @@ +import EventEmitter from 'events'; +import { Logger, LogProvider } from '../logger'; +import { Knex } from 'knex'; +import { IFavoriteProject } from '../types/favorites'; +import { + IFavoriteProjectKey, + IFavoriteProjectsStore, +} from '../types/stores/favorite-projects'; + +const T = { + FAVORITE_PROJECTS: 'favorite_projects', +}; + +interface IFavoriteProjectRow { + user_id: number; + project: string; + created_at: Date; +} + +const rowToFavorite = (row: IFavoriteProjectRow) => { + return { + userId: row.user_id, + project: row.project, + createdAt: row.created_at, + }; +}; + +export class FavoriteProjectsStore implements IFavoriteProjectsStore { + private logger: Logger; + + private eventBus: EventEmitter; + + private db: Knex; + + constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { + this.db = db; + this.eventBus = eventBus; + this.logger = getLogger('lib/db/favorites-store.ts'); + } + + async addFavoriteProject({ + userId, + project, + }: IFavoriteProjectKey): Promise { + const insertedProject = await this.db( + T.FAVORITE_PROJECTS, + ) + .insert({ project, user_id: userId }) + .onConflict(['user_id', 'project']) + .merge() + .returning('*'); + + return rowToFavorite(insertedProject[0]); + } + + async delete({ userId, project }: IFavoriteProjectKey): Promise { + return this.db(T.FAVORITE_PROJECTS) + .where({ project, user_id: userId }) + .del(); + } + + async deleteAll(): Promise { + await this.db(T.FAVORITE_PROJECTS).del(); + } + + destroy(): void {} + + async exists({ userId, project }: IFavoriteProjectKey): Promise { + const result = await this.db.raw( + `SELECT EXISTS(SELECT 1 FROM ${T.FAVORITE_PROJECTS} WHERE user_id = ? AND project = ?) AS present`, + [userId, project], + ); + const { present } = result.rows[0]; + return present; + } + + async get({ + userId, + project, + }: IFavoriteProjectKey): Promise { + const favorite = await this.db + .table(T.FAVORITE_PROJECTS) + .select() + .where({ project, user_id: userId }) + .first(); + + return rowToFavorite(favorite); + } + + async getAll(): Promise { + const groups = await this.db( + T.FAVORITE_PROJECTS, + ).select(); + return groups.map(rowToFavorite); + } +} diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index 97c1ede7d0..1015cf50c6 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -22,6 +22,7 @@ import { ensureStringValue } from '../util/ensureStringValue'; import { mapValues } from '../util/map-values'; import { IFlagResolver } from '../types/experimental'; import { IFeatureProjectUserParams } from '../routes/admin-api/project/features'; +import Raw = Knex.Raw; const COLUMNS = [ 'id', @@ -257,7 +258,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { .where('name', featureName) .modify(FeatureToggleStore.filterByArchived, archived); - let selectColumns = ['features_view.*']; + let selectColumns = ['features_view.*'] as (string | Raw)[]; if (userId && this.flagResolver.isEnabled('favorites')) { query = query.leftJoin(`favorite_features`, function () { this.on( @@ -267,7 +268,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { }); selectColumns = [ ...selectColumns, - 'favorite_features.feature as favorite', + this.db.raw( + 'favorite_features.feature is not null as favorite', + ), ]; } const rows = await query.select(selectColumns); @@ -279,7 +282,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { } acc.name = r.name; - acc.favorite = r.favorite != null; + acc.favorite = r.favorite; acc.impressionData = r.impression_data; acc.description = r.description; acc.project = r.project; @@ -428,7 +431,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { 'feature_environments.environment as environment', 'environments.type as environment_type', 'environments.sort_order as environment_sort_order', - ]; + ] as (string | Raw)[]; if (this.flagResolver.isEnabled('toggleTagFiltering')) { query = query.leftJoin( @@ -443,14 +446,19 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { ]; } if (userId && this.flagResolver.isEnabled('favorites')) { - query = query.leftJoin(`favorite_features as ff`, function () { - this.on('ff.feature', 'features.name').andOnVal( - 'ff.user_id', + query = query.leftJoin(`favorite_features`, function () { + this.on('favorite_features.feature', 'features.name').andOnVal( + 'favorite_features.user_id', '=', userId, ); }); - selectColumns = [...selectColumns, 'ff.feature as favorite']; + selectColumns = [ + ...selectColumns, + this.db.raw( + 'favorite_features.feature is not null as favorite', + ), + ]; } query = query.select(selectColumns); @@ -469,7 +477,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { } else { acc[r.feature_name] = { type: r.type, - favorite: r.favorite != null, + favorite: r.favorite, name: r.feature_name, createdAt: r.created_at, lastSeenAt: r.last_seen_at, diff --git a/src/lib/db/feature-toggle-client-store.ts b/src/lib/db/feature-toggle-client-store.ts index a3908feb0f..81ad4e8ffa 100644 --- a/src/lib/db/feature-toggle-client-store.ts +++ b/src/lib/db/feature-toggle-client-store.ts @@ -16,6 +16,7 @@ import FeatureToggleStore from './feature-toggle-store'; import { ensureStringValue } from '../util/ensureStringValue'; import { mapValues } from '../util/map-values'; import { IFlagResolver } from '../types/experimental'; +import Raw = Knex.Raw; export interface FeaturesTable { name: string; @@ -101,7 +102,7 @@ export default class FeatureToggleClientStore 'fs.constraints as constraints', 'segments.id as segment_id', 'segments.constraints as segment_constraints', - ]; + ] as (string | Raw)[]; let query = this.db('features') .modify(FeatureToggleStore.filterByArchived, archived) @@ -148,14 +149,18 @@ export default class FeatureToggleClientStore } if (userId && this.flagResolver.isEnabled('favorites')) { - query = query.leftJoin(`favorite_features as ff`, function () { - this.on('ff.feature', 'features.name').andOnVal( - 'ff.user_id', - '=', - userId, - ); + query = query.leftJoin(`favorite_features`, function () { + this.on( + 'favorite_features.feature', + 'features.name', + ).andOnVal('favorite_features.user_id', '=', userId); }); - selectColumns = [...selectColumns, 'ff.feature as favorite']; + selectColumns = [ + ...selectColumns, + this.db.raw( + 'favorite_features.feature is not null as favorite', + ), + ]; } } @@ -207,7 +212,7 @@ export default class FeatureToggleClientStore feature.impressionData = r.impression_data; feature.enabled = !!r.enabled; feature.name = r.name; - feature.favorite = r.favorite != null; + feature.favorite = r.favorite; feature.description = r.description; feature.project = r.project; feature.stale = r.stale; diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 1f8e4e3542..4cff815106 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -33,6 +33,7 @@ import GroupStore from './group-store'; import PatStore from './pat-store'; import { PublicSignupTokenStore } from './public-signup-token-store'; import { FavoriteFeaturesStore } from './favorite-features-store'; +import { FavoriteProjectsStore } from './favorite-projects-store'; export const createStores = ( config: IUnleashConfig, @@ -56,7 +57,12 @@ export const createStores = ( contextFieldStore: new ContextFieldStore(db, getLogger), settingStore: new SettingStore(db, getLogger), userStore: new UserStore(db, getLogger), - projectStore: new ProjectStore(db, eventBus, getLogger), + projectStore: new ProjectStore( + db, + eventBus, + getLogger, + config.flagResolver, + ), tagStore: new TagStore(db, eventBus, getLogger), tagTypeStore: new TagTypeStore(db, eventBus, getLogger), addonStore: new AddonStore(db, eventBus, getLogger), @@ -100,6 +106,11 @@ export const createStores = ( eventBus, getLogger, ), + favoriteProjectsStore: new FavoriteProjectsStore( + db, + eventBus, + getLogger, + ), }; }; diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index 07e93f2310..d49bd08b9f 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -13,6 +13,8 @@ import { DEFAULT_ENV } from '../util/constants'; import metricsHelper from '../util/metrics-helper'; import { DB_TIME } from '../metric-events'; import EventEmitter from 'events'; +import { IFlagResolver } from '../types'; +import Raw = Knex.Raw; const COLUMNS = [ 'id', @@ -39,9 +41,16 @@ class ProjectStore implements IProjectStore { private logger: Logger; + private flagResolver: IFlagResolver; + private timer: Function; - constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { + constructor( + db: Knex, + eventBus: EventEmitter, + getLogger: LogProvider, + flagResolver: IFlagResolver, + ) { this.db = db; this.logger = getLogger('project-store.ts'); this.timer = (action) => @@ -49,6 +58,7 @@ class ProjectStore implements IProjectStore { store: 'project', action, }); + this.flagResolver = flagResolver; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -73,21 +83,43 @@ class ProjectStore implements IProjectStore { async getProjectsWithCounts( query?: IProjectQuery, + userId?: number, ): Promise { const projectTimer = this.timer('getProjectsWithCount'); let projects = this.db(TABLE) - .select( - this.db.raw( - 'projects.id, projects.name, projects.description, projects.health, projects.updated_at, count(features.name) AS number_of_features', - ), - ) .leftJoin('features', 'features.project', 'projects.id') - .groupBy('projects.id') .orderBy('projects.name', 'asc'); if (query) { projects = projects.where(query); } - const projectAndFeatureCount = await projects; + let selectColumns = [ + this.db.raw( + 'projects.id, projects.name, projects.description, projects.health, projects.updated_at, count(features.name) AS number_of_features', + ), + ] as (string | Raw)[]; + + let groupByColumns = ['projects.id']; + + if (userId && this.flagResolver.isEnabled('favorites')) { + projects = projects.leftJoin(`favorite_projects`, function () { + this.on('favorite_projects.project', 'projects.id').andOnVal( + 'favorite_projects.user_id', + '=', + userId, + ); + }); + selectColumns = [ + ...selectColumns, + this.db.raw( + 'favorite_projects.project is not null as favorite', + ), + ]; + groupByColumns = [...groupByColumns, 'favorite_projects.project']; + } + + const projectAndFeatureCount = await projects + .select(selectColumns) + .groupBy(groupByColumns); const projectsWithFeatureCount = projectAndFeatureCount.map( this.mapProjectWithCountRow, @@ -112,6 +144,7 @@ class ProjectStore implements IProjectStore { id: row.id, description: row.description, health: row.health, + favorite: row.favorite, featureCount: Number(row.number_of_features) || 0, memberCount: Number(row.number_of_users) || 0, updatedAt: row.updated_at, diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index ca75279126..e3a2956cb5 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -45,6 +45,9 @@ export const healthOverviewSchema = { format: 'date-time', nullable: true, }, + favorite: { + type: 'boolean', + }, }, components: { schemas: { diff --git a/src/lib/openapi/spec/project-schema.ts b/src/lib/openapi/spec/project-schema.ts index 0fab13a074..09b5de7a35 100644 --- a/src/lib/openapi/spec/project-schema.ts +++ b/src/lib/openapi/spec/project-schema.ts @@ -36,6 +36,9 @@ export const projectSchema = { changeRequestsEnabled: { type: 'boolean', }, + favorite: { + type: 'boolean', + }, }, components: {}, } as const; diff --git a/src/lib/routes/admin-api/favorites.ts b/src/lib/routes/admin-api/favorites.ts index 091a20fd18..77db15e9d5 100644 --- a/src/lib/routes/admin-api/favorites.ts +++ b/src/lib/routes/admin-api/favorites.ts @@ -52,6 +52,34 @@ export default class FavoritesController extends Controller { }), ], }); + + this.route({ + method: 'post', + path: '/:projectId/favorites', + handler: this.addFavoriteProject, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Features'], + operationId: 'addFavoriteProject', + responses: { 200: emptyResponse }, + }), + ], + }); + + this.route({ + method: 'delete', + path: '/:projectId/favorites', + handler: this.removeFavoriteProject, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Features'], + operationId: 'removeFavoriteProject', + responses: { 200: emptyResponse }, + }), + ], + }); } async addFavoriteFeature( @@ -79,4 +107,30 @@ export default class FavoritesController extends Controller { }); res.status(200).end(); } + + async addFavoriteProject( + req: IAuthRequest<{ projectId: string }>, + res: Response, + ): Promise { + const { projectId } = req.params; + const { user } = req; + await this.favoritesService.addFavoriteProject({ + project: projectId, + userId: user.id, + }); + res.status(200).end(); + } + + async removeFavoriteProject( + req: IAuthRequest<{ projectId: string }>, + res: Response, + ): Promise { + const { projectId } = req.params; + const { user } = req; + await this.favoritesService.removeFavoriteProject({ + project: projectId, + userId: user.id, + }); + res.status(200).end(); + } } diff --git a/src/lib/routes/admin-api/project/health-report.ts b/src/lib/routes/admin-api/project/health-report.ts index 79835ebde2..906fbd6feb 100644 --- a/src/lib/routes/admin-api/project/health-report.ts +++ b/src/lib/routes/admin-api/project/health-report.ts @@ -17,6 +17,7 @@ import { healthReportSchema, HealthReportSchema, } from '../../../openapi/spec/health-report-schema'; +import { IAuthRequest } from '../../unleash-types'; export default class ProjectHealthReport extends Controller { private projectHealthService: ProjectHealthService; @@ -71,14 +72,16 @@ export default class ProjectHealthReport extends Controller { } async getProjectHealthOverview( - req: Request, + req: IAuthRequest, res: Response, ): Promise { const { projectId } = req.params; const { archived } = req.query; + const { user } = req; const overview = await this.projectHealthService.getProjectOverview( projectId, archived, + user.id, ); this.openApiService.respondWithValidation( 200, diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index fda44c9d0c..e5a91fa7fa 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express'; +import { Response } from 'express'; import Controller from '../../controller'; import { IUnleashConfig } from '../../../types/option'; import { IUnleashServices } from '../../../types/services'; @@ -15,6 +15,7 @@ import { import { OpenApiService } from '../../../services/openapi-service'; import { serializeDates } from '../../../types/serialize-dates'; import { createResponseSchema } from '../../../openapi/util/create-response-schema'; +import { IAuthRequest } from '../../unleash-types'; export default class ProjectApi extends Controller { private projectService: ProjectService; @@ -49,12 +50,16 @@ export default class ProjectApi extends Controller { } async getProjects( - req: Request, + req: IAuthRequest, res: Response, ): Promise { - const projects = await this.projectService.getProjects({ - id: 'default', - }); + const { user } = req; + const projects = await this.projectService.getProjects( + { + id: 'default', + }, + user.id, + ); this.openApiService.respondWithValidation( 200, diff --git a/src/lib/services/favorites-service.ts b/src/lib/services/favorites-service.ts index c459f882bd..426ea8b2d5 100644 --- a/src/lib/services/favorites-service.ts +++ b/src/lib/services/favorites-service.ts @@ -5,7 +5,11 @@ import { IFavoriteFeatureKey, IFavoriteFeaturesStore, } from '../types/stores/favorite-features'; -import { IFavoriteFeature } from '../types/favorites'; +import { IFavoriteFeature, IFavoriteProject } from '../types/favorites'; +import { + IFavoriteProjectKey, + IFavoriteProjectsStore, +} from '../types/stores/favorite-projects'; export class FavoritesService { private config: IUnleashConfig; @@ -14,15 +18,22 @@ export class FavoritesService { private favoriteFeaturesStore: IFavoriteFeaturesStore; + private favoriteProjectsStore: IFavoriteProjectsStore; + constructor( { favoriteFeaturesStore, - }: Pick, + favoriteProjectsStore, + }: Pick< + IUnleashStores, + 'favoriteFeaturesStore' | 'favoriteProjectsStore' + >, config: IUnleashConfig, ) { this.config = config; this.logger = config.getLogger('services/favorites-service.ts'); this.favoriteFeaturesStore = favoriteFeaturesStore; + this.favoriteProjectsStore = favoriteProjectsStore; } async addFavoriteFeature( @@ -34,4 +45,27 @@ export class FavoritesService { async removeFavoriteFeature(favorite: IFavoriteFeatureKey): Promise { return this.favoriteFeaturesStore.delete(favorite); } + + async addFavoriteProject( + favorite: IFavoriteProjectKey, + ): Promise { + return this.favoriteProjectsStore.addFavoriteProject(favorite); + } + + async removeFavoriteProject(favorite: IFavoriteProjectKey): Promise { + return this.favoriteProjectsStore.delete(favorite); + } + + async isFavoriteProject( + projectId: string, + userId?: number, + ): Promise { + if (userId) { + return this.favoriteProjectsStore.exists({ + project: projectId, + userId, + }); + } + return Promise.resolve(false); + } } diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 8ae9d88b86..f0a310ef53 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -84,10 +84,12 @@ export const createServices = ( ); const environmentService = new EnvironmentService(stores, config); const featureTagService = new FeatureTagService(stores, config); + const favoritesService = new FavoritesService(stores, config); const projectHealthService = new ProjectHealthService( stores, config, featureToggleServiceV2, + favoritesService, ); const projectService = new ProjectService( stores, @@ -124,7 +126,6 @@ export const createServices = ( config, versionService, ); - const favoritesService = new FavoritesService(stores, config); return { accessService, diff --git a/src/lib/services/project-health-service.ts b/src/lib/services/project-health-service.ts index aacf14a685..74cf67a80f 100644 --- a/src/lib/services/project-health-service.ts +++ b/src/lib/services/project-health-service.ts @@ -14,6 +14,7 @@ import { IProjectStore } from '../types/stores/project-store'; import FeatureToggleService from './feature-toggle-service'; import { hoursToMilliseconds } from 'date-fns'; import Timer = NodeJS.Timer; +import { FavoritesService } from './favorites-service'; export default class ProjectHealthService { private logger: Logger; @@ -30,6 +31,8 @@ export default class ProjectHealthService { private featureToggleService: FeatureToggleService; + private favoritesService: FavoritesService; + constructor( { projectStore, @@ -41,6 +44,7 @@ export default class ProjectHealthService { >, { getLogger }: Pick, featureToggleService: FeatureToggleService, + favoritesService: FavoritesService, ) { this.logger = getLogger('services/project-health-service.ts'); this.projectStore = projectStore; @@ -52,12 +56,14 @@ export default class ProjectHealthService { hoursToMilliseconds(1), ).unref(); this.featureToggleService = featureToggleService; + this.favoritesService = favoritesService; } // TODO: duplicate from project-service. async getProjectOverview( projectId: string, archived: boolean = false, + userId?: number, ): Promise { const project = await this.projectStore.get(projectId); const environments = await this.projectStore.getEnvironmentsForProject( @@ -70,10 +76,16 @@ export default class ProjectHealthService { const members = await this.projectStore.getMembersCountByProject( projectId, ); + + const favorite = await this.favoritesService.isFavoriteProject( + projectId, + userId, + ); return { name: project.name, description: project.description, health: project.health, + favorite: favorite, updatedAt: project.updatedAt, environments, features, @@ -85,7 +97,11 @@ export default class ProjectHealthService { async getProjectHealthReport( projectId: string, ): Promise { - const overview = await this.getProjectOverview(projectId, false); + const overview = await this.getProjectOverview( + projectId, + false, + undefined, + ); return { ...overview, potentiallyStaleCount: await this.potentiallyStaleCount( diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 037880538c..e1d0cdc19a 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -120,8 +120,11 @@ export default class ProjectService { this.logger = config.getLogger('services/project-service.js'); } - async getProjects(query?: IProjectQuery): Promise { - return this.store.getProjectsWithCounts(query); + async getProjects( + query?: IProjectQuery, + userId?: number, + ): Promise { + return this.store.getProjectsWithCounts(query, userId); } async getProject(id: string): Promise { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 2288830a0c..1c75c894be 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -70,7 +70,6 @@ export interface IFeatureToggleClient { lastSeenAt?: Date; createdAt?: Date; tags?: ITag[]; - favorite?: boolean; } @@ -176,6 +175,7 @@ export interface IProjectOverview { members: number; version: number; health: number; + favorite?: boolean; updatedAt?: Date; } @@ -371,6 +371,7 @@ export interface ICustomRole { export interface IProjectWithCount extends IProject { featureCount: number; memberCount: number; + favorite?: boolean; } export interface ISegment { diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 828e3f92cd..88d5ba4376 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -29,6 +29,7 @@ import { IGroupStore } from './stores/group-store'; import { IPatStore } from './stores/pat-store'; import { IPublicSignupTokenStore } from './stores/public-signup-token-store'; import { IFavoriteFeaturesStore } from './stores/favorite-features'; +import { IFavoriteProjectsStore } from './stores/favorite-projects'; export interface IUnleashStores { accessStore: IAccessStore; @@ -62,6 +63,7 @@ export interface IUnleashStores { patStore: IPatStore; publicSignupTokenStore: IPublicSignupTokenStore; favoriteFeaturesStore: IFavoriteFeaturesStore; + favoriteProjectsStore: IFavoriteProjectsStore; } export { @@ -96,4 +98,5 @@ export { IUserSplashStore, IUserStore, IFavoriteFeaturesStore, + IFavoriteProjectsStore, }; diff --git a/src/lib/types/stores/favorite-projects.ts b/src/lib/types/stores/favorite-projects.ts new file mode 100644 index 0000000000..3af0813cb3 --- /dev/null +++ b/src/lib/types/stores/favorite-projects.ts @@ -0,0 +1,14 @@ +import { IFavoriteProject } from '../favorites'; +import { Store } from './store'; + +export interface IFavoriteProjectKey { + userId: number; + project: string; +} + +export interface IFavoriteProjectsStore + extends Store { + addFavoriteProject( + favorite: IFavoriteProjectKey, + ): Promise; +} diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index c574d318b5..6a3de1e2c0 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -34,21 +34,37 @@ export interface IProjectEnvironmentWithChangeRequests { export interface IProjectStore extends Store { hasProject(id: string): Promise; + updateHealth(healthUpdate: IProjectHealthUpdate): Promise; + create(project: IProjectInsert): Promise; + update(update: IProjectInsert): Promise; + importProjects( projects: IProjectInsert[], environments?: IEnvironment[], ): Promise; + addEnvironmentToProject(id: string, environment: string): Promise; + deleteEnvironmentForProject(id: string, environment: string): Promise; + getEnvironmentsForProject(id: string): Promise; + getMembersCountByProject(projectId: string): Promise; + getProjectsByUser(userId: number): Promise; + getMembersCount(): Promise; - getProjectsWithCounts(query?: IProjectQuery): Promise; + + getProjectsWithCounts( + query?: IProjectQuery, + userId?: number, + ): Promise; + count(): Promise; + getAll(query?: IProjectQuery): Promise; getProjectLinksForEnvironments( diff --git a/src/test/e2e/api/admin/favorites.e2e.test.ts b/src/test/e2e/api/admin/favorites.e2e.test.ts index ce86fcf2e6..624a5d6868 100644 --- a/src/test/e2e/api/admin/favorites.e2e.test.ts +++ b/src/test/e2e/api/admin/favorites.e2e.test.ts @@ -51,6 +51,34 @@ const unfavoriteFeature = async (featureName: string) => { .expect(200); }; +const favoriteProject = async (projectName = 'default') => { + await app.request + .post(`/api/admin/projects/${projectName}/favorites`) + .set('Content-Type', 'application/json') + .expect(200); +}; + +const unfavoriteProject = async (projectName = 'default') => { + await app.request + .delete(`/api/admin/projects/${projectName}/favorites`) + .set('Content-Type', 'application/json') + .expect(200); +}; + +const getProject = async (projectName = 'default') => { + return app.request + .get(`/api/admin/projects/${projectName}`) + .set('Content-Type', 'application/json') + .expect(200); +}; + +const getProjects = async () => { + return app.request + .get(`/api/admin/projects`) + .set('Content-Type', 'application/json') + .expect(200); +}; + beforeAll(async () => { db = await dbInit('favorites_api_serial', getLogger); app = await setupAppWithAuth(db.stores); @@ -148,7 +176,6 @@ test('should be favorited in project single feature endpoint', async () => { test('should be able to unfavorite feature', async () => { const featureName = 'test-feature'; await createFeature(featureName); - await favoriteFeature(featureName); await unfavoriteFeature(featureName); @@ -162,3 +189,38 @@ test('should be able to unfavorite feature', async () => { favorite: false, }); }); + +test('should be favorited in projects list', async () => { + await favoriteProject(); + + const { body } = await getProjects(); + + expect(body.projects).toHaveLength(1); + expect(body.projects[0]).toMatchObject({ + name: 'Default', + favorite: true, + }); +}); + +test('should be favorited in single project endpoint', async () => { + await favoriteProject(); + + const { body } = await getProject(); + + expect(body).toMatchObject({ + name: 'Default', + favorite: true, + }); +}); + +test('project should not be favorited by default', async () => { + await favoriteProject(); + await unfavoriteProject(); + + const { body } = await getProject(); + + expect(body).toMatchObject({ + name: 'Default', + favorite: false, + }); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 90117cf899..0325d71276 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -1549,6 +1549,9 @@ exports[`should serve the OpenAPI spec 1`] = ` }, "type": "array", }, + "favorite": { + "type": "boolean", + }, "features": { "items": { "$ref": "#/components/schemas/featureSchema", @@ -1594,6 +1597,9 @@ exports[`should serve the OpenAPI spec 1`] = ` }, "type": "array", }, + "favorite": { + "type": "boolean", + }, "features": { "items": { "$ref": "#/components/schemas/featureSchema", @@ -2352,6 +2358,9 @@ exports[`should serve the OpenAPI spec 1`] = ` "description": { "type": "string", }, + "favorite": { + "type": "boolean", + }, "featureCount": { "type": "number", }, @@ -5028,6 +5037,50 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/projects/{projectId}/favorites": { + "delete": { + "operationId": "removeFavoriteProject", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Features", + ], + }, + "post": { + "operationId": "addFavoriteProject", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Features", + ], + }, + }, "/api/admin/projects/{projectId}/features": { "get": { "operationId": "getFeatures", diff --git a/src/test/e2e/services/project-health-service.e2e.test.ts b/src/test/e2e/services/project-health-service.e2e.test.ts index d6155d7ce1..a445420ba3 100644 --- a/src/test/e2e/services/project-health-service.e2e.test.ts +++ b/src/test/e2e/services/project-health-service.e2e.test.ts @@ -9,6 +9,7 @@ import { IUnleashStores } from '../../../lib/types'; import { IUser } from '../../../lib/server-impl'; import { SegmentService } from '../../../lib/services/segment-service'; import { GroupService } from '../../../lib/services/group-service'; +import { FavoritesService } from '../../../lib/services'; let stores: IUnleashStores; let db: ITestDb; @@ -17,6 +18,7 @@ let groupService; let accessService; let projectHealthService; let featureToggleService; +let favoritesService; let user: IUser; beforeAll(async () => { @@ -42,10 +44,12 @@ beforeAll(async () => { featureToggleService, groupService, ); + favoritesService = new FavoritesService(stores, config); projectHealthService = new ProjectHealthService( stores, config, featureToggleService, + favoritesService, ); }); diff --git a/src/test/fixtures/fake-favorite-projects-store.ts b/src/test/fixtures/fake-favorite-projects-store.ts new file mode 100644 index 0000000000..e78889de36 --- /dev/null +++ b/src/test/fixtures/fake-favorite-projects-store.ts @@ -0,0 +1,35 @@ +import { IFavoriteProjectsStore } from '../../lib/types'; +import { IFavoriteProjectKey } from '../../lib/types/stores/favorite-projects'; +import { IFavoriteProject } from '../../lib/types/favorites'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +export default class FakeFavoriteProjectsStore + implements IFavoriteProjectsStore +{ + addFavoriteProject( + favorite: IFavoriteProjectKey, + ): Promise { + return Promise.resolve(undefined); + } + + delete(key: IFavoriteProjectKey): Promise { + return Promise.resolve(undefined); + } + + deleteAll(): Promise { + return Promise.resolve(undefined); + } + + destroy(): void {} + + exists(key: IFavoriteProjectKey): Promise { + return Promise.resolve(false); + } + + get(key: IFavoriteProjectKey): Promise { + return Promise.resolve(undefined); + } + + getAll(query?: Object): Promise { + return Promise.resolve([]); + } +} diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index e1c3b00df5..1657130958 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -30,6 +30,7 @@ import FakeGroupStore from './fake-group-store'; import FakePatStore from './fake-pat-store'; import FakePublicSignupStore from './fake-public-signup-store'; import FakeFavoriteFeaturesStore from './fake-favorite-features-store'; +import FakeFavoriteProjectsStore from './fake-favorite-projects-store'; const createStores: () => IUnleashStores = () => { const db = { @@ -71,6 +72,7 @@ const createStores: () => IUnleashStores = () => { patStore: new FakePatStore(), publicSignupTokenStore: new FakePublicSignupStore(), favoriteFeaturesStore: new FakeFavoriteFeaturesStore(), + favoriteProjectsStore: new FakeFavoriteProjectsStore(), }; };