mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-02 01:17:58 +02:00
Feat/project status monthly (#2986)
This PR takes the project status API a step further by adding the capability of providing a date to control the selection. We are currently making calculations based on a gliding 30 day window, updated once a day. The initial database structure and method for updating the UI is outlined in this PR.
This commit is contained in:
parent
b80e84b438
commit
d8a250dc9c
@ -17,7 +17,48 @@ const EVENT_COLUMNS = [
|
|||||||
'feature_name',
|
'feature_name',
|
||||||
'project',
|
'project',
|
||||||
'environment',
|
'environment',
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
export type IQueryOperations =
|
||||||
|
| IWhereOperation
|
||||||
|
| IBeforeDateOperation
|
||||||
|
| IBetweenDatesOperation
|
||||||
|
| IForFeaturesOperation;
|
||||||
|
|
||||||
|
interface IWhereOperation {
|
||||||
|
op: 'where';
|
||||||
|
parameters: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IBeforeDateOperation {
|
||||||
|
op: 'beforeDate';
|
||||||
|
parameters: {
|
||||||
|
dateAccessor: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IBetweenDatesOperation {
|
||||||
|
op: 'betweenDate';
|
||||||
|
parameters: {
|
||||||
|
dateAccessor: string;
|
||||||
|
range: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IForFeaturesOperation {
|
||||||
|
op: 'forFeatures';
|
||||||
|
parameters: IForFeaturesParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IForFeaturesParams {
|
||||||
|
type: string;
|
||||||
|
projectId: string;
|
||||||
|
environments: string[];
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IEventTable {
|
export interface IEventTable {
|
||||||
id: number;
|
id: number;
|
||||||
@ -120,25 +161,77 @@ class EventStore extends AnyEventEmitter implements IEventStore {
|
|||||||
return present;
|
return present;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getForFeatures(
|
async query(operations: IQueryOperations[]): Promise<IEvent[]> {
|
||||||
features: string[],
|
|
||||||
environments: string[],
|
|
||||||
query: { type: string; projectId: string },
|
|
||||||
): Promise<IEvent[]> {
|
|
||||||
try {
|
try {
|
||||||
const rows = await this.db
|
let query: Knex.QueryBuilder = this.select();
|
||||||
.select(EVENT_COLUMNS)
|
|
||||||
.from(TABLE)
|
|
||||||
.where({ type: query.type, project: query.projectId })
|
|
||||||
.whereIn('feature_name', features)
|
|
||||||
.whereIn('environment', environments);
|
|
||||||
|
|
||||||
|
operations.forEach((operation) => {
|
||||||
|
if (operation.op === 'where') {
|
||||||
|
query = this.where(query, operation.parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.op === 'forFeatures') {
|
||||||
|
query = this.forFeatures(query, operation.parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.op === 'beforeDate') {
|
||||||
|
query = this.beforeDate(query, operation.parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.op === 'betweenDate') {
|
||||||
|
query = this.betweenDate(query, operation.parameters);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await query;
|
||||||
return rows.map(this.rowToEvent);
|
return rows.map(this.rowToEvent);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
where(
|
||||||
|
query: Knex.QueryBuilder,
|
||||||
|
parameters: { [key: string]: string },
|
||||||
|
): Knex.QueryBuilder {
|
||||||
|
return query.where(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeDate(
|
||||||
|
query: Knex.QueryBuilder,
|
||||||
|
parameters: { dateAccessor: string; date: string },
|
||||||
|
): Knex.QueryBuilder {
|
||||||
|
return query.andWhere(parameters.dateAccessor, '>=', parameters.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
betweenDate(
|
||||||
|
query: Knex.QueryBuilder,
|
||||||
|
parameters: { dateAccessor: string; range: string[] },
|
||||||
|
): Knex.QueryBuilder {
|
||||||
|
if (parameters.range && parameters.range.length === 2) {
|
||||||
|
return query.andWhereBetween(parameters.dateAccessor, [
|
||||||
|
parameters.range[0],
|
||||||
|
parameters.range[1],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
select(): Knex.QueryBuilder {
|
||||||
|
return this.db.select(EVENT_COLUMNS).from(TABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
forFeatures(
|
||||||
|
query: Knex.QueryBuilder,
|
||||||
|
parameters: IForFeaturesParams,
|
||||||
|
): Knex.QueryBuilder {
|
||||||
|
return query
|
||||||
|
.where({ type: parameters.type, project: parameters.projectId })
|
||||||
|
.whereIn('feature_name', parameters.features)
|
||||||
|
.whereIn('environment', parameters.environments);
|
||||||
|
}
|
||||||
|
|
||||||
async get(key: number): Promise<IEvent> {
|
async get(key: number): Promise<IEvent> {
|
||||||
const row = await this.db(TABLE).where({ id: key }).first();
|
const row = await this.db(TABLE).where({ id: key }).first();
|
||||||
return this.rowToEvent(row);
|
return this.rowToEvent(row);
|
||||||
|
@ -111,6 +111,35 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return rows.map(this.rowToFeature);
|
return rows.map(this.rowToFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getByDate(queryModifiers: {
|
||||||
|
archived?: boolean;
|
||||||
|
project?: string;
|
||||||
|
date?: string;
|
||||||
|
range?: string[];
|
||||||
|
dateAccessor: string;
|
||||||
|
}): Promise<FeatureToggle[]> {
|
||||||
|
const { project, archived, dateAccessor } = queryModifiers;
|
||||||
|
let query = this.db
|
||||||
|
.select(FEATURE_COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.where({ project })
|
||||||
|
.modify(FeatureToggleStore.filterByArchived, archived);
|
||||||
|
|
||||||
|
if (queryModifiers.date) {
|
||||||
|
query.andWhere(dateAccessor, '>=', queryModifiers.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryModifiers.range && queryModifiers.range.length === 2) {
|
||||||
|
query.andWhereBetween(dateAccessor, [
|
||||||
|
queryModifiers.range[0],
|
||||||
|
queryModifiers.range[1],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query;
|
||||||
|
return rows.map(this.rowToFeature);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get projectId from feature filtered by name. Used by Rbac middleware
|
* Get projectId from feature filtered by name. Used by Rbac middleware
|
||||||
* @deprecated
|
* @deprecated
|
||||||
|
@ -35,6 +35,7 @@ import { PublicSignupTokenStore } from './public-signup-token-store';
|
|||||||
import { FavoriteFeaturesStore } from './favorite-features-store';
|
import { FavoriteFeaturesStore } from './favorite-features-store';
|
||||||
import { FavoriteProjectsStore } from './favorite-projects-store';
|
import { FavoriteProjectsStore } from './favorite-projects-store';
|
||||||
import { AccountStore } from './account-store';
|
import { AccountStore } from './account-store';
|
||||||
|
import ProjectStatsStore from './project-stats-store';
|
||||||
|
|
||||||
export const createStores = (
|
export const createStores = (
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -113,6 +114,7 @@ export const createStores = (
|
|||||||
eventBus,
|
eventBus,
|
||||||
getLogger,
|
getLogger,
|
||||||
),
|
),
|
||||||
|
projectStatsStore: new ProjectStatsStore(db, eventBus, getLogger),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
52
src/lib/db/project-stats-store.ts
Normal file
52
src/lib/db/project-stats-store.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { Logger, LogProvider } from '../logger';
|
||||||
|
|
||||||
|
import metricsHelper from '../util/metrics-helper';
|
||||||
|
import { DB_TIME } from '../metric-events';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
|
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||||
|
|
||||||
|
const TABLE = 'project_stats';
|
||||||
|
|
||||||
|
class ProjectStatsStore implements IProjectStatsStore {
|
||||||
|
private db: Knex;
|
||||||
|
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private timer: Function;
|
||||||
|
|
||||||
|
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||||
|
this.db = db;
|
||||||
|
this.logger = getLogger('project-stats-store.ts');
|
||||||
|
this.timer = (action) =>
|
||||||
|
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||||
|
store: 'project_stats',
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProjectStats(
|
||||||
|
projectId: string,
|
||||||
|
status: IProjectStats,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.db(TABLE)
|
||||||
|
.insert({
|
||||||
|
avg_time_to_prod_current_window:
|
||||||
|
status.avgTimeToProdCurrentWindow,
|
||||||
|
avg_time_to_prod_past_window: status.avgTimeToProdPastWindow,
|
||||||
|
project: projectId,
|
||||||
|
features_created_current_window: status.createdCurrentWindow,
|
||||||
|
features_created_past_window: status.createdPastWindow,
|
||||||
|
features_archived_current_window: status.archivedCurrentWindow,
|
||||||
|
features_archived_past_window: status.archivedPastWindow,
|
||||||
|
project_changes_current_window:
|
||||||
|
status.projectActivityCurrentWindow,
|
||||||
|
project_changes_past_window: status.projectActivityPastWindow,
|
||||||
|
})
|
||||||
|
.onConflict('project')
|
||||||
|
.merge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectStatsStore;
|
@ -1,6 +1,6 @@
|
|||||||
import { addDays, subDays } from 'date-fns';
|
import { addDays, subDays } from 'date-fns';
|
||||||
import { IEvent } from 'lib/types';
|
import { IEvent } from 'lib/types';
|
||||||
import { ProjectStatus } from './project-status';
|
import { TimeToProduction } from './time-to-production';
|
||||||
|
|
||||||
const modifyEventCreatedAt = (events: IEvent[], days: number): IEvent[] => {
|
const modifyEventCreatedAt = (events: IEvent[], days: number): IEvent[] => {
|
||||||
return events.map((event) => {
|
return events.map((event) => {
|
||||||
@ -97,7 +97,7 @@ const features = [
|
|||||||
type: 'release',
|
type: 'release',
|
||||||
project: 'average-time-to-prod',
|
project: 'average-time-to-prod',
|
||||||
stale: false,
|
stale: false,
|
||||||
createdAt: new Date('2023-01-19T09:37:32.483Z'),
|
createdAt: new Date('2022-12-05T09:37:32.483Z'),
|
||||||
lastSeenAt: null,
|
lastSeenAt: null,
|
||||||
impressionData: false,
|
impressionData: false,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@ -143,7 +143,11 @@ const features = [
|
|||||||
|
|
||||||
describe('calculate average time to production', () => {
|
describe('calculate average time to production', () => {
|
||||||
test('should build a map of feature events', () => {
|
test('should build a map of feature events', () => {
|
||||||
const projectStatus = new ProjectStatus(features, environments, events);
|
const projectStatus = new TimeToProduction(
|
||||||
|
features,
|
||||||
|
environments,
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
|
||||||
const featureEvents = projectStatus.getFeatureEvents();
|
const featureEvents = projectStatus.getFeatureEvents();
|
||||||
|
|
||||||
@ -153,15 +157,19 @@ describe('calculate average time to production', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should calculate average correctly', () => {
|
test('should calculate average correctly', () => {
|
||||||
const projectStatus = new ProjectStatus(features, environments, events);
|
const projectStatus = new TimeToProduction(
|
||||||
|
features,
|
||||||
|
environments,
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
|
||||||
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
||||||
|
|
||||||
expect(timeToProduction).toBe(9.75);
|
expect(timeToProduction).toBe(21);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should sort events by createdAt', () => {
|
test('should sort events by createdAt', () => {
|
||||||
const projectStatus = new ProjectStatus(features, environments, [
|
const projectStatus = new TimeToProduction(features, environments, [
|
||||||
...modifyEventCreatedAt(events, 5),
|
...modifyEventCreatedAt(events, 5),
|
||||||
...events,
|
...events,
|
||||||
]);
|
]);
|
||||||
@ -192,7 +200,7 @@ describe('calculate average time to production', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should not count events that are development environments', () => {
|
test('should not count events that are development environments', () => {
|
||||||
const projectStatus = new ProjectStatus(features, environments, [
|
const projectStatus = new TimeToProduction(features, environments, [
|
||||||
createEvent('development', {
|
createEvent('development', {
|
||||||
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
|
createdAt: subDays(new Date('2023-01-25T09:37:32.504Z'), 10),
|
||||||
}),
|
}),
|
||||||
@ -203,6 +211,6 @@ describe('calculate average time to production', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
const timeToProduction = projectStatus.calculateAverageTimeToProd();
|
||||||
expect(timeToProduction).toBe(9.75);
|
expect(timeToProduction).toBe(21);
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -10,7 +10,7 @@ interface IFeatureTimeToProdData {
|
|||||||
events: IEvent[];
|
events: IEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectStatus {
|
export class TimeToProduction {
|
||||||
private features: FeatureToggle[];
|
private features: FeatureToggle[];
|
||||||
|
|
||||||
private productionEnvironments: IProjectEnvironment[];
|
private productionEnvironments: IProjectEnvironment[];
|
||||||
@ -31,16 +31,25 @@ export class ProjectStatus {
|
|||||||
const featureEvents = this.getFeatureEvents();
|
const featureEvents = this.getFeatureEvents();
|
||||||
const sortedFeatureEvents =
|
const sortedFeatureEvents =
|
||||||
this.sortFeatureEventsByCreatedAt(featureEvents);
|
this.sortFeatureEventsByCreatedAt(featureEvents);
|
||||||
|
|
||||||
const timeToProdPerFeature =
|
const timeToProdPerFeature =
|
||||||
this.calculateTimeToProdForFeatures(sortedFeatureEvents);
|
this.calculateTimeToProdForFeatures(sortedFeatureEvents);
|
||||||
|
if (timeToProdPerFeature.length) {
|
||||||
|
const sum = timeToProdPerFeature.reduce(
|
||||||
|
(acc, curr) => acc + curr,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const sum = timeToProdPerFeature.reduce((acc, curr) => acc + curr, 0);
|
return Number(
|
||||||
|
(sum / Object.keys(sortedFeatureEvents).length).toFixed(1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return sum / Object.keys(sortedFeatureEvents).length;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFeatureEvents(): IFeatureTimeToProdCalculationMap {
|
getFeatureEvents(): IFeatureTimeToProdCalculationMap {
|
||||||
return this.filterEvents(this.events).reduce((acc, event) => {
|
return this.getProductionEvents(this.events).reduce((acc, event) => {
|
||||||
if (acc[event.featureName]) {
|
if (acc[event.featureName]) {
|
||||||
acc[event.featureName].events.push(event);
|
acc[event.featureName].events.push(event);
|
||||||
} else {
|
} else {
|
||||||
@ -55,7 +64,7 @@ export class ProjectStatus {
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
filterEvents(events: IEvent[]): IEvent[] {
|
getProductionEvents(events: IEvent[]): IEvent[] {
|
||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
const found = this.productionEnvironments.find(
|
const found = this.productionEnvironments.find(
|
||||||
(env) => env.name === event.environment,
|
(env) => env.name === event.environment,
|
||||||
@ -74,6 +83,7 @@ export class ProjectStatus {
|
|||||||
): number[] {
|
): number[] {
|
||||||
return Object.keys(featureEvents).map((featureName) => {
|
return Object.keys(featureEvents).map((featureName) => {
|
||||||
const feature = featureEvents[featureName];
|
const feature = featureEvents[featureName];
|
||||||
|
|
||||||
const earliestEvent = feature.events[0];
|
const earliestEvent = feature.events[0];
|
||||||
|
|
||||||
const createdAtDate = new Date(feature.createdAt);
|
const createdAtDate = new Date(feature.createdAt);
|
@ -164,7 +164,7 @@ export const createServices = (
|
|||||||
if (config.flagResolver.isEnabled('projectStatusApi')) {
|
if (config.flagResolver.isEnabled('projectStatusApi')) {
|
||||||
const ONE_DAY = 1440;
|
const ONE_DAY = 1440;
|
||||||
schedulerService.schedule(
|
schedulerService.schedule(
|
||||||
projectService.statusJob.bind(projectHealthService),
|
projectService.statusJob.bind(projectService),
|
||||||
minutesToMilliseconds(ONE_DAY),
|
minutesToMilliseconds(ONE_DAY),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { subDays } from 'date-fns';
|
||||||
import User, { IUser } from '../types/user';
|
import User, { IUser } from '../types/user';
|
||||||
import { AccessService } from './access-service';
|
import { AccessService } from './access-service';
|
||||||
import NameExistsError from '../error/name-exists-error';
|
import NameExistsError from '../error/name-exists-error';
|
||||||
@ -47,7 +48,8 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
|
|||||||
import { GroupService } from './group-service';
|
import { GroupService } from './group-service';
|
||||||
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
||||||
import { FavoritesService } from './favorites-service';
|
import { FavoritesService } from './favorites-service';
|
||||||
import { ProjectStatus } from '../read-models/project-status/project-status';
|
import { TimeToProduction } from '../read-models/time-to-production/time-to-production';
|
||||||
|
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||||
|
|
||||||
const getCreatedBy = (user: IUser) => user.email || user.username;
|
const getCreatedBy = (user: IUser) => user.email || user.username;
|
||||||
|
|
||||||
@ -57,6 +59,25 @@ export interface AccessWithRoles {
|
|||||||
groups: IGroupModelWithProjectRole[];
|
groups: IGroupModelWithProjectRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Days = number;
|
||||||
|
type Count = number;
|
||||||
|
|
||||||
|
export interface IProjectStats {
|
||||||
|
avgTimeToProdCurrentWindow: Days;
|
||||||
|
avgTimeToProdPastWindow: Days;
|
||||||
|
createdCurrentWindow: Count;
|
||||||
|
createdPastWindow: Count;
|
||||||
|
archivedCurrentWindow: Count;
|
||||||
|
archivedPastWindow: Count;
|
||||||
|
projectActivityCurrentWindow: Count;
|
||||||
|
projectActivityPastWindow: Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICalculateStatus {
|
||||||
|
projectId: string;
|
||||||
|
updates: IProjectStats;
|
||||||
|
}
|
||||||
|
|
||||||
export default class ProjectService {
|
export default class ProjectService {
|
||||||
private store: IProjectStore;
|
private store: IProjectStore;
|
||||||
|
|
||||||
@ -84,6 +105,8 @@ export default class ProjectService {
|
|||||||
|
|
||||||
private favoritesService: FavoritesService;
|
private favoritesService: FavoritesService;
|
||||||
|
|
||||||
|
private projectStatsStore: IProjectStatsStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{
|
{
|
||||||
projectStore,
|
projectStore,
|
||||||
@ -94,6 +117,7 @@ export default class ProjectService {
|
|||||||
featureEnvironmentStore,
|
featureEnvironmentStore,
|
||||||
featureTagStore,
|
featureTagStore,
|
||||||
accountStore,
|
accountStore,
|
||||||
|
projectStatsStore,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
IUnleashStores,
|
IUnleashStores,
|
||||||
| 'projectStore'
|
| 'projectStore'
|
||||||
@ -104,6 +128,7 @@ export default class ProjectService {
|
|||||||
| 'featureEnvironmentStore'
|
| 'featureEnvironmentStore'
|
||||||
| 'featureTagStore'
|
| 'featureTagStore'
|
||||||
| 'accountStore'
|
| 'accountStore'
|
||||||
|
| 'projectStatsStore'
|
||||||
>,
|
>,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
accessService: AccessService,
|
accessService: AccessService,
|
||||||
@ -123,6 +148,7 @@ export default class ProjectService {
|
|||||||
this.tagStore = featureTagStore;
|
this.tagStore = featureTagStore;
|
||||||
this.accountStore = accountStore;
|
this.accountStore = accountStore;
|
||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
|
this.projectStatsStore = projectStatsStore;
|
||||||
this.logger = config.getLogger('services/project-service.js');
|
this.logger = config.getLogger('services/project-service.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -596,20 +622,82 @@ export default class ProjectService {
|
|||||||
async statusJob(): Promise<void> {
|
async statusJob(): Promise<void> {
|
||||||
const projects = await this.store.getAll();
|
const projects = await this.store.getAll();
|
||||||
|
|
||||||
|
const statusUpdates = await Promise.all(
|
||||||
|
projects.map((project) => this.getStatusUpdates(project.id)),
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
projects.map((project) =>
|
statusUpdates.map((statusUpdate) => {
|
||||||
this.calculateAverageTimeToProd(project.id),
|
return this.projectStatsStore.updateProjectStats(
|
||||||
),
|
statusUpdate.projectId,
|
||||||
|
statusUpdate.updates,
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculateAverageTimeToProd(projectId: string): Promise<number> {
|
async getStatusUpdates(projectId: string): Promise<ICalculateStatus> {
|
||||||
// Get all features for project with type release
|
// Get all features for project with type release
|
||||||
const features = await this.featureToggleStore.getAll({
|
const features = await this.featureToggleStore.getAll({
|
||||||
type: 'release',
|
type: 'release',
|
||||||
project: projectId,
|
project: projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dateMinusThirtyDays = subDays(new Date(), 30).toISOString();
|
||||||
|
const dateMinusSixtyDays = subDays(new Date(), 60).toISOString();
|
||||||
|
|
||||||
|
const [createdCurrentWindow, createdPastWindow] = await Promise.all([
|
||||||
|
await this.featureToggleStore.getByDate({
|
||||||
|
project: projectId,
|
||||||
|
dateAccessor: 'created_at',
|
||||||
|
date: dateMinusThirtyDays,
|
||||||
|
}),
|
||||||
|
await this.featureToggleStore.getByDate({
|
||||||
|
project: projectId,
|
||||||
|
dateAccessor: 'created_at',
|
||||||
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [archivedCurrentWindow, archivedPastWindow] = await Promise.all([
|
||||||
|
await this.featureToggleStore.getByDate({
|
||||||
|
project: projectId,
|
||||||
|
archived: true,
|
||||||
|
dateAccessor: 'archived_at',
|
||||||
|
date: dateMinusThirtyDays,
|
||||||
|
}),
|
||||||
|
await this.featureToggleStore.getByDate({
|
||||||
|
project: projectId,
|
||||||
|
archived: true,
|
||||||
|
dateAccessor: 'archived_at',
|
||||||
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [projectActivityCurrentWindow, projectActivityPastWindow] =
|
||||||
|
await Promise.all([
|
||||||
|
this.eventStore.query([
|
||||||
|
{ op: 'where', parameters: { project: projectId } },
|
||||||
|
{
|
||||||
|
op: 'beforeDate',
|
||||||
|
parameters: {
|
||||||
|
dateAccessor: 'created_at',
|
||||||
|
date: dateMinusThirtyDays,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
this.eventStore.query([
|
||||||
|
{ op: 'where', parameters: { project: projectId } },
|
||||||
|
{
|
||||||
|
op: 'betweenDate',
|
||||||
|
parameters: {
|
||||||
|
dateAccessor: 'created_at',
|
||||||
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
// Get all project environments with type of production
|
// Get all project environments with type of production
|
||||||
const productionEnvironments =
|
const productionEnvironments =
|
||||||
await this.environmentStore.getProjectEnvironments(projectId, {
|
await this.environmentStore.getProjectEnvironments(projectId, {
|
||||||
@ -618,21 +706,73 @@ export default class ProjectService {
|
|||||||
|
|
||||||
// Get all events for features that correspond to feature toggle environment ON
|
// Get all events for features that correspond to feature toggle environment ON
|
||||||
// Filter out events that are not a production evironment
|
// Filter out events that are not a production evironment
|
||||||
const events = await this.eventStore.getForFeatures(
|
|
||||||
features.map((feature) => feature.name),
|
|
||||||
productionEnvironments.map((env) => env.name),
|
|
||||||
{
|
|
||||||
type: FEATURE_ENVIRONMENT_ENABLED,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectStatus = new ProjectStatus(
|
const eventsCurrentWindow = await this.eventStore.query([
|
||||||
|
{
|
||||||
|
op: 'forFeatures',
|
||||||
|
parameters: {
|
||||||
|
features: features.map((feature) => feature.name),
|
||||||
|
environments: productionEnvironments.map((env) => env.name),
|
||||||
|
type: FEATURE_ENVIRONMENT_ENABLED,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'beforeDate',
|
||||||
|
parameters: {
|
||||||
|
dateAccessor: 'created_at',
|
||||||
|
date: dateMinusThirtyDays,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const eventsPastWindow = await this.eventStore.query([
|
||||||
|
{
|
||||||
|
op: 'forFeatures',
|
||||||
|
parameters: {
|
||||||
|
features: features.map((feature) => feature.name),
|
||||||
|
environments: productionEnvironments.map((env) => env.name),
|
||||||
|
type: FEATURE_ENVIRONMENT_ENABLED,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'betweenDate',
|
||||||
|
parameters: {
|
||||||
|
dateAccessor: 'created_at',
|
||||||
|
range: [dateMinusSixtyDays, dateMinusThirtyDays],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentWindowTimeToProdReadModel = new TimeToProduction(
|
||||||
features,
|
features,
|
||||||
productionEnvironments,
|
productionEnvironments,
|
||||||
events,
|
eventsCurrentWindow,
|
||||||
);
|
);
|
||||||
return projectStatus.calculateAverageTimeToProd();
|
|
||||||
|
const pastWindowTimeToProdReadModel = new TimeToProduction(
|
||||||
|
features,
|
||||||
|
productionEnvironments,
|
||||||
|
eventsPastWindow,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
updates: {
|
||||||
|
avgTimeToProdCurrentWindow:
|
||||||
|
currentWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
||||||
|
avgTimeToProdPastWindow:
|
||||||
|
pastWindowTimeToProdReadModel.calculateAverageTimeToProd(),
|
||||||
|
createdCurrentWindow: createdCurrentWindow.length,
|
||||||
|
createdPastWindow: createdPastWindow.length,
|
||||||
|
archivedCurrentWindow: archivedCurrentWindow.length,
|
||||||
|
archivedPastWindow: archivedPastWindow.length,
|
||||||
|
projectActivityCurrentWindow:
|
||||||
|
projectActivityCurrentWindow.length,
|
||||||
|
projectActivityPastWindow: projectActivityPastWindow.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjectOverview(
|
async getProjectOverview(
|
||||||
|
@ -31,6 +31,7 @@ import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
|
|||||||
import { IFavoriteFeaturesStore } from './stores/favorite-features';
|
import { IFavoriteFeaturesStore } from './stores/favorite-features';
|
||||||
import { IFavoriteProjectsStore } from './stores/favorite-projects';
|
import { IFavoriteProjectsStore } from './stores/favorite-projects';
|
||||||
import { IAccountStore } from './stores/account-store';
|
import { IAccountStore } from './stores/account-store';
|
||||||
|
import { IProjectStatsStore } from './stores/project-stats-store-type';
|
||||||
|
|
||||||
export interface IUnleashStores {
|
export interface IUnleashStores {
|
||||||
accessStore: IAccessStore;
|
accessStore: IAccessStore;
|
||||||
@ -66,6 +67,7 @@ export interface IUnleashStores {
|
|||||||
publicSignupTokenStore: IPublicSignupTokenStore;
|
publicSignupTokenStore: IPublicSignupTokenStore;
|
||||||
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||||
favoriteProjectsStore: IFavoriteProjectsStore;
|
favoriteProjectsStore: IFavoriteProjectsStore;
|
||||||
|
projectStatsStore: IProjectStatsStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -2,6 +2,7 @@ import { IBaseEvent, IEvent } from '../events';
|
|||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
|
import { IQueryOperations } from 'lib/db/event-store';
|
||||||
|
|
||||||
export interface IEventStore extends Store<IEvent, number>, EventEmitter {
|
export interface IEventStore extends Store<IEvent, number>, EventEmitter {
|
||||||
store(event: IBaseEvent): Promise<void>;
|
store(event: IBaseEvent): Promise<void>;
|
||||||
@ -10,9 +11,5 @@ export interface IEventStore extends Store<IEvent, number>, EventEmitter {
|
|||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
filteredCount(search: SearchEventsSchema): Promise<number>;
|
filteredCount(search: SearchEventsSchema): Promise<number>;
|
||||||
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
|
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
|
||||||
getForFeatures(
|
query(operations: IQueryOperations[]): Promise<IEvent[]>;
|
||||||
features: string[],
|
|
||||||
environments: string[],
|
|
||||||
query: { type: string; projectId: string },
|
|
||||||
): Promise<IEvent[]>;
|
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,13 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
revive(featureName: string): Promise<FeatureToggle>;
|
revive(featureName: string): Promise<FeatureToggle>;
|
||||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||||
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
||||||
|
getByDate(queryModifiers: {
|
||||||
|
archived?: boolean;
|
||||||
|
project?: string;
|
||||||
|
date?: string;
|
||||||
|
range?: string[];
|
||||||
|
dateAccessor: string;
|
||||||
|
}): Promise<FeatureToggle[]>;
|
||||||
/**
|
/**
|
||||||
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
|
* @deprecated - Variants should be fetched from FeatureEnvironmentStore (since variants are now; since 4.18, connected to environments)
|
||||||
* @param featureName
|
* @param featureName
|
||||||
|
5
src/lib/types/stores/project-stats-store-type.ts
Normal file
5
src/lib/types/stores/project-stats-store-type.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
|
|
||||||
|
export interface IProjectStatsStore {
|
||||||
|
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
||||||
|
}
|
28
src/migrations/20230125065315-project-stats-table.js
Normal file
28
src/migrations/20230125065315-project-stats-table.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
exports.up = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`CREATE TABLE IF NOT EXISTS project_stats (
|
||||||
|
project VARCHAR(255) NOT NULL,
|
||||||
|
avg_time_to_prod_current_window FLOAT DEFAULT 0,
|
||||||
|
avg_time_to_prod_past_window FLOAT DEFAULT 0,
|
||||||
|
project_changes_current_window INTEGER DEFAULT 0,
|
||||||
|
project_changes_past_window INTEGER DEFAULT 0,
|
||||||
|
features_created_current_window INTEGER DEFAULT 0,
|
||||||
|
features_created_past_window INTEGER DEFAULT 0,
|
||||||
|
features_archived_current_window INTEGER DEFAULT 0,
|
||||||
|
features_archived_past_window INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (project) references projects(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(project)
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, cb) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
DROP TABLE project_status;
|
||||||
|
`,
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
};
|
@ -43,6 +43,7 @@ process.nextTick(async () => {
|
|||||||
maintenance: false,
|
maintenance: false,
|
||||||
featuresExportImport: true,
|
featuresExportImport: true,
|
||||||
newProjectOverview: true,
|
newProjectOverview: true,
|
||||||
|
projectStatusApi: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
@ -13,7 +13,7 @@ import { SegmentService } from '../../../lib/services/segment-service';
|
|||||||
import { GroupService } from '../../../lib/services/group-service';
|
import { GroupService } from '../../../lib/services/group-service';
|
||||||
import { FavoritesService } from '../../../lib/services';
|
import { FavoritesService } from '../../../lib/services';
|
||||||
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
|
import { FeatureEnvironmentEvent } from '../../../lib/types/events';
|
||||||
import { addDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
let stores;
|
let stores;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
@ -1057,13 +1057,20 @@ test('should only count active feature toggles for project', async () => {
|
|||||||
expect(theProject?.featureCount).toBe(1);
|
expect(theProject?.featureCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateEventCreatedAt = async (days: number, featureName: string) => {
|
const updateEventCreatedAt = async (date: Date, featureName: string) => {
|
||||||
await db.rawDatabase
|
return db.rawDatabase
|
||||||
.table('events')
|
.table('events')
|
||||||
.update({ created_at: addDays(new Date(), days) })
|
.update({ created_at: date })
|
||||||
.where({ feature_name: featureName });
|
.where({ feature_name: featureName });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateFeature = async (featureName: string, update: any) => {
|
||||||
|
return db.rawDatabase
|
||||||
|
.table('features')
|
||||||
|
.update(update)
|
||||||
|
.where({ name: featureName });
|
||||||
|
};
|
||||||
|
|
||||||
test('should calculate average time to production', async () => {
|
test('should calculate average time to production', async () => {
|
||||||
const project = {
|
const project = {
|
||||||
id: 'average-time-to-prod',
|
id: 'average-time-to-prod',
|
||||||
@ -1077,6 +1084,7 @@ test('should calculate average time to production', async () => {
|
|||||||
{ name: 'average-prod-time-2' },
|
{ name: 'average-prod-time-2' },
|
||||||
{ name: 'average-prod-time-3' },
|
{ name: 'average-prod-time-3' },
|
||||||
{ name: 'average-prod-time-4' },
|
{ name: 'average-prod-time-4' },
|
||||||
|
{ name: 'average-prod-time-5' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const featureToggles = await Promise.all(
|
const featureToggles = await Promise.all(
|
||||||
@ -1104,11 +1112,103 @@ test('should calculate average time to production', async () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateEventCreatedAt(6, 'average-prod-time');
|
await updateEventCreatedAt(subDays(new Date(), 31), 'average-prod-time-5');
|
||||||
await updateEventCreatedAt(12, 'average-prod-time-2');
|
|
||||||
await updateEventCreatedAt(7, 'average-prod-time-3');
|
|
||||||
await updateEventCreatedAt(14, 'average-prod-time-4');
|
|
||||||
|
|
||||||
const result = await projectService.calculateAverageTimeToProd(project.id);
|
await Promise.all(
|
||||||
expect(result).toBe(9.75);
|
featureToggles.map((toggle) =>
|
||||||
|
updateFeature(toggle.name, { created_at: subDays(new Date(), 15) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateFeature('average-prod-time-5', {
|
||||||
|
created_at: subDays(new Date(), 33),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await projectService.getStatusUpdates(project.id);
|
||||||
|
expect(result.updates.avgTimeToProdCurrentWindow).toBe(14);
|
||||||
|
expect(result.updates.avgTimeToProdPastWindow).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get correct amount of features created in current and past window', async () => {
|
||||||
|
const project = {
|
||||||
|
id: 'features-created',
|
||||||
|
name: 'features-created',
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectService.createProject(project, user.id);
|
||||||
|
|
||||||
|
const toggles = [
|
||||||
|
{ name: 'features-created' },
|
||||||
|
{ name: 'features-created-2' },
|
||||||
|
{ name: 'features-created-3' },
|
||||||
|
{ name: 'features-created-4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
toggles.map((toggle) => {
|
||||||
|
return featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
toggle,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
updateFeature(toggles[2].name, { created_at: subDays(new Date(), 31) }),
|
||||||
|
updateFeature(toggles[3].name, { created_at: subDays(new Date(), 31) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await projectService.getStatusUpdates(project.id);
|
||||||
|
expect(result.updates.createdCurrentWindow).toBe(2);
|
||||||
|
expect(result.updates.createdPastWindow).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get correct amount of features archived in current and past window', async () => {
|
||||||
|
const project = {
|
||||||
|
id: 'features-archived',
|
||||||
|
name: 'features-archived',
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectService.createProject(project, user.id);
|
||||||
|
|
||||||
|
const toggles = [
|
||||||
|
{ name: 'features-archived' },
|
||||||
|
{ name: 'features-archived-2' },
|
||||||
|
{ name: 'features-archived-3' },
|
||||||
|
{ name: 'features-archived-4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
toggles.map((toggle) => {
|
||||||
|
return featureToggleService.createFeatureToggle(
|
||||||
|
project.id,
|
||||||
|
toggle,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
updateFeature(toggles[0].name, {
|
||||||
|
archived_at: new Date(),
|
||||||
|
archived: true,
|
||||||
|
}),
|
||||||
|
updateFeature(toggles[1].name, {
|
||||||
|
archived_at: new Date(),
|
||||||
|
archived: true,
|
||||||
|
}),
|
||||||
|
updateFeature(toggles[2].name, {
|
||||||
|
archived_at: subDays(new Date(), 31),
|
||||||
|
archived: true,
|
||||||
|
}),
|
||||||
|
updateFeature(toggles[3].name, {
|
||||||
|
archived_at: subDays(new Date(), 31),
|
||||||
|
archived: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await projectService.getStatusUpdates(project.id);
|
||||||
|
expect(result.updates.archivedCurrentWindow).toBe(2);
|
||||||
|
expect(result.updates.archivedPastWindow).toBe(2);
|
||||||
});
|
});
|
||||||
|
6
src/test/fixtures/fake-event-store.ts
vendored
6
src/test/fixtures/fake-event-store.ts
vendored
@ -1,6 +1,7 @@
|
|||||||
import { IEventStore } from '../../lib/types/stores/event-store';
|
import { IEventStore } from '../../lib/types/stores/event-store';
|
||||||
import { IEvent } from '../../lib/types/events';
|
import { IEvent } from '../../lib/types/events';
|
||||||
import { AnyEventEmitter } from '../../lib/util/anyEventEmitter';
|
import { AnyEventEmitter } from '../../lib/util/anyEventEmitter';
|
||||||
|
import { IQueryOperations } from 'lib/db/event-store';
|
||||||
|
|
||||||
class FakeEventStore extends AnyEventEmitter implements IEventStore {
|
class FakeEventStore extends AnyEventEmitter implements IEventStore {
|
||||||
events: IEvent[];
|
events: IEvent[];
|
||||||
@ -80,6 +81,11 @@ class FakeEventStore extends AnyEventEmitter implements IEventStore {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async query(operations: IQueryOperations[]): Promise<IEvent[]> {
|
||||||
|
if (operations) return [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = FakeEventStore;
|
module.exports = FakeEventStore;
|
||||||
|
35
src/test/fixtures/fake-feature-toggle-store.ts
vendored
35
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -173,6 +173,41 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return Promise.resolve(newVariants);
|
return Promise.resolve(newVariants);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getByDate(queryModifiers: {
|
||||||
|
archived?: boolean;
|
||||||
|
project?: string;
|
||||||
|
date?: string;
|
||||||
|
range?: string[];
|
||||||
|
dateAccessor: string;
|
||||||
|
}): Promise<FeatureToggle[]> {
|
||||||
|
return this.features.filter((feature) => {
|
||||||
|
if (feature.archived === queryModifiers.archived) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.project === queryModifiers.project) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
new Date(feature[queryModifiers.dateAccessor]).getTime() >=
|
||||||
|
new Date(queryModifiers.date).getTime()
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureDate = new Date(
|
||||||
|
feature[queryModifiers.dateAccessor],
|
||||||
|
).getTime();
|
||||||
|
if (
|
||||||
|
featureDate >= new Date(queryModifiers.range[0]).getTime() &&
|
||||||
|
featureDate <= new Date(queryModifiers.range[1]).getTime()
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
dropAllVariants(): Promise<void> {
|
dropAllVariants(): Promise<void> {
|
||||||
this.features.forEach((feature) => (feature.variants = []));
|
this.features.forEach((feature) => (feature.variants = []));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
12
src/test/fixtures/fake-project-stats-store.ts
vendored
Normal file
12
src/test/fixtures/fake-project-stats-store.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { IProjectStats } from 'lib/services/project-service';
|
||||||
|
import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
|
export default class FakeProjectStatsStore implements IProjectStatsStore {
|
||||||
|
updateProjectStats(
|
||||||
|
projectId: string,
|
||||||
|
status: IProjectStats,
|
||||||
|
): Promise<void> {
|
||||||
|
throw new Error('not implemented');
|
||||||
|
}
|
||||||
|
}
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -32,6 +32,7 @@ import FakePublicSignupStore from './fake-public-signup-store';
|
|||||||
import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
|
import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
|
||||||
import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
|
import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
|
||||||
import { FakeAccountStore } from './fake-account-store';
|
import { FakeAccountStore } from './fake-account-store';
|
||||||
|
import FakeProjectStatsStore from './fake-project-stats-store';
|
||||||
|
|
||||||
const createStores: () => IUnleashStores = () => {
|
const createStores: () => IUnleashStores = () => {
|
||||||
const db = {
|
const db = {
|
||||||
@ -75,6 +76,7 @@ const createStores: () => IUnleashStores = () => {
|
|||||||
publicSignupTokenStore: new FakePublicSignupStore(),
|
publicSignupTokenStore: new FakePublicSignupStore(),
|
||||||
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
||||||
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
|
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
|
||||||
|
projectStatsStore: new FakeProjectStatsStore(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user