diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 87b905d3e2..ef17d0de6a 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -52,8 +52,8 @@ import { LargestResourcesReadModel } from '../features/metrics/sizes/largest-res import { IntegrationEventsStore } from '../features/integration-events/integration-events-store'; import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model'; import { createProjectReadModel } from '../features/project/createProjectReadModel'; -import { OnboardingReadModel } from '../features/onboarding/onboarding-read-model'; import { OnboardingStore } from '../features/onboarding/onboarding-store'; +import { createOnboardingReadModel } from '../features/onboarding/createOnboardingReadModel'; export const createStores = ( config: IUnleashConfig, @@ -173,7 +173,7 @@ export const createStores = ( projectFlagCreatorsReadModel: new ProjectFlagCreatorsReadModel(db), featureLifecycleStore: new FeatureLifecycleStore(db), featureStrategiesReadModel: new FeatureStrategiesReadModel(db), - onboardingReadModel: new OnboardingReadModel(db), + onboardingReadModel: createOnboardingReadModel(db), onboardingStore: new OnboardingStore(db), featureLifecycleReadModel: new FeatureLifecycleReadModel( db, diff --git a/src/lib/features/onboarding/createOnboardingReadModel.ts b/src/lib/features/onboarding/createOnboardingReadModel.ts new file mode 100644 index 0000000000..abc3f1e7a6 --- /dev/null +++ b/src/lib/features/onboarding/createOnboardingReadModel.ts @@ -0,0 +1,12 @@ +import type { Db } from '../../server-impl'; +import type { IOnboardingReadModel } from '../../types'; +import { OnboardingReadModel } from './onboarding-read-model'; +import { FakeOnboardingReadModel } from './fake-onboarding-read-model'; + +export const createOnboardingReadModel = (db: Db): IOnboardingReadModel => { + return new OnboardingReadModel(db); +}; + +export const createFakeOnboardingReadModel = (): IOnboardingReadModel => { + return new FakeOnboardingReadModel(); +}; diff --git a/src/lib/features/onboarding/fake-onboarding-read-model.ts b/src/lib/features/onboarding/fake-onboarding-read-model.ts index edc823bbcb..61318602de 100644 --- a/src/lib/features/onboarding/fake-onboarding-read-model.ts +++ b/src/lib/features/onboarding/fake-onboarding-read-model.ts @@ -1,6 +1,7 @@ import type { IOnboardingReadModel } from '../../types'; import type { InstanceOnboarding, + OnboardingStatus, ProjectOnboarding, } from './onboarding-read-model-type'; @@ -17,4 +18,10 @@ export class FakeOnboardingReadModel implements IOnboardingReadModel { getProjectsOnboardingMetrics(): Promise { return Promise.resolve([]); } + + getOnboardingStatusForProject( + projectId: string, + ): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/lib/features/onboarding/onboarding-read-model-type.ts b/src/lib/features/onboarding/onboarding-read-model-type.ts index 8a2a4adbc8..f88f522722 100644 --- a/src/lib/features/onboarding/onboarding-read-model-type.ts +++ b/src/lib/features/onboarding/onboarding-read-model-type.ts @@ -1,5 +1,9 @@ +import type { ProjectOverviewSchema } from '../../openapi'; + +export type OnboardingStatus = ProjectOverviewSchema['onboardingStatus']; + /** - * All the values are in minutes + * All the values are in seconds */ export type InstanceOnboarding = { firstLogin: number | null; @@ -10,7 +14,7 @@ export type InstanceOnboarding = { }; /** - * All the values are in minutes + * All the values are in seconds */ export type ProjectOnboarding = { project: string; @@ -22,4 +26,5 @@ export type ProjectOnboarding = { export interface IOnboardingReadModel { getInstanceOnboardingMetrics(): Promise; getProjectsOnboardingMetrics(): Promise>; + getOnboardingStatusForProject(projectId: string): Promise; } diff --git a/src/lib/features/onboarding/onboarding-read-model.test.ts b/src/lib/features/onboarding/onboarding-read-model.test.ts index c67abdd900..2b9d1c5602 100644 --- a/src/lib/features/onboarding/onboarding-read-model.test.ts +++ b/src/lib/features/onboarding/onboarding-read-model.test.ts @@ -1,19 +1,27 @@ import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; import getLogger from '../../../test/fixtures/no-logger'; -import type { IOnboardingStore } from '../../types'; -import { OnboardingReadModel } from './onboarding-read-model'; +import { + type IFeatureToggleStore, + type ILastSeenStore, + type IOnboardingStore, + SYSTEM_USER, +} from '../../types'; import type { IOnboardingReadModel } from './onboarding-read-model-type'; let db: ITestDb; let onboardingReadModel: IOnboardingReadModel; let onBoardingStore: IOnboardingStore; +let featureToggleStore: IFeatureToggleStore; +let lastSeenStore: ILastSeenStore; beforeAll(async () => { db = await dbInit('onboarding_read_model', getLogger, { experimental: { flags: { onboardingMetrics: true } }, }); - onboardingReadModel = new OnboardingReadModel(db.rawDatabase); + onboardingReadModel = db.stores.onboardingReadModel; onBoardingStore = db.stores.onboardingStore; + featureToggleStore = db.stores.featureToggleStore; + lastSeenStore = db.stores.lastSeenStore; }); afterAll(async () => { @@ -109,3 +117,39 @@ test('can get instance onboarding durations', async () => { }, ]); }); + +test('can get project onboarding status', async () => { + const onboardingStartedResult = + await onboardingReadModel.getOnboardingStatusForProject('default'); + + expect(onboardingStartedResult).toMatchObject({ + status: 'onboarding-started', + }); + + await featureToggleStore.create('default', { + name: 'my-flag', + createdByUserId: SYSTEM_USER.id, + }); + + const firstFlagResult = + await onboardingReadModel.getOnboardingStatusForProject('default'); + + expect(firstFlagResult).toMatchObject({ + status: 'first-flag-created', + feature: 'my-flag', + }); + + await lastSeenStore.setLastSeen([ + { + environment: 'default', + featureName: 'my-flag', + }, + ]); + + const onboardedResult = + await onboardingReadModel.getOnboardingStatusForProject('default'); + + expect(onboardedResult).toMatchObject({ + status: 'onboarded', + }); +}); diff --git a/src/lib/features/onboarding/onboarding-read-model.ts b/src/lib/features/onboarding/onboarding-read-model.ts index 251d5066ea..a124e520c3 100644 --- a/src/lib/features/onboarding/onboarding-read-model.ts +++ b/src/lib/features/onboarding/onboarding-read-model.ts @@ -3,6 +3,7 @@ import type { IOnboardingReadModel, InstanceOnboarding, ProjectOnboarding, + OnboardingStatus, } from './onboarding-read-model-type'; const instanceEventLookup = { @@ -78,4 +79,30 @@ export class OnboardingReadModel implements IOnboardingReadModel { return projects; } + + async getOnboardingStatusForProject( + projectId: string, + ): Promise { + const feature = await this.db('features') + .select('name') + .where('project', projectId) + .first(); + + if (!feature) { + return { status: 'onboarding-started' }; + } + + const lastSeen = await this.db('last_seen_at_metrics as lsm') + .select('lsm.feature_name') + .innerJoin('features as f', 'f.name', 'lsm.feature_name') + .innerJoin('projects as p', 'p.id', 'f.project') + .where('p.id', projectId) + .first(); + + if (lastSeen) { + return { status: 'onboarded' }; + } else { + return { status: 'first-flag-created', feature: feature.name }; + } + } } diff --git a/src/lib/features/onboarding/onboarding-service.e2e.test.ts b/src/lib/features/onboarding/onboarding-service.e2e.test.ts index 5e19b747ba..5cc5b2ccbe 100644 --- a/src/lib/features/onboarding/onboarding-service.e2e.test.ts +++ b/src/lib/features/onboarding/onboarding-service.e2e.test.ts @@ -7,7 +7,6 @@ import { createTestConfig } from '../../../test/config/test-config'; import { createOnboardingService } from './createOnboardingService'; import type EventEmitter from 'events'; import { STAGE_ENTERED, USER_LOGIN } from '../../metric-events'; -import { OnboardingReadModel } from './onboarding-read-model'; let db: ITestDb; let stores: IUnleashStores; @@ -24,7 +23,7 @@ beforeAll(async () => { eventBus = config.eventBus; onboardingService = createOnboardingService(config)(db.rawDatabase); onboardingService.listen(); - onboardingReadModel = new OnboardingReadModel(db.rawDatabase); + onboardingReadModel = db.stores.onboardingReadModel; }); afterAll(async () => { diff --git a/src/lib/features/project/createProjectService.ts b/src/lib/features/project/createProjectService.ts index ec31ad30ab..d4a42888eb 100644 --- a/src/lib/features/project/createProjectService.ts +++ b/src/lib/features/project/createProjectService.ts @@ -52,6 +52,10 @@ import { createFakeProjectReadModel, createProjectReadModel, } from './createProjectReadModel'; +import { + createFakeOnboardingReadModel, + createOnboardingReadModel, +} from '../onboarding/createOnboardingReadModel'; export const createProjectService = ( db: Db, @@ -130,6 +134,8 @@ export const createProjectService = ( config.flagResolver, ); + const onboardingReadModel = createOnboardingReadModel(db); + return new ProjectService( { projectStore, @@ -142,6 +148,7 @@ export const createProjectService = ( projectOwnersReadModel, projectFlagCreatorsReadModel, projectReadModel, + onboardingReadModel, }, config, accessService, @@ -197,6 +204,8 @@ export const createFakeProjectService = ( const projectReadModel = createFakeProjectReadModel(); + const onboardingReadModel = createFakeOnboardingReadModel(); + return new ProjectService( { projectStore, @@ -209,6 +218,7 @@ export const createFakeProjectService = ( accountStore, projectStatsStore, projectReadModel, + onboardingReadModel, }, config, accessService, diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index 811758177e..6e210ada46 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -54,6 +54,7 @@ import { RoleName, SYSTEM_USER_ID, type IProjectReadModel, + type IOnboardingReadModel, } from '../../types'; import type { IProjectAccessModel, @@ -164,6 +165,8 @@ export default class ProjectService { private projectReadModel: IProjectReadModel; + private onboardingReadModel: IOnboardingReadModel; + constructor( { projectStore, @@ -176,6 +179,7 @@ export default class ProjectService { accountStore, projectStatsStore, projectReadModel, + onboardingReadModel, }: Pick< IUnleashStores, | 'projectStore' @@ -188,6 +192,7 @@ export default class ProjectService { | 'accountStore' | 'projectStatsStore' | 'projectReadModel' + | 'onboardingReadModel' >, config: IUnleashConfig, accessService: AccessService, @@ -220,6 +225,7 @@ export default class ProjectService { this.resourceLimits = config.resourceLimits; this.eventBus = config.eventBus; this.projectReadModel = projectReadModel; + this.onboardingReadModel = onboardingReadModel; } async getProjects( @@ -1491,6 +1497,7 @@ export default class ProjectService { members, favorite, projectStats, + onboardingStatus, ] = await Promise.all([ this.projectStore.get(projectId), this.projectStore.getEnvironmentsForProject(projectId), @@ -1507,6 +1514,7 @@ export default class ProjectService { }) : Promise.resolve(false), this.projectStatsStore.getProjectStats(projectId), + this.onboardingReadModel.getOnboardingStatusForProject(projectId), ]); return { @@ -1524,6 +1532,7 @@ export default class ProjectService { ? { archivedAt: project.archivedAt } : {}), createdAt: project.createdAt, + onboardingStatus, environments, featureTypeCounts, members, diff --git a/src/lib/openapi/spec/project-overview-schema.test.ts b/src/lib/openapi/spec/project-overview-schema.test.ts index 8fa7b84436..1413feeab0 100644 --- a/src/lib/openapi/spec/project-overview-schema.test.ts +++ b/src/lib/openapi/spec/project-overview-schema.test.ts @@ -16,6 +16,9 @@ test('projectOverviewSchema', () => { count: 1, }, ], + onboardingStatus: { + status: 'onboarding-started', + }, }; expect( diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index e7a86c6c55..49ff96f33a 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -19,7 +19,7 @@ export const projectOverviewSchema = { $id: '#/components/schemas/projectOverviewSchema', type: 'object', additionalProperties: false, - required: ['version', 'name'], + required: ['version', 'name', 'onboardingStatus'], description: 'A high-level overview of a project. It contains information such as project statistics, the name of the project, what members and what features it contains, etc.', properties: { @@ -135,6 +135,41 @@ export const projectOverviewSchema = { description: '`true` if the project was favorited, otherwise `false`.', }, + onboardingStatus: { + type: 'object', + oneOf: [ + { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['onboarding-started', 'onboarded'], + example: 'onboarding-started', + }, + }, + required: ['status'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['first-flag-created'], + example: 'first-flag-created', + }, + feature: { + type: 'string', + description: 'The name of the feature flag', + example: 'my-feature-flag', + }, + }, + required: ['status', 'feature'], + additionalProperties: false, + }, + ], + description: 'The current onboarding status of the project.', + }, }, components: { schemas: { diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 85e589550c..5f87996301 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -4,7 +4,10 @@ import type { IRole } from './stores/access-store'; import type { IUser } from './user'; import type { ALL_OPERATORS } from '../util'; import type { IProjectStats } from '../features/project/project-service'; -import type { CreateFeatureStrategySchema } from '../openapi'; +import type { + CreateFeatureStrategySchema, + ProjectOverviewSchema, +} from '../openapi'; import type { ProjectEnvironment } from '../features/project/project-store-type'; import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema'; import type { IntegrationEventsService } from '../features/integration-events/integration-events-service'; @@ -313,6 +316,7 @@ export interface IProjectOverview { featureLimit?: number; featureNaming?: IFeatureNaming; defaultStickiness: string; + onboardingStatus: ProjectOverviewSchema['onboardingStatus']; } export interface IProjectHealthReport extends IProjectHealth { diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index fa3d60eb05..56fd2747fa 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -52,8 +52,8 @@ import { FakeFeatureLifecycleReadModel } from '../../lib/features/feature-lifecy import { FakeLargestResourcesReadModel } from '../../lib/features/metrics/sizes/fake-largest-resources-read-model'; import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-toggle/fake-feature-collaborators-read-model'; import { createFakeProjectReadModel } from '../../lib/features/project/createProjectReadModel'; -import { FakeOnboardingReadModel } from '../../lib/features/onboarding/fake-onboarding-read-model'; import { FakeOnboardingStore } from '../../lib/features/onboarding/fake-onboarding-store'; +import { createFakeOnboardingReadModel } from '../../lib/features/onboarding/createOnboardingReadModel'; const db = { select: () => ({ @@ -111,7 +111,7 @@ const createStores: () => IUnleashStores = () => { featureLifecycleStore: new FakeFeatureLifecycleStore(), featureStrategiesReadModel: new FakeFeatureStrategiesReadModel(), featureLifecycleReadModel: new FakeFeatureLifecycleReadModel(), - onboardingReadModel: new FakeOnboardingReadModel(), + onboardingReadModel: createFakeOnboardingReadModel(), largestResourcesReadModel: new FakeLargestResourcesReadModel(), integrationEventsStore: {} as IntegrationEventsStore, featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(),