diff --git a/package.json b/package.json index ecb323fc8a..79f07699a5 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "stoppable": "^1.1.0", "ts-toolbelt": "^9.6.0", "type-is": "^1.6.18", + "ulidx": "^2.4.1", "unleash-client": "^6.6.0", "uuid": "^9.0.0" }, diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index d704c7c4a5..3fa32b12b3 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,4 +1,11 @@ -import type { IUnleashConfig, IUnleashStores } from '../types'; +import { + type IUnleashConfig, + type IUnleashStores, + ReleasePlanMilestoneStore, + ReleasePlanMilestoneStrategyStore, + ReleasePlanStore, + ReleasePlanTemplateStore, +} from '../types'; import EventStore from '../features/events/event-store'; import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store'; @@ -191,6 +198,11 @@ export const createStores = ( uniqueConnectionReadModel: new UniqueConnectionReadModel( new UniqueConnectionStore(db), ), + releasePlanStore: new ReleasePlanStore(db, config), + releasePlanTemplateStore: new ReleasePlanTemplateStore(db, config), + releasePlanMilestoneStore: new ReleasePlanMilestoneStore(db, config), + releasePlanMilestoneStrategyStore: + new ReleasePlanMilestoneStrategyStore(db, config), }; }; diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 20df3ea864..9c771b4087 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -1426,32 +1426,50 @@ test('should return change request ids per environment', async () => { }); }); -const createReleasePlan = async ({ - feature, - environment, - planId, -}: { feature: string; environment: string; planId: string }) => { - await db.rawDatabase('release_plan_definitions').insert({ - id: planId, - discriminator: 'plan', +const createReleasePlan = async ( + { + feature, + environment, + planId, + }: { feature: string; environment: string; planId: string }, + milestones: { + name: string; + order: number; + }[], +) => { + const result = await db.stores.releasePlanTemplateStore.insert({ name: 'plan', - feature_name: feature, - environment: environment, - created_by_user_id: 1, + createdByUserId: 1, + discriminator: 'template', }); + const releasePlan = await db.stores.releasePlanStore.insert({ + id: planId, + name: 'plan', + featureName: feature, + environment: environment, + createdByUserId: 1, + releasePlanTemplateId: result.id, + }); + const milestoneResults = await Promise.all( + milestones.map((milestone) => + createMilestone({ + ...milestone, + planId: releasePlan.id, + }), + ), + ); + return { releasePlan, milestones: milestoneResults }; }; const createMilestone = async ({ - id, name, order, planId, -}: { id: string; name: string; order: number; planId: string }) => { - await db.rawDatabase('milestones').insert({ - id, +}: { name: string; order: number; planId: string }) => { + return db.stores.releasePlanMilestoneStore.insert({ name, - sort_order: order, - release_plan_definition_id: planId, + sortOrder: order, + releasePlanDefinitionId: planId, }); }; @@ -1459,39 +1477,39 @@ const activateMilestone = async ({ planId, milestoneId, }: { planId: string; milestoneId: string }) => { - await db - .rawDatabase('release_plan_definitions') - .update({ active_milestone_id: milestoneId }) - .where('id', planId); + await db.stores.releasePlanStore.update(planId, { + activeMilestoneId: milestoneId, + }); }; test('should return release plan milestones', async () => { await app.createFeature('my_feature_a'); - await createReleasePlan({ - feature: 'my_feature_a', - environment: 'development', - planId: 'plan0', + const { releasePlan, milestones } = await createReleasePlan( + { + feature: 'my_feature_a', + environment: 'development', + planId: 'plan0', + }, + [ + { + name: 'Milestone 1', + order: 0, + }, + { + name: 'Milestone 2', + order: 1, + }, + { + name: 'Milestone 3', + order: 2, + }, + ], + ); + await activateMilestone({ + planId: releasePlan.id, + milestoneId: milestones[1].id, }); - await createMilestone({ - id: 'milestone0', - name: 'Milestone 1', - order: 0, - planId: 'plan0', - }); - await createMilestone({ - id: 'milestone1', - name: 'Milestone 2', - order: 1, - planId: 'plan0', - }); - await createMilestone({ - id: 'milestone3', - name: 'Milestone 3', - order: 2, - planId: 'plan0', - }); - await activateMilestone({ planId: 'plan0', milestoneId: 'milestone1' }); const { body } = await searchFeatures({}); diff --git a/src/lib/features/release-plans/release-plan-milestone-store.ts b/src/lib/features/release-plans/release-plan-milestone-store.ts new file mode 100644 index 0000000000..9a6c7fb679 --- /dev/null +++ b/src/lib/features/release-plans/release-plan-milestone-store.ts @@ -0,0 +1,48 @@ +import { ulid } from 'ulidx'; +import type { ReleasePlanMilestone } from './release-plan-milestone'; +import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store'; +import type { Row } from '../../db/crud/row-type'; +import type { Db } from '../../db/db'; + +const TABLE = 'milestones'; + +const fromRow = (row: any): ReleasePlanMilestone => { + return { + id: row.id, + name: row.name, + sortOrder: row.sort_order, + releasePlanDefinitionId: row.release_plan_definition_id, + strategies: [], + }; +}; + +export type ReleasePlanMilestoneWriteModel = Omit; + +export class ReleasePlanMilestoneStore extends CRUDStore< + ReleasePlanMilestone, + ReleasePlanMilestoneWriteModel, + Row, + ReleasePlanMilestone, + string +> { + constructor(db: Db, config: CrudStoreConfig) { + super(TABLE, db, config); + } + + override async insert( + item: ReleasePlanMilestoneWriteModel, + ): Promise { + const row = this.toRow(item); + row.id = ulid(); + await this.db(TABLE).insert(row); + return fromRow(row); + } + + async deleteAllConnectedToReleasePlanTemplate( + templateId: string, + ): Promise { + await this.db(TABLE) + .where('release_plan_definition_id', templateId) + .delete(); + } +} diff --git a/src/lib/features/release-plans/release-plan-milestone-strategy-store.ts b/src/lib/features/release-plans/release-plan-milestone-strategy-store.ts new file mode 100644 index 0000000000..63154d5e2e --- /dev/null +++ b/src/lib/features/release-plans/release-plan-milestone-strategy-store.ts @@ -0,0 +1,118 @@ +import { ulid } from 'ulidx'; +import type { ReleasePlanMilestoneStrategy } from './release-plan-milestone-strategy'; +import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store'; +import type { Row } from '../../db/crud/row-type'; +import type { Db } from '../../db/db'; + +const TABLE = 'milestone_strategies'; + +export type ReleasePlanMilestoneStrategyWriteModel = Omit< + ReleasePlanMilestoneStrategy, + 'id' +>; + +const fromRow = (row: any): ReleasePlanMilestoneStrategy => { + return { + id: row.id, + milestoneId: row.milestone_id, + sortOrder: row.sort_order, + title: row.title, + strategyName: row.strategy_name, + parameters: row.parameters, + constraints: JSON.parse(row.constraints), + variants: JSON.parse(row.variants), + segments: [], + }; +}; + +const toRow = (item: ReleasePlanMilestoneStrategyWriteModel) => { + return { + id: ulid(), + milestone_id: item.milestoneId, + sort_order: item.sortOrder, + title: item.title, + strategy_name: item.strategyName, + parameters: item.parameters ?? {}, + constraints: JSON.stringify(item.constraints ?? []), + variants: JSON.stringify(item.variants ?? []), + }; +}; + +const toUpdateRow = (item: ReleasePlanMilestoneStrategyWriteModel) => { + return { + milestone_id: item.milestoneId, + sort_order: item.sortOrder, + title: item.title, + strategy_name: item.strategyName, + parameters: item.parameters ?? {}, + constraints: JSON.stringify(item.constraints ?? []), + variants: JSON.stringify(item.variants ?? []), + }; +}; + +export class ReleasePlanMilestoneStrategyStore extends CRUDStore< + ReleasePlanMilestoneStrategy, + ReleasePlanMilestoneStrategyWriteModel, + Row, + ReleasePlanMilestoneStrategy, + string +> { + constructor(db: Db, config: CrudStoreConfig) { + super(TABLE, db, config); + } + + override async insert({ + segments, + ...strategy + }: ReleasePlanMilestoneStrategyWriteModel): Promise { + const row = toRow(strategy); + await this.db(TABLE).insert(row); + segments?.forEach(async (segmentId) => { + const segmentRow = { + milestone_strategy_id: row.id, + segment_id: segmentId, + }; + await this.db('milestone_strategy_segments').insert(segmentRow); + }); + return fromRow(row); + } + + private async updateStrategy( + strategyId: string, + { segments, ...strategy }: ReleasePlanMilestoneStrategyWriteModel, + ): Promise { + const rows = await this.db(this.tableName) + .where({ id: strategyId }) + .update(toUpdateRow(strategy)) + .returning('*'); + return this.fromRow(rows[0]) as ReleasePlanMilestoneStrategy; + } + + async upsert( + strategyId: string, + { segments, ...strategy }: ReleasePlanMilestoneStrategyWriteModel, + ): Promise { + const releasePlanMilestoneStrategy = await this.updateStrategy( + strategyId, + strategy, + ); + // now delete + await this.db('milestone_strategy_segments') + .where('milestone_strategy_id', strategyId) + .delete(); + for (const segmentId of segments ?? []) { + const segmentRow = { + milestone_strategy_id: strategyId, + segment_id: segmentId, + }; + await this.db('milestone_strategy_segments').insert(segmentRow); + } + return releasePlanMilestoneStrategy; + } + + async deleteStrategiesForMilestone(milestoneId: string): Promise { + await this.db('milestone_strategies') + .where('milestone_id', milestoneId) + .delete(); + } +} diff --git a/src/lib/features/release-plans/release-plan-milestone-strategy.ts b/src/lib/features/release-plans/release-plan-milestone-strategy.ts new file mode 100644 index 0000000000..044f63800e --- /dev/null +++ b/src/lib/features/release-plans/release-plan-milestone-strategy.ts @@ -0,0 +1,14 @@ +import type { IFeatureStrategy } from '../../types'; + +export interface ReleasePlanMilestoneStrategy + extends Partial< + Pick< + IFeatureStrategy, + 'title' | 'parameters' | 'constraints' | 'variants' | 'segments' + > + > { + id: string; + milestoneId: string; + sortOrder: number; + strategyName: string; +} diff --git a/src/lib/features/release-plans/release-plan-milestone.ts b/src/lib/features/release-plans/release-plan-milestone.ts new file mode 100644 index 0000000000..c6e379f7e8 --- /dev/null +++ b/src/lib/features/release-plans/release-plan-milestone.ts @@ -0,0 +1,9 @@ +import type { ReleasePlanMilestoneStrategy } from './release-plan-milestone-strategy'; + +export interface ReleasePlanMilestone { + id: string; + name: string; + sortOrder: number; + releasePlanDefinitionId: string; + strategies?: ReleasePlanMilestoneStrategy[]; +} diff --git a/src/lib/features/release-plans/release-plan-store.ts b/src/lib/features/release-plans/release-plan-store.ts new file mode 100644 index 0000000000..9b12c0e2f4 --- /dev/null +++ b/src/lib/features/release-plans/release-plan-store.ts @@ -0,0 +1,369 @@ +import type { ReleasePlan } from './release-plan'; +import type { ReleasePlanMilestoneStrategy } from './release-plan-milestone-strategy'; +import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store'; +import type { Row } from '../../db/crud/row-type'; +import type { Db } from '../../db/db'; +import { defaultToRow } from '../../db/crud/default-mappings'; +import type { IAuditUser } from '../../types'; + +const TABLE = 'release_plan_definitions'; + +type ReleasePlanWriteModel = Omit< + ReleasePlan, + 'discriminator' | 'createdAt' | 'milestones' +>; + +const selectColumns = [ + 'rpd.id AS planId', + 'rpd.discriminator AS planDiscriminator', + 'rpd.name AS planName', + 'rpd.description as planDescription', + 'rpd.feature_name as planFeatureName', + 'rpd.environment as planEnvironment', + 'rpd.created_by_user_id as planCreatedByUserId', + 'rpd.created_at as planCreatedAt', + 'rpd.active_milestone_id as planActiveMilestoneId', + 'rpd.release_plan_template_id as planTemplateId', + 'mi.id AS milestoneId', + 'mi.name AS milestoneName', + 'mi.sort_order AS milestoneSortOrder', + 'ms.id AS strategyId', + 'ms.sort_order AS strategySortOrder', + 'ms.title AS strategyTitle', + 'ms.strategy_name AS strategyName', + 'ms.parameters AS strategyParameters', + 'ms.constraints AS strategyConstraints', + 'ms.variants AS strategyVariants', + 'mss.segment_id AS segmentId', +]; +const processReleasePlanRows = (templateRows): ReleasePlan[] => + templateRows.reduce( + ( + acc: ReleasePlan[], + { + planId, + planDiscriminator, + planName, + planDescription, + planFeatureName, + planEnvironment, + planCreatedByUserId, + planCreatedAt, + planActiveMilestoneId, + planTemplateId, + milestoneId, + milestoneName, + milestoneSortOrder, + strategyId, + strategySortOrder, + strategyTitle, + strategyName, + strategyParameters, + strategyConstraints, + strategyVariants, + segmentId, + }, + ) => { + let plan = acc.find(({ id }) => id === planId); + + if (!plan) { + plan = { + id: planId, + discriminator: planDiscriminator, + name: planName, + description: planDescription, + featureName: planFeatureName, + environment: planEnvironment, + createdByUserId: planCreatedByUserId, + createdAt: planCreatedAt, + activeMilestoneId: planActiveMilestoneId, + releasePlanTemplateId: planTemplateId, + milestones: [], + }; + acc.push(plan); + } + + if (!milestoneId) { + return acc; + } + + let milestone = plan.milestones.find( + ({ id }) => id === milestoneId, + ); + if (!milestone) { + milestone = { + id: milestoneId, + name: milestoneName, + sortOrder: milestoneSortOrder, + strategies: [], + releasePlanDefinitionId: planId, + }; + plan.milestones.push(milestone); + } + + if (!strategyId) { + return acc; + } + + let strategy = milestone.strategies?.find( + ({ id }) => id === strategyId, + ); + + if (!strategy) { + strategy = { + id: strategyId, + milestoneId: milestoneId, + sortOrder: strategySortOrder, + title: strategyTitle, + strategyName: strategyName, + parameters: strategyParameters ?? {}, + constraints: strategyConstraints, + variants: strategyVariants ?? [], + segments: [], + }; + milestone.strategies = [ + ...(milestone.strategies || []), + strategy, + ]; + } + + if (segmentId) { + strategy.segments = [...(strategy.segments || []), segmentId]; + } + + return acc; + }, + [], + ); + +const processMilestoneStrategyRows = ( + rows: any, +): ReleasePlanMilestoneStrategy[] => { + return rows.map((row) => { + return { + id: row.id, + sortOrder: row.sort_order, + title: row.title, + strategyName: row.strategy_name, + parameters: row.parameters, + constraints: row.constraints, + variants: row.variants, + segments: [], + }; + }); +}; + +export class ReleasePlanStore extends CRUDStore< + ReleasePlan, + ReleasePlanWriteModel, + Row, + ReleasePlan, + string +> { + constructor(db: Db, config: CrudStoreConfig) { + super(TABLE, db, config, { + fromRow: (row) => { + return { + id: row.id, + discriminator: row.discriminator, + name: row.name, + description: row.description, + featureName: row.feature_name, + environment: row.environment, + createdByUserId: row.created_by_user_id, + createdAt: row.created_at, + activeMilestoneId: row.active_milestone_id, + releasePlanTemplateId: row.release_plan_template_id, + milestones: [], + }; + }, + toRow: (item) => ({ + ...defaultToRow(item), + discriminator: 'plan', + }), + }); + } + + override async count( + query?: Partial, + ): Promise { + let countQuery = this.db(this.tableName) + .where('discriminator', 'plan') + .count('*'); + if (query) { + countQuery = countQuery.where(this.toRow(query)); + } + const { count } = (await countQuery.first()) ?? { count: 0 }; + return Number(count); + } + + async getByFeatureFlagEnvironmentAndPlanId( + featureName: string, + environment: string, + planId: string, + ): Promise { + const endTimer = this.timer('getByFeatureFlagEnvironmentAndPlanId'); + const rows = await this.db(`${this.tableName} AS rpd`) + .where('rpd.discriminator', 'plan') + .andWhere('rpd.feature_name', featureName) + .andWhere('rpd.environment', environment) + .andWhere('rpd.id', planId) + .leftJoin( + 'milestones AS mi', + 'mi.release_plan_definition_id', + 'rpd.id', + ) + .leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id') + .leftJoin( + 'milestone_strategy_segments AS mss', + 'mss.milestone_strategy_id', + 'ms.id', + ) + .orderBy('mi.sort_order', 'asc') + .orderBy('ms.sort_order', 'asc') + .select(selectColumns); + endTimer(); + return processReleasePlanRows(rows)[0]; + } + + async getByPlanId(planId: string): Promise { + const endTimer = this.timer('getByPlanId'); + const rows = await this.db(`${this.tableName} AS rpd`) + .where('rpd.discriminator', 'plan') + .andWhere('rpd.id', planId) + .leftJoin( + 'milestones AS mi', + 'mi.release_plan_definition_id', + 'rpd.id', + ) + .leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id') + .leftJoin( + 'milestone_strategy_segments AS mss', + 'mss.milestone_strategy_id', + 'ms.id', + ) + .orderBy('mi.sort_order', 'asc') + .orderBy('ms.sort_order', 'asc') + .select(selectColumns); + endTimer(); + const releasePlans = processReleasePlanRows(rows); + if (releasePlans.length === 0) { + return; + } + return releasePlans[0]; + } + async getByFeatureFlagAndEnvironment( + featureName: string, + environment: string, + ): Promise { + const endTimer = this.timer('getByFeatureFlagAndEnvironment'); + const planRows = await this.db(`${this.tableName} AS rpd`) + .where('rpd.discriminator', 'plan') + .andWhere('rpd.feature_name', featureName) + .andWhere('rpd.environment', environment) + .leftJoin( + 'milestones AS mi', + 'mi.release_plan_definition_id', + 'rpd.id', + ) + .leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id') + .leftJoin( + 'milestone_strategy_segments AS mss', + 'mss.milestone_strategy_id', + 'ms.id', + ) + .orderBy('mi.sort_order', 'asc') + .orderBy('ms.sort_order', 'asc') + .select(selectColumns); + endTimer(); + return processReleasePlanRows(planRows); + } + + async activateStrategiesForMilestone( + planId: string, + auditUser: IAuditUser, + ): Promise { + const endTimer = this.timer('activateStrategiesForMilestone'); + const rows = await this.db.raw( + ` + INSERT INTO feature_strategies(id, feature_name, project_name, environment, strategy_name, parameters, constraints, sort_order, title, variants, created_by_user_id, milestone_id) + SELECT ms.id, rpd.feature_name, feature.project, rpd.environment, ms.strategy_name, ms.parameters, ms.constraints, ms.sort_order, ms.title, ms.variants, :userId, ms.milestone_id + FROM milestone_strategies AS ms + LEFT JOIN milestones AS m ON m.id = ms.milestone_id + LEFT JOIN release_plan_definitions AS rpd ON rpd.active_milestone_id = m.id AND rpd.discriminator = 'plan' + LEFT JOIN features AS feature ON rpd.feature_name = feature.name + WHERE rpd.id = :planId AND rpd.discriminator = 'plan' + ON CONFLICT DO NOTHING + RETURNING * + `, + { userId: auditUser.id, planId }, + ); + endTimer(); + return processMilestoneStrategyRows(rows.rows); + } + + async deactivateStrategiesForMilestone( + templateId: string, + ): Promise { + const endTimer = this.timer('deactivateStrategiesForMilestone'); + const deletedRows = await this.db.raw( + `DELETE FROM feature_strategies + WHERE milestone_id = (SELECT active_milestone_id FROM release_plan_definitions WHERE id = :templateId) + RETURNING *`, + { templateId }, + ); + endTimer(); + return processMilestoneStrategyRows(deletedRows.rows); + } + + async getActiveStrategiesForPlan( + planId: string, + ): Promise { + const endTimer = this.timer('getActiveStrategiesForPlan'); + const rows = await this.db + .select( + 'id', + 'strategy_name', + 'sort_order', + 'title', + 'parameters', + 'variants', + 'constraints', + ) + .from('feature_strategies') + .whereRaw( + `milestone_id IN (SELECT active_milestone_id FROM release_plan_definitions WHERE id = :id)`, + { id: planId }, + ); + endTimer(); + return processMilestoneStrategyRows(rows); + } + + async activateStrategySegmentsForMilestone( + milestone_id: string, + ): Promise { + const endTimer = this.timer('activateStrategySegmentsForMilestone'); + const rows = await this.db.raw( + ` + INSERT INTO feature_strategy_segment(feature_strategy_id, segment_id) SELECT milestone_strategy_id, segment_id FROM milestone_strategy_segments WHERE milestone_strategy_id IN (SELECT id FROM milestone_strategies WHERE milestone_id = :milestone_id) RETURNING segment_id + `, + { milestone_id }, + ); + endTimer(); + return rows.rows.map((row) => row.segment_id); + } + + async featureAndEnvironmentHasPlan( + featureName: string, + environment: string, + ): Promise { + const endTimer = this.timer('featureAndEnvironmentHasPlan'); + const result = await this.db.raw( + `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE discriminator = 'plan' AND feature_name = :featureName AND environment = :environment) AS present`, + { featureName, environment }, + ); + const { present } = result.rows[0]; + endTimer(); + return present; + } +} diff --git a/src/lib/features/release-plans/release-plan-template-store.ts b/src/lib/features/release-plans/release-plan-template-store.ts new file mode 100644 index 0000000000..f61cca56e1 --- /dev/null +++ b/src/lib/features/release-plans/release-plan-template-store.ts @@ -0,0 +1,205 @@ +import { ulid } from 'ulidx'; +import type { ReleasePlanTemplate } from './release-plan-template'; +import type { ReleasePlanMilestone } from './release-plan-milestone'; +import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store'; +import type { Row } from '../../db/crud/row-type'; +import type { Db } from '../../db/db'; +import { NotFoundError } from '../../error'; + +const TABLE = 'release_plan_definitions'; + +export type ReleasePlanTemplateWriteModel = Omit< + ReleasePlanTemplate, + 'id' | 'createdAt' | 'milestones' +>; + +const fromRow = (row: any): ReleasePlanTemplate => { + return { + id: row.id, + name: row.name, + createdAt: row.created_at, + description: row.description, + discriminator: row.discriminator, + createdByUserId: row.created_by_user_id, + }; +}; + +export class ReleasePlanTemplateStore extends CRUDStore< + ReleasePlanTemplate, + ReleasePlanTemplateWriteModel, + Row, + ReleasePlanTemplate, + string +> { + constructor(db: Db, config: CrudStoreConfig) { + super(TABLE, db, config); + } + + override async getAll(): Promise { + const endTimer = this.timer('getAll'); + const templates = await this.db(TABLE) + .where('discriminator', 'template') + .where('archived_at', null) + .orderBy('created_at'); + endTimer(); + return templates.map(({ milestones, ...template }) => + fromRow(template), + ); + } + + override async count( + query?: Partial, + ): Promise { + let countQuery = this.db(this.tableName) + .where('discriminator', 'template') + .whereNull('archived_at') + .count('*'); + if (query) { + countQuery = countQuery.where(this.toRow(query)); + } + const { count } = (await countQuery.first()) ?? { count: 0 }; + return Number(count); + } + + async checkNameAlreadyExists(name: string, id?: string): Promise { + const exists = await this.db(TABLE) + .where('discriminator', 'template') + .where({ name }) + .modify((qb) => { + if (id) { + qb.whereNot('id', id); + } + }) + .first() + .select('id'); + + return Boolean(exists); + } + + processReleasePlanTemplateRows(templateRows): ReleasePlanTemplate { + return { + id: templateRows[0].templateId, + discriminator: templateRows[0].templateDiscriminator, + name: templateRows[0].templateName, + description: templateRows[0].templateDescription, + createdByUserId: templateRows[0].templateCreatedByUserId, + createdAt: templateRows[0].templateCreatedAt, + milestones: templateRows.reduce( + (acc: ReleasePlanMilestone[], row) => { + if (!row.milestoneId) { + return acc; + } + let milestone = acc.find((m) => m.id === row.milestoneId); + if (!milestone) { + milestone = { + id: row.milestoneId, + name: row.milestoneName, + sortOrder: row.milestoneSortOrder, + strategies: [], + releasePlanDefinitionId: row.templateId, + }; + acc.push(milestone); + } + if (!row.strategyId) { + return acc; + } + let strategy = milestone.strategies?.find( + (s) => s.id === row.strategyId, + ); + if (!strategy) { + strategy = { + id: row.strategyId, + milestoneId: row.milestoneId, + sortOrder: row.strategySortOrder, + title: row.strategyTitle, + strategyName: row.strategyName, + parameters: row.strategyParameters ?? {}, + constraints: row.strategyConstraints, + variants: row.strategyVariants ?? [], + segments: [], + }; + milestone.strategies = [ + ...(milestone.strategies || []), + strategy, + ]; + } + + if (row.segmentId) { + strategy.segments = [ + ...(strategy.segments || []), + row.segmentId, + ]; + } + + return acc; + }, + [], + ), + archivedAt: templateRows[0].templateArchivedAt, + }; + } + + async getById(id: string): Promise { + const endTimer = this.timer('getById'); + const templateRows = await this.db(`${TABLE} AS rpd`) + .where('rpd.id', id) + .leftJoin( + 'milestones AS mi', + 'mi.release_plan_definition_id', + 'rpd.id', + ) + .leftJoin('milestone_strategies AS ms', 'ms.milestone_id', 'mi.id') + .leftJoin( + 'milestone_strategy_segments AS mss', + 'mss.milestone_strategy_id', + 'ms.id', + ) + .orderBy('mi.sort_order', 'asc') + .orderBy('ms.sort_order', 'asc') + .select( + 'rpd.id AS templateId', + 'rpd.discriminator AS templateDiscriminator', + 'rpd.name AS templateName', + 'rpd.description as templateDescription', + 'rpd.created_by_user_id as templateCreatedByUserId', + 'rpd.created_at as templateCreatedAt', + 'rpd.archived_at AS templateArchivedAt', + 'mi.id AS milestoneId', + 'mi.name AS milestoneName', + 'mi.sort_order AS milestoneSortOrder', + 'ms.id AS strategyId', + 'ms.sort_order AS strategySortOrder', + 'ms.title AS strategyTitle', + 'ms.strategy_name AS strategyName', + 'ms.parameters AS strategyParameters', + 'ms.constraints AS strategyConstraints', + 'ms.variants AS strategyVariants', + 'mss.segment_id AS segmentId', + ); + endTimer(); + + if (!templateRows.length) { + throw new NotFoundError(`Could not find template with id ${id}`); + } + + return this.processReleasePlanTemplateRows(templateRows); + } + + override async insert( + item: ReleasePlanTemplateWriteModel, + ): Promise { + const endTimer = this.timer('insert'); + const row = this.toRow(item); + row.id = ulid(); + await this.db(TABLE).insert(row); + endTimer(); + return fromRow(row); + } + + async archive(id: string): Promise { + const endTimer = this.timer('archive'); + const now = new Date(); + await this.db(TABLE).where('id', id).update({ archived_at: now }); + endTimer(); + } +} diff --git a/src/lib/features/release-plans/release-plan-template.ts b/src/lib/features/release-plans/release-plan-template.ts new file mode 100644 index 0000000000..3bca22dee7 --- /dev/null +++ b/src/lib/features/release-plans/release-plan-template.ts @@ -0,0 +1,12 @@ +import type { ReleasePlanMilestone } from './release-plan-milestone'; + +export interface ReleasePlanTemplate { + id: string; + discriminator: 'template'; + name: string; + description?: string | null; + createdByUserId: number; + createdAt: string; + milestones?: ReleasePlanMilestone[]; + archivedAt?: string; +} diff --git a/src/lib/features/release-plans/release-plan.ts b/src/lib/features/release-plans/release-plan.ts new file mode 100644 index 0000000000..82863c085b --- /dev/null +++ b/src/lib/features/release-plans/release-plan.ts @@ -0,0 +1,15 @@ +import type { ReleasePlanMilestone } from './release-plan-milestone'; + +export interface ReleasePlan { + id: string; + discriminator: 'plan'; + name: string; + description?: string | null; + featureName: string; + environment: string; + createdByUserId: number; + createdAt: string; + activeMilestoneId?: string; + milestones: ReleasePlanMilestone[]; + releasePlanTemplateId: string; +} diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index baf3768e2b..688c65def7 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -55,6 +55,10 @@ import type { IUserUnsubscribeStore } from '../features/user-subscriptions/user- import type { IUserSubscriptionsReadModel } from '../features/user-subscriptions/user-subscriptions-read-model-type'; import { IUniqueConnectionStore } from '../features/unique-connection/unique-connection-store-type'; import { IUniqueConnectionReadModel } from '../features/unique-connection/unique-connection-read-model-type'; +import { ReleasePlanStore } from '../features/release-plans/release-plan-store'; +import { ReleasePlanTemplateStore } from '../features/release-plans/release-plan-template-store'; +import { ReleasePlanMilestoneStore } from '../features/release-plans/release-plan-milestone-store'; +import { ReleasePlanMilestoneStrategyStore } from '../features/release-plans/release-plan-milestone-strategy-store'; export interface IUnleashStores { accessStore: IAccessStore; @@ -114,6 +118,10 @@ export interface IUnleashStores { userSubscriptionsReadModel: IUserSubscriptionsReadModel; uniqueConnectionStore: IUniqueConnectionStore; uniqueConnectionReadModel: IUniqueConnectionReadModel; + releasePlanStore: ReleasePlanStore; + releasePlanTemplateStore: ReleasePlanTemplateStore; + releasePlanMilestoneStore: ReleasePlanMilestoneStore; + releasePlanMilestoneStrategyStore: ReleasePlanMilestoneStrategyStore; } export { @@ -171,4 +179,8 @@ export { type IUserSubscriptionsReadModel, IUniqueConnectionStore, IUniqueConnectionReadModel, + ReleasePlanStore, + ReleasePlanTemplateStore, + ReleasePlanMilestoneStore, + ReleasePlanMilestoneStrategyStore, }; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index d4b0d7b10a..8d57b1429c 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -20,6 +20,10 @@ import type { IntegrationEventsStore, IPrivateProjectStore, IUnleashStores, + ReleasePlanMilestoneStore, + ReleasePlanMilestoneStrategyStore, + ReleasePlanStore, + ReleasePlanTemplateStore, } from '../../lib/types'; import FakeSessionStore from './fake-session-store'; import FakeFeatureEnvironmentStore from './fake-feature-environment-store'; @@ -129,6 +133,11 @@ const createStores: () => IUnleashStores = () => { uniqueConnectionReadModel: new UniqueConnectionReadModel( uniqueConnectionStore, ), + releasePlanStore: {} as ReleasePlanStore, + releasePlanMilestoneStore: {} as ReleasePlanMilestoneStore, + releasePlanTemplateStore: {} as ReleasePlanTemplateStore, + releasePlanMilestoneStrategyStore: + {} as ReleasePlanMilestoneStrategyStore, }; }; diff --git a/yarn.lock b/yarn.lock index b81ff68d49..534f107e44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6246,6 +6246,13 @@ __metadata: languageName: node linkType: hard +"layerr@npm:^3.0.0": + version: 3.0.0 + resolution: "layerr@npm:3.0.0" + checksum: 10c0/320c9b9cf1392c73c9ff8f8d1bb7a782093ac7341fe5d7fea6ebfeea6d785af8e0fc573541431b6ffcb268577f8aea66168757e73a15035568a00ab9e4370705 + languageName: node + linkType: hard + "lazy-cache@npm:^0.2.3": version: 0.2.7 resolution: "lazy-cache@npm:0.2.7" @@ -9290,6 +9297,15 @@ __metadata: languageName: node linkType: hard +"ulidx@npm:^2.4.1": + version: 2.4.1 + resolution: "ulidx@npm:2.4.1" + dependencies: + layerr: "npm:^3.0.0" + checksum: 10c0/3cbe05123b4b49d262e26cd0edd3ac38a4f9e97f043d47ae7daa88502748881d1615d832e3bcb29698ca2429947c24494929ba35b40d6bf38df891cb15642d2e + languageName: node + linkType: hard + "undici-types@npm:~5.26.4": version: 5.26.5 resolution: "undici-types@npm:5.26.5" @@ -9464,6 +9480,7 @@ __metadata: tsc-watch: "npm:6.2.1" type-is: "npm:^1.6.18" typescript: "npm:5.8.2" + ulidx: "npm:^2.4.1" unleash-client: "npm:^6.6.0" uuid: "npm:^9.0.0" wait-on: "npm:^8.0.0"