diff --git a/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts b/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts new file mode 100644 index 0000000000..a77b894e20 --- /dev/null +++ b/src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts @@ -0,0 +1,105 @@ +import { addDays } from 'date-fns'; +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import getLogger from '../../../test/fixtures/no-logger'; +import { ProjectLifecycleSummaryReadModel } from './project-lifecycle-summary-read-model'; +import type { StageName } from '../../types'; +import { randomId } from '../../util'; + +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('project_lifecycle_summary_read_model_serial', getLogger); +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } +}); + +const updateFeatureStageDate = async ( + flagName: string, + stage: string, + newDate: Date, +) => { + await db + .rawDatabase('feature_lifecycles') + .where({ feature: flagName, stage: stage }) + .update({ created_at: newDate }); +}; + +describe('Average time calculation', () => { + test('it calculates the average time for each stage', async () => { + const project1 = await db.stores.projectStore.create({ + name: 'project1', + id: 'project1', + }); + const now = new Date(); + + const flags = [ + { name: randomId(), offsets: [2, 5, 6, 10] }, + { name: randomId(), offsets: [1, null, 4, 7] }, + { name: randomId(), offsets: [12, 25, 8, 9] }, + { name: randomId(), offsets: [1, 2, 3, null] }, + ]; + + for (const { name, offsets } of flags) { + const created = await db.stores.featureToggleStore.create( + project1.id, + { + name, + createdByUserId: 1, + }, + ); + await db.stores.featureLifecycleStore.insert([ + { + feature: name, + stage: 'initial', + }, + ]); + + const stages = ['pre-live', 'live', 'completed', 'archived']; + for (const [index, stage] of stages.entries()) { + const offset = offsets[index]; + if (offset === null) { + continue; + } + + const offsetFromInitial = offsets + .slice(0, index + 1) + .reduce((a, b) => (a ?? 0) + (b ?? 0), 0) as number; + + await db.stores.featureLifecycleStore.insert([ + { + feature: created.name, + stage: stage as StageName, + }, + ]); + + await updateFeatureStageDate( + created.name, + stage, + addDays(now, offsetFromInitial), + ); + } + } + + const readModel = new ProjectLifecycleSummaryReadModel(db.rawDatabase); + + const result = await readModel.getAverageTimeInEachStage(project1.id); + + expect(result).toMatchObject({ + initial: 4, // (2 + 1 + 12 + 1) / 4 = 4 + 'pre-live': 9, // (5 + 25 + 2 + 4) / 4 = 9 + live: 6, // (6 + 8 + 3) / 3 ~= 5.67 ~= 6 + completed: 9, // (10 + 7 + 9) / 3 ~= 8.67 ~= 9 + }); + }); + + test('it returns `null` if it has no data for something', async () => {}); + test('it rounds to the nearest whole number', async () => {}); + test('it ignores flags in other projects', async () => {}); + test('it ignores flags in other projects', async () => {}); + + test("it ignores rows that don't have a next stage", async () => {}); +}); diff --git a/src/lib/features/project-status/project-lifecycle-summary-read-model.ts b/src/lib/features/project-status/project-lifecycle-summary-read-model.ts new file mode 100644 index 0000000000..b4bf1050ba --- /dev/null +++ b/src/lib/features/project-status/project-lifecycle-summary-read-model.ts @@ -0,0 +1,136 @@ +import * as permissions from '../../types/permissions'; +import type { Db } from '../../db/db'; + +const { ADMIN } = permissions; + +export type IProjectLifecycleSummaryReadModel = {}; + +type ProjectLifecycleSummary = { + initial: { + averageDays: number; + currentFlags: number; + }; + preLive: { + averageDays: number; + currentFlags: number; + }; + live: { + averageDays: number; + currentFlags: number; + }; + completed: { + averageDays: number; + currentFlags: number; + }; + archived: { + currentFlags: number; + archivedFlagsOverLastMonth: number; + }; +}; + +export class ProjectLifecycleSummaryReadModel + implements IProjectLifecycleSummaryReadModel +{ + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getAverageTimeInEachStage(projectId: string): Promise<{ + initial: number; + 'pre-live': number; + live: number; + completed: number; + }> { + const q = this.db + .with( + 'stage_durations', + this.db('feature_lifecycles as fl1') + .select( + 'fl1.feature', + 'fl1.stage', + this.db.raw( + 'EXTRACT(EPOCH FROM (MIN(fl2.created_at) - fl1.created_at)) / 86400 AS days_in_stage', + ), + ) + .join('feature_lifecycles as fl2', function () { + this.on('fl1.feature', '=', 'fl2.feature').andOn( + 'fl2.created_at', + '>', + 'fl1.created_at', + ); + }) + .innerJoin('features as f', 'fl1.feature', 'f.name') + .where('f.project', projectId) + .whereNot('fl1.stage', 'archived') + .groupBy('fl1.feature', 'fl1.stage'), + ) + .select('stage_durations.stage') + .select( + this.db.raw('ROUND(AVG(days_in_stage)) AS avg_days_in_stage'), + ) + .from('stage_durations') + .groupBy('stage_durations.stage'); + + const result = await q; + return result.reduce( + (acc, row) => { + acc[row.stage] = Number(row.avg_days_in_stage); + return acc; + }, + { + initial: 0, + 'pre-live': 0, + live: 0, + completed: 0, + }, + ); + } + + async getCurrentFlagsInEachStage(projectId: string) { + return 0; + } + + async getArchivedFlagsOverLastMonth(projectId: string) { + return 0; + } + + async getProjectLifecycleSummary( + projectId: string, + ): Promise { + const [ + averageTimeInEachStage, + currentFlagsInEachStage, + archivedFlagsOverLastMonth, + ] = await Promise.all([ + this.getAverageTimeInEachStage(projectId), + this.getCurrentFlagsInEachStage(projectId), + this.getArchivedFlagsOverLastMonth(projectId), + ]); + + // collate the data + return { + initial: { + averageDays: 0, + currentFlags: 0, + }, + preLive: { + averageDays: 0, + currentFlags: 0, + }, + live: { + averageDays: 0, + currentFlags: 0, + }, + completed: { + averageDays: 0, + currentFlags: 0, + }, + archived: { + currentFlags: 0, + archivedFlagsOverLastMonth: 0, + }, + }; + } +} diff --git a/src/lib/openapi/spec/project-status-schema.ts b/src/lib/openapi/spec/project-status-schema.ts index 912a970653..1efdd36259 100644 --- a/src/lib/openapi/spec/project-status-schema.ts +++ b/src/lib/openapi/spec/project-status-schema.ts @@ -32,25 +32,25 @@ export const projectStatusSchema = { description: 'Key resources within the project', properties: { connectedEnvironments: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of environments that have received SDK traffic in this project.', }, apiTokens: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of API tokens created specifically for this project.', }, members: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of users who have been granted roles in this project. Does not include users who have access via groups.', }, segments: { - type: 'number', + type: 'integer', minimum: 0, description: 'The number of segments that are scoped to this project.',