diff --git a/packages/api-website-builder/src/domain/experiment/EntryToExperimentMapper.ts b/packages/api-website-builder/src/domain/experiment/EntryToExperimentMapper.ts new file mode 100644 index 00000000000..0164a8d465c --- /dev/null +++ b/packages/api-website-builder/src/domain/experiment/EntryToExperimentMapper.ts @@ -0,0 +1,34 @@ +import type { CmsEntry } from "@webiny/api-headless-cms/types"; +import type { + CmsEntryWbExperimentValues, + ExperimentStatus, + WbExperiment +} from "~/domain/experiment/abstractions.js"; + +export class EntryToExperimentMapper { + static toExperiment(entry: CmsEntry): WbExperiment { + const values = entry.values; + return { + id: entry.id, + entryId: entry.entryId, + version: entry.version, + locked: entry.locked, + createdOn: entry.createdOn, + createdBy: entry.createdBy, + savedOn: entry.savedOn, + savedBy: entry.savedBy, + tenant: entry.tenant, + pageEntryId: values.pageEntryId, + baselineRevisionId: values.baselineRevisionId, + status: (values.status as ExperimentStatus) || "draft", + name: values.name || "", + trafficSplit: values.trafficSplit ?? { control: 100, variants: {} }, + targeting: values.targeting ?? { trafficPercentage: 100 }, + goals: values.goals ?? {}, + analytics: values.analytics ?? { provider: "posthog" }, + startedOn: values.startedOn ?? null, + stoppedOn: values.stoppedOn ?? null, + winningVariantId: values.winningVariantId ?? null + }; + } +} diff --git a/packages/api-website-builder/src/domain/experiment/abstractions.ts b/packages/api-website-builder/src/domain/experiment/abstractions.ts new file mode 100644 index 00000000000..457c40765d3 --- /dev/null +++ b/packages/api-website-builder/src/domain/experiment/abstractions.ts @@ -0,0 +1,84 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import type { WbIdentity } from "~/domain/shared/abstractions.js"; + +/** + * The reserved variant id used for the control bucket. The control bucket renders the + * baseline revision directly, so it has no Variant object of its own. + */ +export const CONTROL_VARIANT_ID = "control"; + +export type ExperimentStatus = "draft" | "running" | "stopped" | "graduated"; + +export type DeviceType = "desktop" | "mobile" | "tablet"; + +/** + * Traffic split between the control bucket and each variant, expressed in whole percentages + * that sum to 100. Variants are keyed by their Variant entryId. + */ +export interface ExperimentTrafficSplit { + control: number; + variants: Record; +} + +export interface ExperimentTargeting { + /** Percentage (0-100) of eligible visitors entered into the experiment. */ + trafficPercentage: number; + /** Optional ISO country codes the experiment is limited to. */ + geo?: string[]; + /** Optional device types the experiment is limited to. */ + device?: DeviceType[]; +} + +/** + * Conversion goals. Opaque to Webiny — forwarded to the analytics provider as-is. + */ +export interface ExperimentGoals { + primaryMetric?: string; + [key: string]: unknown; +} + +/** + * Provider-agnostic analytics configuration. No provider-specific field name may leak into + * the render path or the assignment logic — adapters read this and map it to their own shape. + */ +export interface ExperimentAnalyticsConfig { + provider: string; + [key: string]: unknown; +} + +export interface CmsEntryWbExperimentValues { + pageEntryId: string; + baselineRevisionId: string; + status: ExperimentStatus; + name: string; + trafficSplit: ExperimentTrafficSplit; + targeting: ExperimentTargeting; + goals: ExperimentGoals; + analytics: ExperimentAnalyticsConfig; + startedOn: string | null; + stoppedOn: string | null; + winningVariantId: string | null; +} + +export interface WbExperiment extends CmsEntryWbExperimentValues { + id: string; + entryId: string; + version: number; + locked: boolean; + createdOn: string; + createdBy: WbIdentity; + savedOn: string; + savedBy: WbIdentity; + tenant: string; +} + +/** + * ExperimentModel abstraction - represents the Website Builder experiment CMS model. + * Registered via container.registerInstance in the composite feature. + */ +export const ExperimentModel = createAbstraction("Wb/ExperimentModel"); + +export namespace ExperimentModel { + export type Interface = CmsModel; +} diff --git a/packages/api-website-builder/src/domain/experiment/errors.ts b/packages/api-website-builder/src/domain/experiment/errors.ts new file mode 100644 index 00000000000..b924ca2bf1b --- /dev/null +++ b/packages/api-website-builder/src/domain/experiment/errors.ts @@ -0,0 +1,79 @@ +import { BaseError } from "@webiny/feature/api"; + +export class ExperimentNotFoundError extends BaseError<{ id: string }> { + override readonly code = "WebsiteBuilder/Experiment/NotFound" as const; + + constructor(id: string) { + super({ + message: "Experiment not found!", + data: { + id + } + }); + } +} + +export class ExperimentPersistenceError extends BaseError { + override readonly code = "WebsiteBuilder/Experiment/PersistenceError" as const; + + constructor(error: Error) { + super({ message: error.message }); + } +} + +export class ExperimentValidationError extends BaseError { + override readonly code = "WebsiteBuilder/Experiment/ValidationError" as const; + + constructor(message: string) { + super({ message }); + } +} + +/** + * Raised when starting an experiment while another experiment is already running on the + * same revision. v1 allows only one active experiment per revision. + */ +export class ExperimentAlreadyActiveError extends BaseError<{ revisionId: string }> { + override readonly code = "WebsiteBuilder/Experiment/AlreadyActive" as const; + + constructor(revisionId: string) { + super({ + message: "An experiment is already active on this revision.", + data: { + revisionId + } + }); + } +} + +export class ExperimentNotAuthorizedError extends BaseError { + override readonly code = "WebsiteBuilder/Experiment/NotAuthorized" as const; + + constructor() { + super({ message: "Not authorized!" }); + } +} + +/** Raised when there is no active (published, running) experiment for a path or revision. */ +export class NoActiveExperimentError extends BaseError<{ reference: string }> { + override readonly code = "WebsiteBuilder/Experiment/NoActiveExperiment" as const; + + constructor(reference: string) { + super({ + message: "No active experiment found.", + data: { reference } + }); + } +} + +/** Raised when the active experiment is paused (kill-switch); serving falls back to the control. */ +export class ExperimentPausedError extends BaseError<{ id: string }> { + override readonly code = "WebsiteBuilder/Experiment/Paused" as const; + + constructor(id: string) { + super({ + message: "The experiment is paused.", + data: { id } + }); + } +} diff --git a/packages/api-website-builder/src/domain/experiment/experiment.model.ts b/packages/api-website-builder/src/domain/experiment/experiment.model.ts new file mode 100644 index 00000000000..8897872c3ef --- /dev/null +++ b/packages/api-website-builder/src/domain/experiment/experiment.model.ts @@ -0,0 +1,41 @@ +import { ModelFactory } from "@webiny/api-headless-cms/features/modelBuilder/index.js"; + +export const EXPERIMENT_MODEL_ID = "wbyWbExperiment"; + +class ExperimentModelFactory implements ModelFactory.Interface { + async execute(builder: ModelFactory.Builder) { + const model = builder.private({ + modelId: EXPERIMENT_MODEL_ID, + name: "Website Builder - Experiment" + }); + + model.fields(fields => ({ + // The page (CMS entryId) this experiment belongs to. + pageEntryId: fields.text().label("Page entry ID"), + // The immutable, explicit baseline revision (CMS revision id) this experiment is pinned to. + baselineRevisionId: fields.text().label("Baseline revision ID"), + // Experiment lifecycle status: draft | running | stopped | graduated. + status: fields.text().label("Status"), + name: fields.text().label("Name"), + // Traffic split between the control bucket and each variant. + trafficSplit: fields.json().label("Traffic split"), + // Targeting rules: traffic percentage and optional geo / device. + targeting: fields.json().label("Targeting"), + // Conversion goals, opaque to Webiny and forwarded to the analytics provider. + goals: fields.json().label("Goals"), + // Provider-agnostic analytics configuration (e.g. provider id + experiment key). + analytics: fields.json().label("Analytics"), + startedOn: fields.datetime().label("Started on"), + stoppedOn: fields.datetime().label("Stopped on"), + // The variant graduated into a new revision when the experiment concluded. + winningVariantId: fields.text().label("Winning variant ID") + })); + + return [model]; + } +} + +export const ExperimentModelPlugin = ModelFactory.createImplementation({ + implementation: ExperimentModelFactory, + dependencies: [] +}); diff --git a/packages/api-website-builder/src/domain/variant/EntryToVariantMapper.ts b/packages/api-website-builder/src/domain/variant/EntryToVariantMapper.ts new file mode 100644 index 00000000000..d0e82bfc9c9 --- /dev/null +++ b/packages/api-website-builder/src/domain/variant/EntryToVariantMapper.ts @@ -0,0 +1,31 @@ +import type { CmsEntry } from "@webiny/api-headless-cms/types"; +import type { + CmsEntryWbVariantValues, + VariantStatus, + WbVariant +} from "~/domain/variant/abstractions.js"; + +export class EntryToVariantMapper { + static toVariant(entry: CmsEntry): WbVariant { + const values = entry.values; + return { + id: entry.id, + entryId: entry.entryId, + version: entry.version, + locked: entry.locked, + createdOn: entry.createdOn, + createdBy: entry.createdBy, + savedOn: entry.savedOn, + savedBy: entry.savedBy, + tenant: entry.tenant, + experimentId: values.experimentId, + name: values.name || "", + status: (values.status as VariantStatus) || "draft", + properties: values.properties ?? {}, + metadata: values.metadata ?? {}, + bindings: values.bindings ?? {}, + elements: values.elements ?? {}, + extensions: values.extensions ?? {} + }; + } +} diff --git a/packages/api-website-builder/src/domain/variant/abstractions.ts b/packages/api-website-builder/src/domain/variant/abstractions.ts new file mode 100644 index 00000000000..de709d71439 --- /dev/null +++ b/packages/api-website-builder/src/domain/variant/abstractions.ts @@ -0,0 +1,45 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import type { WbIdentity } from "~/domain/shared/abstractions.js"; + +export type VariantStatus = "draft" | "ready"; + +/** + * The content snapshot of a variant. Mirrors the page content fields exactly so a variant + * can be created as a copy of the baseline revision's content and then modified. + */ +export interface WbVariantContent { + properties: Record; + metadata: Record; + bindings: Record; + elements: Record; + extensions?: Record; +} + +export interface CmsEntryWbVariantValues extends WbVariantContent { + experimentId: string; + name: string; + status: VariantStatus; +} + +export interface WbVariant extends CmsEntryWbVariantValues { + id: string; + entryId: string; + version: number; + locked: boolean; + createdOn: string; + createdBy: WbIdentity; + savedOn: string; + savedBy: WbIdentity; + tenant: string; +} + +/** + * VariantModel abstraction - represents the Website Builder variant CMS model. + * Registered via container.registerInstance in the composite feature. + */ +export const VariantModel = createAbstraction("Wb/VariantModel"); + +export namespace VariantModel { + export type Interface = CmsModel; +} diff --git a/packages/api-website-builder/src/domain/variant/errors.ts b/packages/api-website-builder/src/domain/variant/errors.ts new file mode 100644 index 00000000000..d0db7215db7 --- /dev/null +++ b/packages/api-website-builder/src/domain/variant/errors.ts @@ -0,0 +1,38 @@ +import { BaseError } from "@webiny/feature/api"; + +export class VariantNotFoundError extends BaseError<{ id: string }> { + override readonly code = "WebsiteBuilder/Variant/NotFound" as const; + + constructor(id: string) { + super({ + message: "Variant not found!", + data: { + id + } + }); + } +} + +export class VariantPersistenceError extends BaseError { + override readonly code = "WebsiteBuilder/Variant/PersistenceError" as const; + + constructor(error: Error) { + super({ message: error.message }); + } +} + +export class VariantValidationError extends BaseError { + override readonly code = "WebsiteBuilder/Variant/ValidationError" as const; + + constructor(message: string) { + super({ message }); + } +} + +export class VariantNotAuthorizedError extends BaseError { + override readonly code = "WebsiteBuilder/Variant/NotAuthorized" as const; + + constructor() { + super({ message: "Not authorized!" }); + } +} diff --git a/packages/api-website-builder/src/domain/variant/variant.model.ts b/packages/api-website-builder/src/domain/variant/variant.model.ts new file mode 100644 index 00000000000..74c454128b6 --- /dev/null +++ b/packages/api-website-builder/src/domain/variant/variant.model.ts @@ -0,0 +1,33 @@ +import { ModelFactory } from "@webiny/api-headless-cms/features/modelBuilder/index.js"; + +export const VARIANT_MODEL_ID = "wbyWbVariant"; + +class VariantModelFactory implements ModelFactory.Interface { + async execute(builder: ModelFactory.Builder) { + const model = builder.private({ + modelId: VARIANT_MODEL_ID, + name: "Website Builder - Variant" + }); + + model.fields(fields => ({ + // The experiment (CMS entryId) this variant belongs to. + experimentId: fields.text().label("Experiment ID"), + name: fields.text().label("Name"), + // Variant lifecycle status: draft | ready. + status: fields.text().label("Status"), + // Full content snapshot — mirrors the page content fields. A variant is never a revision. + properties: fields.searchableJson().label("Properties"), + metadata: fields.searchableJson().label("Metadata"), + bindings: fields.json().label("Bindings"), + elements: fields.json().label("Elements"), + extensions: fields.searchableJson().label("Extensions") + })); + + return [model]; + } +} + +export const VariantModelPlugin = ModelFactory.createImplementation({ + implementation: VariantModelFactory, + dependencies: [] +}); diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/CreateExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/CreateExperimentRepository.ts new file mode 100644 index 00000000000..b5ba2caf1c6 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/CreateExperimentRepository.ts @@ -0,0 +1,52 @@ +import { Result } from "@webiny/feature/api"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import { CreateExperimentRepository as RepositoryAbstraction } from "./abstractions/CreateExperimentRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +class CreateExperimentRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private createEntry: CreateEntryUseCase.Interface, + private experimentModel: ExperimentModel.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const values: CmsEntryWbExperimentValues = { + pageEntryId: params.pageEntryId, + baselineRevisionId: params.baselineRevisionId, + status: "draft", + name: params.name, + trafficSplit: params.trafficSplit ?? { control: 100, variants: {} }, + targeting: params.targeting ?? { trafficPercentage: 100 }, + goals: params.goals ?? {}, + analytics: params.analytics ?? { provider: "posthog" }, + startedOn: null, + stoppedOn: null, + winningVariantId: null + }; + + const result = await this.createEntry.execute( + this.experimentModel, + { values } + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/ValidationError") { + return Result.fail(new ExperimentValidationError(result.error.message)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + return Result.ok(EntryToExperimentMapper.toExperiment(result.value)); + } +} + +export const CreateExperimentRepository = RepositoryAbstraction.createImplementation({ + implementation: CreateExperimentRepositoryImpl, + dependencies: [CreateEntryUseCase, ExperimentModel] +}); diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/CreateExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/CreateExperimentUseCase.ts new file mode 100644 index 00000000000..28e4d2ed040 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/CreateExperimentUseCase.ts @@ -0,0 +1,40 @@ +import { Result } from "@webiny/feature/api"; +import { EventPublisher } from "@webiny/api-core/features/eventPublisher/index.js"; +import { CreateExperimentUseCase as UseCaseAbstraction } from "./abstractions/CreateExperimentUseCase.js"; +import { CreateExperimentRepository } from "./abstractions/CreateExperimentRepository.js"; +import { ExperimentBeforeCreateEvent, ExperimentAfterCreateEvent } from "./events.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +class CreateExperimentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private eventPublisher: EventPublisher.Interface, + private repository: CreateExperimentRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canCreate("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + await this.eventPublisher.publish(new ExperimentBeforeCreateEvent({ input: params })); + + const result = await this.repository.execute(params); + if (result.isFail()) { + return result; + } + + await this.eventPublisher.publish( + new ExperimentAfterCreateEvent({ experiment: result.value }) + ); + + return Result.ok(result.value); + } +} + +export const CreateExperimentUseCase = UseCaseAbstraction.createImplementation({ + implementation: CreateExperimentUseCaseImpl, + dependencies: [WbPermissions, EventPublisher, CreateExperimentRepository] +}); diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/CreateExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/CreateExperimentRepository.ts new file mode 100644 index 00000000000..d8015d41cfa --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/CreateExperimentRepository.ts @@ -0,0 +1,29 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import { + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; +import type { ICreateExperimentParams } from "./CreateExperimentUseCase.js"; + +export interface ICreateExperimentRepository { + execute(params: ICreateExperimentParams): Promise>; +} + +export interface ICreateExperimentRepositoryErrors { + validation: ExperimentValidationError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = ICreateExperimentRepositoryErrors[keyof ICreateExperimentRepositoryErrors]; + +export const CreateExperimentRepository = createAbstraction( + "Wb/CreateExperimentRepository" +); + +export namespace CreateExperimentRepository { + export type Interface = ICreateExperimentRepository; + export type Params = ICreateExperimentParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/CreateExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/CreateExperimentUseCase.ts new file mode 100644 index 00000000000..e3861fc5f05 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/CreateExperimentUseCase.ts @@ -0,0 +1,48 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + ExperimentAnalyticsConfig, + ExperimentGoals, + ExperimentTargeting, + ExperimentTrafficSplit, + WbExperiment +} from "~/domain/experiment/abstractions.js"; +import { + ExperimentNotAuthorizedError, + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +export interface ICreateExperimentParams { + pageEntryId: string; + baselineRevisionId: string; + name: string; + trafficSplit?: ExperimentTrafficSplit; + targeting?: ExperimentTargeting; + goals?: ExperimentGoals; + analytics?: ExperimentAnalyticsConfig; +} + +export interface ICreateExperimentUseCase { + execute(params: ICreateExperimentParams): Promise>; +} + +export interface ICreateExperimentUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + validation: ExperimentValidationError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = ICreateExperimentUseCaseErrors[keyof ICreateExperimentUseCaseErrors]; + +/** Create a new experiment against a published page revision. */ +export const CreateExperimentUseCase = createAbstraction( + "Wb/CreateExperimentUseCase" +); + +export namespace CreateExperimentUseCase { + export type Interface = ICreateExperimentUseCase; + export type Params = ICreateExperimentParams; + export type Return = Promise>; + export type Error = UseCaseError; + export type Experiment = WbExperiment; +} diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/ExperimentAfterCreateEventHandler.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/ExperimentAfterCreateEventHandler.ts new file mode 100644 index 00000000000..2cd8b256766 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/ExperimentAfterCreateEventHandler.ts @@ -0,0 +1,17 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { DomainEvent, IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; + +export interface ExperimentAfterCreatePayload { + experiment: WbExperiment; +} + +/** Hook into experiment lifecycle after an experiment is created. */ +export const ExperimentAfterCreateEventHandler = createAbstraction< + IEventHandler> +>("Wb/ExperimentAfterCreateEventHandler"); + +export namespace ExperimentAfterCreateEventHandler { + export type Interface = IEventHandler>; + export type Event = DomainEvent; +} diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/ExperimentBeforeCreateEventHandler.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/ExperimentBeforeCreateEventHandler.ts new file mode 100644 index 00000000000..9158ad0801b --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/abstractions/ExperimentBeforeCreateEventHandler.ts @@ -0,0 +1,17 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { DomainEvent, IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; +import type { ICreateExperimentParams } from "./CreateExperimentUseCase.js"; + +export interface ExperimentBeforeCreatePayload { + input: ICreateExperimentParams; +} + +/** Hook into experiment lifecycle before an experiment is created. */ +export const ExperimentBeforeCreateEventHandler = createAbstraction< + IEventHandler> +>("Wb/ExperimentBeforeCreateEventHandler"); + +export namespace ExperimentBeforeCreateEventHandler { + export type Interface = IEventHandler>; + export type Event = DomainEvent; +} diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/events.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/events.ts new file mode 100644 index 00000000000..2b1d7331702 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/events.ts @@ -0,0 +1,25 @@ +import { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js"; +import { + ExperimentBeforeCreateEventHandler, + type ExperimentBeforeCreatePayload +} from "./abstractions/ExperimentBeforeCreateEventHandler.js"; +import { + ExperimentAfterCreateEventHandler, + type ExperimentAfterCreatePayload +} from "./abstractions/ExperimentAfterCreateEventHandler.js"; + +export class ExperimentBeforeCreateEvent extends DomainEvent { + eventType = "experiment.beforeCreate" as const; + + getHandlerAbstraction() { + return ExperimentBeforeCreateEventHandler; + } +} + +export class ExperimentAfterCreateEvent extends DomainEvent { + eventType = "experiment.afterCreate" as const; + + getHandlerAbstraction() { + return ExperimentAfterCreateEventHandler; + } +} diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/feature.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/feature.ts new file mode 100644 index 00000000000..72cac42a022 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateExperimentRepository } from "./CreateExperimentRepository.js"; +import { CreateExperimentUseCase } from "./CreateExperimentUseCase.js"; + +export const CreateExperimentFeature = createFeature({ + name: "WebsiteBuilder/CreateExperiment", + register(container) { + container.register(CreateExperimentRepository).inSingletonScope(); + container.register(CreateExperimentUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/CreateExperiment/index.ts b/packages/api-website-builder/src/features/experiments/CreateExperiment/index.ts new file mode 100644 index 00000000000..4563368026f --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/CreateExperiment/index.ts @@ -0,0 +1,3 @@ +export { CreateExperimentUseCase } from "./abstractions/CreateExperimentUseCase.js"; +export { ExperimentBeforeCreateEventHandler } from "./abstractions/ExperimentBeforeCreateEventHandler.js"; +export { ExperimentAfterCreateEventHandler } from "./abstractions/ExperimentAfterCreateEventHandler.js"; diff --git a/packages/api-website-builder/src/features/experiments/DeleteExperiment/DeleteExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/DeleteExperiment/DeleteExperimentRepository.ts new file mode 100644 index 00000000000..5d5e8ff2440 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/DeleteExperiment/DeleteExperimentRepository.ts @@ -0,0 +1,30 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"; +import { DeleteExperimentRepository as RepositoryAbstraction } from "./abstractions/DeleteExperimentRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import { ExperimentNotFoundError, ExperimentPersistenceError } from "~/domain/experiment/errors.js"; + +class DeleteExperimentRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private deleteEntry: DeleteEntryUseCase.Interface, + private experimentModel: ExperimentModel.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const result = await this.deleteEntry.execute(this.experimentModel, params.id); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new ExperimentNotFoundError(params.id)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + return Result.ok(true); + } +} + +export const DeleteExperimentRepository = RepositoryAbstraction.createImplementation({ + implementation: DeleteExperimentRepositoryImpl, + dependencies: [DeleteEntryUseCase, ExperimentModel] +}); diff --git a/packages/api-website-builder/src/features/experiments/DeleteExperiment/DeleteExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/DeleteExperiment/DeleteExperimentUseCase.ts new file mode 100644 index 00000000000..0a528bd58b4 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/DeleteExperiment/DeleteExperimentUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteExperimentUseCase as UseCaseAbstraction } from "./abstractions/DeleteExperimentUseCase.js"; +import { DeleteExperimentRepository } from "./abstractions/DeleteExperimentRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +class DeleteExperimentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: DeleteExperimentRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canDelete("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + return this.repository.execute(params); + } +} + +export const DeleteExperimentUseCase = UseCaseAbstraction.createImplementation({ + implementation: DeleteExperimentUseCaseImpl, + dependencies: [WbPermissions, DeleteExperimentRepository] +}); diff --git a/packages/api-website-builder/src/features/experiments/DeleteExperiment/abstractions/DeleteExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/DeleteExperiment/abstractions/DeleteExperimentRepository.ts new file mode 100644 index 00000000000..b24f5cbacb3 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/DeleteExperiment/abstractions/DeleteExperimentRepository.ts @@ -0,0 +1,31 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + ExperimentNotFoundError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +export interface IDeleteExperimentParams { + id: string; +} + +export interface IDeleteExperimentRepository { + execute(params: IDeleteExperimentParams): Promise>; +} + +export interface IDeleteExperimentRepositoryErrors { + notFound: ExperimentNotFoundError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = IDeleteExperimentRepositoryErrors[keyof IDeleteExperimentRepositoryErrors]; + +export const DeleteExperimentRepository = createAbstraction( + "Wb/DeleteExperimentRepository" +); + +export namespace DeleteExperimentRepository { + export type Interface = IDeleteExperimentRepository; + export type Params = IDeleteExperimentParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/DeleteExperiment/abstractions/DeleteExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/DeleteExperiment/abstractions/DeleteExperimentUseCase.ts new file mode 100644 index 00000000000..28961d8796b --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/DeleteExperiment/abstractions/DeleteExperimentUseCase.ts @@ -0,0 +1,31 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + ExperimentNotAuthorizedError, + ExperimentNotFoundError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; +import type { IDeleteExperimentParams } from "./DeleteExperimentRepository.js"; + +export interface IDeleteExperimentUseCase { + execute(params: IDeleteExperimentParams): Promise>; +} + +export interface IDeleteExperimentUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + notFound: ExperimentNotFoundError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = IDeleteExperimentUseCaseErrors[keyof IDeleteExperimentUseCaseErrors]; + +/** Delete an experiment. */ +export const DeleteExperimentUseCase = createAbstraction( + "Wb/DeleteExperimentUseCase" +); + +export namespace DeleteExperimentUseCase { + export type Interface = IDeleteExperimentUseCase; + export type Params = IDeleteExperimentParams; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-website-builder/src/features/experiments/DeleteExperiment/feature.ts b/packages/api-website-builder/src/features/experiments/DeleteExperiment/feature.ts new file mode 100644 index 00000000000..0492b76bacf --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/DeleteExperiment/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteExperimentRepository } from "./DeleteExperimentRepository.js"; +import { DeleteExperimentUseCase } from "./DeleteExperimentUseCase.js"; + +export const DeleteExperimentFeature = createFeature({ + name: "WebsiteBuilder/DeleteExperiment", + register(container) { + container.register(DeleteExperimentRepository).inSingletonScope(); + container.register(DeleteExperimentUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/DeleteExperiment/index.ts b/packages/api-website-builder/src/features/experiments/DeleteExperiment/index.ts new file mode 100644 index 00000000000..527a186c2f4 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/DeleteExperiment/index.ts @@ -0,0 +1 @@ +export { DeleteExperimentUseCase } from "./abstractions/DeleteExperimentUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/EndExperimentOnPublish/EndExperimentOnPublishHandler.ts b/packages/api-website-builder/src/features/experiments/EndExperimentOnPublish/EndExperimentOnPublishHandler.ts new file mode 100644 index 00000000000..1ca0e524db0 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/EndExperimentOnPublish/EndExperimentOnPublishHandler.ts @@ -0,0 +1,88 @@ +import { PageAfterPublishEventHandler } from "~/features/pages/PublishPage/abstractions.js"; +import { GetEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntry"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { PublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/PublishEntry"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; +import type { CmsEntryWbVariantValues } from "~/domain/variant/abstractions.js"; +import { EntryToVariantMapper } from "~/domain/variant/EntryToVariantMapper.js"; + +/** + * Governance: experiments and variants only serve once they are PUBLISHED, and the only place + * a publish happens is here — as a cascade of the page publish, which is itself gated by the + * approval workflow (ValidateWorkflowStateOnPageBeforePublish). So approving + publishing the + * page is what takes its running experiment and ready variants live. Draft edits never serve. + * + * This is a system cascade authorised by the (already approved) page publish, so it works + * directly with the CMS use cases rather than the permission-gated Website Builder use cases. + */ +class EndExperimentOnPublishHandlerImpl implements PageAfterPublishEventHandler.Interface { + constructor( + private getEntry: GetEntryUseCase.Interface, + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private publishEntry: PublishEntryUseCase.Interface, + private experimentModel: ExperimentModel.Interface, + private variantModel: VariantModel.Interface + ) {} + + async handle(event: PageAfterPublishEventHandler.Event): Promise { + const page = event.payload.page; + + // Find the page's running experiment (latest draft state) and publish it. + const experimentResult = await this.getEntry.execute( + this.experimentModel, + { + where: { + latest: true, + values: { + pageEntryId: page.entryId, + status: "running" + } + } + } + ); + + if (experimentResult.isFail() || !experimentResult.value) { + return; + } + + const experiment = EntryToExperimentMapper.toExperiment(experimentResult.value); + await this.publishEntry.execute(this.experimentModel, experiment.id); + + // Publish its ready variants so their content goes live alongside the experiment. + const variantsResult = await this.listLatestEntries.execute( + this.variantModel, + { + where: { + values: { + experimentId: experiment.id, + status: "ready" + } + }, + limit: 1000 + } + ); + + if (variantsResult.isFail()) { + return; + } + + for (const entry of variantsResult.value.entries) { + const variant = EntryToVariantMapper.toVariant(entry); + await this.publishEntry.execute(this.variantModel, variant.id); + } + } +} + +export const EndExperimentOnPublishHandler = PageAfterPublishEventHandler.createImplementation({ + implementation: EndExperimentOnPublishHandlerImpl, + dependencies: [ + GetEntryUseCase, + ListLatestEntriesUseCase, + PublishEntryUseCase, + ExperimentModel, + VariantModel + ] +}); diff --git a/packages/api-website-builder/src/features/experiments/EndExperimentOnPublish/feature.ts b/packages/api-website-builder/src/features/experiments/EndExperimentOnPublish/feature.ts new file mode 100644 index 00000000000..59280f0c298 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/EndExperimentOnPublish/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { EndExperimentOnPublishHandler } from "./EndExperimentOnPublishHandler.js"; + +export const EndExperimentOnPublishFeature = createFeature({ + name: "WebsiteBuilder/EndExperimentOnPublish", + register(container) { + container.register(EndExperimentOnPublishHandler); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/IsExperimentPausedUseCase.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/IsExperimentPausedUseCase.ts new file mode 100644 index 00000000000..668de5b213b --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/IsExperimentPausedUseCase.ts @@ -0,0 +1,22 @@ +import { Result } from "@webiny/feature/api"; +import { KeyValueStore } from "@webiny/api-core/features/keyValueStore/index.js"; +import { IsExperimentPausedUseCase as UseCaseAbstraction } from "./abstractions/IsExperimentPausedUseCase.js"; +import { experimentPauseKey } from "./abstractions/experimentPauseKey.js"; + +/** Reads the kill-switch flag. Consulted uncached on the serving path so pausing is instant. */ +class IsExperimentPausedUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private keyValueStore: KeyValueStore.Interface) {} + + async execute(experimentEntryId: string): UseCaseAbstraction.Return { + const result = await this.keyValueStore.get(experimentPauseKey(experimentEntryId)); + if (result.isFail()) { + return Result.ok(false); + } + return Result.ok(result.value === true); + } +} + +export const IsExperimentPausedUseCase = UseCaseAbstraction.createImplementation({ + implementation: IsExperimentPausedUseCaseImpl, + dependencies: [KeyValueStore] +}); diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/PauseExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/PauseExperimentUseCase.ts new file mode 100644 index 00000000000..e1edec88054 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/PauseExperimentUseCase.ts @@ -0,0 +1,31 @@ +import { Result } from "@webiny/feature/api"; +import { KeyValueStore } from "@webiny/api-core/features/keyValueStore/index.js"; +import { PauseExperimentUseCase as UseCaseAbstraction } from "./abstractions/PauseExperimentUseCase.js"; +import { experimentPauseKey } from "./abstractions/experimentPauseKey.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +/** + * The kill-switch is a runtime flag, deliberately decoupled from the published content: pausing + * only ever reverts to serving the already-approved control, so it needs no publish/approval and + * takes effect immediately. + */ +class PauseExperimentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private keyValueStore: KeyValueStore.Interface + ) {} + + async execute(experimentEntryId: string): UseCaseAbstraction.Return { + if (!(await this.permissions.canPublish("page"))) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + await this.keyValueStore.set(experimentPauseKey(experimentEntryId), true); + return Result.ok(true); + } +} + +export const PauseExperimentUseCase = UseCaseAbstraction.createImplementation({ + implementation: PauseExperimentUseCaseImpl, + dependencies: [WbPermissions, KeyValueStore] +}); diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/ResumeExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/ResumeExperimentUseCase.ts new file mode 100644 index 00000000000..4fc689f5ff4 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/ResumeExperimentUseCase.ts @@ -0,0 +1,27 @@ +import { Result } from "@webiny/feature/api"; +import { KeyValueStore } from "@webiny/api-core/features/keyValueStore/index.js"; +import { ResumeExperimentUseCase as UseCaseAbstraction } from "./abstractions/ResumeExperimentUseCase.js"; +import { experimentPauseKey } from "./abstractions/experimentPauseKey.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +/** Clears the kill-switch flag, so serving resumes bucketing visitors into the experiment. */ +class ResumeExperimentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private keyValueStore: KeyValueStore.Interface + ) {} + + async execute(experimentEntryId: string): UseCaseAbstraction.Return { + if (!(await this.permissions.canPublish("page"))) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + await this.keyValueStore.delete(experimentPauseKey(experimentEntryId)); + return Result.ok(true); + } +} + +export const ResumeExperimentUseCase = UseCaseAbstraction.createImplementation({ + implementation: ResumeExperimentUseCaseImpl, + dependencies: [WbPermissions, KeyValueStore] +}); diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/IsExperimentPausedUseCase.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/IsExperimentPausedUseCase.ts new file mode 100644 index 00000000000..82097449426 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/IsExperimentPausedUseCase.ts @@ -0,0 +1,20 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + ExperimentNotAuthorizedError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +type UseCaseError = ExperimentNotAuthorizedError | ExperimentPersistenceError; + +export interface IIsExperimentPausedUseCase { + execute(experimentEntryId: string): Promise>; +} + +export const IsExperimentPausedUseCase = createAbstraction( + "Wb/IsExperimentPausedUseCase" +); + +export namespace IsExperimentPausedUseCase { + export type Interface = IIsExperimentPausedUseCase; + export type Return = Promise>; +} diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/PauseExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/PauseExperimentUseCase.ts new file mode 100644 index 00000000000..9e04f00f7c1 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/PauseExperimentUseCase.ts @@ -0,0 +1,20 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + ExperimentNotAuthorizedError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +type UseCaseError = ExperimentNotAuthorizedError | ExperimentPersistenceError; + +export interface IPauseExperimentUseCase { + execute(experimentEntryId: string): Promise>; +} + +export const PauseExperimentUseCase = createAbstraction( + "Wb/PauseExperimentUseCase" +); + +export namespace PauseExperimentUseCase { + export type Interface = IPauseExperimentUseCase; + export type Return = Promise>; +} diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/ResumeExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/ResumeExperimentUseCase.ts new file mode 100644 index 00000000000..53ab1f7125e --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/ResumeExperimentUseCase.ts @@ -0,0 +1,20 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + ExperimentNotAuthorizedError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +type UseCaseError = ExperimentNotAuthorizedError | ExperimentPersistenceError; + +export interface IResumeExperimentUseCase { + execute(experimentEntryId: string): Promise>; +} + +export const ResumeExperimentUseCase = createAbstraction( + "Wb/ResumeExperimentUseCase" +); + +export namespace ResumeExperimentUseCase { + export type Interface = IResumeExperimentUseCase; + export type Return = Promise>; +} diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/experimentPauseKey.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/experimentPauseKey.ts new file mode 100644 index 00000000000..0e4a45a61e0 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/abstractions/experimentPauseKey.ts @@ -0,0 +1,4 @@ +/** Runtime kill-switch key. Experiment entryIds are globally unique, so this is tenant-safe. */ +export const experimentPauseKey = (experimentEntryId: string): string => { + return `WbExperimentPaused:${experimentEntryId}`; +}; diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/feature.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/feature.ts new file mode 100644 index 00000000000..a6953644804 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/feature.ts @@ -0,0 +1,13 @@ +import { createFeature } from "@webiny/feature/api"; +import { PauseExperimentUseCase } from "./PauseExperimentUseCase.js"; +import { ResumeExperimentUseCase } from "./ResumeExperimentUseCase.js"; +import { IsExperimentPausedUseCase } from "./IsExperimentPausedUseCase.js"; + +export const ExperimentPauseFeature = createFeature({ + name: "WebsiteBuilder/ExperimentPause", + register(container) { + container.register(PauseExperimentUseCase); + container.register(ResumeExperimentUseCase); + container.register(IsExperimentPausedUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/ExperimentPause/index.ts b/packages/api-website-builder/src/features/experiments/ExperimentPause/index.ts new file mode 100644 index 00000000000..3f45a3252b3 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ExperimentPause/index.ts @@ -0,0 +1,3 @@ +export { PauseExperimentUseCase } from "./abstractions/PauseExperimentUseCase.js"; +export { ResumeExperimentUseCase } from "./abstractions/ResumeExperimentUseCase.js"; +export { IsExperimentPausedUseCase } from "./abstractions/IsExperimentPausedUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/GetActiveExperimentForPathRepository.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/GetActiveExperimentForPathRepository.ts new file mode 100644 index 00000000000..7c17881355a --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/GetActiveExperimentForPathRepository.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { GetEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntry"; +import { GetActiveExperimentForPathRepository as RepositoryAbstraction } from "./abstractions/GetActiveExperimentForPathRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { ExperimentPersistenceError, NoActiveExperimentError } from "~/domain/experiment/errors.js"; + +class GetActiveExperimentForPathRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private experimentModel: ExperimentModel.Interface, + private getEntry: GetEntryUseCase.Interface + ) {} + + async execute(pageEntryId: string): RepositoryAbstraction.Return { + const result = await this.getEntry.execute( + this.experimentModel, + { + where: { + published: true, + values: { + pageEntryId, + status: "running" + } + } + } + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new NoActiveExperimentError(pageEntryId)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + if (!result.value) { + return Result.fail(new NoActiveExperimentError(pageEntryId)); + } + + return Result.ok(EntryToExperimentMapper.toExperiment(result.value)); + } +} + +export const GetActiveExperimentForPathRepository = RepositoryAbstraction.createImplementation({ + implementation: GetActiveExperimentForPathRepositoryImpl, + dependencies: [ExperimentModel, GetEntryUseCase] +}); diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/GetActiveExperimentForPathUseCase.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/GetActiveExperimentForPathUseCase.ts new file mode 100644 index 00000000000..0df33b61be5 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/GetActiveExperimentForPathUseCase.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { GetActiveExperimentForPathUseCase as UseCaseAbstraction } from "./abstractions/GetActiveExperimentForPathUseCase.js"; +import { GetActiveExperimentForPathRepository } from "./abstractions/GetActiveExperimentForPathRepository.js"; +import { GetPageByPathUseCase } from "~/features/pages/GetPageByPath/index.js"; +import { IsExperimentPausedUseCase } from "~/features/experiments/ExperimentPause/index.js"; +import { ExperimentPausedError } from "~/domain/experiment/errors.js"; + +class GetActiveExperimentForPathUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getPageByPath: GetPageByPathUseCase.Interface, + private repository: GetActiveExperimentForPathRepository.Interface, + private isPaused: IsExperimentPausedUseCase.Interface + ) {} + + async execute(path: string): UseCaseAbstraction.Return { + // Resolve the live (published) page — this is the control. + const pageResult = await this.getPageByPath.execute(path); + if (pageResult.isFail()) { + return Result.fail(pageResult.error); + } + const page = pageResult.value; + + // The published, running experiment for this page (draft experiments never serve). Fails + // with NoActiveExperimentError when there is none. + const experimentResult = await this.repository.execute(page.entryId); + if (experimentResult.isFail()) { + return Result.fail(experimentResult.error); + } + const experiment = experimentResult.value; + + // Honour the instant kill-switch: a paused experiment serves the control. + const pausedResult = await this.isPaused.execute(experiment.entryId); + if (pausedResult.isOk() && pausedResult.value) { + return Result.fail(new ExperimentPausedError(experiment.entryId)); + } + + return Result.ok({ + experiment, + revisionId: page.id, + pageEntryId: page.entryId, + path + }); + } +} + +export const GetActiveExperimentForPathUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetActiveExperimentForPathUseCaseImpl, + dependencies: [ + GetPageByPathUseCase, + GetActiveExperimentForPathRepository, + IsExperimentPausedUseCase + ] +}); diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/abstractions/GetActiveExperimentForPathRepository.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/abstractions/GetActiveExperimentForPathRepository.ts new file mode 100644 index 00000000000..7bc40206e18 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/abstractions/GetActiveExperimentForPathRepository.ts @@ -0,0 +1,32 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentPersistenceError, + NoActiveExperimentError +} from "~/domain/experiment/errors.js"; + +// Repository: reads the published, running experiment for a page. Fails with +// NoActiveExperimentError when there is none. + +export interface IGetActiveExperimentForPathRepository { + execute(pageEntryId: string): Promise>; +} + +export interface IGetActiveExperimentForPathRepositoryErrors { + noActiveExperiment: NoActiveExperimentError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = + IGetActiveExperimentForPathRepositoryErrors[keyof IGetActiveExperimentForPathRepositoryErrors]; + +export const GetActiveExperimentForPathRepository = + createAbstraction( + "Wb/GetActiveExperimentForPathRepository" + ); + +export namespace GetActiveExperimentForPathRepository { + export type Interface = IGetActiveExperimentForPathRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/abstractions/GetActiveExperimentForPathUseCase.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/abstractions/GetActiveExperimentForPathUseCase.ts new file mode 100644 index 00000000000..2cead24d39a --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/abstractions/GetActiveExperimentForPathUseCase.ts @@ -0,0 +1,55 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotAuthorizedError, + ExperimentPausedError, + ExperimentPersistenceError, + NoActiveExperimentError +} from "~/domain/experiment/errors.js"; +import type { + PageNotAuthorizedError, + PageNotFoundError, + PagePersistenceError +} from "~/domain/page/errors.js"; + +/** + * The active experiment for a requested path. Resolved against the live (published) page and + * the PUBLISHED experiment for it — draft experiments/variants never serve. The set of + * participating variants is derived from the experiment's traffic split (keyed by variant + * entryId), and each variant's published content is fetched separately by the SDK. + */ +export interface ActiveExperimentForPath { + experiment: WbExperiment; + revisionId: string; + pageEntryId: string; + path: string; +} + +// Use case. Fails with NoActiveExperimentError (none running) or ExperimentPausedError (paused). + +export interface IGetActiveExperimentForPathUseCase { + execute(path: string): Promise>; +} + +export interface IGetActiveExperimentForPathUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + noActiveExperiment: NoActiveExperimentError; + paused: ExperimentPausedError; + notFound: PageNotFoundError; + persistence: ExperimentPersistenceError; + pageNotAuthorized: PageNotAuthorizedError; + pagePersistence: PagePersistenceError; +} + +type UseCaseError = + IGetActiveExperimentForPathUseCaseErrors[keyof IGetActiveExperimentForPathUseCaseErrors]; + +/** Resolve the active, published, non-paused experiment for the live page at a path. */ +export const GetActiveExperimentForPathUseCase = + createAbstraction("Wb/GetActiveExperimentForPathUseCase"); + +export namespace GetActiveExperimentForPathUseCase { + export type Interface = IGetActiveExperimentForPathUseCase; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/feature.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/feature.ts new file mode 100644 index 00000000000..fbb6a593733 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetActiveExperimentForPathRepository } from "./GetActiveExperimentForPathRepository.js"; +import { GetActiveExperimentForPathUseCase } from "./GetActiveExperimentForPathUseCase.js"; + +export const GetActiveExperimentForPathFeature = createFeature({ + name: "WebsiteBuilder/GetActiveExperimentForPath", + register(container) { + container.register(GetActiveExperimentForPathRepository).inSingletonScope(); + container.register(GetActiveExperimentForPathUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/index.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/index.ts new file mode 100644 index 00000000000..af30f5aeeb7 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForPath/index.ts @@ -0,0 +1,2 @@ +export { GetActiveExperimentForPathUseCase } from "./abstractions/GetActiveExperimentForPathUseCase.js"; +export type { ActiveExperimentForPath } from "./abstractions/GetActiveExperimentForPathUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/GetActiveExperimentForRevisionRepository.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/GetActiveExperimentForRevisionRepository.ts new file mode 100644 index 00000000000..9678ac8bd87 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/GetActiveExperimentForRevisionRepository.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { GetEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntry"; +import { GetActiveExperimentForRevisionRepository as RepositoryAbstraction } from "./abstractions/GetActiveExperimentForRevisionRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { ExperimentPersistenceError, NoActiveExperimentError } from "~/domain/experiment/errors.js"; + +class GetActiveExperimentForRevisionRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private experimentModel: ExperimentModel.Interface, + private getEntry: GetEntryUseCase.Interface + ) {} + + async execute(revisionId: string): RepositoryAbstraction.Return { + const result = await this.getEntry.execute( + this.experimentModel, + { + where: { + latest: true, + values: { + baselineRevisionId: revisionId, + status: "running" + } + } + } + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new NoActiveExperimentError(revisionId)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + if (!result.value) { + return Result.fail(new NoActiveExperimentError(revisionId)); + } + + return Result.ok(EntryToExperimentMapper.toExperiment(result.value)); + } +} + +export const GetActiveExperimentForRevisionRepository = RepositoryAbstraction.createImplementation({ + implementation: GetActiveExperimentForRevisionRepositoryImpl, + dependencies: [ExperimentModel, GetEntryUseCase] +}); diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/GetActiveExperimentForRevisionUseCase.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/GetActiveExperimentForRevisionUseCase.ts new file mode 100644 index 00000000000..4cf01360514 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/GetActiveExperimentForRevisionUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { GetActiveExperimentForRevisionUseCase as UseCaseAbstraction } from "./abstractions/GetActiveExperimentForRevisionUseCase.js"; +import { GetActiveExperimentForRevisionRepository } from "./abstractions/GetActiveExperimentForRevisionRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +class GetActiveExperimentForRevisionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: GetActiveExperimentForRevisionRepository.Interface + ) {} + + async execute(revisionId: string): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canRead("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + return this.repository.execute(revisionId); + } +} + +export const GetActiveExperimentForRevisionUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetActiveExperimentForRevisionUseCaseImpl, + dependencies: [WbPermissions, GetActiveExperimentForRevisionRepository] +}); diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/abstractions/GetActiveExperimentForRevisionRepository.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/abstractions/GetActiveExperimentForRevisionRepository.ts new file mode 100644 index 00000000000..2ec55f26602 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/abstractions/GetActiveExperimentForRevisionRepository.ts @@ -0,0 +1,29 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentPersistenceError, + NoActiveExperimentError +} from "~/domain/experiment/errors.js"; + +export interface IGetActiveExperimentForRevisionRepository { + execute(revisionId: string): Promise>; +} + +export interface IGetActiveExperimentForRevisionRepositoryErrors { + noActiveExperiment: NoActiveExperimentError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = + IGetActiveExperimentForRevisionRepositoryErrors[keyof IGetActiveExperimentForRevisionRepositoryErrors]; + +export const GetActiveExperimentForRevisionRepository = + createAbstraction( + "Wb/GetActiveExperimentForRevisionRepository" + ); + +export namespace GetActiveExperimentForRevisionRepository { + export type Interface = IGetActiveExperimentForRevisionRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/abstractions/GetActiveExperimentForRevisionUseCase.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/abstractions/GetActiveExperimentForRevisionUseCase.ts new file mode 100644 index 00000000000..9f1bd27569c --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/abstractions/GetActiveExperimentForRevisionUseCase.ts @@ -0,0 +1,33 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotAuthorizedError, + ExperimentPersistenceError, + NoActiveExperimentError +} from "~/domain/experiment/errors.js"; + +export interface IGetActiveExperimentForRevisionUseCase { + execute(revisionId: string): Promise>; +} + +export interface IGetActiveExperimentForRevisionUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + noActiveExperiment: NoActiveExperimentError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = + IGetActiveExperimentForRevisionUseCaseErrors[keyof IGetActiveExperimentForRevisionUseCaseErrors]; + +/** Retrieve the single active (running) experiment pinned to a given revision, if any. */ +export const GetActiveExperimentForRevisionUseCase = + createAbstraction( + "Wb/GetActiveExperimentForRevisionUseCase" + ); + +export namespace GetActiveExperimentForRevisionUseCase { + export type Interface = IGetActiveExperimentForRevisionUseCase; + export type Return = Promise>; + export type Error = UseCaseError; + export type Experiment = WbExperiment; +} diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/feature.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/feature.ts new file mode 100644 index 00000000000..a0249353ef8 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetActiveExperimentForRevisionRepository } from "./GetActiveExperimentForRevisionRepository.js"; +import { GetActiveExperimentForRevisionUseCase } from "./GetActiveExperimentForRevisionUseCase.js"; + +export const GetActiveExperimentForRevisionFeature = createFeature({ + name: "WebsiteBuilder/GetActiveExperimentForRevision", + register(container) { + container.register(GetActiveExperimentForRevisionRepository).inSingletonScope(); + container.register(GetActiveExperimentForRevisionUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/index.ts b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/index.ts new file mode 100644 index 00000000000..6d823d04323 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetActiveExperimentForRevision/index.ts @@ -0,0 +1 @@ +export { GetActiveExperimentForRevisionUseCase } from "./abstractions/GetActiveExperimentForRevisionUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/GetExperimentById/GetExperimentByIdRepository.ts b/packages/api-website-builder/src/features/experiments/GetExperimentById/GetExperimentByIdRepository.ts new file mode 100644 index 00000000000..e5857fc3249 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetExperimentById/GetExperimentByIdRepository.ts @@ -0,0 +1,35 @@ +import { Result } from "@webiny/feature/api"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { GetExperimentByIdRepository as RepositoryAbstraction } from "./abstractions/GetExperimentByIdRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { ExperimentNotFoundError, ExperimentPersistenceError } from "~/domain/experiment/errors.js"; + +class GetExperimentByIdRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private experimentModel: ExperimentModel.Interface, + private getEntryById: GetEntryByIdUseCase.Interface + ) {} + + async execute(id: string): RepositoryAbstraction.Return { + const result = await this.getEntryById.execute( + this.experimentModel, + id + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new ExperimentNotFoundError(id)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + return Result.ok(EntryToExperimentMapper.toExperiment(result.value)); + } +} + +export const GetExperimentByIdRepository = RepositoryAbstraction.createImplementation({ + implementation: GetExperimentByIdRepositoryImpl, + dependencies: [ExperimentModel, GetEntryByIdUseCase] +}); diff --git a/packages/api-website-builder/src/features/experiments/GetExperimentById/GetExperimentByIdUseCase.ts b/packages/api-website-builder/src/features/experiments/GetExperimentById/GetExperimentByIdUseCase.ts new file mode 100644 index 00000000000..8b89e940c71 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetExperimentById/GetExperimentByIdUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { GetExperimentByIdUseCase as UseCaseAbstraction } from "./abstractions/GetExperimentByIdUseCase.js"; +import { GetExperimentByIdRepository } from "./abstractions/GetExperimentByIdRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +class GetExperimentByIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: GetExperimentByIdRepository.Interface + ) {} + + async execute(id: string): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canRead("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + return this.repository.execute(id); + } +} + +export const GetExperimentByIdUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetExperimentByIdUseCaseImpl, + dependencies: [WbPermissions, GetExperimentByIdRepository] +}); diff --git a/packages/api-website-builder/src/features/experiments/GetExperimentById/abstractions/GetExperimentByIdRepository.ts b/packages/api-website-builder/src/features/experiments/GetExperimentById/abstractions/GetExperimentByIdRepository.ts new file mode 100644 index 00000000000..8dbd9ae0cd2 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetExperimentById/abstractions/GetExperimentByIdRepository.ts @@ -0,0 +1,27 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotFoundError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +export interface IGetExperimentByIdRepository { + execute(id: string): Promise>; +} + +export interface IGetExperimentByIdRepositoryErrors { + notFound: ExperimentNotFoundError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = IGetExperimentByIdRepositoryErrors[keyof IGetExperimentByIdRepositoryErrors]; + +export const GetExperimentByIdRepository = createAbstraction( + "Wb/GetExperimentByIdRepository" +); + +export namespace GetExperimentByIdRepository { + export type Interface = IGetExperimentByIdRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/GetExperimentById/abstractions/GetExperimentByIdUseCase.ts b/packages/api-website-builder/src/features/experiments/GetExperimentById/abstractions/GetExperimentByIdUseCase.ts new file mode 100644 index 00000000000..48e6706e89f --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetExperimentById/abstractions/GetExperimentByIdUseCase.ts @@ -0,0 +1,31 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotAuthorizedError, + ExperimentNotFoundError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +export interface IGetExperimentByIdUseCase { + execute(id: string): Promise>; +} + +export interface IGetExperimentByIdUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + notFound: ExperimentNotFoundError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = IGetExperimentByIdUseCaseErrors[keyof IGetExperimentByIdUseCaseErrors]; + +/** Retrieve an experiment by ID. */ +export const GetExperimentByIdUseCase = createAbstraction( + "Wb/GetExperimentByIdUseCase" +); + +export namespace GetExperimentByIdUseCase { + export type Interface = IGetExperimentByIdUseCase; + export type Return = Promise>; + export type Error = UseCaseError; + export type Experiment = WbExperiment; +} diff --git a/packages/api-website-builder/src/features/experiments/GetExperimentById/feature.ts b/packages/api-website-builder/src/features/experiments/GetExperimentById/feature.ts new file mode 100644 index 00000000000..d1244892712 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetExperimentById/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetExperimentByIdRepository } from "./GetExperimentByIdRepository.js"; +import { GetExperimentByIdUseCase } from "./GetExperimentByIdUseCase.js"; + +export const GetExperimentByIdFeature = createFeature({ + name: "WebsiteBuilder/GetExperimentById", + register(container) { + container.register(GetExperimentByIdRepository).inSingletonScope(); + container.register(GetExperimentByIdUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/GetExperimentById/index.ts b/packages/api-website-builder/src/features/experiments/GetExperimentById/index.ts new file mode 100644 index 00000000000..916da3afbd7 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GetExperimentById/index.ts @@ -0,0 +1 @@ +export { GetExperimentByIdUseCase } from "./abstractions/GetExperimentByIdUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/GraduateVariant/GraduateVariantRepository.ts b/packages/api-website-builder/src/features/experiments/GraduateVariant/GraduateVariantRepository.ts new file mode 100644 index 00000000000..cff0c8bff22 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GraduateVariant/GraduateVariantRepository.ts @@ -0,0 +1,89 @@ +import { Result } from "@webiny/feature/api"; +import { CreateEntryRevisionFromUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntryRevisionFrom/index.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { GraduateVariantRepository as RepositoryAbstraction } from "./abstractions/GraduateVariantRepository.js"; +import { PageModel } from "~/domain/page/abstractions.js"; +import type { WbPage } from "~/domain/page/abstractions.js"; +import { EntryToPageMapper } from "~/domain/page/EntryToPageMapper.js"; +import { PageNotFoundError, PagePersistenceError } from "~/domain/page/errors.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { ExperimentPersistenceError } from "~/domain/experiment/errors.js"; + +class GraduateVariantRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private createRevisionFrom: CreateEntryRevisionFromUseCase.Interface, + private getEntryById: GetEntryByIdUseCase.Interface, + private updateEntry: UpdateEntryUseCase.Interface, + private pageModel: PageModel.Interface, + private experimentModel: ExperimentModel.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + // Resolve the baseline revision to copy its location onto the new revision. + const baselineResult = await this.getEntryById.execute( + this.pageModel, + params.baselineRevisionId + ); + if (baselineResult.isFail()) { + if (baselineResult.error.code === "Cms/Entry/NotFound") { + return Result.fail(new PageNotFoundError(params.baselineRevisionId)); + } + return Result.fail(new PagePersistenceError(baselineResult.error)); + } + + // Create a new revision from the baseline, overriding its content with the variant snapshot. + const revisionResult = await this.createRevisionFrom.execute( + this.pageModel, + params.baselineRevisionId, + { + location: baselineResult.value.location, + values: { + properties: params.content.properties, + metadata: params.content.metadata, + bindings: params.content.bindings, + elements: params.content.elements, + extensions: params.content.extensions ?? {} + } + } + ); + + if (revisionResult.isFail()) { + if (revisionResult.error.code === "Cms/Entry/NotFound") { + return Result.fail(new PageNotFoundError(params.baselineRevisionId)); + } + return Result.fail(new PagePersistenceError(revisionResult.error)); + } + + // Mark the experiment graduated and record the winning variant. + const updateResult = await this.updateEntry.execute( + this.experimentModel, + params.experimentId, + { + values: { + status: "graduated", + winningVariantId: params.variantId, + stoppedOn: new Date().toISOString() + } + } + ); + + if (updateResult.isFail()) { + return Result.fail(new ExperimentPersistenceError(updateResult.error)); + } + + return Result.ok(EntryToPageMapper.toPage(revisionResult.value)); + } +} + +export const GraduateVariantRepository = RepositoryAbstraction.createImplementation({ + implementation: GraduateVariantRepositoryImpl, + dependencies: [ + CreateEntryRevisionFromUseCase, + GetEntryByIdUseCase, + UpdateEntryUseCase, + PageModel, + ExperimentModel + ] +}); diff --git a/packages/api-website-builder/src/features/experiments/GraduateVariant/GraduateVariantUseCase.ts b/packages/api-website-builder/src/features/experiments/GraduateVariant/GraduateVariantUseCase.ts new file mode 100644 index 00000000000..c937ac408ee --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GraduateVariant/GraduateVariantUseCase.ts @@ -0,0 +1,76 @@ +import { Result } from "@webiny/feature/api"; +import { GraduateVariantUseCase as UseCaseAbstraction } from "./abstractions/GraduateVariantUseCase.js"; +import { GraduateVariantRepository } from "./abstractions/GraduateVariantRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { GetExperimentByIdUseCase } from "~/features/experiments/GetExperimentById/index.js"; +import { GetVariantByIdUseCase } from "~/features/variants/GetVariantById/index.js"; +import { + ExperimentNotAuthorizedError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +class GraduateVariantUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private getExperimentById: GetExperimentByIdUseCase.Interface, + private getVariantById: GetVariantByIdUseCase.Interface, + private repository: GraduateVariantRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const canPublishExperiment = await this.permissions.canPublish("page"); + const canCreatePage = await this.permissions.canCreate("page"); + if (!canPublishExperiment || !canCreatePage) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + const experimentResult = await this.getExperimentById.execute(params.experimentId); + if (experimentResult.isFail()) { + return Result.fail(experimentResult.error); + } + const experiment = experimentResult.value; + + if (experiment.status === "graduated") { + return Result.fail( + new ExperimentValidationError("This experiment has already been graduated.") + ); + } + + const variantResult = await this.getVariantById.execute(params.variantId); + if (variantResult.isFail()) { + return Result.fail(variantResult.error); + } + const variant = variantResult.value; + + if (variant.experimentId !== experiment.id) { + return Result.fail( + new ExperimentValidationError( + "The variant does not belong to the given experiment." + ) + ); + } + + return this.repository.execute({ + experimentId: experiment.id, + baselineRevisionId: experiment.baselineRevisionId, + variantId: params.variantId, + content: { + properties: variant.properties, + metadata: variant.metadata, + bindings: variant.bindings, + elements: variant.elements, + extensions: variant.extensions + } + }); + } +} + +export const GraduateVariantUseCase = UseCaseAbstraction.createImplementation({ + implementation: GraduateVariantUseCaseImpl, + dependencies: [ + WbPermissions, + GetExperimentByIdUseCase, + GetVariantByIdUseCase, + GraduateVariantRepository + ] +}); diff --git a/packages/api-website-builder/src/features/experiments/GraduateVariant/abstractions/GraduateVariantRepository.ts b/packages/api-website-builder/src/features/experiments/GraduateVariant/abstractions/GraduateVariantRepository.ts new file mode 100644 index 00000000000..cf15e786a0d --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GraduateVariant/abstractions/GraduateVariantRepository.ts @@ -0,0 +1,36 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbPage } from "~/domain/page/abstractions.js"; +import type { WbVariantContent } from "~/domain/variant/abstractions.js"; +import type { ExperimentPersistenceError } from "~/domain/experiment/errors.js"; +import type { PageNotFoundError, PagePersistenceError } from "~/domain/page/errors.js"; + +/** Repository input: the winning variant content has already been resolved by the use case. */ +export interface IGraduateVariantRepositoryParams { + experimentId: string; + baselineRevisionId: string; + variantId: string; + content: WbVariantContent; +} + +export interface IGraduateVariantRepository { + execute(params: IGraduateVariantRepositoryParams): Promise>; +} + +export interface IGraduateVariantRepositoryErrors { + notFound: PageNotFoundError; + persistence: PagePersistenceError; + experimentPersistence: ExperimentPersistenceError; +} + +type RepositoryError = IGraduateVariantRepositoryErrors[keyof IGraduateVariantRepositoryErrors]; + +export const GraduateVariantRepository = createAbstraction( + "Wb/GraduateVariantRepository" +); + +export namespace GraduateVariantRepository { + export type Interface = IGraduateVariantRepository; + export type Params = IGraduateVariantRepositoryParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/GraduateVariant/abstractions/GraduateVariantUseCase.ts b/packages/api-website-builder/src/features/experiments/GraduateVariant/abstractions/GraduateVariantUseCase.ts new file mode 100644 index 00000000000..12b57c598be --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GraduateVariant/abstractions/GraduateVariantUseCase.ts @@ -0,0 +1,55 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbPage } from "~/domain/page/abstractions.js"; +import type { + ExperimentNotAuthorizedError, + ExperimentNotFoundError, + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; +import type { + VariantNotAuthorizedError, + VariantNotFoundError, + VariantPersistenceError +} from "~/domain/variant/errors.js"; +import type { PageNotFoundError, PagePersistenceError } from "~/domain/page/errors.js"; + +/** Use case input: conclude an experiment by graduating one variant into a new revision. */ +export interface IGraduateVariantParams { + experimentId: string; + variantId: string; +} + +export interface IGraduateVariantUseCase { + execute(params: IGraduateVariantParams): Promise>; +} + +export interface IGraduateVariantUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + experimentNotFound: ExperimentNotFoundError; + variantNotFound: VariantNotFoundError; + variantNotAuthorized: VariantNotAuthorizedError; + variantPersistence: VariantPersistenceError; + validation: ExperimentValidationError; + notFound: PageNotFoundError; + persistence: PagePersistenceError; + experimentPersistence: ExperimentPersistenceError; +} + +type UseCaseError = IGraduateVariantUseCaseErrors[keyof IGraduateVariantUseCaseErrors]; + +/** + * Conclude an experiment by graduating a winning variant into a new page revision (N+1). + * That revision is an ordinary, reviewable revision that goes through the normal publishing + * workflow. This is the single bridge from an experiment back into the revision timeline. + */ +export const GraduateVariantUseCase = createAbstraction( + "Wb/GraduateVariantUseCase" +); + +export namespace GraduateVariantUseCase { + export type Interface = IGraduateVariantUseCase; + export type Params = IGraduateVariantParams; + export type Return = Promise>; + export type Error = UseCaseError; + export type Page = WbPage; +} diff --git a/packages/api-website-builder/src/features/experiments/GraduateVariant/feature.ts b/packages/api-website-builder/src/features/experiments/GraduateVariant/feature.ts new file mode 100644 index 00000000000..affdf5c4c96 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GraduateVariant/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GraduateVariantRepository } from "./GraduateVariantRepository.js"; +import { GraduateVariantUseCase } from "./GraduateVariantUseCase.js"; + +export const GraduateVariantFeature = createFeature({ + name: "WebsiteBuilder/GraduateVariant", + register(container) { + container.register(GraduateVariantRepository).inSingletonScope(); + container.register(GraduateVariantUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/GraduateVariant/index.ts b/packages/api-website-builder/src/features/experiments/GraduateVariant/index.ts new file mode 100644 index 00000000000..8bf16e51666 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/GraduateVariant/index.ts @@ -0,0 +1 @@ +export { GraduateVariantUseCase } from "./abstractions/GraduateVariantUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/ListExperiments/ListExperimentsRepository.ts b/packages/api-website-builder/src/features/experiments/ListExperiments/ListExperimentsRepository.ts new file mode 100644 index 00000000000..69f55e69ac9 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ListExperiments/ListExperimentsRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { ListExperimentsRepository as RepositoryAbstraction } from "./abstractions/ListExperimentsRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { ExperimentPersistenceError } from "~/domain/experiment/errors.js"; + +class ListExperimentsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private experimentModel: ExperimentModel.Interface, + private listLatestEntries: ListLatestEntriesUseCase.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const result = await this.listLatestEntries.execute( + this.experimentModel, + { + where: { + values: { + pageEntryId: params.pageEntryId + } + }, + sort: ["createdOn_DESC"], + limit: 1000 + } + ); + + if (result.isFail()) { + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + const experiments = result.value.entries.map(entry => + EntryToExperimentMapper.toExperiment(entry) + ); + + return Result.ok(experiments); + } +} + +export const ListExperimentsRepository = RepositoryAbstraction.createImplementation({ + implementation: ListExperimentsRepositoryImpl, + dependencies: [ExperimentModel, ListLatestEntriesUseCase] +}); diff --git a/packages/api-website-builder/src/features/experiments/ListExperiments/ListExperimentsUseCase.ts b/packages/api-website-builder/src/features/experiments/ListExperiments/ListExperimentsUseCase.ts new file mode 100644 index 00000000000..152c9d323c8 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ListExperiments/ListExperimentsUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { ListExperimentsUseCase as UseCaseAbstraction } from "./abstractions/ListExperimentsUseCase.js"; +import { ListExperimentsRepository } from "./abstractions/ListExperimentsRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +class ListExperimentsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: ListExperimentsRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canRead("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + return this.repository.execute(params); + } +} + +export const ListExperimentsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListExperimentsUseCaseImpl, + dependencies: [WbPermissions, ListExperimentsRepository] +}); diff --git a/packages/api-website-builder/src/features/experiments/ListExperiments/abstractions/ListExperimentsRepository.ts b/packages/api-website-builder/src/features/experiments/ListExperiments/abstractions/ListExperimentsRepository.ts new file mode 100644 index 00000000000..508367ff742 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ListExperiments/abstractions/ListExperimentsRepository.ts @@ -0,0 +1,28 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { ExperimentPersistenceError } from "~/domain/experiment/errors.js"; + +export interface IListExperimentsParams { + pageEntryId: string; +} + +export interface IListExperimentsRepository { + execute(params: IListExperimentsParams): Promise>; +} + +export interface IListExperimentsRepositoryErrors { + persistence: ExperimentPersistenceError; +} + +type RepositoryError = IListExperimentsRepositoryErrors[keyof IListExperimentsRepositoryErrors]; + +export const ListExperimentsRepository = createAbstraction( + "Wb/ListExperimentsRepository" +); + +export namespace ListExperimentsRepository { + export type Interface = IListExperimentsRepository; + export type Params = IListExperimentsParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/ListExperiments/abstractions/ListExperimentsUseCase.ts b/packages/api-website-builder/src/features/experiments/ListExperiments/abstractions/ListExperimentsUseCase.ts new file mode 100644 index 00000000000..9b970afa393 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ListExperiments/abstractions/ListExperimentsUseCase.ts @@ -0,0 +1,30 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotAuthorizedError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; +import type { IListExperimentsParams } from "./ListExperimentsRepository.js"; + +export interface IListExperimentsUseCase { + execute(params: IListExperimentsParams): Promise>; +} + +export interface IListExperimentsUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = IListExperimentsUseCaseErrors[keyof IListExperimentsUseCaseErrors]; + +/** List experiments for a page. */ +export const ListExperimentsUseCase = createAbstraction( + "Wb/ListExperimentsUseCase" +); + +export namespace ListExperimentsUseCase { + export type Interface = IListExperimentsUseCase; + export type Params = IListExperimentsParams; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-website-builder/src/features/experiments/ListExperiments/feature.ts b/packages/api-website-builder/src/features/experiments/ListExperiments/feature.ts new file mode 100644 index 00000000000..e60cf205a8f --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ListExperiments/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListExperimentsRepository } from "./ListExperimentsRepository.js"; +import { ListExperimentsUseCase } from "./ListExperimentsUseCase.js"; + +export const ListExperimentsFeature = createFeature({ + name: "WebsiteBuilder/ListExperiments", + register(container) { + container.register(ListExperimentsRepository).inSingletonScope(); + container.register(ListExperimentsUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/ListExperiments/index.ts b/packages/api-website-builder/src/features/experiments/ListExperiments/index.ts new file mode 100644 index 00000000000..d12b63cf159 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/ListExperiments/index.ts @@ -0,0 +1 @@ +export { ListExperimentsUseCase } from "./abstractions/ListExperimentsUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/StartExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/StartExperimentRepository.ts new file mode 100644 index 00000000000..34115820cbb --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/StartExperimentRepository.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { StartExperimentRepository as RepositoryAbstraction } from "./abstractions/StartExperimentRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { ExperimentNotFoundError, ExperimentPersistenceError } from "~/domain/experiment/errors.js"; + +class StartExperimentRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private experimentModel: ExperimentModel.Interface + ) {} + + async execute(id: string): RepositoryAbstraction.Return { + const result = await this.updateEntry.execute( + this.experimentModel, + id, + { + values: { + status: "running", + startedOn: new Date().toISOString() + } + } + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new ExperimentNotFoundError(id)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + return Result.ok(EntryToExperimentMapper.toExperiment(result.value)); + } +} + +export const StartExperimentRepository = RepositoryAbstraction.createImplementation({ + implementation: StartExperimentRepositoryImpl, + dependencies: [UpdateEntryUseCase, ExperimentModel] +}); diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/StartExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/StartExperimentUseCase.ts new file mode 100644 index 00000000000..5819563fe05 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/StartExperimentUseCase.ts @@ -0,0 +1,79 @@ +import { Result } from "@webiny/feature/api"; +import { EventPublisher } from "@webiny/api-core/features/eventPublisher/index.js"; +import { StartExperimentUseCase as UseCaseAbstraction } from "./abstractions/StartExperimentUseCase.js"; +import { StartExperimentRepository } from "./abstractions/StartExperimentRepository.js"; +import { ExperimentAfterStartEvent } from "./events.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { GetExperimentByIdUseCase } from "~/features/experiments/GetExperimentById/index.js"; +import { GetActiveExperimentForRevisionUseCase } from "~/features/experiments/GetActiveExperimentForRevision/index.js"; +import { + ExperimentAlreadyActiveError, + ExperimentNotAuthorizedError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +class StartExperimentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private eventPublisher: EventPublisher.Interface, + private getExperimentById: GetExperimentByIdUseCase.Interface, + private getActiveExperiment: GetActiveExperimentForRevisionUseCase.Interface, + private repository: StartExperimentRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canPublish("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + const experimentResult = await this.getExperimentById.execute(params.id); + if (experimentResult.isFail()) { + return Result.fail(experimentResult.error); + } + + const experiment = experimentResult.value; + + // A draft experiment can be started; a stopped one can be re-activated. + if (experiment.status !== "draft" && experiment.status !== "stopped") { + return Result.fail( + new ExperimentValidationError( + `Only a draft or stopped experiment can be started (current status: "${experiment.status}").` + ) + ); + } + + // v1: one active experiment per revision. + const activeResult = await this.getActiveExperiment.execute(experiment.baselineRevisionId); + if (activeResult.isFail()) { + // No active experiment on the revision is the normal case — it's what lets us start. + if (activeResult.error.code !== "WebsiteBuilder/Experiment/NoActiveExperiment") { + return Result.fail(activeResult.error); + } + } else if (activeResult.value.entryId !== experiment.entryId) { + return Result.fail(new ExperimentAlreadyActiveError(experiment.baselineRevisionId)); + } + + const result = await this.repository.execute(params.id); + if (result.isFail()) { + return Result.fail(result.error); + } + + await this.eventPublisher.publish( + new ExperimentAfterStartEvent({ experiment: result.value }) + ); + + return Result.ok(result.value); + } +} + +export const StartExperimentUseCase = UseCaseAbstraction.createImplementation({ + implementation: StartExperimentUseCaseImpl, + dependencies: [ + WbPermissions, + EventPublisher, + GetExperimentByIdUseCase, + GetActiveExperimentForRevisionUseCase, + StartExperimentRepository + ] +}); diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/ExperimentAfterStartEventHandler.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/ExperimentAfterStartEventHandler.ts new file mode 100644 index 00000000000..27329c072b2 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/ExperimentAfterStartEventHandler.ts @@ -0,0 +1,17 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { DomainEvent, IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; + +export interface ExperimentAfterStartPayload { + experiment: WbExperiment; +} + +/** Hook into experiment lifecycle after an experiment is started. */ +export const ExperimentAfterStartEventHandler = createAbstraction< + IEventHandler> +>("Wb/ExperimentAfterStartEventHandler"); + +export namespace ExperimentAfterStartEventHandler { + export type Interface = IEventHandler>; + export type Event = DomainEvent; +} diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/StartExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/StartExperimentRepository.ts new file mode 100644 index 00000000000..a634ee79c9b --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/StartExperimentRepository.ts @@ -0,0 +1,27 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotFoundError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +export interface IStartExperimentRepository { + execute(id: string): Promise>; +} + +export interface IStartExperimentRepositoryErrors { + notFound: ExperimentNotFoundError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = IStartExperimentRepositoryErrors[keyof IStartExperimentRepositoryErrors]; + +export const StartExperimentRepository = createAbstraction( + "Wb/StartExperimentRepository" +); + +export namespace StartExperimentRepository { + export type Interface = IStartExperimentRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/StartExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/StartExperimentUseCase.ts new file mode 100644 index 00000000000..9a6c3cf7a29 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/abstractions/StartExperimentUseCase.ts @@ -0,0 +1,40 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentAlreadyActiveError, + ExperimentNotAuthorizedError, + ExperimentNotFoundError, + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +export interface IStartExperimentParams { + id: string; +} + +export interface IStartExperimentUseCase { + execute(params: IStartExperimentParams): Promise>; +} + +export interface IStartExperimentUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + notFound: ExperimentNotFoundError; + alreadyActive: ExperimentAlreadyActiveError; + validation: ExperimentValidationError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = IStartExperimentUseCaseErrors[keyof IStartExperimentUseCaseErrors]; + +/** Start an experiment against its baseline revision. One active experiment per revision. */ +export const StartExperimentUseCase = createAbstraction( + "Wb/StartExperimentUseCase" +); + +export namespace StartExperimentUseCase { + export type Interface = IStartExperimentUseCase; + export type Params = IStartExperimentParams; + export type Return = Promise>; + export type Error = UseCaseError; + export type Experiment = WbExperiment; +} diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/events.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/events.ts new file mode 100644 index 00000000000..5afa34f4810 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/events.ts @@ -0,0 +1,11 @@ +import { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js"; +import { ExperimentAfterStartEventHandler } from "./abstractions/ExperimentAfterStartEventHandler.js"; +import type { ExperimentAfterStartPayload } from "./abstractions/ExperimentAfterStartEventHandler.js"; + +export class ExperimentAfterStartEvent extends DomainEvent { + eventType = "experiment.afterStart" as const; + + getHandlerAbstraction() { + return ExperimentAfterStartEventHandler; + } +} diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/feature.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/feature.ts new file mode 100644 index 00000000000..4581032907a --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { StartExperimentRepository } from "./StartExperimentRepository.js"; +import { StartExperimentUseCase } from "./StartExperimentUseCase.js"; + +export const StartExperimentFeature = createFeature({ + name: "WebsiteBuilder/StartExperiment", + register(container) { + container.register(StartExperimentRepository).inSingletonScope(); + container.register(StartExperimentUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/StartExperiment/index.ts b/packages/api-website-builder/src/features/experiments/StartExperiment/index.ts new file mode 100644 index 00000000000..767ab8691db --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StartExperiment/index.ts @@ -0,0 +1,2 @@ +export { StartExperimentUseCase } from "./abstractions/StartExperimentUseCase.js"; +export { ExperimentAfterStartEventHandler } from "./abstractions/ExperimentAfterStartEventHandler.js"; diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/StopExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/StopExperimentRepository.ts new file mode 100644 index 00000000000..691673d1f62 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/StopExperimentRepository.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { StopExperimentRepository as RepositoryAbstraction } from "./abstractions/StopExperimentRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { ExperimentNotFoundError, ExperimentPersistenceError } from "~/domain/experiment/errors.js"; + +class StopExperimentRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private experimentModel: ExperimentModel.Interface + ) {} + + async execute(id: string): RepositoryAbstraction.Return { + const result = await this.updateEntry.execute( + this.experimentModel, + id, + { + values: { + status: "stopped", + stoppedOn: new Date().toISOString() + } + } + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new ExperimentNotFoundError(id)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + return Result.ok(EntryToExperimentMapper.toExperiment(result.value)); + } +} + +export const StopExperimentRepository = RepositoryAbstraction.createImplementation({ + implementation: StopExperimentRepositoryImpl, + dependencies: [UpdateEntryUseCase, ExperimentModel] +}); diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/StopExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/StopExperimentUseCase.ts new file mode 100644 index 00000000000..e4943049f81 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/StopExperimentUseCase.ts @@ -0,0 +1,61 @@ +import { Result } from "@webiny/feature/api"; +import { EventPublisher } from "@webiny/api-core/features/eventPublisher/index.js"; +import { StopExperimentUseCase as UseCaseAbstraction } from "./abstractions/StopExperimentUseCase.js"; +import { StopExperimentRepository } from "./abstractions/StopExperimentRepository.js"; +import { ExperimentAfterStopEvent } from "./events.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { GetExperimentByIdUseCase } from "~/features/experiments/GetExperimentById/index.js"; +import { + ExperimentNotAuthorizedError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +class StopExperimentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private eventPublisher: EventPublisher.Interface, + private getExperimentById: GetExperimentByIdUseCase.Interface, + private repository: StopExperimentRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canPublish("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + const experimentResult = await this.getExperimentById.execute(params.id); + if (experimentResult.isFail()) { + return Result.fail(experimentResult.error); + } + + if (experimentResult.value.status !== "running") { + return Result.fail( + new ExperimentValidationError( + `Only a running experiment can be stopped (current status: "${experimentResult.value.status}").` + ) + ); + } + + const result = await this.repository.execute(params.id); + if (result.isFail()) { + return Result.fail(result.error); + } + + await this.eventPublisher.publish( + new ExperimentAfterStopEvent({ experiment: result.value, reason: "manual" }) + ); + + return Result.ok(result.value); + } +} + +export const StopExperimentUseCase = UseCaseAbstraction.createImplementation({ + implementation: StopExperimentUseCaseImpl, + dependencies: [ + WbPermissions, + EventPublisher, + GetExperimentByIdUseCase, + StopExperimentRepository + ] +}); diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/ExperimentAfterStopEventHandler.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/ExperimentAfterStopEventHandler.ts new file mode 100644 index 00000000000..95cfcdc335b --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/ExperimentAfterStopEventHandler.ts @@ -0,0 +1,19 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { DomainEvent, IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; + +export interface ExperimentAfterStopPayload { + experiment: WbExperiment; + /** Why the experiment was stopped: an explicit user action, or a cascade from publishing. */ + reason: "manual" | "revisionPublished"; +} + +/** Hook into experiment lifecycle after an experiment is stopped. */ +export const ExperimentAfterStopEventHandler = createAbstraction< + IEventHandler> +>("Wb/ExperimentAfterStopEventHandler"); + +export namespace ExperimentAfterStopEventHandler { + export type Interface = IEventHandler>; + export type Event = DomainEvent; +} diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/StopExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/StopExperimentRepository.ts new file mode 100644 index 00000000000..4db717ca548 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/StopExperimentRepository.ts @@ -0,0 +1,27 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotFoundError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; + +export interface IStopExperimentRepository { + execute(id: string): Promise>; +} + +export interface IStopExperimentRepositoryErrors { + notFound: ExperimentNotFoundError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = IStopExperimentRepositoryErrors[keyof IStopExperimentRepositoryErrors]; + +export const StopExperimentRepository = createAbstraction( + "Wb/StopExperimentRepository" +); + +export namespace StopExperimentRepository { + export type Interface = IStopExperimentRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/StopExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/StopExperimentUseCase.ts new file mode 100644 index 00000000000..3231f56c6cb --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/abstractions/StopExperimentUseCase.ts @@ -0,0 +1,38 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { + ExperimentNotAuthorizedError, + ExperimentNotFoundError, + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +export interface IStopExperimentParams { + id: string; +} + +export interface IStopExperimentUseCase { + execute(params: IStopExperimentParams): Promise>; +} + +export interface IStopExperimentUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + notFound: ExperimentNotFoundError; + validation: ExperimentValidationError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = IStopExperimentUseCaseErrors[keyof IStopExperimentUseCaseErrors]; + +/** Stop a running experiment. */ +export const StopExperimentUseCase = createAbstraction( + "Wb/StopExperimentUseCase" +); + +export namespace StopExperimentUseCase { + export type Interface = IStopExperimentUseCase; + export type Params = IStopExperimentParams; + export type Return = Promise>; + export type Error = UseCaseError; + export type Experiment = WbExperiment; +} diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/events.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/events.ts new file mode 100644 index 00000000000..39c235f185a --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/events.ts @@ -0,0 +1,11 @@ +import { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js"; +import { ExperimentAfterStopEventHandler } from "./abstractions/ExperimentAfterStopEventHandler.js"; +import type { ExperimentAfterStopPayload } from "./abstractions/ExperimentAfterStopEventHandler.js"; + +export class ExperimentAfterStopEvent extends DomainEvent { + eventType = "experiment.afterStop" as const; + + getHandlerAbstraction() { + return ExperimentAfterStopEventHandler; + } +} diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/feature.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/feature.ts new file mode 100644 index 00000000000..206e49b9013 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { StopExperimentRepository } from "./StopExperimentRepository.js"; +import { StopExperimentUseCase } from "./StopExperimentUseCase.js"; + +export const StopExperimentFeature = createFeature({ + name: "WebsiteBuilder/StopExperiment", + register(container) { + container.register(StopExperimentRepository).inSingletonScope(); + container.register(StopExperimentUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/StopExperiment/index.ts b/packages/api-website-builder/src/features/experiments/StopExperiment/index.ts new file mode 100644 index 00000000000..4e2cb06700a --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/StopExperiment/index.ts @@ -0,0 +1,2 @@ +export { StopExperimentUseCase } from "./abstractions/StopExperimentUseCase.js"; +export { ExperimentAfterStopEventHandler } from "./abstractions/ExperimentAfterStopEventHandler.js"; diff --git a/packages/api-website-builder/src/features/experiments/UpdateExperiment/UpdateExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/UpdateExperiment/UpdateExperimentRepository.ts new file mode 100644 index 00000000000..50e824e61b3 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/UpdateExperiment/UpdateExperimentRepository.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { UpdateExperimentRepository as RepositoryAbstraction } from "./abstractions/UpdateExperimentRepository.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import type { CmsEntryWbExperimentValues } from "~/domain/experiment/abstractions.js"; +import { EntryToExperimentMapper } from "~/domain/experiment/EntryToExperimentMapper.js"; +import { + ExperimentNotFoundError, + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +class UpdateExperimentRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private getEntryById: GetEntryByIdUseCase.Interface, + private experimentModel: ExperimentModel.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const getResult = await this.getEntryById.execute(this.experimentModel, params.id); + if (getResult.isFail()) { + if (getResult.error.code === "Cms/Entry/NotFound") { + return Result.fail(new ExperimentNotFoundError(params.id)); + } + return Result.fail(new ExperimentPersistenceError(getResult.error)); + } + + const result = await this.updateEntry.execute( + this.experimentModel, + params.id, + { values: params.data as Partial } + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/ValidationError") { + return Result.fail(new ExperimentValidationError(result.error.message)); + } + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new ExperimentNotFoundError(params.id)); + } + return Result.fail(new ExperimentPersistenceError(result.error)); + } + + return Result.ok(EntryToExperimentMapper.toExperiment(result.value)); + } +} + +export const UpdateExperimentRepository = RepositoryAbstraction.createImplementation({ + implementation: UpdateExperimentRepositoryImpl, + dependencies: [UpdateEntryUseCase, GetEntryByIdUseCase, ExperimentModel] +}); diff --git a/packages/api-website-builder/src/features/experiments/UpdateExperiment/UpdateExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/UpdateExperiment/UpdateExperimentUseCase.ts new file mode 100644 index 00000000000..217ff797cec --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/UpdateExperiment/UpdateExperimentUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateExperimentUseCase as UseCaseAbstraction } from "./abstractions/UpdateExperimentUseCase.js"; +import { UpdateExperimentRepository } from "./abstractions/UpdateExperimentRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { ExperimentNotAuthorizedError } from "~/domain/experiment/errors.js"; + +class UpdateExperimentUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: UpdateExperimentRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canEdit("page"); + if (!hasPermission) { + return Result.fail(new ExperimentNotAuthorizedError()); + } + + return this.repository.execute(params); + } +} + +export const UpdateExperimentUseCase = UseCaseAbstraction.createImplementation({ + implementation: UpdateExperimentUseCaseImpl, + dependencies: [WbPermissions, UpdateExperimentRepository] +}); diff --git a/packages/api-website-builder/src/features/experiments/UpdateExperiment/abstractions/UpdateExperimentRepository.ts b/packages/api-website-builder/src/features/experiments/UpdateExperiment/abstractions/UpdateExperimentRepository.ts new file mode 100644 index 00000000000..f9ceb49209a --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/UpdateExperiment/abstractions/UpdateExperimentRepository.ts @@ -0,0 +1,49 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + ExperimentAnalyticsConfig, + ExperimentGoals, + ExperimentTargeting, + ExperimentTrafficSplit, + WbExperiment +} from "~/domain/experiment/abstractions.js"; +import { + ExperimentNotFoundError, + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; + +export interface IUpdateExperimentData { + name?: string; + trafficSplit?: ExperimentTrafficSplit; + targeting?: ExperimentTargeting; + goals?: ExperimentGoals; + analytics?: ExperimentAnalyticsConfig; +} + +export interface IUpdateExperimentParams { + id: string; + data: IUpdateExperimentData; +} + +export interface IUpdateExperimentRepository { + execute(params: IUpdateExperimentParams): Promise>; +} + +export interface IUpdateExperimentRepositoryErrors { + notFound: ExperimentNotFoundError; + validation: ExperimentValidationError; + persistence: ExperimentPersistenceError; +} + +type RepositoryError = IUpdateExperimentRepositoryErrors[keyof IUpdateExperimentRepositoryErrors]; + +export const UpdateExperimentRepository = createAbstraction( + "Wb/UpdateExperimentRepository" +); + +export namespace UpdateExperimentRepository { + export type Interface = IUpdateExperimentRepository; + export type Params = IUpdateExperimentParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/experiments/UpdateExperiment/abstractions/UpdateExperimentUseCase.ts b/packages/api-website-builder/src/features/experiments/UpdateExperiment/abstractions/UpdateExperimentUseCase.ts new file mode 100644 index 00000000000..765c635906c --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/UpdateExperiment/abstractions/UpdateExperimentUseCase.ts @@ -0,0 +1,34 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import { + ExperimentNotAuthorizedError, + ExperimentNotFoundError, + ExperimentPersistenceError, + ExperimentValidationError +} from "~/domain/experiment/errors.js"; +import type { IUpdateExperimentParams } from "./UpdateExperimentRepository.js"; + +export interface IUpdateExperimentUseCase { + execute(params: IUpdateExperimentParams): Promise>; +} + +export interface IUpdateExperimentUseCaseErrors { + notAuthorized: ExperimentNotAuthorizedError; + notFound: ExperimentNotFoundError; + validation: ExperimentValidationError; + persistence: ExperimentPersistenceError; +} + +type UseCaseError = IUpdateExperimentUseCaseErrors[keyof IUpdateExperimentUseCaseErrors]; + +/** Update an experiment's configuration (name, traffic split, targeting, goals, analytics). */ +export const UpdateExperimentUseCase = createAbstraction( + "Wb/UpdateExperimentUseCase" +); + +export namespace UpdateExperimentUseCase { + export type Interface = IUpdateExperimentUseCase; + export type Params = IUpdateExperimentParams; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-website-builder/src/features/experiments/UpdateExperiment/feature.ts b/packages/api-website-builder/src/features/experiments/UpdateExperiment/feature.ts new file mode 100644 index 00000000000..98598cd0d86 --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/UpdateExperiment/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateExperimentRepository } from "./UpdateExperimentRepository.js"; +import { UpdateExperimentUseCase } from "./UpdateExperimentUseCase.js"; + +export const UpdateExperimentFeature = createFeature({ + name: "WebsiteBuilder/UpdateExperiment", + register(container) { + container.register(UpdateExperimentRepository).inSingletonScope(); + container.register(UpdateExperimentUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/experiments/UpdateExperiment/index.ts b/packages/api-website-builder/src/features/experiments/UpdateExperiment/index.ts new file mode 100644 index 00000000000..aacb1069f1f --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/UpdateExperiment/index.ts @@ -0,0 +1 @@ +export { UpdateExperimentUseCase } from "./abstractions/UpdateExperimentUseCase.js"; diff --git a/packages/api-website-builder/src/features/experiments/feature.ts b/packages/api-website-builder/src/features/experiments/feature.ts new file mode 100644 index 00000000000..681c9a869be --- /dev/null +++ b/packages/api-website-builder/src/features/experiments/feature.ts @@ -0,0 +1,32 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateExperimentFeature } from "./CreateExperiment/feature.js"; +import { UpdateExperimentFeature } from "./UpdateExperiment/feature.js"; +import { GetExperimentByIdFeature } from "./GetExperimentById/feature.js"; +import { ListExperimentsFeature } from "./ListExperiments/feature.js"; +import { GetActiveExperimentForRevisionFeature } from "./GetActiveExperimentForRevision/feature.js"; +import { GetActiveExperimentForPathFeature } from "./GetActiveExperimentForPath/feature.js"; +import { StartExperimentFeature } from "./StartExperiment/feature.js"; +import { StopExperimentFeature } from "./StopExperiment/feature.js"; +import { DeleteExperimentFeature } from "./DeleteExperiment/feature.js"; +import { GraduateVariantFeature } from "./GraduateVariant/feature.js"; +import { EndExperimentOnPublishFeature } from "./EndExperimentOnPublish/feature.js"; +import { ExperimentPauseFeature } from "./ExperimentPause/feature.js"; + +/** Registers every experiment use case (CRUD, lifecycle, kill-switch, publish handler). */ +export const ExperimentFeature = createFeature({ + name: "WebsiteBuilder/Experiment", + register(container) { + CreateExperimentFeature.register(container); + UpdateExperimentFeature.register(container); + GetExperimentByIdFeature.register(container); + ListExperimentsFeature.register(container); + GetActiveExperimentForRevisionFeature.register(container); + GetActiveExperimentForPathFeature.register(container); + StartExperimentFeature.register(container); + StopExperimentFeature.register(container); + DeleteExperimentFeature.register(container); + GraduateVariantFeature.register(container); + EndExperimentOnPublishFeature.register(container); + ExperimentPauseFeature.register(container); + } +}); diff --git a/packages/api-website-builder/src/features/installer/ApiKeyInstaller.ts b/packages/api-website-builder/src/features/installer/ApiKeyInstaller.ts index c45b443bd73..45c9df6827d 100644 --- a/packages/api-website-builder/src/features/installer/ApiKeyInstaller.ts +++ b/packages/api-website-builder/src/features/installer/ApiKeyInstaller.ts @@ -19,7 +19,12 @@ class ApiKeyInstallerImpl implements AppInstaller.Interface { name: "Website Builder", description: "Integrate Next.js or custom frontend with Website Builder.", slug: "website-builder", - permissions: [{ name: "$wb.readonly" }, { name: "wb.page", rwd: "r" }] + permissions: [ + // Experiments and variants are governed by the Website Builder page permission, + // so read-only page access is all the frontend key needs to serve/preview them. + { name: "$wb.readonly" }, + { name: "wb.page", rwd: "r" } + ] }); if (result.isOk()) { diff --git a/packages/api-website-builder/src/features/variants/CreateVariant/CreateVariantRepository.ts b/packages/api-website-builder/src/features/variants/CreateVariant/CreateVariantRepository.ts new file mode 100644 index 00000000000..0be43e1c3cc --- /dev/null +++ b/packages/api-website-builder/src/features/variants/CreateVariant/CreateVariantRepository.ts @@ -0,0 +1,45 @@ +import { Result } from "@webiny/feature/api"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import { CreateVariantRepository as RepositoryAbstraction } from "./abstractions/CreateVariantRepository.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; +import type { CmsEntryWbVariantValues } from "~/domain/variant/abstractions.js"; +import { EntryToVariantMapper } from "~/domain/variant/EntryToVariantMapper.js"; +import { VariantPersistenceError, VariantValidationError } from "~/domain/variant/errors.js"; + +class CreateVariantRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private createEntry: CreateEntryUseCase.Interface, + private variantModel: VariantModel.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const values: CmsEntryWbVariantValues = { + experimentId: params.experimentId, + name: params.name, + status: "draft", + properties: params.content.properties, + metadata: params.content.metadata, + bindings: params.content.bindings, + elements: params.content.elements, + extensions: params.content.extensions ?? {} + }; + + const result = await this.createEntry.execute(this.variantModel, { + values + }); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/ValidationError") { + return Result.fail(new VariantValidationError(result.error.message)); + } + return Result.fail(new VariantPersistenceError(result.error)); + } + + return Result.ok(EntryToVariantMapper.toVariant(result.value)); + } +} + +export const CreateVariantRepository = RepositoryAbstraction.createImplementation({ + implementation: CreateVariantRepositoryImpl, + dependencies: [CreateEntryUseCase, VariantModel] +}); diff --git a/packages/api-website-builder/src/features/variants/CreateVariant/CreateVariantUseCase.ts b/packages/api-website-builder/src/features/variants/CreateVariant/CreateVariantUseCase.ts new file mode 100644 index 00000000000..cccfb884f22 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/CreateVariant/CreateVariantUseCase.ts @@ -0,0 +1,61 @@ +import { Result } from "@webiny/feature/api"; +import { CreateVariantUseCase as UseCaseAbstraction } from "./abstractions/CreateVariantUseCase.js"; +import { CreateVariantRepository } from "./abstractions/CreateVariantRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { GetExperimentByIdUseCase } from "~/features/experiments/GetExperimentById/index.js"; +import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; +import { VariantNotAuthorizedError } from "~/domain/variant/errors.js"; + +class CreateVariantUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private getExperimentById: GetExperimentByIdUseCase.Interface, + private getPageById: GetPageByIdUseCase.Interface, + private repository: CreateVariantRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canCreate("page"); + if (!hasPermission) { + return Result.fail(new VariantNotAuthorizedError()); + } + + // Resolve the experiment to find the immutable baseline revision. + const experimentResult = await this.getExperimentById.execute(params.experimentId); + if (experimentResult.isFail()) { + return Result.fail(experimentResult.error); + } + + // A variant begins as a copy of the baseline revision's content. + const baselineResult = await this.getPageById.execute( + experimentResult.value.baselineRevisionId + ); + if (baselineResult.isFail()) { + return Result.fail(baselineResult.error); + } + + const baseline = baselineResult.value; + + return this.repository.execute({ + experimentId: params.experimentId, + name: params.name, + content: { + properties: baseline.properties, + metadata: baseline.metadata, + bindings: baseline.bindings, + elements: baseline.elements, + extensions: baseline.extensions + } + }); + } +} + +export const CreateVariantUseCase = UseCaseAbstraction.createImplementation({ + implementation: CreateVariantUseCaseImpl, + dependencies: [ + WbPermissions, + GetExperimentByIdUseCase, + GetPageByIdUseCase, + CreateVariantRepository + ] +}); diff --git a/packages/api-website-builder/src/features/variants/CreateVariant/abstractions/CreateVariantRepository.ts b/packages/api-website-builder/src/features/variants/CreateVariant/abstractions/CreateVariantRepository.ts new file mode 100644 index 00000000000..6b173bb674a --- /dev/null +++ b/packages/api-website-builder/src/features/variants/CreateVariant/abstractions/CreateVariantRepository.ts @@ -0,0 +1,32 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbVariant, WbVariantContent } from "~/domain/variant/abstractions.js"; +import type { VariantPersistenceError, VariantValidationError } from "~/domain/variant/errors.js"; + +/** Repository input: the baseline content has already been resolved by the use case. */ +export interface ICreateVariantRepositoryParams { + experimentId: string; + name: string; + content: WbVariantContent; +} + +export interface ICreateVariantRepository { + execute(params: ICreateVariantRepositoryParams): Promise>; +} + +export interface ICreateVariantRepositoryErrors { + validation: VariantValidationError; + persistence: VariantPersistenceError; +} + +type RepositoryError = ICreateVariantRepositoryErrors[keyof ICreateVariantRepositoryErrors]; + +export const CreateVariantRepository = createAbstraction( + "Wb/CreateVariantRepository" +); + +export namespace CreateVariantRepository { + export type Interface = ICreateVariantRepository; + export type Params = ICreateVariantRepositoryParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/variants/CreateVariant/abstractions/CreateVariantUseCase.ts b/packages/api-website-builder/src/features/variants/CreateVariant/abstractions/CreateVariantUseCase.ts new file mode 100644 index 00000000000..a3f571d5335 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/CreateVariant/abstractions/CreateVariantUseCase.ts @@ -0,0 +1,53 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbVariant } from "~/domain/variant/abstractions.js"; +import type { + VariantNotAuthorizedError, + VariantPersistenceError, + VariantValidationError +} from "~/domain/variant/errors.js"; +import type { + ExperimentNotAuthorizedError, + ExperimentNotFoundError, + ExperimentPersistenceError +} from "~/domain/experiment/errors.js"; +import type { + PageNotAuthorizedError, + PageNotFoundError, + PagePersistenceError +} from "~/domain/page/errors.js"; + +/** Use case input: a variant starts as a copy of the experiment's baseline revision content. */ +export interface ICreateVariantParams { + experimentId: string; + name: string; +} + +export interface ICreateVariantUseCase { + execute(params: ICreateVariantParams): Promise>; +} + +export interface ICreateVariantUseCaseErrors { + notAuthorized: VariantNotAuthorizedError; + validation: VariantValidationError; + persistence: VariantPersistenceError; + experimentNotFound: ExperimentNotFoundError; + experimentNotAuthorized: ExperimentNotAuthorizedError; + experimentPersistence: ExperimentPersistenceError; + baselineNotFound: PageNotFoundError; + baselineNotAuthorized: PageNotAuthorizedError; + baselinePersistence: PagePersistenceError; +} + +type UseCaseError = ICreateVariantUseCaseErrors[keyof ICreateVariantUseCaseErrors]; + +/** Create a variant as a full content snapshot of the experiment's baseline revision. */ +export const CreateVariantUseCase = + createAbstraction("Wb/CreateVariantUseCase"); + +export namespace CreateVariantUseCase { + export type Interface = ICreateVariantUseCase; + export type Params = ICreateVariantParams; + export type Return = Promise>; + export type Error = UseCaseError; + export type Variant = WbVariant; +} diff --git a/packages/api-website-builder/src/features/variants/CreateVariant/feature.ts b/packages/api-website-builder/src/features/variants/CreateVariant/feature.ts new file mode 100644 index 00000000000..efdca51bfdc --- /dev/null +++ b/packages/api-website-builder/src/features/variants/CreateVariant/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateVariantRepository } from "./CreateVariantRepository.js"; +import { CreateVariantUseCase } from "./CreateVariantUseCase.js"; + +export const CreateVariantFeature = createFeature({ + name: "WebsiteBuilder/CreateVariant", + register(container) { + container.register(CreateVariantRepository).inSingletonScope(); + container.register(CreateVariantUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/variants/CreateVariant/index.ts b/packages/api-website-builder/src/features/variants/CreateVariant/index.ts new file mode 100644 index 00000000000..51f2c47bb3a --- /dev/null +++ b/packages/api-website-builder/src/features/variants/CreateVariant/index.ts @@ -0,0 +1 @@ +export { CreateVariantUseCase } from "./abstractions/CreateVariantUseCase.js"; diff --git a/packages/api-website-builder/src/features/variants/DeleteVariant/DeleteVariantRepository.ts b/packages/api-website-builder/src/features/variants/DeleteVariant/DeleteVariantRepository.ts new file mode 100644 index 00000000000..75a593aafe6 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/DeleteVariant/DeleteVariantRepository.ts @@ -0,0 +1,30 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"; +import { DeleteVariantRepository as RepositoryAbstraction } from "./abstractions/DeleteVariantRepository.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; +import { VariantNotFoundError, VariantPersistenceError } from "~/domain/variant/errors.js"; + +class DeleteVariantRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private deleteEntry: DeleteEntryUseCase.Interface, + private variantModel: VariantModel.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const result = await this.deleteEntry.execute(this.variantModel, params.id); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new VariantNotFoundError(params.id)); + } + return Result.fail(new VariantPersistenceError(result.error)); + } + + return Result.ok(true); + } +} + +export const DeleteVariantRepository = RepositoryAbstraction.createImplementation({ + implementation: DeleteVariantRepositoryImpl, + dependencies: [DeleteEntryUseCase, VariantModel] +}); diff --git a/packages/api-website-builder/src/features/variants/DeleteVariant/DeleteVariantUseCase.ts b/packages/api-website-builder/src/features/variants/DeleteVariant/DeleteVariantUseCase.ts new file mode 100644 index 00000000000..39d13f61905 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/DeleteVariant/DeleteVariantUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteVariantUseCase as UseCaseAbstraction } from "./abstractions/DeleteVariantUseCase.js"; +import { DeleteVariantRepository } from "./abstractions/DeleteVariantRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { VariantNotAuthorizedError } from "~/domain/variant/errors.js"; + +class DeleteVariantUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: DeleteVariantRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canDelete("page"); + if (!hasPermission) { + return Result.fail(new VariantNotAuthorizedError()); + } + + return this.repository.execute(params); + } +} + +export const DeleteVariantUseCase = UseCaseAbstraction.createImplementation({ + implementation: DeleteVariantUseCaseImpl, + dependencies: [WbPermissions, DeleteVariantRepository] +}); diff --git a/packages/api-website-builder/src/features/variants/DeleteVariant/abstractions/DeleteVariantRepository.ts b/packages/api-website-builder/src/features/variants/DeleteVariant/abstractions/DeleteVariantRepository.ts new file mode 100644 index 00000000000..bca2a72cda1 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/DeleteVariant/abstractions/DeleteVariantRepository.ts @@ -0,0 +1,28 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { VariantNotFoundError, VariantPersistenceError } from "~/domain/variant/errors.js"; + +export interface IDeleteVariantParams { + id: string; +} + +export interface IDeleteVariantRepository { + execute(params: IDeleteVariantParams): Promise>; +} + +export interface IDeleteVariantRepositoryErrors { + notFound: VariantNotFoundError; + persistence: VariantPersistenceError; +} + +type RepositoryError = IDeleteVariantRepositoryErrors[keyof IDeleteVariantRepositoryErrors]; + +export const DeleteVariantRepository = createAbstraction( + "Wb/DeleteVariantRepository" +); + +export namespace DeleteVariantRepository { + export type Interface = IDeleteVariantRepository; + export type Params = IDeleteVariantParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/variants/DeleteVariant/abstractions/DeleteVariantUseCase.ts b/packages/api-website-builder/src/features/variants/DeleteVariant/abstractions/DeleteVariantUseCase.ts new file mode 100644 index 00000000000..e08a5fe7e1f --- /dev/null +++ b/packages/api-website-builder/src/features/variants/DeleteVariant/abstractions/DeleteVariantUseCase.ts @@ -0,0 +1,30 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { + VariantNotAuthorizedError, + VariantNotFoundError, + VariantPersistenceError +} from "~/domain/variant/errors.js"; +import type { IDeleteVariantParams } from "./DeleteVariantRepository.js"; + +export interface IDeleteVariantUseCase { + execute(params: IDeleteVariantParams): Promise>; +} + +export interface IDeleteVariantUseCaseErrors { + notAuthorized: VariantNotAuthorizedError; + notFound: VariantNotFoundError; + persistence: VariantPersistenceError; +} + +type UseCaseError = IDeleteVariantUseCaseErrors[keyof IDeleteVariantUseCaseErrors]; + +/** Delete a variant. */ +export const DeleteVariantUseCase = + createAbstraction("Wb/DeleteVariantUseCase"); + +export namespace DeleteVariantUseCase { + export type Interface = IDeleteVariantUseCase; + export type Params = IDeleteVariantParams; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-website-builder/src/features/variants/DeleteVariant/feature.ts b/packages/api-website-builder/src/features/variants/DeleteVariant/feature.ts new file mode 100644 index 00000000000..b14eadd4ce4 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/DeleteVariant/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteVariantRepository } from "./DeleteVariantRepository.js"; +import { DeleteVariantUseCase } from "./DeleteVariantUseCase.js"; + +export const DeleteVariantFeature = createFeature({ + name: "WebsiteBuilder/DeleteVariant", + register(container) { + container.register(DeleteVariantRepository).inSingletonScope(); + container.register(DeleteVariantUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/variants/DeleteVariant/index.ts b/packages/api-website-builder/src/features/variants/DeleteVariant/index.ts new file mode 100644 index 00000000000..43b48c2855f --- /dev/null +++ b/packages/api-website-builder/src/features/variants/DeleteVariant/index.ts @@ -0,0 +1 @@ +export { DeleteVariantUseCase } from "./abstractions/DeleteVariantUseCase.js"; diff --git a/packages/api-website-builder/src/features/variants/GetVariantById/GetVariantByIdRepository.ts b/packages/api-website-builder/src/features/variants/GetVariantById/GetVariantByIdRepository.ts new file mode 100644 index 00000000000..b3b9173da48 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/GetVariantById/GetVariantByIdRepository.ts @@ -0,0 +1,35 @@ +import { Result } from "@webiny/feature/api"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { GetVariantByIdRepository as RepositoryAbstraction } from "./abstractions/GetVariantByIdRepository.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; +import type { CmsEntryWbVariantValues } from "~/domain/variant/abstractions.js"; +import { EntryToVariantMapper } from "~/domain/variant/EntryToVariantMapper.js"; +import { VariantNotFoundError, VariantPersistenceError } from "~/domain/variant/errors.js"; + +class GetVariantByIdRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private variantModel: VariantModel.Interface, + private getEntryById: GetEntryByIdUseCase.Interface + ) {} + + async execute(id: string): RepositoryAbstraction.Return { + const result = await this.getEntryById.execute( + this.variantModel, + id + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new VariantNotFoundError(id)); + } + return Result.fail(new VariantPersistenceError(result.error)); + } + + return Result.ok(EntryToVariantMapper.toVariant(result.value)); + } +} + +export const GetVariantByIdRepository = RepositoryAbstraction.createImplementation({ + implementation: GetVariantByIdRepositoryImpl, + dependencies: [VariantModel, GetEntryByIdUseCase] +}); diff --git a/packages/api-website-builder/src/features/variants/GetVariantById/GetVariantByIdUseCase.ts b/packages/api-website-builder/src/features/variants/GetVariantById/GetVariantByIdUseCase.ts new file mode 100644 index 00000000000..9c634bb58e4 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/GetVariantById/GetVariantByIdUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { GetVariantByIdUseCase as UseCaseAbstraction } from "./abstractions/GetVariantByIdUseCase.js"; +import { GetVariantByIdRepository } from "./abstractions/GetVariantByIdRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { VariantNotAuthorizedError } from "~/domain/variant/errors.js"; + +class GetVariantByIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: GetVariantByIdRepository.Interface + ) {} + + async execute(id: string): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canRead("page"); + if (!hasPermission) { + return Result.fail(new VariantNotAuthorizedError()); + } + + return this.repository.execute(id); + } +} + +export const GetVariantByIdUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetVariantByIdUseCaseImpl, + dependencies: [WbPermissions, GetVariantByIdRepository] +}); diff --git a/packages/api-website-builder/src/features/variants/GetVariantById/abstractions/GetVariantByIdRepository.ts b/packages/api-website-builder/src/features/variants/GetVariantById/abstractions/GetVariantByIdRepository.ts new file mode 100644 index 00000000000..8c0cdc8397c --- /dev/null +++ b/packages/api-website-builder/src/features/variants/GetVariantById/abstractions/GetVariantByIdRepository.ts @@ -0,0 +1,24 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbVariant } from "~/domain/variant/abstractions.js"; +import type { VariantNotFoundError, VariantPersistenceError } from "~/domain/variant/errors.js"; + +export interface IGetVariantByIdRepository { + execute(id: string): Promise>; +} + +export interface IGetVariantByIdRepositoryErrors { + notFound: VariantNotFoundError; + persistence: VariantPersistenceError; +} + +type RepositoryError = IGetVariantByIdRepositoryErrors[keyof IGetVariantByIdRepositoryErrors]; + +export const GetVariantByIdRepository = createAbstraction( + "Wb/GetVariantByIdRepository" +); + +export namespace GetVariantByIdRepository { + export type Interface = IGetVariantByIdRepository; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/variants/GetVariantById/abstractions/GetVariantByIdUseCase.ts b/packages/api-website-builder/src/features/variants/GetVariantById/abstractions/GetVariantByIdUseCase.ts new file mode 100644 index 00000000000..8a0668fd5f6 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/GetVariantById/abstractions/GetVariantByIdUseCase.ts @@ -0,0 +1,31 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbVariant } from "~/domain/variant/abstractions.js"; +import type { + VariantNotAuthorizedError, + VariantNotFoundError, + VariantPersistenceError +} from "~/domain/variant/errors.js"; + +export interface IGetVariantByIdUseCase { + execute(id: string): Promise>; +} + +export interface IGetVariantByIdUseCaseErrors { + notAuthorized: VariantNotAuthorizedError; + notFound: VariantNotFoundError; + persistence: VariantPersistenceError; +} + +type UseCaseError = IGetVariantByIdUseCaseErrors[keyof IGetVariantByIdUseCaseErrors]; + +/** Retrieve a variant by ID. */ +export const GetVariantByIdUseCase = createAbstraction( + "Wb/GetVariantByIdUseCase" +); + +export namespace GetVariantByIdUseCase { + export type Interface = IGetVariantByIdUseCase; + export type Return = Promise>; + export type Error = UseCaseError; + export type Variant = WbVariant; +} diff --git a/packages/api-website-builder/src/features/variants/GetVariantById/feature.ts b/packages/api-website-builder/src/features/variants/GetVariantById/feature.ts new file mode 100644 index 00000000000..66c1fb99bf8 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/GetVariantById/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetVariantByIdRepository } from "./GetVariantByIdRepository.js"; +import { GetVariantByIdUseCase } from "./GetVariantByIdUseCase.js"; + +export const GetVariantByIdFeature = createFeature({ + name: "WebsiteBuilder/GetVariantById", + register(container) { + container.register(GetVariantByIdRepository).inSingletonScope(); + container.register(GetVariantByIdUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/variants/GetVariantById/index.ts b/packages/api-website-builder/src/features/variants/GetVariantById/index.ts new file mode 100644 index 00000000000..1dbdc5d8d87 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/GetVariantById/index.ts @@ -0,0 +1 @@ +export { GetVariantByIdUseCase } from "./abstractions/GetVariantByIdUseCase.js"; diff --git a/packages/api-website-builder/src/features/variants/ListVariants/ListVariantsRepository.ts b/packages/api-website-builder/src/features/variants/ListVariants/ListVariantsRepository.ts new file mode 100644 index 00000000000..4cb88b7a9bc --- /dev/null +++ b/packages/api-website-builder/src/features/variants/ListVariants/ListVariantsRepository.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { ListVariantsRepository as RepositoryAbstraction } from "./abstractions/ListVariantsRepository.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; +import type { CmsEntryWbVariantValues } from "~/domain/variant/abstractions.js"; +import { EntryToVariantMapper } from "~/domain/variant/EntryToVariantMapper.js"; +import { VariantPersistenceError } from "~/domain/variant/errors.js"; + +class ListVariantsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private variantModel: VariantModel.Interface, + private listLatestEntries: ListLatestEntriesUseCase.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const result = await this.listLatestEntries.execute( + this.variantModel, + { + where: { + values: { + experimentId: params.experimentId + } + }, + sort: ["createdOn_ASC"], + limit: 1000 + } + ); + + if (result.isFail()) { + return Result.fail(new VariantPersistenceError(result.error)); + } + + const variants = result.value.entries.map(entry => EntryToVariantMapper.toVariant(entry)); + return Result.ok(variants); + } +} + +export const ListVariantsRepository = RepositoryAbstraction.createImplementation({ + implementation: ListVariantsRepositoryImpl, + dependencies: [VariantModel, ListLatestEntriesUseCase] +}); diff --git a/packages/api-website-builder/src/features/variants/ListVariants/ListVariantsUseCase.ts b/packages/api-website-builder/src/features/variants/ListVariants/ListVariantsUseCase.ts new file mode 100644 index 00000000000..1437feb37c3 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/ListVariants/ListVariantsUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { ListVariantsUseCase as UseCaseAbstraction } from "./abstractions/ListVariantsUseCase.js"; +import { ListVariantsRepository } from "./abstractions/ListVariantsRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { VariantNotAuthorizedError } from "~/domain/variant/errors.js"; + +class ListVariantsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: ListVariantsRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canRead("page"); + if (!hasPermission) { + return Result.fail(new VariantNotAuthorizedError()); + } + + return this.repository.execute(params); + } +} + +export const ListVariantsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListVariantsUseCaseImpl, + dependencies: [WbPermissions, ListVariantsRepository] +}); diff --git a/packages/api-website-builder/src/features/variants/ListVariants/abstractions/ListVariantsRepository.ts b/packages/api-website-builder/src/features/variants/ListVariants/abstractions/ListVariantsRepository.ts new file mode 100644 index 00000000000..8be70344f9d --- /dev/null +++ b/packages/api-website-builder/src/features/variants/ListVariants/abstractions/ListVariantsRepository.ts @@ -0,0 +1,28 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbVariant } from "~/domain/variant/abstractions.js"; +import type { VariantPersistenceError } from "~/domain/variant/errors.js"; + +export interface IListVariantsParams { + experimentId: string; +} + +export interface IListVariantsRepository { + execute(params: IListVariantsParams): Promise>; +} + +export interface IListVariantsRepositoryErrors { + persistence: VariantPersistenceError; +} + +type RepositoryError = IListVariantsRepositoryErrors[keyof IListVariantsRepositoryErrors]; + +export const ListVariantsRepository = createAbstraction( + "Wb/ListVariantsRepository" +); + +export namespace ListVariantsRepository { + export type Interface = IListVariantsRepository; + export type Params = IListVariantsParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/variants/ListVariants/abstractions/ListVariantsUseCase.ts b/packages/api-website-builder/src/features/variants/ListVariants/abstractions/ListVariantsUseCase.ts new file mode 100644 index 00000000000..291b3004494 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/ListVariants/abstractions/ListVariantsUseCase.ts @@ -0,0 +1,29 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbVariant } from "~/domain/variant/abstractions.js"; +import type { + VariantNotAuthorizedError, + VariantPersistenceError +} from "~/domain/variant/errors.js"; +import type { IListVariantsParams } from "./ListVariantsRepository.js"; + +export interface IListVariantsUseCase { + execute(params: IListVariantsParams): Promise>; +} + +export interface IListVariantsUseCaseErrors { + notAuthorized: VariantNotAuthorizedError; + persistence: VariantPersistenceError; +} + +type UseCaseError = IListVariantsUseCaseErrors[keyof IListVariantsUseCaseErrors]; + +/** List variants for an experiment. */ +export const ListVariantsUseCase = + createAbstraction("Wb/ListVariantsUseCase"); + +export namespace ListVariantsUseCase { + export type Interface = IListVariantsUseCase; + export type Params = IListVariantsParams; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-website-builder/src/features/variants/ListVariants/feature.ts b/packages/api-website-builder/src/features/variants/ListVariants/feature.ts new file mode 100644 index 00000000000..27832fddc7c --- /dev/null +++ b/packages/api-website-builder/src/features/variants/ListVariants/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListVariantsRepository } from "./ListVariantsRepository.js"; +import { ListVariantsUseCase } from "./ListVariantsUseCase.js"; + +export const ListVariantsFeature = createFeature({ + name: "WebsiteBuilder/ListVariants", + register(container) { + container.register(ListVariantsRepository).inSingletonScope(); + container.register(ListVariantsUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/variants/ListVariants/index.ts b/packages/api-website-builder/src/features/variants/ListVariants/index.ts new file mode 100644 index 00000000000..5eba0666233 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/ListVariants/index.ts @@ -0,0 +1 @@ +export { ListVariantsUseCase } from "./abstractions/ListVariantsUseCase.js"; diff --git a/packages/api-website-builder/src/features/variants/UpdateVariant/UpdateVariantRepository.ts b/packages/api-website-builder/src/features/variants/UpdateVariant/UpdateVariantRepository.ts new file mode 100644 index 00000000000..497c81363ab --- /dev/null +++ b/packages/api-website-builder/src/features/variants/UpdateVariant/UpdateVariantRepository.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { UpdateVariantRepository as RepositoryAbstraction } from "./abstractions/UpdateVariantRepository.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; +import type { CmsEntryWbVariantValues } from "~/domain/variant/abstractions.js"; +import { EntryToVariantMapper } from "~/domain/variant/EntryToVariantMapper.js"; +import { + VariantNotFoundError, + VariantPersistenceError, + VariantValidationError +} from "~/domain/variant/errors.js"; + +class UpdateVariantRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private getEntryById: GetEntryByIdUseCase.Interface, + private variantModel: VariantModel.Interface + ) {} + + async execute(params: RepositoryAbstraction.Params): RepositoryAbstraction.Return { + const getResult = await this.getEntryById.execute(this.variantModel, params.id); + if (getResult.isFail()) { + if (getResult.error.code === "Cms/Entry/NotFound") { + return Result.fail(new VariantNotFoundError(params.id)); + } + return Result.fail(new VariantPersistenceError(getResult.error)); + } + + const result = await this.updateEntry.execute( + this.variantModel, + params.id, + { values: params.data as Partial } + ); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/ValidationError") { + return Result.fail(new VariantValidationError(result.error.message)); + } + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new VariantNotFoundError(params.id)); + } + return Result.fail(new VariantPersistenceError(result.error)); + } + + return Result.ok(EntryToVariantMapper.toVariant(result.value)); + } +} + +export const UpdateVariantRepository = RepositoryAbstraction.createImplementation({ + implementation: UpdateVariantRepositoryImpl, + dependencies: [UpdateEntryUseCase, GetEntryByIdUseCase, VariantModel] +}); diff --git a/packages/api-website-builder/src/features/variants/UpdateVariant/UpdateVariantUseCase.ts b/packages/api-website-builder/src/features/variants/UpdateVariant/UpdateVariantUseCase.ts new file mode 100644 index 00000000000..468f482e8a7 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/UpdateVariant/UpdateVariantUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateVariantUseCase as UseCaseAbstraction } from "./abstractions/UpdateVariantUseCase.js"; +import { UpdateVariantRepository } from "./abstractions/UpdateVariantRepository.js"; +import { WbPermissions } from "~/features/permissions/abstractions.js"; +import { VariantNotAuthorizedError } from "~/domain/variant/errors.js"; + +class UpdateVariantUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private permissions: WbPermissions.Interface, + private repository: UpdateVariantRepository.Interface + ) {} + + async execute(params: UseCaseAbstraction.Params): UseCaseAbstraction.Return { + const hasPermission = await this.permissions.canEdit("page"); + if (!hasPermission) { + return Result.fail(new VariantNotAuthorizedError()); + } + + return this.repository.execute(params); + } +} + +export const UpdateVariantUseCase = UseCaseAbstraction.createImplementation({ + implementation: UpdateVariantUseCaseImpl, + dependencies: [WbPermissions, UpdateVariantRepository] +}); diff --git a/packages/api-website-builder/src/features/variants/UpdateVariant/abstractions/UpdateVariantRepository.ts b/packages/api-website-builder/src/features/variants/UpdateVariant/abstractions/UpdateVariantRepository.ts new file mode 100644 index 00000000000..040a50b2bd9 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/UpdateVariant/abstractions/UpdateVariantRepository.ts @@ -0,0 +1,45 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { VariantStatus, WbVariant } from "~/domain/variant/abstractions.js"; +import type { + VariantNotFoundError, + VariantPersistenceError, + VariantValidationError +} from "~/domain/variant/errors.js"; + +export interface IUpdateVariantData { + name?: string; + status?: VariantStatus; + properties?: Record; + metadata?: Record; + bindings?: Record; + elements?: Record; + extensions?: Record; +} + +export interface IUpdateVariantParams { + id: string; + data: IUpdateVariantData; +} + +export interface IUpdateVariantRepository { + execute(params: IUpdateVariantParams): Promise>; +} + +export interface IUpdateVariantRepositoryErrors { + notFound: VariantNotFoundError; + validation: VariantValidationError; + persistence: VariantPersistenceError; +} + +type RepositoryError = IUpdateVariantRepositoryErrors[keyof IUpdateVariantRepositoryErrors]; + +export const UpdateVariantRepository = createAbstraction( + "Wb/UpdateVariantRepository" +); + +export namespace UpdateVariantRepository { + export type Interface = IUpdateVariantRepository; + export type Params = IUpdateVariantParams; + export type Return = Promise>; + export type Error = RepositoryError; +} diff --git a/packages/api-website-builder/src/features/variants/UpdateVariant/abstractions/UpdateVariantUseCase.ts b/packages/api-website-builder/src/features/variants/UpdateVariant/abstractions/UpdateVariantUseCase.ts new file mode 100644 index 00000000000..bdc37029f4a --- /dev/null +++ b/packages/api-website-builder/src/features/variants/UpdateVariant/abstractions/UpdateVariantUseCase.ts @@ -0,0 +1,33 @@ +import { createAbstraction, type Result } from "@webiny/feature/api"; +import type { WbVariant } from "~/domain/variant/abstractions.js"; +import type { + VariantNotAuthorizedError, + VariantNotFoundError, + VariantPersistenceError, + VariantValidationError +} from "~/domain/variant/errors.js"; +import type { IUpdateVariantParams } from "./UpdateVariantRepository.js"; + +export interface IUpdateVariantUseCase { + execute(params: IUpdateVariantParams): Promise>; +} + +export interface IUpdateVariantUseCaseErrors { + notAuthorized: VariantNotAuthorizedError; + notFound: VariantNotFoundError; + validation: VariantValidationError; + persistence: VariantPersistenceError; +} + +type UseCaseError = IUpdateVariantUseCaseErrors[keyof IUpdateVariantUseCaseErrors]; + +/** Update a variant's content snapshot or status. */ +export const UpdateVariantUseCase = + createAbstraction("Wb/UpdateVariantUseCase"); + +export namespace UpdateVariantUseCase { + export type Interface = IUpdateVariantUseCase; + export type Params = IUpdateVariantParams; + export type Return = Promise>; + export type Error = UseCaseError; +} diff --git a/packages/api-website-builder/src/features/variants/UpdateVariant/feature.ts b/packages/api-website-builder/src/features/variants/UpdateVariant/feature.ts new file mode 100644 index 00000000000..2c567f6a979 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/UpdateVariant/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateVariantRepository } from "./UpdateVariantRepository.js"; +import { UpdateVariantUseCase } from "./UpdateVariantUseCase.js"; + +export const UpdateVariantFeature = createFeature({ + name: "WebsiteBuilder/UpdateVariant", + register(container) { + container.register(UpdateVariantRepository).inSingletonScope(); + container.register(UpdateVariantUseCase); + } +}); diff --git a/packages/api-website-builder/src/features/variants/UpdateVariant/index.ts b/packages/api-website-builder/src/features/variants/UpdateVariant/index.ts new file mode 100644 index 00000000000..66d3519e0d0 --- /dev/null +++ b/packages/api-website-builder/src/features/variants/UpdateVariant/index.ts @@ -0,0 +1 @@ +export { UpdateVariantUseCase } from "./abstractions/UpdateVariantUseCase.js"; diff --git a/packages/api-website-builder/src/features/variants/feature.ts b/packages/api-website-builder/src/features/variants/feature.ts new file mode 100644 index 00000000000..8de75ac860f --- /dev/null +++ b/packages/api-website-builder/src/features/variants/feature.ts @@ -0,0 +1,18 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateVariantFeature } from "./CreateVariant/feature.js"; +import { UpdateVariantFeature } from "./UpdateVariant/feature.js"; +import { DeleteVariantFeature } from "./DeleteVariant/feature.js"; +import { GetVariantByIdFeature } from "./GetVariantById/feature.js"; +import { ListVariantsFeature } from "./ListVariants/feature.js"; + +/** Registers every variant use case (CRUD + reads). */ +export const VariantFeature = createFeature({ + name: "WebsiteBuilder/Variant", + register(container) { + CreateVariantFeature.register(container); + UpdateVariantFeature.register(container); + DeleteVariantFeature.register(container); + GetVariantByIdFeature.register(container); + ListVariantsFeature.register(container); + } +}); diff --git a/packages/api-website-builder/src/graphql/createGraphQL.ts b/packages/api-website-builder/src/graphql/createGraphQL.ts index 19a32f6574a..41053a8c443 100644 --- a/packages/api-website-builder/src/graphql/createGraphQL.ts +++ b/packages/api-website-builder/src/graphql/createGraphQL.ts @@ -1,6 +1,7 @@ import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { createPagesSchema } from "~/graphql/pages/pages.gql.js"; import { createRedirectsSchema } from "./redirects/redirects.gql.js"; +import { createExperimentsSchema } from "~/graphql/experiments/experiments.gql.js"; const emptyResolver = () => ({}); @@ -78,5 +79,5 @@ const baseSchema = new GraphQLSchemaPlugin({ }); export const createGraphQL = () => { - return [baseSchema, createPagesSchema(), createRedirectsSchema()]; + return [baseSchema, createPagesSchema(), createRedirectsSchema(), createExperimentsSchema()]; }; diff --git a/packages/api-website-builder/src/graphql/experiments/experiments.gql.ts b/packages/api-website-builder/src/graphql/experiments/experiments.gql.ts new file mode 100644 index 00000000000..8094b985278 --- /dev/null +++ b/packages/api-website-builder/src/graphql/experiments/experiments.gql.ts @@ -0,0 +1,346 @@ +import { GraphQLSchemaPlugin, NotFoundError } from "@webiny/handler-graphql"; +import { ensureAuthentication } from "~/utils/ensureAuthentication.js"; +import { resolve } from "~/utils/resolve.js"; +import { experimentsTypeDefs } from "~/graphql/experiments/experiments.typeDefs.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import { CONTROL_VARIANT_ID } from "~/domain/experiment/abstractions.js"; +import type { WbExperiment } from "~/domain/experiment/abstractions.js"; +import type { WbVariant } from "~/domain/variant/abstractions.js"; +import { CreateExperimentUseCase } from "~/features/experiments/CreateExperiment/index.js"; +import { UpdateExperimentUseCase } from "~/features/experiments/UpdateExperiment/index.js"; +import { GetExperimentByIdUseCase } from "~/features/experiments/GetExperimentById/index.js"; +import { ListExperimentsUseCase } from "~/features/experiments/ListExperiments/index.js"; +import { GetActiveExperimentForRevisionUseCase } from "~/features/experiments/GetActiveExperimentForRevision/index.js"; +import { StartExperimentUseCase } from "~/features/experiments/StartExperiment/index.js"; +import { StopExperimentUseCase } from "~/features/experiments/StopExperiment/index.js"; +import { DeleteExperimentUseCase } from "~/features/experiments/DeleteExperiment/index.js"; +import { GraduateVariantUseCase } from "~/features/experiments/GraduateVariant/index.js"; +import { GetActiveExperimentForPathUseCase } from "~/features/experiments/GetActiveExperimentForPath/index.js"; +import { + PauseExperimentUseCase, + ResumeExperimentUseCase, + IsExperimentPausedUseCase +} from "~/features/experiments/ExperimentPause/index.js"; +import { GetPublishedRevisionByEntryIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedRevisionByEntryId/index.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; +import type { CmsEntryWbVariantValues } from "~/domain/variant/abstractions.js"; +import { CreateVariantUseCase } from "~/features/variants/CreateVariant/index.js"; +import { UpdateVariantUseCase } from "~/features/variants/UpdateVariant/index.js"; +import { DeleteVariantUseCase } from "~/features/variants/DeleteVariant/index.js"; +import { GetVariantByIdUseCase } from "~/features/variants/GetVariantById/index.js"; +import { ListVariantsUseCase } from "~/features/variants/ListVariants/index.js"; + +const mapExperiment = (experiment: WbExperiment) => ({ + id: experiment.id, + entryId: experiment.entryId, + pageEntryId: experiment.pageEntryId, + baselineRevisionId: experiment.baselineRevisionId, + status: experiment.status, + name: experiment.name, + trafficSplit: experiment.trafficSplit, + targeting: experiment.targeting, + goals: experiment.goals, + analytics: experiment.analytics, + startedOn: experiment.startedOn, + stoppedOn: experiment.stoppedOn, + winningVariantId: experiment.winningVariantId, + createdOn: experiment.createdOn, + savedOn: experiment.savedOn, + createdBy: experiment.createdBy +}); + +const mapVariant = (variant: WbVariant) => ({ + id: variant.id, + entryId: variant.entryId, + experimentId: variant.experimentId, + name: variant.name, + status: variant.status, + properties: variant.properties, + metadata: variant.metadata, + bindings: variant.bindings, + elements: variant.elements, + extensions: variant.extensions, + createdOn: variant.createdOn, + savedOn: variant.savedOn +}); + +export const createExperimentsSchema = () => { + const schema = new GraphQLSchemaPlugin({ + typeDefs: experimentsTypeDefs, + resolvers: { + WbQuery: { + getExperiment: async (_, { id }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(GetExperimentByIdUseCase); + const result = await useCase.execute(id); + if (result.isFail()) { + throw new NotFoundError(`Experiment "${id}" was not found!`); + } + return mapExperiment(result.value); + }); + }, + getActiveExperiment: async (_, { revisionId }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve( + GetActiveExperimentForRevisionUseCase + ); + const result = await useCase.execute(revisionId); + if (result.isFail()) { + if ( + result.error.code === "WebsiteBuilder/Experiment/NoActiveExperiment" + ) { + return null; + } + throw new Error(result.error.message); + } + return mapExperiment(result.value); + }); + }, + listExperiments: async (_, { pageEntryId }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(ListExperimentsUseCase); + const result = await useCase.execute({ pageEntryId }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return result.value.map(mapExperiment); + }); + }, + getVariant: async (_, { id }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(GetVariantByIdUseCase); + const result = await useCase.execute(id); + if (result.isFail()) { + throw new NotFoundError(`Variant "${id}" was not found!`); + } + return mapVariant(result.value); + }); + }, + listVariants: async (_, { experimentId }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(ListVariantsUseCase); + const result = await useCase.execute({ experimentId }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return result.value.map(mapVariant); + }); + }, + getPageExperiment: async (_, { path }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve( + GetActiveExperimentForPathUseCase + ); + const result = await useCase.execute(path); + if (result.isFail()) { + // No experiment to serve (none running, paused, or no such page) — the + // SDK reads this null as "serve the control", so it isn't an error. + const code = result.error.code; + if ( + code === "WebsiteBuilder/Experiment/NoActiveExperiment" || + code === "WebsiteBuilder/Experiment/Paused" || + code === "WebsiteBuilder/Page/NotFound" + ) { + return null; + } + throw new Error(result.error.message); + } + const active = result.value; + const trafficSplit = active.experiment.trafficSplit ?? { + control: 100, + variants: {} + }; + return { + experimentId: active.experiment.entryId, + revisionId: active.revisionId, + pageEntryId: active.pageEntryId, + path: active.path, + status: active.experiment.status, + tenantId: active.experiment.tenant, + controlVariantId: CONTROL_VARIANT_ID, + trafficSplit, + targeting: active.experiment.targeting, + analytics: active.experiment.analytics, + // Participating variants are the (published) variant entryIds carried + // in the traffic split. Their content is fetched via getVariantContent. + variants: Object.keys(trafficSplit.variants ?? {}).map(variantId => ({ + variantId, + name: "" + })) + }; + }); + }, + getVariantContent: async (_, { id }, context) => { + return resolve(async () => { + ensureAuthentication(context); + // Serve only PUBLISHED variant content, by entryId. + const getPublished = context.container.resolve( + GetPublishedRevisionByEntryIdUseCase + ); + const variantModel = context.container.resolve(VariantModel); + const result = await getPublished.execute( + variantModel, + id + ); + if (result.isFail() || !result.value) { + throw new NotFoundError(`Published variant "${id}" was not found!`); + } + const values = result.value.values; + return { + id: result.value.entryId, + properties: values.properties, + bindings: values.bindings, + elements: values.elements, + extensions: values.extensions, + metadata: values.metadata + }; + }); + }, + getExperimentPaused: async (_, { experimentId }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(IsExperimentPausedUseCase); + const result = await useCase.execute(experimentId); + if (result.isFail()) { + throw new Error(result.error.message); + } + return result.value; + }); + } + }, + WbMutation: { + createExperiment: async (_, { data }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(CreateExperimentUseCase); + const result = await useCase.execute(data); + if (result.isFail()) { + throw new Error(result.error.message); + } + return mapExperiment(result.value); + }); + }, + updateExperiment: async (_, { id, data }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(UpdateExperimentUseCase); + const result = await useCase.execute({ id, data }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return mapExperiment(result.value); + }); + }, + startExperiment: async (_, { id }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(StartExperimentUseCase); + const result = await useCase.execute({ id }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return mapExperiment(result.value); + }); + }, + stopExperiment: async (_, { id }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(StopExperimentUseCase); + const result = await useCase.execute({ id }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return mapExperiment(result.value); + }); + }, + pauseExperiment: async (_, { experimentId }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(PauseExperimentUseCase); + const result = await useCase.execute(experimentId); + if (result.isFail()) { + throw new Error(result.error.message); + } + return result.value; + }); + }, + resumeExperiment: async (_, { experimentId }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(ResumeExperimentUseCase); + const result = await useCase.execute(experimentId); + if (result.isFail()) { + throw new Error(result.error.message); + } + return result.value; + }); + }, + deleteExperiment: async (_, { id }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(DeleteExperimentUseCase); + const result = await useCase.execute({ id }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return true; + }); + }, + graduateVariant: async (_, { experimentId, variantId }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(GraduateVariantUseCase); + const result = await useCase.execute({ experimentId, variantId }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return result.value; + }); + }, + createVariant: async (_, { data }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(CreateVariantUseCase); + const result = await useCase.execute(data); + if (result.isFail()) { + throw new Error(result.error.message); + } + return mapVariant(result.value); + }); + }, + updateVariant: async (_, { id, data }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(UpdateVariantUseCase); + const result = await useCase.execute({ id, data }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return mapVariant(result.value); + }); + }, + deleteVariant: async (_, { id }, context) => { + return resolve(async () => { + ensureAuthentication(context); + const useCase = context.container.resolve(DeleteVariantUseCase); + const result = await useCase.execute({ id }); + if (result.isFail()) { + throw new Error(result.error.message); + } + return true; + }); + } + } + } + }); + + schema.name = "wb.graphql.experiments"; + + return schema; +}; diff --git a/packages/api-website-builder/src/graphql/experiments/experiments.typeDefs.ts b/packages/api-website-builder/src/graphql/experiments/experiments.typeDefs.ts new file mode 100644 index 00000000000..e89e6b072b5 --- /dev/null +++ b/packages/api-website-builder/src/graphql/experiments/experiments.typeDefs.ts @@ -0,0 +1,155 @@ +export const experimentsTypeDefs = /* GraphQL */ ` + type WbExperiment { + id: ID! + entryId: String! + pageEntryId: String! + baselineRevisionId: String! + status: String! + name: String! + trafficSplit: JSON + targeting: JSON + goals: JSON + analytics: JSON + startedOn: DateTime + stoppedOn: DateTime + winningVariantId: String + createdOn: DateTime + savedOn: DateTime + createdBy: WbIdentity + } + + type WbVariant { + id: ID! + entryId: String! + experimentId: String! + name: String! + status: String! + properties: JSON + metadata: JSON + bindings: JSON + elements: JSON + extensions: JSON + createdOn: DateTime + savedOn: DateTime + } + + input WbExperimentCreateInput { + pageEntryId: String! + baselineRevisionId: String! + name: String! + trafficSplit: JSON + targeting: JSON + goals: JSON + analytics: JSON + } + + input WbExperimentUpdateInput { + name: String + trafficSplit: JSON + targeting: JSON + goals: JSON + analytics: JSON + } + + input WbVariantCreateInput { + experimentId: String! + name: String! + } + + input WbVariantUpdateInput { + name: String + status: String + properties: JSON + metadata: JSON + bindings: JSON + elements: JSON + extensions: JSON + } + + type WbExperimentResponse { + data: WbExperiment + error: WbError + } + + type WbExperimentsListResponse { + data: [WbExperiment!] + error: WbError + } + + type WbVariantResponse { + data: WbVariant + error: WbError + } + + type WbVariantsListResponse { + data: [WbVariant!] + error: WbError + } + + # Public, SDK-facing shapes. These never expose provider-specific fields. + type WbActiveExperimentVariant { + variantId: String! + name: String! + } + + type WbActiveExperiment { + experimentId: String! + revisionId: String! + pageEntryId: String! + path: String! + status: String! + tenantId: String! + controlVariantId: String! + trafficSplit: JSON + targeting: JSON + analytics: JSON + variants: [WbActiveExperimentVariant!]! + } + + type WbActiveExperimentResponse { + data: WbActiveExperiment + error: WbError + } + + type WbVariantContent { + id: ID! + properties: JSON + bindings: JSON + elements: JSON + extensions: JSON + metadata: JSON + } + + type WbVariantContentResponse { + data: WbVariantContent + error: WbError + } + + extend type WbQuery { + getExperiment(id: ID!): WbExperimentResponse + getActiveExperiment(revisionId: ID!): WbExperimentResponse + listExperiments(pageEntryId: String!): WbExperimentsListResponse + getVariant(id: ID!): WbVariantResponse + listVariants(experimentId: String!): WbVariantsListResponse + + # SDK-facing public reads. + getPageExperiment(path: String!): WbActiveExperimentResponse + getVariantContent(id: ID!): WbVariantContentResponse + getExperimentPaused(experimentId: String!): WbBooleanResponse + } + + extend type WbMutation { + createExperiment(data: WbExperimentCreateInput!): WbExperimentResponse + updateExperiment(id: ID!, data: WbExperimentUpdateInput!): WbExperimentResponse + startExperiment(id: ID!): WbExperimentResponse + stopExperiment(id: ID!): WbExperimentResponse + deleteExperiment(id: ID!): WbBooleanResponse + pauseExperiment(experimentId: String!): WbBooleanResponse + resumeExperiment(experimentId: String!): WbBooleanResponse + graduateVariant(experimentId: ID!, variantId: ID!): WbPageResponse + + createVariant(data: WbVariantCreateInput!): WbVariantResponse + updateVariant(id: ID!, data: WbVariantUpdateInput!): WbVariantResponse + deleteVariant(id: ID!): WbBooleanResponse + } +`; diff --git a/packages/api-website-builder/src/index.ts b/packages/api-website-builder/src/index.ts index 11e203f7d12..c08ad412563 100644 --- a/packages/api-website-builder/src/index.ts +++ b/packages/api-website-builder/src/index.ts @@ -26,9 +26,16 @@ import { TranslatePageFeature } from "./features/pages/TranslatePage/feature.js" import { MovePageFeature } from "./features/pages/MovePage/feature.js"; import { PageModelPlugin, PAGE_MODEL_ID } from "~/domain/page/page.model.js"; import { RedirectModelPlugin, REDIRECT_MODEL_ID } from "~/domain/redirect/redirect.model.js"; +import { + ExperimentModelPlugin, + EXPERIMENT_MODEL_ID +} from "~/domain/experiment/experiment.model.js"; +import { VariantModelPlugin, VARIANT_MODEL_ID } from "~/domain/variant/variant.model.js"; import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; import { PageModel } from "~/domain/page/abstractions.js"; import { RedirectModel } from "~/domain/redirect/abstractions.js"; +import { ExperimentModel } from "~/domain/experiment/abstractions.js"; +import { VariantModel } from "~/domain/variant/abstractions.js"; import { WbPermissionsFeature } from "~/features/permissions/feature.js"; import { ApiKeyInstallerFeature } from "~/features/installer/feature.js"; import { NextjsGraphQLSchema } from "~/graphql/nextjs/NextjsGraphQLSchema.js"; @@ -42,12 +49,16 @@ import { GetDeletedPageByIdFeature } from "~/features/pages/GetDeletedPageById/f import { GetPageLanguagePathsFeature } from "~/features/pages/GetPageLanguagePaths/feature.js"; import { UpdatePageRevisionDescriptionFeature } from "./features/pages/UpdatePageRevisionDescription/feature.js"; import { WbWebhooksFeature } from "./features/webhooks/feature.js"; +import { ExperimentFeature } from "~/features/experiments/feature.js"; +import { VariantFeature } from "~/features/variants/feature.js"; // import { TenantModelExtensionFeature } from "~/features/tenantManager/feature.js"; const createContext = () => { const modelsPlugin = createRegisterExtensionPlugin(context => { context.container.register(PageModelPlugin); context.container.register(RedirectModelPlugin); + context.container.register(ExperimentModelPlugin); + context.container.register(VariantModelPlugin); }); const contextPlugin = createContextPlugin( @@ -58,13 +69,19 @@ const createContext = () => { const getModel = container.resolve(GetModelUseCase); await identityContext.withoutAuthorization(async () => { - const [pageModel, redirectModel] = await Promise.all([ - getModel.execute(PAGE_MODEL_ID), - getModel.execute(REDIRECT_MODEL_ID) - ]); + const [pageModel, redirectModel, experimentModel, variantModel] = await Promise.all( + [ + getModel.execute(PAGE_MODEL_ID), + getModel.execute(REDIRECT_MODEL_ID), + getModel.execute(EXPERIMENT_MODEL_ID), + getModel.execute(VARIANT_MODEL_ID) + ] + ); container.registerInstance(PageModel, pageModel.value); container.registerInstance(RedirectModel, redirectModel.value); + container.registerInstance(ExperimentModel, experimentModel.value); + container.registerInstance(VariantModel, variantModel.value); }); // Register permissions @@ -102,6 +119,10 @@ const createContext = () => { NextjsFeature.register(container); NuxtFeature.register(container); WbWebhooksFeature.register(container); + + // A/B testing — experiments and variants. + ExperimentFeature.register(container); + VariantFeature.register(container); // TenantModelExtensionFeature.register(container); // Register GraphQL diff --git a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/AddressBar/usePreviewLink.ts b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/AddressBar/usePreviewLink.ts index 3b113416eba..4aa60bc18b6 100644 --- a/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/AddressBar/usePreviewLink.ts +++ b/packages/app-website-builder/src/BaseEditor/defaultConfig/Content/AddressBar/usePreviewLink.ts @@ -7,6 +7,12 @@ export const usePreviewLink = () => { const id = useSelectFromDocument(document => document.id); const path = useSelectFromDocument(document => document.properties.path); const documentType = useSelectFromDocument(document => document.metadata.documentType); + // Set when the edited document is an A/B variant (see variantDocument.ts). When present, the + // preview must open the variant's draft — not the page the document borrows its identity from. + const previewVariantId = useSelectFromDocument( + document => + (document.metadata as Record).wbVariantPreviewId as string | undefined + ); return useMemo(() => { if (!iframeUrl) { @@ -21,11 +27,17 @@ export const usePreviewLink = () => { } }); + if (previewVariantId) { + // Variant draft preview — the site renders this variant's draft content on its page. + url.searchParams.set("wb-variant-draft", previewVariantId); + return url.toString(); + } + // Add preview params url.searchParams.set("wb.preview", "true"); url.searchParams.set("wb.type", documentType); url.searchParams.set("wb.id", String(id)); url.searchParams.set("wb.path", path); return url.toString(); - }, [iframeUrl]); + }, [iframeUrl, id, path, documentType, previewVariantId]); }; diff --git a/packages/app-website-builder/src/Extension.tsx b/packages/app-website-builder/src/Extension.tsx index 742a446fb41..c5dba33b056 100644 --- a/packages/app-website-builder/src/Extension.tsx +++ b/packages/app-website-builder/src/Extension.tsx @@ -34,6 +34,11 @@ import { GetPageRevisionsFeature } from "~/features/pages/getPageRevisions/index import { SharedPageInfrastructureFeature } from "~/features/pages/shared/feature.js"; import { CreatePageConfig } from "./presentation/pages/CreatePage/CreatePageConfig.js"; import { TranslatePageConfig } from "./presentation/pages/TranslatePage/TranslatePageConfig.js"; +import { ExperimentsEditorConfig } from "./presentation/experiments/config/ExperimentsEditorConfig.js"; +import { ExperimentsFeature } from "~/features/experiments/index.js"; +import { ExperimentsEditorPresenterFeature } from "~/presentation/experiments/ExperimentsEditor/index.js"; +import { ExperimentsManagerPresenterFeature } from "~/presentation/experiments/ExperimentsManager/index.js"; +import { ExperimentFormPresenterFeature } from "~/presentation/experiments/ExperimentForm/index.js"; import { CreatePageFeature } from "~/presentation/pages/CreatePage/feature.js"; import { PageSettingsFeature } from "~/presentation/pages/PageEditor/PageSettings/feature.js"; import { DeletePageRevisionFeature } from "~/features/pages/deletePageRevision/index.js"; @@ -58,6 +63,10 @@ export const Extension = () => { + + + + @@ -140,6 +149,7 @@ export const Extension = () => { + ); }; diff --git a/packages/app-website-builder/src/features/experiments/ExperimentsGateway.ts b/packages/app-website-builder/src/features/experiments/ExperimentsGateway.ts new file mode 100644 index 00000000000..2307a621c79 --- /dev/null +++ b/packages/app-website-builder/src/features/experiments/ExperimentsGateway.ts @@ -0,0 +1,376 @@ +import { ExperimentsGateway as GatewayAbstraction } from "./abstractions.js"; +import type { + CreateExperimentInput, + ExperimentDto, + UpdateExperimentInput, + UpdateVariantInput, + VariantContentDto, + VariantDto +} from "./types.js"; +import { MainGraphQLClient } from "@webiny/app/features/mainGraphQLClient"; + +const EXPERIMENT_FIELDS = /* GraphQL */ ` + id + entryId + pageEntryId + baselineRevisionId + status + name + trafficSplit + targeting + analytics + startedOn + stoppedOn + winningVariantId +`; + +const VARIANT_FIELDS = /* GraphQL */ ` + id + entryId + experimentId + name + status +`; + +const VARIANT_CONTENT_FIELDS = /* GraphQL */ ` + ${VARIANT_FIELDS} + properties + metadata + bindings + elements + extensions +`; + +const ERROR_FIELDS = /* GraphQL */ ` + code + message + data +`; + +type Envelope = { data: T; error: null } | { data: null; error: { message: string } }; + +class ExperimentsGatewayImpl implements GatewayAbstraction.Interface { + constructor(private client: MainGraphQLClient.Interface) {} + + private unwrap(envelope: Envelope, fallbackMessage: string): T { + if (envelope.error) { + throw new Error(envelope.error.message || fallbackMessage); + } + return envelope.data as T; + } + + async listExperiments(pageEntryId: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { listExperiments: Envelope }; + }>({ + query: /* GraphQL */ ` + query ListExperiments($pageEntryId: String!) { + websiteBuilder { + listExperiments(pageEntryId: $pageEntryId) { + data { ${EXPERIMENT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { pageEntryId } + }); + return ( + this.unwrap(response.websiteBuilder.listExperiments, "Could not list experiments.") ?? + [] + ); + } + + async getExperiment(id: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { getExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + query GetExperiment($id: ID!) { + websiteBuilder { + getExperiment(id: $id) { + data { ${EXPERIMENT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id } + }); + return this.unwrap(response.websiteBuilder.getExperiment, "Could not load experiment."); + } + + async listVariants(experimentId: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { listVariants: Envelope }; + }>({ + query: /* GraphQL */ ` + query ListVariants($experimentId: String!) { + websiteBuilder { + listVariants(experimentId: $experimentId) { + data { ${VARIANT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { experimentId } + }); + return this.unwrap(response.websiteBuilder.listVariants, "Could not list variants.") ?? []; + } + + async getVariant(id: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { getVariant: Envelope }; + }>({ + query: /* GraphQL */ ` + query GetVariant($id: ID!) { + websiteBuilder { + getVariant(id: $id) { + data { ${VARIANT_CONTENT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id } + }); + return this.unwrap(response.websiteBuilder.getVariant, "Could not load variant."); + } + + async createExperiment(input: CreateExperimentInput): Promise { + const response = await this.client.execute<{ + websiteBuilder: { createExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation CreateExperiment($data: WbExperimentCreateInput!) { + websiteBuilder { + createExperiment(data: $data) { + data { ${EXPERIMENT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { data: input } + }); + return this.unwrap( + response.websiteBuilder.createExperiment, + "Could not create experiment." + ); + } + + async updateExperiment(id: string, input: UpdateExperimentInput): Promise { + const response = await this.client.execute<{ + websiteBuilder: { updateExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation UpdateExperiment($id: ID!, $data: WbExperimentUpdateInput!) { + websiteBuilder { + updateExperiment(id: $id, data: $data) { + data { ${EXPERIMENT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id, data: input } + }); + return this.unwrap( + response.websiteBuilder.updateExperiment, + "Could not update experiment." + ); + } + + async startExperiment(id: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { startExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation StartExperiment($id: ID!) { + websiteBuilder { + startExperiment(id: $id) { + data { ${EXPERIMENT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id } + }); + return this.unwrap(response.websiteBuilder.startExperiment, "Could not start experiment."); + } + + async stopExperiment(id: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { stopExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation StopExperiment($id: ID!) { + websiteBuilder { + stopExperiment(id: $id) { + data { ${EXPERIMENT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id } + }); + return this.unwrap(response.websiteBuilder.stopExperiment, "Could not stop experiment."); + } + + async deleteExperiment(id: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { deleteExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation DeleteExperiment($id: ID!) { + websiteBuilder { + deleteExperiment(id: $id) { + data + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id } + }); + return ( + this.unwrap(response.websiteBuilder.deleteExperiment, "Could not delete experiment.") ?? + false + ); + } + + async pauseExperiment(experimentEntryId: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { pauseExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation PauseExperiment($experimentId: String!) { + websiteBuilder { + pauseExperiment(experimentId: $experimentId) { + data + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { experimentId: experimentEntryId } + }); + return ( + this.unwrap(response.websiteBuilder.pauseExperiment, "Could not pause experiment.") ?? + false + ); + } + + async resumeExperiment(experimentEntryId: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { resumeExperiment: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation ResumeExperiment($experimentId: String!) { + websiteBuilder { + resumeExperiment(experimentId: $experimentId) { + data + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { experimentId: experimentEntryId } + }); + return ( + this.unwrap(response.websiteBuilder.resumeExperiment, "Could not resume experiment.") ?? + false + ); + } + + async getExperimentPaused(experimentEntryId: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { getExperimentPaused: Envelope }; + }>({ + query: /* GraphQL */ ` + query GetExperimentPaused($experimentId: String!) { + websiteBuilder { + getExperimentPaused(experimentId: $experimentId) { + data + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { experimentId: experimentEntryId } + }); + return ( + this.unwrap( + response.websiteBuilder.getExperimentPaused, + "Could not read the experiment status." + ) ?? false + ); + } + + async createVariant(input: { experimentId: string; name: string }): Promise { + const response = await this.client.execute<{ + websiteBuilder: { createVariant: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation CreateVariant($data: WbVariantCreateInput!) { + websiteBuilder { + createVariant(data: $data) { + data { ${VARIANT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { data: input } + }); + return this.unwrap(response.websiteBuilder.createVariant, "Could not create variant."); + } + + async updateVariant(id: string, input: UpdateVariantInput): Promise { + const response = await this.client.execute<{ + websiteBuilder: { updateVariant: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation UpdateVariant($id: ID!, $data: WbVariantUpdateInput!) { + websiteBuilder { + updateVariant(id: $id, data: $data) { + data { ${VARIANT_FIELDS} } + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id, data: input } + }); + return this.unwrap(response.websiteBuilder.updateVariant, "Could not update variant."); + } + + async deleteVariant(id: string): Promise { + const response = await this.client.execute<{ + websiteBuilder: { deleteVariant: Envelope }; + }>({ + query: /* GraphQL */ ` + mutation DeleteVariant($id: ID!) { + websiteBuilder { + deleteVariant(id: $id) { + data + error { ${ERROR_FIELDS} } + } + } + } + `, + variables: { id } + }); + return ( + this.unwrap(response.websiteBuilder.deleteVariant, "Could not delete variant.") ?? false + ); + } +} + +export const ExperimentsGateway = GatewayAbstraction.createImplementation({ + implementation: ExperimentsGatewayImpl, + dependencies: [MainGraphQLClient] +}); diff --git a/packages/app-website-builder/src/features/experiments/abstractions.ts b/packages/app-website-builder/src/features/experiments/abstractions.ts new file mode 100644 index 00000000000..a853d1f6005 --- /dev/null +++ b/packages/app-website-builder/src/features/experiments/abstractions.ts @@ -0,0 +1,36 @@ +import { createAbstraction } from "@webiny/feature/admin"; +import type { + CreateExperimentInput, + ExperimentDto, + UpdateExperimentInput, + UpdateVariantInput, + VariantContentDto, + VariantDto +} from "./types.js"; + +export interface IExperimentsGateway { + listExperiments(pageEntryId: string): Promise; + getExperiment(id: string): Promise; + listVariants(experimentId: string): Promise; + getVariant(id: string): Promise; + createExperiment(input: CreateExperimentInput): Promise; + updateExperiment(id: string, input: UpdateExperimentInput): Promise; + startExperiment(id: string): Promise; + stopExperiment(id: string): Promise; + deleteExperiment(id: string): Promise; + // Runtime kill-switch, keyed on the experiment entryId (matches the serving/getPageExperiment id). + pauseExperiment(experimentEntryId: string): Promise; + resumeExperiment(experimentEntryId: string): Promise; + getExperimentPaused(experimentEntryId: string): Promise; + createVariant(input: { experimentId: string; name: string }): Promise; + updateVariant(id: string, input: UpdateVariantInput): Promise; + deleteVariant(id: string): Promise; +} + +export const ExperimentsGateway = createAbstraction( + "WebsiteBuilder/ExperimentsGateway" +); + +export namespace ExperimentsGateway { + export type Interface = IExperimentsGateway; +} diff --git a/packages/app-website-builder/src/features/experiments/feature.ts b/packages/app-website-builder/src/features/experiments/feature.ts new file mode 100644 index 00000000000..6d745d67288 --- /dev/null +++ b/packages/app-website-builder/src/features/experiments/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/admin"; +import { ExperimentsGateway as GatewayAbstraction } from "./abstractions.js"; +import { ExperimentsGateway } from "./ExperimentsGateway.js"; + +export const ExperimentsFeature = createFeature({ + name: "WebsiteBuilder/Experiments", + register(container) { + container.register(ExperimentsGateway).inSingletonScope(); + }, + resolve(container) { + return { + gateway: container.resolve(GatewayAbstraction) + }; + } +}); diff --git a/packages/app-website-builder/src/features/experiments/index.ts b/packages/app-website-builder/src/features/experiments/index.ts new file mode 100644 index 00000000000..c48c9b4e310 --- /dev/null +++ b/packages/app-website-builder/src/features/experiments/index.ts @@ -0,0 +1,10 @@ +export { ExperimentsFeature } from "./feature.js"; +export { ExperimentsGateway } from "./abstractions.js"; +export type { + ExperimentDto, + VariantDto, + VariantContentDto, + UpdateVariantInput, + CreateExperimentInput, + UpdateExperimentInput +} from "./types.js"; diff --git a/packages/app-website-builder/src/features/experiments/types.ts b/packages/app-website-builder/src/features/experiments/types.ts new file mode 100644 index 00000000000..82666b12778 --- /dev/null +++ b/packages/app-website-builder/src/features/experiments/types.ts @@ -0,0 +1,57 @@ +export interface ExperimentDto { + id: string; + entryId: string; + pageEntryId: string; + baselineRevisionId: string; + status: string; + name: string; + trafficSplit: { control: number; variants: Record } | null; + targeting: Record | null; + analytics: Record | null; + startedOn: string | null; + stoppedOn: string | null; + winningVariantId: string | null; +} + +export interface VariantDto { + id: string; + entryId: string; + experimentId: string; + name: string; + status: string; +} + +/** A variant with its full, editable page content. */ +export interface VariantContentDto extends VariantDto { + properties: Record | null; + metadata: Record | null; + bindings: Record | null; + elements: Record | null; + extensions: Record | null; +} + +export interface UpdateVariantInput { + name?: string; + status?: string; + properties?: Record; + metadata?: Record; + bindings?: Record; + elements?: Record; + extensions?: Record; +} + +export interface CreateExperimentInput { + pageEntryId: string; + baselineRevisionId: string; + name: string; + trafficSplit?: { control: number; variants: Record }; + targeting?: Record; + analytics?: Record; +} + +export interface UpdateExperimentInput { + name?: string; + trafficSplit?: { control: number; variants: Record }; + targeting?: Record; + analytics?: Record; +} diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentForm/ExperimentFormPresenter.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/ExperimentFormPresenter.ts new file mode 100644 index 00000000000..68559c64bcc --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/ExperimentFormPresenter.ts @@ -0,0 +1,225 @@ +import { computed, makeAutoObservable } from "mobx"; +import slugify from "slugify"; +import { + ExperimentFormPresenter as Abstraction, + type ExperimentFormInitial, + type FormBucket, + type IExperimentFormPresenter, + type IExperimentFormViewModel, + type NewExperimentPayload +} from "./abstractions/ExperimentFormPresenter.js"; + +const toKey = (value: string): string => slugify(value, { lower: true, strict: true }); + +const defaultBuckets = (): FormBucket[] => [ + { + id: "control", + isControl: true, + name: "Control", + key: "control", + keyEdited: false, + description: "", + weight: 50 + }, + { + id: crypto.randomUUID(), + isControl: false, + name: "Variant B", + key: "variant-b", + keyEdited: false, + description: "", + weight: 50 + } +]; + +/** Even split summing to 100, with any remainder distributed from the first bucket. */ +const evenSplit = (buckets: FormBucket[]): FormBucket[] => { + const n = buckets.length; + const each = Math.floor(100 / n); + const next = buckets.map(b => ({ ...b, weight: each })); + let used = each * n; + let i = 0; + while (used < 100) { + next[i % n].weight += 1; + used++; + i++; + } + return next; +}; + +/** Set one bucket's weight and auto-balance the rest (proportionally) so the total stays 100. */ +const rebalance = (buckets: FormBucket[], index: number, rawValue: number): FormBucket[] => { + const value = Math.max(0, Math.min(100, Math.round(rawValue))); + const next = buckets.map(b => ({ ...b })); + next[index].weight = value; + + const others = next.map((b, i) => ({ b, i })).filter(x => x.i !== index); + if (others.length === 0) { + next[index].weight = 100; + return next; + } + + const remaining = 100 - value; + const sumOthers = others.reduce((sum, x) => sum + x.b.weight, 0); + + if (sumOthers <= 0) { + const each = Math.floor(remaining / others.length); + others.forEach(x => (next[x.i].weight = each)); + let used = each * others.length; + let k = 0; + while (used < remaining) { + next[others[k % others.length].i].weight += 1; + used++; + k++; + } + } else { + let allocated = 0; + others.forEach(x => { + const w = Math.max(0, Math.round((x.b.weight / sumOthers) * remaining)); + next[x.i].weight = w; + allocated += w; + }); + const drift = remaining - allocated; + if (drift !== 0) { + const largest = others.reduce((a, b) => (next[a.i].weight >= next[b.i].weight ? a : b)); + next[largest.i].weight = Math.max(0, next[largest.i].weight + drift); + } + } + + return next; +}; + +class ExperimentFormPresenterImpl implements IExperimentFormPresenter { + private _name = ""; + private _key = ""; + private _keyEdited = false; + private _buckets: FormBucket[] = defaultBuckets(); + private _submitLabel = "Create experiment"; + private _allowStructureChange = true; + private _onSubmit: (payload: NewExperimentPayload) => void = () => { + return; + }; + + public constructor() { + makeAutoObservable(this, { vm: computed }); + } + + public get vm(): IExperimentFormViewModel { + const total = this._buckets.reduce((sum, b) => sum + b.weight, 0); + const variantCount = this._buckets.filter(b => !b.isControl).length; + const canSubmit = this._name.trim().length > 0 && this._key.trim().length > 0; + + return { + name: this._name, + key: this._key, + buckets: this._buckets, + total, + variantCount, + canSubmit, + submitLabel: this._submitLabel, + allowStructureChange: this._allowStructureChange + }; + } + + public init( + initial: ExperimentFormInitial | undefined, + options: { + submitLabel: string; + allowStructureChange: boolean; + onSubmit: (payload: NewExperimentPayload) => void; + } + ): void { + this._name = initial?.name ?? ""; + this._key = initial?.key ?? ""; + this._keyEdited = Boolean(initial?.key); + this._buckets = initial?.buckets ?? defaultBuckets(); + this._submitLabel = options.submitLabel; + this._allowStructureChange = options.allowStructureChange; + this._onSubmit = options.onSubmit; + } + + public setName(value: string): void { + this._name = value; + if (!this._keyEdited) { + this._key = toKey(value); + } + } + + public setKey(value: string): void { + this._keyEdited = true; + this._key = value; + } + + public addVariant(): void { + const count = this._buckets.filter(b => !b.isControl).length; + const variantName = `Variant ${String.fromCharCode(66 + count)}`; + this._buckets = evenSplit([ + ...this._buckets, + { + id: crypto.randomUUID(), + isControl: false, + weight: 0, + name: variantName, + key: toKey(variantName), + keyEdited: false, + description: "" + } + ]); + } + + public removeVariant(index: number): void { + this._buckets = evenSplit(this._buckets.filter((_, i) => i !== index)); + } + + public changeWeight(index: number, value: number): void { + this._buckets = rebalance(this._buckets, index, value); + } + + public changeName(index: number, value: string): void { + this._buckets = this._buckets.map((b, i) => + i === index ? { ...b, name: value, key: b.keyEdited ? b.key : toKey(value) } : b + ); + } + + public changeKey(index: number, value: string): void { + this._buckets = this._buckets.map((b, i) => + i === index ? { ...b, key: value, keyEdited: true } : b + ); + } + + public changeDescription(index: number, value: string): void { + this._buckets = this._buckets.map((b, i) => + i === index ? { ...b, description: value } : b + ); + } + + public submit(): void { + const canSubmit = this._name.trim().length > 0 && this._key.trim().length > 0; + if (!canSubmit) { + return; + } + const control = this._buckets.find(b => b.isControl)!; + const variants = this._buckets + .filter(b => !b.isControl) + .map(b => ({ + id: b.id, + revisionId: b.revisionId, + name: b.name, + key: b.key, + description: b.description, + weight: b.weight + })); + + this._onSubmit({ + name: this._name.trim(), + key: this._key.trim(), + control: { key: control.key, description: control.description, weight: control.weight }, + variants + }); + } +} + +export const ExperimentFormPresenter = Abstraction.createImplementation({ + implementation: ExperimentFormPresenterImpl, + dependencies: [] +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentForm/abstractions/ExperimentFormPresenter.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/abstractions/ExperimentFormPresenter.ts new file mode 100644 index 00000000000..2527a9db76a --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/abstractions/ExperimentFormPresenter.ts @@ -0,0 +1,74 @@ +import { createAbstraction } from "@webiny/feature/admin"; + +/** A single form row: the control or a variant, with its traffic weight and analytics key. */ +export interface FormBucket { + id: string; + isControl: boolean; + name: string; + key: string; + keyEdited: boolean; + description: string; + weight: number; + // CMS revision id of an existing variant (edit mode); absent for control and new variants. + revisionId?: string; +} + +export interface ExperimentFormInitial { + name: string; + key: string; + buckets: FormBucket[]; +} + +export interface NewExperimentPayload { + name: string; + key: string; + control: { key: string; description: string; weight: number }; + variants: Array<{ + id: string; + revisionId?: string; + name: string; + key: string; + description: string; + weight: number; + }>; +} + +export interface IExperimentFormViewModel { + name: string; + key: string; + buckets: FormBucket[]; + total: number; + variantCount: number; + canSubmit: boolean; + submitLabel: string; + allowStructureChange: boolean; +} + +export interface IExperimentFormPresenter { + vm: IExperimentFormViewModel; + init( + initial: ExperimentFormInitial | undefined, + options: { + submitLabel: string; + allowStructureChange: boolean; + onSubmit: (payload: NewExperimentPayload) => void; + } + ): void; + setName(value: string): void; + setKey(value: string): void; + addVariant(): void; + removeVariant(index: number): void; + changeWeight(index: number, value: number): void; + changeName(index: number, value: string): void; + changeKey(index: number, value: string): void; + changeDescription(index: number, value: string): void; + submit(): void; +} + +export const ExperimentFormPresenter = + createAbstraction("ExperimentFormPresenter"); + +export namespace ExperimentFormPresenter { + export type Interface = IExperimentFormPresenter; + export type ViewModel = IExperimentFormViewModel; +} diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentForm/components/ExperimentFormView.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/components/ExperimentFormView.tsx new file mode 100644 index 00000000000..c06e068469c --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/components/ExperimentFormView.tsx @@ -0,0 +1,172 @@ +import React, { useMemo } from "react"; +import { observer } from "mobx-react-lite"; +import { DiContainerProvider, useContainer, useFeature } from "@webiny/app"; +import { Button, Input, Text } from "@webiny/admin-ui"; +import { ReactComponent as AddIcon } from "@webiny/icons/add.svg"; +import { ReactComponent as InfoIcon } from "@webiny/icons/info.svg"; +import { ReactComponent as CloseIcon } from "@webiny/icons/close.svg"; +import { VariantSplitRow } from "./VariantSplitRow.js"; +import { ExperimentFormPresenterFeature } from "../feature.js"; +import type { + ExperimentFormInitial, + NewExperimentPayload +} from "../abstractions/ExperimentFormPresenter.js"; + +interface Props { + onCancel: () => void; + onSubmit: (payload: NewExperimentPayload) => void; + initial?: ExperimentFormInitial; + submitLabel?: string; + /** When false, variants can't be added/removed (used when editing an existing experiment). */ + allowStructureChange?: boolean; +} + +const ExperimentFormViewInner = observer(function ExperimentFormViewInner({ + onCancel, + onSubmit, + initial, + submitLabel = "Create experiment", + allowStructureChange = true +}: Props) { + const { presenter } = useFeature(ExperimentFormPresenterFeature); + + useMemo(() => { + presenter.init(initial, { submitLabel, allowStructureChange, onSubmit }); + }, [presenter]); + + const { vm } = presenter; + + return ( +
+
+
+ presenter.setName(value)} + placeholder="e.g. Homepage CTA test" + /> +
+ +
+ presenter.setKey(value)} + placeholder="homepage-cta-test" + /> +
+ +
+ + + This key is the identifier reported to your analytics platform to track this + experiment. Auto-generated from the name — edit it to override. + +
+ +
+ Variants & traffic split + + Total {vm.total}% + +
+
+ + Each variant is an independent copy of the page. Sliders auto-balance to + 100%. + +
+ + {vm.buckets.map((bucket, index) => ( + !b.isControl).length} + onNameChange={value => presenter.changeName(index, value)} + onKeyChange={value => presenter.changeKey(index, value)} + onDescriptionChange={value => presenter.changeDescription(index, value)} + onChange={value => presenter.changeWeight(index, value)} + onRemove={ + bucket.isControl || !vm.allowStructureChange || vm.variantCount <= 1 + ? undefined + : () => presenter.removeVariant(index) + } + removeIcon={} + /> + ))} + + {vm.allowStructureChange ? ( +
+
+ ) : null} +
+ +
+
+
+ ); +}); + +export const ExperimentFormView = (props: Props) => { + const container = useContainer(); + + const scopedContainer = useMemo(() => { + const child = container.createChildContainer(); + ExperimentFormPresenterFeature.register(child); + return child; + }, []); + + return ( + + + + ); +}; diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentForm/components/VariantSplitRow.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/components/VariantSplitRow.tsx new file mode 100644 index 00000000000..53760a2a1e7 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/components/VariantSplitRow.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { IconButton, Input } from "@webiny/admin-ui"; +import { bucketColor } from "../../shared/variantColors.js"; + +interface FieldProps { + label: string; + children: React.ReactNode; +} + +const Field = ({ label, children }: FieldProps) => { + return ( +
+
+ {label} +
+ {children} +
+ ); +}; + +interface Props { + name: string; + variantKey: string; + description: string; + isControl: boolean; + weight: number; + variantIndex: number; + onNameChange: (value: string) => void; + onKeyChange: (value: string) => void; + onDescriptionChange: (value: string) => void; + onChange: (value: number) => void; + onRemove?: () => void; + removeIcon: React.ReactNode; +} + +export const VariantSplitRow = ({ + name, + variantKey, + description, + isControl, + weight, + variantIndex, + onNameChange, + onKeyChange, + onDescriptionChange, + onChange, + onRemove, + removeIcon +}: Props) => { + const color = bucketColor(isControl, variantIndex); + + return ( +
+
+
+ + + {isControl ? "Control" : name || "Variant"} + +
+ {isControl ? ( + + Current page + + ) : onRemove ? ( + + ) : null} +
+ + + + + + + + + + + + + +
+ + Traffic + + onChange(Number(event.target.value))} + style={{ flex: 1, accentColor: color }} + /> + + {weight}% + +
+
+ ); +}; diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentForm/feature.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/feature.ts new file mode 100644 index 00000000000..9e385db175a --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/admin"; +import { ExperimentFormPresenter as PresenterAbstraction } from "./abstractions/ExperimentFormPresenter.js"; +import { ExperimentFormPresenter } from "./ExperimentFormPresenter.js"; + +export const ExperimentFormPresenterFeature = createFeature({ + name: "WebsiteBuilder/ExperimentFormPresenter", + register(container) { + container.register(ExperimentFormPresenter).inSingletonScope(); + }, + resolve(container) { + return { + presenter: container.resolve(PresenterAbstraction) + }; + } +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentForm/index.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/index.ts new file mode 100644 index 00000000000..0ee27bad54f --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentForm/index.ts @@ -0,0 +1,3 @@ +export { ExperimentFormView } from "./components/ExperimentFormView.js"; +export { ExperimentFormPresenter } from "./abstractions/ExperimentFormPresenter.js"; +export { ExperimentFormPresenterFeature } from "./feature.js"; diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/ExperimentsEditorDataSource.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/ExperimentsEditorDataSource.ts new file mode 100644 index 00000000000..27045a23ec8 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/ExperimentsEditorDataSource.ts @@ -0,0 +1,31 @@ +import { ExperimentsGateway } from "~/features/experiments/index.js"; +import type { ExperimentDto, VariantDto } from "~/features/experiments/index.js"; +import { + ExperimentsEditorDataSource as Abstraction, + type IExperimentsEditorDataSource +} from "./abstractions/ExperimentsEditorDataSource.js"; + +/** + * Thin gateway-orchestration data source for the editor-embedded experiments UI: loads a page's + * experiments, a single experiment's variants, and the runtime kill-switch state. + */ +class ExperimentsEditorDataSourceImpl implements IExperimentsEditorDataSource { + constructor(private readonly gateway: ExperimentsGateway.Interface) {} + + async listExperiments(pageEntryId: string): Promise { + return this.gateway.listExperiments(pageEntryId).catch(() => [] as ExperimentDto[]); + } + + async listVariants(experimentId: string): Promise { + return this.gateway.listVariants(experimentId).catch(() => [] as VariantDto[]); + } + + async getExperimentPaused(experimentEntryId: string): Promise { + return this.gateway.getExperimentPaused(experimentEntryId).catch(() => false); + } +} + +export const ExperimentsEditorDataSource = Abstraction.createImplementation({ + implementation: ExperimentsEditorDataSourceImpl, + dependencies: [ExperimentsGateway] +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/ExperimentsEditorPresenter.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/ExperimentsEditorPresenter.ts new file mode 100644 index 00000000000..2a9b886245d --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/ExperimentsEditorPresenter.ts @@ -0,0 +1,302 @@ +import { makeAutoObservable, computed, runInAction } from "mobx"; +import { ExperimentsGateway } from "~/features/experiments/index.js"; +import type { + ExperimentDto, + UpdateExperimentInput, + VariantContentDto, + VariantDto +} from "~/features/experiments/index.js"; +import type { NewExperimentPayload } from "../ExperimentForm/abstractions/ExperimentFormPresenter.js"; +import { + ExperimentsEditorPresenter as Abstraction, + type IExperimentsEditorPresenter, + type IExperimentsEditorViewModel, + type SwitcherItem, + type VariantOption +} from "./abstractions/ExperimentsEditorPresenter.js"; +import { ExperimentsEditorDataSource } from "./abstractions/ExperimentsEditorDataSource.js"; +import { bucketColor } from "../shared/variantColors.js"; + +class ExperimentsEditorPresenterImpl implements IExperimentsEditorPresenter { + // Revision id of the page being edited (e.g. "#0001"); the baseline for its experiments. + private baselineRevisionId = ""; + private pageEntryId = ""; + private experiments: ExperimentDto[] = []; + private selectedExperimentId: string | null = null; + // Currently edited bucket: `null` = control, otherwise a variant entry id. + private selectedVariantId: string | null = null; + private variants: VariantDto[] = []; + private paused = false; + private drawerOpen = false; + private editTarget: ExperimentDto | null = null; + // Guards async variant/paused loads against out-of-order responses when the selection changes. + private loadToken = 0; + + constructor( + private readonly gateway: ExperimentsGateway.Interface, + private readonly dataSource: ExperimentsEditorDataSource.Interface + ) { + makeAutoObservable(this, { vm: computed }); + } + + private get selectedExperiment(): ExperimentDto | null { + return ( + this.experiments.find(experiment => experiment.id === this.selectedExperimentId) ?? null + ); + } + + private get selectedVariant(): VariantDto | null { + return this.variants.find(variant => variant.entryId === this.selectedVariantId) ?? null; + } + + private get variantOptions(): VariantOption[] { + const split = this.selectedExperiment?.trafficSplit ?? { control: 0, variants: {} }; + return [ + { + id: null, + name: "Control", + weight: split.control ?? 0, + isControl: true, + color: bucketColor(true, 0) + }, + ...this.variants.map((variant, index) => ({ + id: variant.entryId, + name: variant.name, + weight: split.variants?.[variant.entryId] ?? 0, + isControl: false, + color: bucketColor(false, index) + })) + ]; + } + + private get currentBucket(): VariantOption | null { + const options = this.variantOptions; + return options.find(option => option.id === this.selectedVariantId) ?? options[0] ?? null; + } + + private get runningExperiment(): ExperimentDto | null { + return this.experiments.find(experiment => experiment.status === "running") ?? null; + } + + private get switcherItems(): SwitcherItem[] { + return this.experiments.map( + (experiment): SwitcherItem => ({ + id: experiment.id, + name: experiment.name, + status: experiment.status === "running" ? "active" : "inactive" + }) + ); + } + + get vm(): IExperimentsEditorViewModel { + return { + baselineRevisionId: this.baselineRevisionId, + pageEntryId: this.pageEntryId, + experiments: this.experiments, + selectedExperimentId: this.selectedExperimentId, + selectedExperiment: this.selectedExperiment, + selectedVariantId: this.selectedVariantId, + selectedVariant: this.selectedVariant, + variantOptions: this.variantOptions, + currentBucket: this.currentBucket, + runningExperiment: this.runningExperiment, + switcherItems: this.switcherItems, + paused: this.paused, + drawerOpen: this.drawerOpen, + editTarget: this.editTarget + }; + } + + init(pageRevisionId: string): void { + this.baselineRevisionId = pageRevisionId; + this.pageEntryId = pageRevisionId.split("#")[0]; + this.reload(); + } + + async reload(): Promise { + const list = await this.dataSource.listExperiments(this.pageEntryId); + runInAction(() => { + this.experiments = list; + // Drop the selection if the experiment no longer exists. + if ( + this.selectedExperimentId && + !list.some(experiment => experiment.id === this.selectedExperimentId) + ) { + this.selectedExperimentId = null; + } + }); + } + + selectExperiment(id: string | null): void { + this.selectedExperimentId = id; + // Reset the edited bucket to the control whenever the experiment changes. + this.selectedVariantId = null; + this.loadSelectedExperiment(); + } + + selectVariant(variantId: string | null): void { + this.selectedVariantId = variantId; + } + + /** + * On the published (read-only) view, surface the running experiment and keep it selected so the + * preview toolbar tracks it. Mirrors the old read-only auto-select effect. + */ + syncReadOnlySelection(isReadOnly: boolean): void { + if (!isReadOnly) { + return; + } + const running = + this.experiments.find(experiment => experiment.status === "running") ?? null; + if (running && this.selectedExperimentId !== running.id) { + this.selectExperiment(running.id); + } + } + + openManage(): void { + this.editTarget = null; + this.drawerOpen = true; + } + + editExperiment(experiment: ExperimentDto): void { + this.editTarget = experiment; + this.drawerOpen = true; + } + + closeDrawer(): void { + this.drawerOpen = false; + } + + /** + * Load the variants and kill-switch state for the currently selected experiment. A load token + * guards against a stale response overwriting a newer selection. + */ + private loadSelectedExperiment(): void { + const token = ++this.loadToken; + const experimentId = this.selectedExperimentId; + + if (!experimentId) { + this.variants = []; + this.paused = false; + return; + } + + this.dataSource.listVariants(experimentId).then(list => { + if (token === this.loadToken) { + runInAction(() => { + this.variants = list; + }); + } + }); + + // Load the kill-switch state only for a running experiment (keyed on its entryId). + const experiment = this.experiments.find(item => item.id === experimentId) ?? null; + if (experiment && experiment.status === "running") { + this.dataSource.getExperimentPaused(experiment.entryId).then(value => { + if (token === this.loadToken) { + runInAction(() => { + this.paused = value; + }); + } + }); + } else { + this.paused = false; + } + } + + async pause(): Promise { + const experiment = this.selectedExperiment; + if (!experiment) { + return; + } + await this.gateway.pauseExperiment(experiment.entryId); + runInAction(() => { + this.paused = true; + }); + } + + async resume(): Promise { + const experiment = this.selectedExperiment; + if (!experiment) { + return; + } + await this.gateway.resumeExperiment(experiment.entryId); + runInAction(() => { + this.paused = false; + }); + } + + /** + * Activate an experiment. Only one experiment can run on a page at a time, so any other running + * experiment (from the same page) is stopped first. + */ + async activate(experimentId: string): Promise { + const active = this.experiments.find( + experiment => experiment.status === "running" && experiment.id !== experimentId + ); + if (active) { + await this.gateway.stopExperiment(active.id); + } + return this.gateway.startExperiment(experimentId); + } + + deactivate(experimentId: string): Promise { + return this.gateway.stopExperiment(experimentId); + } + + /** + * Persist a new experiment: create the experiment, then each variant (whose content the API + * copies from the baseline) marked ready, then write the traffic split now that we have the + * variant ids. The experiment key is carried in the analytics config. + */ + async createExperiment(payload: NewExperimentPayload): Promise { + const experiment = await this.gateway.createExperiment({ + pageEntryId: this.pageEntryId, + baselineRevisionId: this.baselineRevisionId, + name: payload.name, + targeting: { trafficPercentage: 100 }, + analytics: { provider: "posthog", experimentKey: payload.key }, + trafficSplit: { control: payload.control.weight, variants: {} } + }); + + const variantSplit: Record = {}; + for (const variant of payload.variants) { + const created = await this.gateway.createVariant({ + experimentId: experiment.id, + name: variant.name + }); + await this.gateway.updateVariant(created.id, { status: "ready" }); + variantSplit[created.entryId] = variant.weight; + } + + return this.gateway.updateExperiment(experiment.id, { + trafficSplit: { control: payload.control.weight, variants: variantSplit } + }); + } + + updateExperiment(id: string, input: UpdateExperimentInput): Promise { + return this.gateway.updateExperiment(id, input); + } + + /** Delete an experiment along with its variants. */ + async deleteExperiment(experimentId: string): Promise { + const variants = await this.gateway.listVariants(experimentId).catch(() => []); + for (const variant of variants) { + await this.gateway.deleteVariant(variant.id).catch(() => undefined); + } + return this.gateway.deleteExperiment(experimentId); + } + + getVariant(id: string): Promise { + return this.gateway.getVariant(id); + } + + saveVariant(revisionId: string, doc: Record): Promise { + return this.gateway.updateVariant(revisionId, doc); + } +} + +export const ExperimentsEditorPresenter = Abstraction.createImplementation({ + implementation: ExperimentsEditorPresenterImpl, + dependencies: [ExperimentsGateway, ExperimentsEditorDataSource] +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/abstractions/ExperimentsEditorDataSource.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/abstractions/ExperimentsEditorDataSource.ts new file mode 100644 index 00000000000..645347afcb5 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/abstractions/ExperimentsEditorDataSource.ts @@ -0,0 +1,19 @@ +import { createAbstraction } from "@webiny/feature/admin"; +import type { ExperimentDto, VariantDto } from "~/features/experiments/index.js"; + +export interface IExperimentsEditorDataSource { + // Load all experiments for a page (by its entry id). + listExperiments(pageEntryId: string): Promise; + // Load the variants of a single experiment. + listVariants(experimentId: string): Promise; + // Read the runtime kill-switch state for an experiment (keyed on its entryId). + getExperimentPaused(experimentEntryId: string): Promise; +} + +export const ExperimentsEditorDataSource = createAbstraction( + "WebsiteBuilder/ExperimentsEditor/DataSource" +); + +export namespace ExperimentsEditorDataSource { + export type Interface = IExperimentsEditorDataSource; +} diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/abstractions/ExperimentsEditorPresenter.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/abstractions/ExperimentsEditorPresenter.ts new file mode 100644 index 00000000000..92217fa3b86 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/abstractions/ExperimentsEditorPresenter.ts @@ -0,0 +1,84 @@ +import { createAbstraction } from "@webiny/feature/admin"; +import type { + ExperimentDto, + UpdateExperimentInput, + VariantContentDto, + VariantDto +} from "~/features/experiments/index.js"; +import type { NewExperimentPayload } from "../../ExperimentForm/abstractions/ExperimentFormPresenter.js"; + +/** A single "thing you can edit" within an experiment: the control, or one of its variants. */ +export interface VariantOption { + // `null` identifies the control (the baseline page); otherwise the variant entry id. + id: string | null; + name: string; + weight: number; + isControl: boolean; + // Palette colour for the bucket, assigned by index (control neutral, variants cycle). + color: string; +} + +/** A row in the top-bar experiment switcher. */ +export interface SwitcherItem { + id: string; + name: string; + status: "active" | "inactive"; +} + +export interface IExperimentsEditorViewModel { + // Revision id of the page being edited; the baseline for its experiments. + baselineRevisionId: string; + // Entry id of the page being edited (derived from the baseline revision id). + pageEntryId: string; + experiments: ExperimentDto[]; + selectedExperimentId: string | null; + selectedExperiment: ExperimentDto | null; + // Currently edited bucket: `null` = control, otherwise a variant entry id. + selectedVariantId: string | null; + // The variant DTO matching `selectedVariantId` (null when editing the control). + selectedVariant: VariantDto | null; + variantOptions: VariantOption[]; + // The bucket currently being previewed/edited (control or the selected variant). + currentBucket: VariantOption | null; + // The single running experiment on the page, if any (drives the read-only indicator). + runningExperiment: ExperimentDto | null; + // Experiments as switcher rows (id + name + active/inactive status). + switcherItems: SwitcherItem[]; + // Kill-switch state of the selected experiment. + paused: boolean; + // Manage/edit drawer UI state. + drawerOpen: boolean; + editTarget: ExperimentDto | null; +} + +export interface IExperimentsEditorPresenter { + readonly vm: IExperimentsEditorViewModel; + init(pageRevisionId: string): void; + reload(): Promise; + selectExperiment(id: string | null): void; + selectVariant(variantId: string | null): void; + // On a published (read-only) page, keep the running experiment selected so the preview tracks it. + syncReadOnlySelection(isReadOnly: boolean): void; + openManage(): void; + editExperiment(experiment: ExperimentDto): void; + closeDrawer(): void; + pause(): Promise; + resume(): Promise; + activate(experimentId: string): Promise; + deactivate(experimentId: string): Promise; + createExperiment(payload: NewExperimentPayload): Promise; + updateExperiment(id: string, input: UpdateExperimentInput): Promise; + deleteExperiment(experimentId: string): Promise; + getVariant(id: string): Promise; + saveVariant(revisionId: string, doc: Record): Promise; +} + +export const ExperimentsEditorPresenter = createAbstraction( + "WebsiteBuilder/ExperimentsEditor/Presenter" +); + +export namespace ExperimentsEditorPresenter { + export type Interface = IExperimentsEditorPresenter; + export type ViewModel = IExperimentsEditorViewModel; + export type Option = VariantOption; +} diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentIndicator.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentIndicator.tsx new file mode 100644 index 00000000000..b038e4b8986 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentIndicator.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { useFeature } from "@webiny/app"; +import { ReactComponent as ScienceIcon } from "@webiny/icons/science.svg"; +import { ReactComponent as PauseIcon } from "@webiny/icons/pause.svg"; +import { ReactComponent as PlayIcon } from "@webiny/icons/play_arrow.svg"; +import { ExperimentsEditorPresenterFeature } from "../feature.js"; +import type { ExperimentDto } from "~/features/experiments/index.js"; + +/** + * Top-bar indicator shown on a published (read-only) page: names the running experiment, shows + * whether it's live (Active) or paused (Inactive), and lets the editor toggle the kill-switch. + */ +export const ExperimentIndicator = observer(function ExperimentIndicator({ + experiment +}: { + experiment: ExperimentDto; +}) { + const { presenter } = useFeature(ExperimentsEditorPresenterFeature); + const { paused } = presenter.vm; + const [busy, setBusy] = useState(false); + const active = !paused; + + const toggle = async () => { + setBusy(true); + try { + await (paused ? presenter.resume() : presenter.pause()); + } finally { + setBusy(false); + } + }; + + const toggleStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 6, + borderRadius: 8, + padding: "6px 12px", + fontWeight: 600, + fontSize: 13, + cursor: busy ? "default" : "pointer", + opacity: busy ? 0.6 : 1, + ...(active + ? { border: "1px solid #d5d7db", background: "#fff", color: "#1f2937" } + : { border: "none", background: "#10b981", color: "#fff" }) + }; + + return ( +
+ + + + {experiment.name} + + + + {active ? "Active" : "Inactive"} + + + +
+ ); +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentPreviewToolbar.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentPreviewToolbar.tsx new file mode 100644 index 00000000000..287ba35a0e8 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentPreviewToolbar.tsx @@ -0,0 +1,178 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { useFeature } from "@webiny/app"; +import { DropdownMenu } from "@webiny/admin-ui"; +import { ReactComponent as ScienceIcon } from "@webiny/icons/science.svg"; +import { ReactComponent as SwapIcon } from "@webiny/icons/swap_horiz.svg"; +import { ReactComponent as CheckIcon } from "@webiny/icons/check.svg"; +import { ReactComponent as EditIcon } from "@webiny/icons/edit.svg"; +import { ExperimentsEditorPresenterFeature } from "../feature.js"; +import { useSelectFromEditor } from "~/BaseEditor/hooks/useSelectFromEditor.js"; + +const Dot = ({ color }: { color: string }) => ( + +); + +/** + * A bar shown between the address bar and the canvas while an experiment is selected. It names the + * experiment, shows its status and the bucket currently being edited, and lets the editor switch + * between the control and each variant or jump to editing the experiment. + */ +export const ExperimentPreviewToolbar = observer(function ExperimentPreviewToolbar() { + const { presenter } = useFeature(ExperimentsEditorPresenterFeature); + const { selectedExperiment, variantOptions: options, currentBucket: current } = presenter.vm; + const isReadOnly = useSelectFromEditor(state => state.isReadOnly); + const [open, setOpen] = useState(false); + + if (!selectedExperiment || !current) { + return null; + } + + const active = selectedExperiment.status === "running"; + + const trigger = ( + + ); + + return ( +
+
+ {/* On a published (read-only) page the experiment name/status live in the top-bar + indicator, so the toolbar just shows what's being previewed. */} + {!isReadOnly ? ( + <> + + + {selectedExperiment.name} + + + + {active ? "Active" : "Inactive"} + + + + ) : null} + + + {isReadOnly ? "Previewing" : "You're editing"} + + + + {current.name} + {current.weight}% + +
+ + + + {options.map(option => ( + } + text={ + + {option.name} + + + {option.weight}% + + {option.id === current.id ? ( + + ) : null} + + + } + onClick={() => presenter.selectVariant(option.id)} + /> + ))} + {!isReadOnly ? ( + <> + + } + text="Edit experiment" + onClick={() => presenter.editExperiment(selectedExperiment)} + /> + + ) : null} + +
+ ); +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentsButton.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentsButton.tsx new file mode 100644 index 00000000000..5cae961616e --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentsButton.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { useFeature } from "@webiny/app"; +import { Button } from "@webiny/admin-ui"; +import { ReactComponent as ScienceIcon } from "@webiny/icons/science.svg"; +import { useSelectFromEditor } from "~/BaseEditor/hooks/useSelectFromEditor.js"; +import { ExperimentsSwitcher, type ExperimentItem } from "./ExperimentsSwitcher.js"; +import { ExperimentIndicator } from "./ExperimentIndicator.js"; +import { ExperimentsEditorPresenterFeature } from "../feature.js"; + +/** + * Top-bar entry point for A/B experiments. + * + * On an editable (draft) page it's a switcher for picking an experiment to edit; on a published + * (read-only) page it's an indicator for the running experiment with a pause/resume kill-switch. + * State is shared through the editor-wide presenter so the in-preview toolbar tracks the selection. + */ +export const ExperimentsButton = observer(function ExperimentsButton() { + const { presenter } = useFeature(ExperimentsEditorPresenterFeature); + const isReadOnly = useSelectFromEditor(state => state.isReadOnly); + + const { runningExperiment, switcherItems, selectedExperimentId } = presenter.vm; + + // On the published (read-only) view, surface the running experiment and keep it selected so the + // preview toolbar tracks it. Forwards the editor read-only selector into the presenter. + useEffect(() => { + presenter.syncReadOnlySelection(isReadOnly); + }, [presenter, isReadOnly, runningExperiment, selectedExperimentId]); + + if (isReadOnly) { + if (!runningExperiment) { + return null; + } + return ( +
+ +
+ ); + } + + const items: ExperimentItem[] = switcherItems; + + return ( +
+ {items.length === 0 ? ( +
+ ); +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentsSwitcher.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentsSwitcher.tsx new file mode 100644 index 00000000000..9234158c9ca --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/components/ExperimentsSwitcher.tsx @@ -0,0 +1,143 @@ +import React, { useState } from "react"; +import { DropdownMenu } from "@webiny/admin-ui"; +import { ReactComponent as ScienceIcon } from "@webiny/icons/science.svg"; +import { ReactComponent as ChevronDownIcon } from "@webiny/icons/expand_more.svg"; +import { ReactComponent as GlobeIcon } from "@webiny/icons/public.svg"; +import { ReactComponent as CheckIcon } from "@webiny/icons/check.svg"; +import { ReactComponent as TuneIcon } from "@webiny/icons/tune.svg"; + +export interface ExperimentItem { + id: string; + name: string; + status: "active" | "inactive"; +} + +interface Props { + experiments: ExperimentItem[]; + selectedId: string | null; + onSelect: (id: string | null) => void; + onManage: () => void; +} + +const Dot = ({ color }: { color: string }) => ( + +); + +const StatusBadge = ({ status }: { status: ExperimentItem["status"] }) => { + const active = status === "active"; + return ( + + {active ? "Active" : "Inactive"} + + ); +}; + +interface RowProps { + title: string; + subtitle?: string; + right?: React.ReactNode; + selected: boolean; +} + +const Row = ({ title, subtitle, right, selected }: RowProps) => ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+
+ {right} + {selected ? : null} +
+
+); + +export const ExperimentsSwitcher = ({ experiments, selectedId, onSelect, onManage }: Props) => { + const [open, setOpen] = useState(false); + const selected = experiments.find(e => e.id === selectedId) ?? null; + const label = selected ? selected.name : "Experiments"; + + const trigger = ( + + ); + + return ( + + + } + text={ + + } + onClick={() => onSelect(null)} + /> + + + {experiments.map(experiment => ( + } + text={ + } + selected={selectedId === experiment.id} + /> + } + onClick={() => onSelect(experiment.id)} + /> + ))} + + } + text="Manage experiments…" + onClick={onManage} + /> + + ); +}; diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/feature.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/feature.ts new file mode 100644 index 00000000000..4d49d6e75d5 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/feature.ts @@ -0,0 +1,17 @@ +import { createFeature } from "@webiny/feature/admin"; +import { ExperimentsFeature } from "~/features/experiments/index.js"; +import { ExperimentsEditorPresenter as PresenterAbstraction } from "./abstractions/ExperimentsEditorPresenter.js"; +import { ExperimentsEditorPresenter } from "./ExperimentsEditorPresenter.js"; +import { ExperimentsEditorDataSource } from "./ExperimentsEditorDataSource.js"; + +export const ExperimentsEditorPresenterFeature = createFeature({ + name: "WebsiteBuilder/ExperimentsEditorPresenter", + register(container) { + ExperimentsFeature.register(container); + container.register(ExperimentsEditorDataSource).inSingletonScope(); + container.register(ExperimentsEditorPresenter).inSingletonScope(); + }, + resolve(container) { + return { presenter: container.resolve(PresenterAbstraction) }; + } +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/index.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/index.ts new file mode 100644 index 00000000000..9f01782e5c6 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsEditor/index.ts @@ -0,0 +1,8 @@ +export { ExperimentsEditorPresenter } from "./abstractions/ExperimentsEditorPresenter.js"; +export type { + IExperimentsEditorPresenter, + IExperimentsEditorViewModel, + VariantOption +} from "./abstractions/ExperimentsEditorPresenter.js"; +export { ExperimentsEditorDataSource } from "./abstractions/ExperimentsEditorDataSource.js"; +export { ExperimentsEditorPresenterFeature } from "./feature.js"; diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/ExperimentsManagerPresenter.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/ExperimentsManagerPresenter.ts new file mode 100644 index 00000000000..e9c34cd63f3 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/ExperimentsManagerPresenter.ts @@ -0,0 +1,260 @@ +import { makeAutoObservable, computed, reaction, runInAction, type IReactionDisposer } from "mobx"; +import slugify from "slugify"; +import { ExperimentsGateway } from "~/features/experiments/index.js"; +import type { ExperimentDto, VariantDto } from "~/features/experiments/index.js"; +import type { + ExperimentFormInitial, + NewExperimentPayload +} from "../ExperimentForm/abstractions/ExperimentFormPresenter.js"; +import { bucketColor } from "../shared/variantColors.js"; +import { ExperimentsEditorPresenter } from "../ExperimentsEditor/index.js"; +import { + ExperimentsManagerPresenter as Abstraction, + type ExperimentCardViewModel, + type ExperimentsManagerView, + type IExperimentsManagerPresenter, + type IExperimentsManagerViewModel +} from "./abstractions/ExperimentsManagerPresenter.js"; + +// Decorative "hero band" colours for the mini page previews (unrelated to bucket dot colours). +const BAND_COLORS = ["#4f46e5", "#0f9d58", "#c2410c", "#1f2937", "#0891b2"]; + +const toKey = (value: string): string => slugify(value, { lower: true, strict: true }); + +/** + * Owns the manage drawer's view state (open/view/editTarget) and the per-experiment variant + * summaries the cards render. All domain actions are delegated to the editor hub presenter, which + * remains the single source of truth for the page's experiments and their lifecycle. + */ +class ExperimentsManagerPresenterImpl implements IExperimentsManagerPresenter { + private view: ExperimentsManagerView = "list"; + private editInitial: ExperimentFormInitial | null = null; + // Variants keyed by experiment id, loaded for the listed experiments so cards need no fetching. + private variantsByExperiment: Record = {}; + private disposers: IReactionDisposer[] = []; + + constructor( + private readonly hub: ExperimentsEditorPresenter.Interface, + // Read-only variant listing for card summaries and the edit form; all mutations go via the hub. + private readonly gateway: ExperimentsGateway.Interface + ) { + makeAutoObservable(this, { + vm: computed, + hub: false, + gateway: false + }); + } + + private get experiments(): ExperimentDto[] { + return this.hub.vm.experiments; + } + + private get cards(): ExperimentCardViewModel[] { + return this.experiments.map(experiment => { + const variants = this.variantsByExperiment[experiment.id] ?? []; + const active = experiment.status === "running"; + const split = experiment.trafficSplit ?? { control: 0, variants: {} }; + + const buckets = [ + { + key: "control", + name: "Control", + weight: split.control ?? 0, + dot: bucketColor(true, 0), + band: BAND_COLORS[0], + isControl: true + }, + ...variants.map((variant, index) => ({ + key: variant.entryId, + name: variant.name, + weight: split.variants?.[variant.entryId] ?? 0, + dot: bucketColor(false, index), + band: BAND_COLORS[(index + 1) % BAND_COLORS.length], + isControl: false + })) + ]; + + return { + experiment, + active, + buckets, + variantCount: Object.keys(split.variants ?? {}).length + }; + }); + } + + get vm(): IExperimentsManagerViewModel { + return { + open: this.hub.vm.drawerOpen, + view: this.view, + editTarget: this.hub.vm.editTarget, + editInitial: this.editInitial, + experiments: this.experiments, + pageEntryId: this.hub.vm.pageEntryId, + baselineRevisionId: this.hub.vm.baselineRevisionId, + cards: this.cards + }; + } + + init(): void { + // Reset the drawer whenever it opens: jump straight to editing when asked, otherwise the + // list. Mirrors the old drawer's open effect, now reading the hub's drawer state. + this.disposers.push( + reaction( + () => ({ open: this.hub.vm.drawerOpen, editTarget: this.hub.vm.editTarget }), + ({ open, editTarget }) => { + if (!open) { + return; + } + if (editTarget) { + this.startEdit(editTarget); + } else { + runInAction(() => { + this.view = "list"; + this.editInitial = null; + }); + } + }, + { fireImmediately: true } + ) + ); + + // Load per-experiment variant summaries for the cards whenever the experiment set changes. + this.disposers.push( + reaction( + () => this.experiments.map(experiment => experiment.id), + () => this.loadCardVariants(), + { fireImmediately: true } + ) + ); + } + + dispose(): void { + this.disposers.forEach(dispose => dispose()); + this.disposers = []; + } + + /** Load the variants for every listed experiment so the cards render as pure observers. */ + private loadCardVariants(): void { + for (const experiment of this.experiments) { + this.gateway + .listVariants(experiment.id) + .then(list => { + runInAction(() => { + this.variantsByExperiment = { + ...this.variantsByExperiment, + [experiment.id]: list + }; + }); + }) + .catch(() => { + runInAction(() => { + this.variantsByExperiment = { + ...this.variantsByExperiment, + [experiment.id]: [] + }; + }); + }); + } + } + + showList(): void { + this.view = "list"; + this.editInitial = null; + } + + showCreate(): void { + this.view = "create"; + } + + async startEdit(experiment: ExperimentDto): Promise { + const variants = await this.gateway.listVariants(experiment.id); + const split = experiment.trafficSplit ?? { control: 100, variants: {} }; + const buckets = [ + { + id: "control", + isControl: true, + name: "Control", + key: "control", + keyEdited: true, + description: "", + weight: split.control ?? 0 + }, + ...variants.map(variant => ({ + id: variant.entryId, + isControl: false, + name: variant.name, + key: toKey(variant.name), + keyEdited: true, + description: "", + weight: split.variants?.[variant.entryId] ?? 0, + revisionId: variant.id + })) + ]; + runInAction(() => { + this.editInitial = { + name: experiment.name, + key: (experiment.analytics?.experimentKey as string | undefined) ?? "", + buckets + }; + this.view = "edit"; + }); + } + + close(): void { + this.hub.closeDrawer(); + } + + async createExperiment(payload: NewExperimentPayload): Promise { + await this.hub.createExperiment(payload); + await this.hub.reload(); + runInAction(() => { + this.view = "list"; + }); + } + + async updateExperiment(payload: NewExperimentPayload): Promise { + const editTarget = this.hub.vm.editTarget; + if (!editTarget) { + return; + } + const variantSplit: Record = {}; + for (const variant of payload.variants) { + variantSplit[variant.id] = variant.weight; + } + await this.hub.updateExperiment(editTarget.id, { + name: payload.name, + analytics: { provider: "posthog", experimentKey: payload.key }, + trafficSplit: { control: payload.control.weight, variants: variantSplit } + }); + for (const variant of payload.variants) { + if (variant.revisionId) { + await this.hub.saveVariant(variant.revisionId, { name: variant.name }); + } + } + await this.hub.reload(); + runInAction(() => { + this.view = "list"; + }); + } + + async activateExperiment(experiment: ExperimentDto): Promise { + await this.hub.activate(experiment.id); + await this.hub.reload(); + } + + async deactivateExperiment(experiment: ExperimentDto): Promise { + await this.hub.deactivate(experiment.id); + await this.hub.reload(); + } + + async deleteExperiment(experiment: ExperimentDto): Promise { + await this.hub.deleteExperiment(experiment.id); + await this.hub.reload(); + } +} + +export const ExperimentsManagerPresenter = Abstraction.createImplementation({ + implementation: ExperimentsManagerPresenterImpl, + dependencies: [ExperimentsEditorPresenter, ExperimentsGateway] +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/abstractions/ExperimentsManagerPresenter.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/abstractions/ExperimentsManagerPresenter.ts new file mode 100644 index 00000000000..289139ca2d8 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/abstractions/ExperimentsManagerPresenter.ts @@ -0,0 +1,71 @@ +import { createAbstraction } from "@webiny/feature/admin"; +import type { ExperimentDto } from "~/features/experiments/index.js"; +import type { + ExperimentFormInitial, + NewExperimentPayload +} from "../../ExperimentForm/abstractions/ExperimentFormPresenter.js"; + +/** Which view the drawer is currently showing. */ +export type ExperimentsManagerView = "list" | "create" | "edit"; + +/** A single bucket (control or variant) summarised for a card's thumbnails/traffic bar/legend. */ +export interface ExperimentCardBucket { + key: string; + name: string; + weight: number; + dot: string; + band: string; + isControl: boolean; +} + +/** Per-experiment summary used to render a card without any per-card data loading. */ +export interface ExperimentCardViewModel { + experiment: ExperimentDto; + active: boolean; + buckets: ExperimentCardBucket[]; + variantCount: number; +} + +export interface IExperimentsManagerViewModel { + // Drawer visibility (driven by the hub presenter's manage/edit entry points). + open: boolean; + // Which sub-view the drawer is showing. + view: ExperimentsManagerView; + // The experiment being edited (null in list/create views). + editTarget: ExperimentDto | null; + // Initial form values for the edit view (null until loaded). + editInitial: ExperimentFormInitial | null; + // The page's experiments (mirrored from the hub presenter). + experiments: ExperimentDto[]; + pageEntryId: string; + baselineRevisionId: string; + // Card summaries (variant thumbnails/traffic/legend), one per listed experiment. + cards: ExperimentCardViewModel[]; +} + +export interface IExperimentsManagerPresenter { + readonly vm: IExperimentsManagerViewModel; + init(): void; + dispose(): void; + showList(): void; + showCreate(): void; + startEdit(experiment: ExperimentDto): Promise; + close(): void; + createExperiment(payload: NewExperimentPayload): Promise; + updateExperiment(payload: NewExperimentPayload): Promise; + activateExperiment(experiment: ExperimentDto): Promise; + deactivateExperiment(experiment: ExperimentDto): Promise; + deleteExperiment(experiment: ExperimentDto): Promise; +} + +export const ExperimentsManagerPresenter = createAbstraction( + "WebsiteBuilder/ExperimentsManager/Presenter" +); + +export namespace ExperimentsManagerPresenter { + export type Interface = IExperimentsManagerPresenter; + export type ViewModel = IExperimentsManagerViewModel; + export type View = ExperimentsManagerView; + export type Card = ExperimentCardViewModel; + export type CardBucket = ExperimentCardBucket; +} diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentCard.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentCard.tsx new file mode 100644 index 00000000000..4def7e3d7d9 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentCard.tsx @@ -0,0 +1,308 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { Button, Text } from "@webiny/admin-ui"; +import { useConfirmationDialog, useSnackbar } from "@webiny/app-admin"; +import { ReactComponent as PlayIcon } from "@webiny/icons/play_arrow.svg"; +import { ReactComponent as PauseIcon } from "@webiny/icons/pause.svg"; +import { ReactComponent as EditIcon } from "@webiny/icons/edit.svg"; +import { ReactComponent as DeleteIcon } from "@webiny/icons/delete.svg"; +import type { + ExperimentCardViewModel, + IExperimentsManagerPresenter +} from "../abstractions/ExperimentsManagerPresenter.js"; + +interface Props { + presenter: IExperimentsManagerPresenter; + card: ExperimentCardViewModel; +} + +const Dot = ({ color }: { color: string }) => ( + +); + +/** A single page thumbnail — a black header, a couple of placeholder lines and a coloured band. */ +const Thumbnail = ({ band }: { band: string }) => ( +
+
+
+
+
+
+
+
+); + +export const ExperimentCard = observer(function ExperimentCard({ presenter, card }: Props) { + const { experiment, active, buckets, variantCount } = card; + const { showSnackbar } = useSnackbar(); + const { showConfirmation } = useConfirmationDialog({ + title: "Delete experiment", + loading: "Deleting experiment...", + message: ( + + You are about to permanently delete this experiment and all of its variants. This + cannot be undone. + + ) + }); + + const activate = async () => { + try { + await presenter.activateExperiment(experiment); + showSnackbar(`"${experiment.name}" is now active.`); + } catch (ex: any) { + showSnackbar(ex.message || "Could not activate the experiment."); + } + }; + + const deactivate = async () => { + try { + await presenter.deactivateExperiment(experiment); + showSnackbar(`"${experiment.name}" was deactivated.`); + } catch (ex: any) { + showSnackbar(ex.message || "Could not deactivate the experiment."); + } + }; + + const remove = () => + showConfirmation(async () => { + try { + await presenter.deleteExperiment(experiment); + showSnackbar(`"${experiment.name}" was deleted.`); + } catch (ex: any) { + showSnackbar(ex.message || "Could not delete the experiment."); + } + }); + + return ( +
+ {/* Header */} +
+
+
+ + {experiment.name} + + + {active ? : null} + {active ? "Active" : "Inactive"} + +
+
+ {variantCount} variant{variantCount === 1 ? "" : "s"} · splitting traffic +
+
+ {active ? ( + + )} +
+ + {/* Thumbnails */} +
+ {buckets.map(bucket => ( +
+ +
+ + + + {bucket.name} + + + {bucket.weight}% +
+
+ ))} +
+ + {/* Traffic bar */} +
+ {buckets.map(bucket => ( +
+ ))} +
+ + {/* Legend */} +
+ {buckets.map(bucket => ( + + + {bucket.name} + · + {bucket.weight}% + + ))} +
+ + {/* Footer */} +
+ +
+
+ ); +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsDrawerView.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsDrawerView.tsx new file mode 100644 index 00000000000..5f2011ee0d2 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsDrawerView.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +import { DiContainerProvider, useContainer, useFeature } from "@webiny/app"; +import { Drawer, IconButton } from "@webiny/admin-ui"; +import { ReactComponent as ScienceIcon } from "@webiny/icons/science.svg"; +import { ReactComponent as BackIcon } from "@webiny/icons/arrow_back.svg"; +import { ExperimentFormView } from "../../ExperimentForm/index.js"; +import { ExperimentsManagerPresenterFeature } from "../feature.js"; +import { ExperimentsListView } from "./ExperimentsListView.js"; + +const ExperimentsDrawerViewInner = observer(function ExperimentsDrawerViewInner() { + const { presenter } = useFeature(ExperimentsManagerPresenterFeature); + + useEffect(() => { + presenter.init(); + return () => presenter.dispose(); + }, [presenter]); + + const { open, view, editInitial } = presenter.vm; + + const flask = ; + const back = ( + } + onClick={() => presenter.showList()} + /> + ); + + let title: React.ReactNode; + if (view === "create") { + title = ( + + {back} + {flask} + New experiment + + ); + } else if (view === "edit") { + title = ( + + {back} + {flask} + Edit experiment + + ); + } else { + title = ( + + {flask} + Experiments + + ); + } + + return ( + presenter.close()} + modal={true} + width={520} + title={title} + headerSeparator={true} + bodyPadding={false} + className={"flex flex-col"} + > + {view === "list" ? : null} + {view === "create" ? ( + presenter.showList()} + onSubmit={payload => presenter.createExperiment(payload)} + submitLabel="Create experiment" + /> + ) : null} + {view === "edit" && editInitial ? ( + presenter.showList()} + onSubmit={payload => presenter.updateExperiment(payload)} + /> + ) : null} + + ); +}); + +export const ExperimentsDrawerView = () => { + const container = useContainer(); + + const scopedContainer = useMemo(() => { + const child = container.createChildContainer(); + ExperimentsManagerPresenterFeature.register(child); + return child; + }, []); + + return ( + + + + ); +}; diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsEmptyState.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsEmptyState.tsx new file mode 100644 index 00000000000..3c9f2ee0810 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsEmptyState.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { Button, Heading, Text, Link } from "@webiny/admin-ui"; +import { ReactComponent as AddIcon } from "@webiny/icons/add.svg"; + +interface Props { + onCreateExperiment: () => void; +} + +/** A/B illustration for the empty state — two page thumbnails with an A/B badge between them. */ +const Illustration = () => { + const card: React.CSSProperties = { + width: 96, + height: 64, + borderRadius: 8, + border: "1px solid var(--wby-color-neutral-muted, #e0e0e0)", + overflow: "hidden", + background: "#fff" + }; + const bar: React.CSSProperties = { height: 16, background: "#1a1a1a" }; + const line: React.CSSProperties = { + height: 4, + width: 48, + margin: 8, + borderRadius: 4, + background: "#d5d5d5" + }; + + return ( +
+
+
+
+
+
+
+
+
+
+
+ A/B +
+
+ ); +}; + +export const ExperimentsEmptyState = ({ onCreateExperiment }: Props) => { + return ( +
+ + No experiments on this page +
+ + Create an A/B experiment to test variants of this page against each other. Split + traffic across full-page variants and run one experiment live at a time. + +
+
+ ); +}; diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsListView.tsx b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsListView.tsx new file mode 100644 index 00000000000..69f598dd6fc --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/components/ExperimentsListView.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { Text } from "@webiny/admin-ui"; +import { ReactComponent as AddIcon } from "@webiny/icons/add.svg"; +import { ExperimentsEmptyState } from "./ExperimentsEmptyState.js"; +import { ExperimentCard } from "./ExperimentCard.js"; +import type { IExperimentsManagerPresenter } from "../abstractions/ExperimentsManagerPresenter.js"; + +interface Props { + presenter: IExperimentsManagerPresenter; +} + +export const ExperimentsListView = observer(function ExperimentsListView({ presenter }: Props) { + const { cards } = presenter.vm; + + if (cards.length === 0) { + return presenter.showCreate()} />; + } + + return ( +
+
+ + Only one experiment can be active at a time. Activating one deactivates the + currently active experiment. + + +
+ + {cards.map(card => ( + + ))} +
+ ); +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/feature.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/feature.ts new file mode 100644 index 00000000000..db245591644 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/feature.ts @@ -0,0 +1,16 @@ +import { createFeature } from "@webiny/feature/admin"; +import { ExperimentsManagerPresenter as PresenterAbstraction } from "./abstractions/ExperimentsManagerPresenter.js"; +import { ExperimentsManagerPresenter } from "./ExperimentsManagerPresenter.js"; + +export const ExperimentsManagerPresenterFeature = createFeature({ + name: "WebsiteBuilder/ExperimentsManagerPresenter", + register(container) { + // The editor hub presenter (ExperimentsEditorPresenterFeature) is registered app/editor-wide + // and resolved through the container's parent chain — the manager delegates to that same + // singleton so drawer open/edit triggered from the editor toolbar are observed here. + container.register(ExperimentsManagerPresenter).inSingletonScope(); + }, + resolve(container) { + return { presenter: container.resolve(PresenterAbstraction) }; + } +}); diff --git a/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/index.ts b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/index.ts new file mode 100644 index 00000000000..6b9a387a19c --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/ExperimentsManager/index.ts @@ -0,0 +1,10 @@ +export { ExperimentsManagerPresenter } from "./abstractions/ExperimentsManagerPresenter.js"; +export type { + IExperimentsManagerPresenter, + IExperimentsManagerViewModel, + ExperimentsManagerView, + ExperimentCardViewModel, + ExperimentCardBucket +} from "./abstractions/ExperimentsManagerPresenter.js"; +export { ExperimentsManagerPresenterFeature } from "./feature.js"; +export { ExperimentsDrawerView } from "./components/ExperimentsDrawerView.js"; diff --git a/packages/app-website-builder/src/presentation/experiments/VariantPageEditorConfig.tsx b/packages/app-website-builder/src/presentation/experiments/VariantPageEditorConfig.tsx new file mode 100644 index 00000000000..b503fed32a5 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/VariantPageEditorConfig.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { InternalPageEditorConfig } from "~/presentation/pages/PageEditor/PageEditorConfig.js"; +import { BackButton } from "~/presentation/pages/PageEditor/TopBar/BackButton.js"; +import { Title } from "~/presentation/pages/PageEditor/TopBar/Title.js"; +import { TopBarOptionsMenu } from "~/BaseEditor/config/TopBar/Layout.js"; +import { VariantAutoSave } from "./config/VariantAutoSave.js"; + +const { Ui } = InternalPageEditorConfig; + +/** + * Editor config used when a variant is the edited document. It reuses the page editor scope (so the + * experiments switcher/toolbar still render) but swaps page-only chrome — publish, revisions, + * settings — for a variant-scoped autosave. + */ +export const VariantPageEditorConfig = () => { + return ( + + } /> + } /> + } /> + } /> + + ); +}; diff --git a/packages/app-website-builder/src/presentation/experiments/config/ExperimentsEditorConfig.tsx b/packages/app-website-builder/src/presentation/experiments/config/ExperimentsEditorConfig.tsx new file mode 100644 index 00000000000..2a2fc81d4cf --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/config/ExperimentsEditorConfig.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { PageEditorConfig } from "~/presentation/pages/PageEditor/PageEditorConfig.js"; +import { ExperimentsButton } from "../ExperimentsEditor/components/ExperimentsButton.js"; +import { ExperimentPreviewToolbar } from "../ExperimentsEditor/components/ExperimentPreviewToolbar.js"; + +const { Ui } = PageEditorConfig; + +/** + * Registers the Experiments button (top bar) and the in-preview experiment toolbar. + * + * The shared selection state lives in the ExperimentsEditor presenter, which PageEditor initializes + * around the editor so it can swap the edited document between the page and a variant. + */ +export const ExperimentsEditorConfig = () => { + return ( + + {/* Draft page: the switcher, before the Publish button. */} + + } + /> + + {/* Published page: the running-experiment indicator + kill-switch, before the version menu. */} + + } + /> + + } + /> + + ); +}; diff --git a/packages/app-website-builder/src/presentation/experiments/config/VariantAutoSave.tsx b/packages/app-website-builder/src/presentation/experiments/config/VariantAutoSave.tsx new file mode 100644 index 00000000000..a8c48d3be18 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/config/VariantAutoSave.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +import debounce from "lodash/debounce.js"; +import { useFeature } from "@webiny/app"; +import type { EditorPage } from "@webiny/website-builder-sdk"; +import { useDocumentEditor } from "~/DocumentEditor/index.js"; +import { ExperimentsEditorPresenterFeature } from "../ExperimentsEditor/index.js"; +import { editorDocumentToVariantUpdate } from "../shared/variantDocument.js"; + +const VariantAutoSaveAction = ({ variantRevisionId }: { variantRevisionId: string }) => { + const editor = useDocumentEditor(); + const { presenter } = useFeature(ExperimentsEditorPresenterFeature); + + const saveVariant = useMemo( + () => + debounce((document: EditorPage) => { + return presenter.saveVariant( + variantRevisionId, + editorDocumentToVariantUpdate(document) + ); + }, 500), + [presenter, variantRevisionId] + ); + + useEffect(() => { + return editor.onDocumentStateChange(async event => { + if (editor.getEditorState().read().isReadOnly) { + return; + } + editor.updateEditor(state => { + state.autoSaving = true; + }); + await saveVariant(event.state); + setTimeout(() => { + editor.updateEditor(state => { + state.autoSaving = false; + }); + }, 500); + }); + }, [saveVariant]); + + return null; +}; + +/** Autosaves the edited document back to the selected variant (mirrors PageAutoSave for variants). */ +export const VariantAutoSave = observer(function VariantAutoSave() { + const { presenter } = useFeature(ExperimentsEditorPresenterFeature); + const { selectedVariant } = presenter.vm; + if (!selectedVariant) { + return null; + } + return ; +}); diff --git a/packages/app-website-builder/src/presentation/experiments/shared/variantColors.ts b/packages/app-website-builder/src/presentation/experiments/shared/variantColors.ts new file mode 100644 index 00000000000..9234ac35458 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/shared/variantColors.ts @@ -0,0 +1,16 @@ +/** + * Shared bucket colours for the A/B UI. The control is always neutral; variants cycle a fixed + * palette by their 0-based index. Used by the experiment form, the list cards, and the in-preview + * toolbar so a given variant shows the same colour everywhere. + */ +export const CONTROL_COLOR = "#9ca3af"; + +export const VARIANT_COLORS = ["#e2572a", "#4285f4", "#0f9d58", "#a142f4", "#f4b400"]; + +/** Colour for a variant by its 0-based index among the experiment's variants. */ +export const variantColor = (variantIndex: number): string => + VARIANT_COLORS[variantIndex % VARIANT_COLORS.length]; + +/** Colour for a bucket: neutral for the control, otherwise the variant's palette colour. */ +export const bucketColor = (isControl: boolean, variantIndex: number): string => + isControl ? CONTROL_COLOR : variantColor(variantIndex); diff --git a/packages/app-website-builder/src/presentation/experiments/shared/variantDocument.ts b/packages/app-website-builder/src/presentation/experiments/shared/variantDocument.ts new file mode 100644 index 00000000000..f413dc6a010 --- /dev/null +++ b/packages/app-website-builder/src/presentation/experiments/shared/variantDocument.ts @@ -0,0 +1,52 @@ +import type { EditorPage } from "@webiny/website-builder-sdk"; +import type { UpdateVariantInput, VariantContentDto } from "~/features/experiments/index.js"; + +/** + * Map a variant's stored content onto an editor document that keeps the *page's* identity. + * + * The preview iframe is addressed by the document `id` and the canvas keys its store by `wb.id` + * (and renders by `properties.id`). The site resolves the server-side base by that id via + * `getPageById`, which only works for a real page — a variant id is not a page. So the document + * keeps the page's id/path/`properties.id` (a valid, resolvable base — the page draft), while its + * content (elements, bindings, properties, …) is the variant's. The editor streams this content + * over the base, and autosave targets the actual variant separately (see VariantAutoSave). This is + * why control and variant share the same `wb.id`; the switch is handled by re-mounting the editor. + */ +export const variantToEditorDocument = ( + variant: VariantContentDto, + page: EditorPage +): EditorPage => ({ + ...page, + // Variant content, but the page's identity (id/path) so the preview base resolves. + properties: { + ...page.properties, + ...(variant.properties ?? {}), + id: (page.properties as Record).id ?? page.id, + path: page.properties.path + } as EditorPage["properties"], + metadata: { + ...page.metadata, + ...(variant.metadata ?? {}), + documentType: (page.metadata as Record).documentType ?? "page", + // Marker so the address-bar "preview in new tab" opens this variant's draft (see + // usePreviewLink). Stripped before persisting — see editorDocumentToVariantUpdate. + wbVariantPreviewId: variant.id + } as EditorPage["metadata"], + bindings: (variant.bindings ?? page.bindings) as EditorPage["bindings"], + elements: (variant.elements ?? page.elements) as EditorPage["elements"], + extensions: variant.extensions ?? page.extensions ?? {} +}); + +/** Extract the persistable content fields from an edited variant document. */ +export const editorDocumentToVariantUpdate = (document: EditorPage): UpdateVariantInput => { + // Drop the editor-only preview marker so it's not persisted onto the variant. + const { wbVariantPreviewId, ...metadata } = document.metadata as Record; + void wbVariantPreviewId; + return { + properties: document.properties, + metadata, + bindings: document.bindings, + elements: document.elements, + extensions: document.extensions + }; +}; diff --git a/packages/app-website-builder/src/presentation/pages/PageEditor/PageEditor.tsx b/packages/app-website-builder/src/presentation/pages/PageEditor/PageEditor.tsx index 5c657caec00..20d48b55243 100644 --- a/packages/app-website-builder/src/presentation/pages/PageEditor/PageEditor.tsx +++ b/packages/app-website-builder/src/presentation/pages/PageEditor/PageEditor.tsx @@ -1,34 +1,16 @@ import React, { useEffect, useState } from "react"; import { useRoute } from "@webiny/app-admin"; -import { DocumentEditor } from "~/DocumentEditor/DocumentEditor.js"; -import { useGetPage } from "~/features/pages/index.js"; -import { OverlayLoader } from "@webiny/admin-ui"; import { useFeature } from "@webiny/app"; +import { OverlayLoader } from "@webiny/admin-ui"; import { GetSettingsFeature } from "~/features/settings/getSettings/index.js"; -import { DefaultPageEditorConfig } from "./DefaultPageEditorConfig.js"; -import { DefaultEditorConfig } from "~/BaseEditor/index.js"; -import { EDITOR_NAME } from "~/presentation/pages/PageEditor/constants.js"; import type { EditorPage } from "@webiny/website-builder-sdk"; -import type { Page } from "~/domain/Page/index.js"; +import { useGetPage } from "~/features/pages/index.js"; import { Routes } from "~/routes.js"; -import { WbPageStatus } from "~/constants.js"; -import { RevisionListDrawer } from "./Revisions/RevisionListDrawer.js"; import { PageEditorDrawerProvider } from "./Revisions/usePageEditorDrawer.js"; - -const getPageDataFromPage = (page: Page): EditorPage => { - return { - ...page, - id: page.id, - version: page.version, - status: page.status, - location: page.location, - properties: page.properties as EditorPage["properties"], - bindings: page.bindings, - elements: page.elements, - metadata: page.metadata, - state: {} - }; -}; +import { PageEditorSurface } from "./PageEditorSurface.js"; +import { pageToEditorDocument } from "./pageDocument.js"; +import { ExperimentsEditorPresenterFeature } from "~/presentation/experiments/ExperimentsEditor/index.js"; +import { ExperimentsDrawerView } from "~/presentation/experiments/ExperimentsManager/index.js"; export const PageEditor = () => { const { useCase: getSettings } = useFeature(GetSettingsFeature); @@ -41,18 +23,28 @@ export const PageEditor = () => { const { getPage } = useGetPage(); + const { presenter } = useFeature(ExperimentsEditorPresenterFeature); + useEffect(() => { setLoading(true); Promise.all([ getSettings.execute(), getPage({ id: route.params.id }).then(page => { - setPage(getPageDataFromPage(page)); + setPage(pageToEditorDocument(page)); }) ]).then(() => { setLoading(false); }); }, [route.params.id]); + // Initialize the experiments hub presenter with the page being edited; it owns the shared + // selection state so the toolbar and editor surface can swap between the page and a variant. + useEffect(() => { + if (page) { + presenter.init(page.id); + } + }, [presenter, page?.id]); + if (loading || !page) { return ; } @@ -62,17 +54,8 @@ export const PageEditor = () => { openRevisionList={openRevisionList} isRevisionListOpen={isRevisionListOpen} > - - key={page.id} - document={page} - name={EDITOR_NAME} - readOnly={page.status !== WbPageStatus.Draft} - > - - - - - + + ); }; diff --git a/packages/app-website-builder/src/presentation/pages/PageEditor/PageEditorSurface.tsx b/packages/app-website-builder/src/presentation/pages/PageEditor/PageEditorSurface.tsx new file mode 100644 index 00000000000..39b357b00d3 --- /dev/null +++ b/packages/app-website-builder/src/presentation/pages/PageEditor/PageEditorSurface.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { useFeature } from "@webiny/app"; +import { OverlayLoader } from "@webiny/admin-ui"; +import type { EditorPage } from "@webiny/website-builder-sdk"; +import { DocumentEditor } from "~/DocumentEditor/DocumentEditor.js"; +import { DefaultEditorConfig } from "~/BaseEditor/index.js"; +import { EDITOR_NAME } from "~/presentation/pages/PageEditor/constants.js"; +import { WbPageStatus } from "~/constants.js"; +import { useGetPage } from "~/features/pages/index.js"; +import { DefaultPageEditorConfig } from "./DefaultPageEditorConfig.js"; +import { RevisionListDrawer } from "./Revisions/RevisionListDrawer.js"; +import { pageToEditorDocument } from "./pageDocument.js"; +import { ExperimentsEditorPresenterFeature } from "~/presentation/experiments/ExperimentsEditor/index.js"; +import { variantToEditorDocument } from "~/presentation/experiments/shared/variantDocument.js"; +import { VariantPageEditorConfig } from "~/presentation/experiments/VariantPageEditorConfig.js"; + +interface Props { + page: EditorPage; +} + +/** + * Renders the editor for the bucket currently selected in the experiments toolbar: the page itself + * (control), or a variant loaded as an equivalent document. Switching buckets re-mounts the editor + * with the matching document and autosave; content is (re)fetched on each switch so returning to a + * bucket always shows its latest saved state. + */ +export const PageEditorSurface = observer(function PageEditorSurface({ page }: Props) { + const { presenter } = useFeature(ExperimentsEditorPresenterFeature); + const { selectedVariant } = presenter.vm; + const { getPage } = useGetPage(); + + const readOnly = page.status !== WbPageStatus.Draft; + + const [activeDocument, setActiveDocument] = useState(page); + const [loading, setLoading] = useState(false); + // The initial control document is already loaded (passed in) — don't refetch it on first render. + const initial = useRef(true); + + useEffect(() => { + if (initial.current && !selectedVariant) { + initial.current = false; + return; + } + initial.current = false; + + let cancelled = false; + setLoading(true); + + const load: Promise = selectedVariant + ? presenter + .getVariant(selectedVariant.id) + .then(content => (content ? variantToEditorDocument(content, page) : null)) + : getPage({ id: page.id }).then(pageToEditorDocument); + + load.then(next => { + if (!cancelled && next) { + setActiveDocument(next); + } + }).finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedVariant, presenter, getPage, page]); + + if (loading) { + return ; + } + + return ( + + key={selectedVariant ? selectedVariant.id : page.id} + document={activeDocument} + name={EDITOR_NAME} + readOnly={readOnly} + > + + {selectedVariant ? ( + + ) : ( + <> + + + + )} + + ); +}); diff --git a/packages/app-website-builder/src/presentation/pages/PageEditor/pageDocument.ts b/packages/app-website-builder/src/presentation/pages/PageEditor/pageDocument.ts new file mode 100644 index 00000000000..a4759e8de44 --- /dev/null +++ b/packages/app-website-builder/src/presentation/pages/PageEditor/pageDocument.ts @@ -0,0 +1,18 @@ +import type { EditorPage } from "@webiny/website-builder-sdk"; +import type { Page } from "~/domain/Page/index.js"; + +/** Map a loaded page revision onto the editor document shape. */ +export const pageToEditorDocument = (page: Page): EditorPage => { + return { + ...page, + id: page.id, + version: page.version, + status: page.status, + location: page.location, + properties: page.properties as EditorPage["properties"], + bindings: page.bindings, + elements: page.elements, + metadata: page.metadata, + state: {} + }; +}; diff --git a/packages/website-builder-nextjs/package.json b/packages/website-builder-nextjs/package.json index 2c240b27562..1efa5e792c6 100644 --- a/packages/website-builder-nextjs/package.json +++ b/packages/website-builder-nextjs/package.json @@ -8,6 +8,7 @@ }, "exports": { ".": "./index.js", + "./middleware": "./middleware.js", "./webpack": "./webpack.js" }, "description": "A Next.js integration SDK for Webiny Website Builder.", diff --git a/packages/website-builder-nextjs/src/middleware.ts b/packages/website-builder-nextjs/src/middleware.ts new file mode 100644 index 00000000000..6cb34e4ed1a --- /dev/null +++ b/packages/website-builder-nextjs/src/middleware.ts @@ -0,0 +1,107 @@ +/** + * Next.js middleware for Website Builder. + * + * Handles the two request-time concerns an app would otherwise hand-write: + * - **Preview / draft mode** — routes `?wb.preview`/`?wb.editing` requests through the draft-mode + * route and disables caching while previewing; exits preview when the flag is gone. + * - **A/B visitor cookie** — sets a stable, PII-free `wb_ab_vid` cookie so variant bucketing is + * deterministic across visits. + * + * `next` is a peer dependency and is imported dynamically so building this package never requires + * it. Drop into a project as: + * + * // middleware.ts + * export { middleware, config } from "@webiny/website-builder-nextjs/middleware"; + */ + +const DEFAULT_PREVIEW_ROUTE = "/api/preview"; +const DEFAULT_VISITOR_COOKIE = "wb_ab_vid"; +const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; + +// Minimal structural view of NextRequest — avoids a static `next` type import. +interface MiddlewareRequest { + url: string; + nextUrl: { searchParams: URLSearchParams; pathname: string }; + headers: Headers; + cookies: { get(name: string): { value: string } | undefined }; +} + +export interface WebsiteBuilderMiddlewareOptions { + /** Route that enables Next.js draft mode for preview. Defaults to `/api/preview`. */ + previewRoute?: string; + /** A/B bucketing cookie name. Defaults to `wb_ab_vid`. */ + visitorCookieName?: string; + /** A/B bucketing cookie max-age in seconds. Defaults to one year. */ + visitorCookieMaxAge?: number; +} + +export function createWebsiteBuilderMiddleware(options: WebsiteBuilderMiddlewareOptions = {}) { + const previewRoute = options.previewRoute ?? DEFAULT_PREVIEW_ROUTE; + const cookieName = options.visitorCookieName ?? DEFAULT_VISITOR_COOKIE; + const cookieMaxAge = options.visitorCookieMaxAge ?? ONE_YEAR_SECONDS; + + return async function middleware(request: MiddlewareRequest) { + // @ts-ignore Peer dependency, resolved from the host app. + const { NextResponse } = await import("next/server"); + // @ts-ignore Peer dependency, resolved from the host app. + const { draftMode } = await import("next/headers"); + + const { searchParams } = request.nextUrl; + const previewRequested = + searchParams.get("wb.preview") === "true" || searchParams.get("wb.editing") === "true"; + + // Forward the tenant so downstream reads resolve against the right tenant. + const requestHeaders = new Headers(request.headers); + requestHeaders.set("X-Tenant", searchParams.get("wb.tenant") ?? "root"); + + const previewMode = await draftMode(); + + if (previewRequested) { + if (previewMode.isEnabled) { + // Already in draft mode — pass the preview params through and disable caching. + const response = NextResponse.next({ request: { headers: requestHeaders } }); + response.headers.set("X-Preview-Params", searchParams.toString()); + response.headers.set( + "Cache-Control", + "no-store, no-cache, must-revalidate, proxy-revalidate" + ); + response.headers.set("Pragma", "no-cache"); + response.headers.set("Expires", "0"); + return response; + } + // Not in draft mode yet — hand off to the route that enables it, keeping all params. + const url = new URL(request.url); + url.pathname = previewRoute; + return NextResponse.redirect(url); + } + + if (previewMode.isEnabled) { + // Preview flag gone but draft mode still on — exit preview. + previewMode.disable(); + return NextResponse.redirect(request.url); + } + + const response = NextResponse.next({ request: { headers: requestHeaders } }); + + // Stable visitor id for deterministic A/B bucketing (functional cookie, no PII). Remove or + // gate this if your consent policy requires opt-in — bucketing still works per-request + // without it, just not stably across visits. + if (!request.cookies.get(cookieName)) { + response.cookies.set(cookieName, crypto.randomUUID(), { + maxAge: cookieMaxAge, + path: "/", + sameSite: "lax" + }); + } + + return response; + }; +} + +/** Turnkey middleware with default options. */ +export const middleware = createWebsiteBuilderMiddleware(); + +/** Default matcher: everything except Next internals, API routes, and static assets. */ +export const config = { + matcher: ["/((?!_next|api|static|favicon.ico|.well-known).*)"] +}; diff --git a/packages/website-builder-react/src/index.ts b/packages/website-builder-react/src/index.ts index 856ec02e021..1ac9c7e8d34 100644 --- a/packages/website-builder-react/src/index.ts +++ b/packages/website-builder-react/src/index.ts @@ -23,6 +23,29 @@ export { setHeadersProvider, getHeadersProvider, registerComponentGroup, + // A/B experiments — server-side variant serving. + getPageWithExperiment, + resolveVisitorContext, + assignVariant, + forcedAssignment, + registerAnalyticsProvider, + getAnalyticsProvider, + FORCED_VARIANT_PARAM, + DEFAULT_VISITOR_COOKIE, + CONTROL_VARIANT_ID, + type ActiveExperiment, + type ActiveExperimentVariant, + type VariantContent, + type VariantAssignment, + type VisitorContext, + type DeviceType, + type ExperimentTrafficSplit, + type ExperimentTargeting, + type ExperimentAnalyticsConfig, + type ExperimentSdk, + type ExperimentRenderResult, + type GetPageWithExperimentOptions, + type ExperimentCookie, type CssProperties, type Document, type DocumentElement, diff --git a/packages/website-builder-sdk/src/ContentSdk.ts b/packages/website-builder-sdk/src/ContentSdk.ts index cc4628f7bba..7704b5c336e 100644 --- a/packages/website-builder-sdk/src/ContentSdk.ts +++ b/packages/website-builder-sdk/src/ContentSdk.ts @@ -1,12 +1,19 @@ import type { Component, IContentSdk, + IDataProvider, ListPagesOptions, ListPagesResult, PublicPage, PublicRedirect, ResolvedComponent } from "~/types.js"; +import type { ActiveExperiment, VariantContent } from "~/experiments/types.js"; +import { + getPageWithExperiment, + type ExperimentRenderResult, + type GetPageWithExperimentOptions +} from "~/experiments/render.js"; import { environment } from "./Environment.js"; import { LiveSdk } from "./LiveSdk.js"; import { EditingSdk } from "./EditingSdk.js"; @@ -69,6 +76,7 @@ export class ContentSdk implements IContentSdk, IRedirects { protected sdk?: InternalContentSdk; private isPreview = false; private lastConfig: any; + private dataProvider?: IDataProvider; public init(config: ContentSDKConfig, afterInit?: () => void): void { const configHash = JSON.stringify(config); @@ -85,6 +93,7 @@ export class ContentSdk implements IContentSdk, IRedirects { }); const dataProvider = new DefaultDataProvider({ apiClient }); + this.dataProvider = dataProvider; let liveSdk: IContentSdk = new LiveSdk(dataProvider); @@ -125,6 +134,41 @@ export class ContentSdk implements IContentSdk, IRedirects { return this.sdk.getPage(path); } + public getPageExperiment(path: string): Promise { + return this.requireDataProvider().getPageExperiment(path); + } + + public getVariantContent(variantId: string): Promise { + return this.requireDataProvider().getVariantContent(variantId); + } + + public getExperimentPaused(experimentId: string): Promise { + return this.requireDataProvider().getExperimentPaused(experimentId); + } + + /** + * Resolve and render the right page for the current visitor (control or a variant), + * server-side. Bucketing, targeting, exposure emission, and cache-key handling are all + * encapsulated here so projects do not reimplement them. See {@link getPageWithExperiment}. + */ + public getPageWithExperiment( + path: string, + options?: GetPageWithExperimentOptions + ): Promise { + this.assertInitialized(); + this.requireDataProvider(); + return getPageWithExperiment( + { + getPage: p => this.getPage(p), + getPageExperiment: p => this.getPageExperiment(p), + getVariantContent: id => this.getVariantContent(id), + getExperimentPaused: id => this.getExperimentPaused(id) + }, + path, + options + ); + } + public listPages(options?: ListPagesOptions) { this.assertInitialized(); return this.sdk.listPages(options); @@ -165,6 +209,13 @@ export class ContentSdk implements IContentSdk, IRedirects { throw new Error(`ContentSdk has not been initialized!`); } } + + private requireDataProvider(): IDataProvider { + if (!this.dataProvider) { + throw new Error(`ContentSdk has not been initialized!`); + } + return this.dataProvider; + } } export const contentSdk = new ContentSdk(); diff --git a/packages/website-builder-sdk/src/dataProviders/ApiClient.ts b/packages/website-builder-sdk/src/dataProviders/ApiClient.ts index 721dc36994d..12f72eb771f 100644 --- a/packages/website-builder-sdk/src/dataProviders/ApiClient.ts +++ b/packages/website-builder-sdk/src/dataProviders/ApiClient.ts @@ -35,12 +35,12 @@ export class ApiClient { }).then(res => res.json()); } - async query({ query, variables }: QueryParams) { - const fetchOptions = { - next: { - revalidate: this.config.preview ? 0 : 60 - } - }; + async query({ query, variables }: QueryParams, options?: { noStore?: boolean }) { + // Runtime reads (e.g. the experiment kill-switch) must bypass the cache so they take + // effect immediately; everything else caches and revalidates on an interval. + const fetchOptions: Pick, "cache" | "next"> = options?.noStore + ? { cache: "no-store" } + : { next: { revalidate: this.config.preview ? 0 : 60 } }; const request: WithPath = { ...fetchOptions, diff --git a/packages/website-builder-sdk/src/dataProviders/DefaultDataProvider.ts b/packages/website-builder-sdk/src/dataProviders/DefaultDataProvider.ts index 63239c417cf..9de25f3d981 100644 --- a/packages/website-builder-sdk/src/dataProviders/DefaultDataProvider.ts +++ b/packages/website-builder-sdk/src/dataProviders/DefaultDataProvider.ts @@ -1,8 +1,12 @@ import type { IDataProvider, ListPagesOptions, ListPagesResult, PublicPage } from "~/types.js"; +import type { ActiveExperiment, VariantContent } from "~/experiments/types.js"; import type { ApiClient } from "~/dataProviders/ApiClient.js"; import { GET_PAGE_BY_PATH } from "./GET_PAGE_BY_PATH.js"; import { GET_PAGE_BY_ID } from "./GET_PAGE_BY_ID.js"; import { LIST_PUBLISHED_PAGES } from "./LIST_PUBLISHED_PAGES.js"; +import { GET_PAGE_EXPERIMENT } from "./GET_PAGE_EXPERIMENT.js"; +import { GET_VARIANT_CONTENT } from "./GET_VARIANT_CONTENT.js"; +import { GET_EXPERIMENT_PAUSED } from "./GET_EXPERIMENT_PAUSED.js"; interface DefaultDataProviderConfig { apiClient: ApiClient; @@ -69,6 +73,54 @@ export class DefaultDataProvider implements IDataProvider { }; } + public async getPageExperiment(path: string): Promise { + const result = await this.config.apiClient.query({ + query: GET_PAGE_EXPERIMENT, + variables: { + path + } + }); + + this.checkForErrors(`getPageExperiment:${path}`, result.websiteBuilder.getPageExperiment); + + return result.websiteBuilder.getPageExperiment.data ?? null; + } + + public async getVariantContent(variantId: string): Promise { + const result = await this.config.apiClient.query({ + query: GET_VARIANT_CONTENT, + variables: { + id: variantId + } + }); + + this.checkForErrors( + `getVariantContent:${variantId}`, + result.websiteBuilder.getVariantContent + ); + + return result.websiteBuilder.getVariantContent.data ?? null; + } + + public async getExperimentPaused(experimentId: string): Promise { + // Uncached: the kill-switch must take effect immediately, even while page/variant content + // stays cached. + const result = await this.config.apiClient.query( + { + query: GET_EXPERIMENT_PAUSED, + variables: { experimentId } + }, + { noStore: true } + ); + + this.checkForErrors( + `getExperimentPaused:${experimentId}`, + result.websiteBuilder.getExperimentPaused + ); + + return result.websiteBuilder.getExperimentPaused.data === true; + } + private checkForErrors(action: string, data: any) { if (data.error) { // TODO: investigate how these ignored actions make their way to the SDK. diff --git a/packages/website-builder-sdk/src/dataProviders/GET_EXPERIMENT_PAUSED.ts b/packages/website-builder-sdk/src/dataProviders/GET_EXPERIMENT_PAUSED.ts new file mode 100644 index 00000000000..da153447cf7 --- /dev/null +++ b/packages/website-builder-sdk/src/dataProviders/GET_EXPERIMENT_PAUSED.ts @@ -0,0 +1,14 @@ +export const GET_EXPERIMENT_PAUSED = /* GraphQL*/ ` + query GetExperimentPaused($experimentId: String!) { + websiteBuilder { + getExperimentPaused(experimentId: $experimentId) { + data + error { + code + message + data + } + } + } + } +`; diff --git a/packages/website-builder-sdk/src/dataProviders/GET_PAGE_EXPERIMENT.ts b/packages/website-builder-sdk/src/dataProviders/GET_PAGE_EXPERIMENT.ts new file mode 100644 index 00000000000..120d17fc838 --- /dev/null +++ b/packages/website-builder-sdk/src/dataProviders/GET_PAGE_EXPERIMENT.ts @@ -0,0 +1,29 @@ +export const GET_PAGE_EXPERIMENT = /* GraphQL*/ ` + query GetPageExperiment($path: String!) { + websiteBuilder { + getPageExperiment(path: $path) { + data { + experimentId + revisionId + pageEntryId + path + status + controlVariantId + tenantId + trafficSplit + targeting + analytics + variants { + variantId + name + } + } + error { + code + message + data + } + } + } + } +`; diff --git a/packages/website-builder-sdk/src/dataProviders/GET_VARIANT_CONTENT.ts b/packages/website-builder-sdk/src/dataProviders/GET_VARIANT_CONTENT.ts new file mode 100644 index 00000000000..d438e526017 --- /dev/null +++ b/packages/website-builder-sdk/src/dataProviders/GET_VARIANT_CONTENT.ts @@ -0,0 +1,21 @@ +export const GET_VARIANT_CONTENT = /* GraphQL*/ ` + query GetVariantContent($id: ID!) { + websiteBuilder { + getVariantContent(id: $id) { + data { + id + properties + elements + bindings + extensions + metadata + } + error { + code + message + data + } + } + } + } +`; diff --git a/packages/website-builder-sdk/src/dataProviders/NullDataProvider.ts b/packages/website-builder-sdk/src/dataProviders/NullDataProvider.ts index e46970474a8..21b5d3fd0e8 100644 --- a/packages/website-builder-sdk/src/dataProviders/NullDataProvider.ts +++ b/packages/website-builder-sdk/src/dataProviders/NullDataProvider.ts @@ -1,5 +1,6 @@ import type { IDataProvider, PublicRedirect } from "~/types.js"; import type { PublicPage } from "~/types.js"; +import type { ActiveExperiment, VariantContent } from "~/experiments/types.js"; export class NullDataProvider implements IDataProvider { getPageById(): Promise { @@ -10,6 +11,18 @@ export class NullDataProvider implements IDataProvider { return Promise.resolve(null); } + getPageExperiment(): Promise { + return Promise.resolve(null); + } + + getVariantContent(): Promise { + return Promise.resolve(null); + } + + getExperimentPaused(): Promise { + return Promise.resolve(false); + } + public async listPages() { return { data: [], meta: { hasMoreItems: false, totalCount: 0, cursor: null } }; } diff --git a/packages/website-builder-sdk/src/experiments/PostHogAnalyticsProvider.ts b/packages/website-builder-sdk/src/experiments/PostHogAnalyticsProvider.ts new file mode 100644 index 00000000000..9dcb94c5348 --- /dev/null +++ b/packages/website-builder-sdk/src/experiments/PostHogAnalyticsProvider.ts @@ -0,0 +1,56 @@ +import type { AnalyticsProvider, ExposureEvent } from "./types.js"; + +/** + * The minimal shape of a PostHog client (server-side `posthog-node` or browser `posthog-js`). + * The project supplies its own client so this package stays dependency-free and framework + * agnostic — no PostHog package is bundled into the SDK. + */ +export interface PostHogClientLike { + capture(params: { + distinctId: string; + event: string; + properties?: Record; + }): void | Promise; +} + +export interface PostHogAnalyticsProviderOptions { + /** Event name to emit. Defaults to "$experiment_exposure". */ + exposureEventName?: string; +} + +/** + * Maps the canonical {@link ExposureEvent} onto a PostHog capture call. PostHog attributes + * experiment results from the `$feature/` property carried on the exposure + * event (and, project-side, on subsequent metric events) — no native PostHog feature flag is + * required. This is the ONLY place PostHog-specific field names exist; they never reach the + * data model, the assignment logic, or the render path. + */ +export class PostHogAnalyticsProvider implements AnalyticsProvider { + public readonly name = "posthog"; + + constructor( + private client: PostHogClientLike, + private options: PostHogAnalyticsProviderOptions = {} + ) {} + + async emitExposure(event: ExposureEvent): Promise { + const featureKey = event.experimentKey ?? event.experimentId; + + await this.client.capture({ + distinctId: event.visitorId, + event: this.options.exposureEventName ?? "$experiment_exposure", + properties: { + // PostHog reads this property to attribute exposures and metrics to a variant. + [`$feature/${featureKey}`]: event.variantId, + experiment_id: event.experimentId, + variant_id: event.variantId, + revision_id: event.revisionId, + tenant_id: event.tenantId, + page_id: event.pageId, + path: event.path, + $current_url: event.path, + timestamp: event.timestamp + } + }); + } +} diff --git a/packages/website-builder-sdk/src/experiments/analyticsProvider.ts b/packages/website-builder-sdk/src/experiments/analyticsProvider.ts new file mode 100644 index 00000000000..229f725a86b --- /dev/null +++ b/packages/website-builder-sdk/src/experiments/analyticsProvider.ts @@ -0,0 +1,20 @@ +import type { AnalyticsProvider } from "./types.js"; + +/** + * A small registry mapping a provider id (e.g. "posthog") to its adapter. The render path + * looks the provider up by the experiment's analytics.provider value, so adding a GA4 adapter + * later requires no change to assignment or the render path. + */ +const providers = new Map(); + +export const registerAnalyticsProvider = (provider: AnalyticsProvider): void => { + providers.set(provider.name, provider); +}; + +export const getAnalyticsProvider = (name: string): AnalyticsProvider | undefined => { + return providers.get(name); +}; + +export const clearAnalyticsProviders = (): void => { + providers.clear(); +}; diff --git a/packages/website-builder-sdk/src/experiments/bucketing.test.ts b/packages/website-builder-sdk/src/experiments/bucketing.test.ts new file mode 100644 index 00000000000..23cb15fa40c --- /dev/null +++ b/packages/website-builder-sdk/src/experiments/bucketing.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { assignVariant, forcedAssignment, hashToUnit, matchesTargeting } from "./bucketing.js"; +import { CONTROL_VARIANT_ID, type ActiveExperiment, type VisitorContext } from "./types.js"; + +const experiment = (overrides: Partial = {}): ActiveExperiment => ({ + experimentId: "exp-1", + revisionId: "page-1#0001", + pageEntryId: "page-1", + path: "/pricing", + status: "running", + tenantId: "root", + controlVariantId: CONTROL_VARIANT_ID, + trafficSplit: { control: 50, variants: { "variant-a": 50 } }, + targeting: { trafficPercentage: 100 }, + analytics: { provider: "posthog" }, + variants: [{ variantId: "variant-a", name: "Variant A" }], + ...overrides +}); + +const visitor = (overrides: Partial = {}): VisitorContext => ({ + visitorId: "visitor-1", + ...overrides +}); + +describe("hashToUnit", () => { + it("is deterministic and within [0, 1)", () => { + const a = hashToUnit("visitor-1:exp-1"); + const b = hashToUnit("visitor-1:exp-1"); + expect(a).toBe(b); + expect(a).toBeGreaterThanOrEqual(0); + expect(a).toBeLessThan(1); + }); + + it("differs across seeds", () => { + expect(hashToUnit("visitor-1:exp-1")).not.toBe(hashToUnit("visitor-2:exp-1")); + }); +}); + +describe("assignVariant", () => { + it("assigns the same visitor to the same bucket every time", () => { + const first = assignVariant(experiment(), visitor()); + const second = assignVariant(experiment(), visitor()); + expect(second).toEqual(first); + }); + + it("buckets the same visitor differently across experiments", () => { + const a = assignVariant(experiment({ experimentId: "exp-1" }), visitor()); + const b = assignVariant(experiment({ experimentId: "exp-2" }), visitor()); + // Not guaranteed to differ, but the seed differs; assert the seeds are independent. + expect(hashToUnit("visitor-1:exp-1")).not.toBe(hashToUnit("visitor-1:exp-2")); + expect([a.variantId, b.variantId].every(Boolean)).toBe(true); + }); + + it("respects the traffic split distribution", () => { + const exp = experiment({ + trafficSplit: { control: 80, variants: { "variant-a": 20 } } + }); + let control = 0; + let variant = 0; + for (let i = 0; i < 5000; i++) { + const assignment = assignVariant(exp, visitor({ visitorId: `v-${i}` })); + if (assignment.isControl) { + control++; + } else { + variant++; + } + } + const variantShare = variant / (control + variant); + // Expect ~20% in the variant, allow a generous tolerance. + expect(variantShare).toBeGreaterThan(0.15); + expect(variantShare).toBeLessThan(0.25); + }); + + it("excludes visitors outside the traffic percentage and serves control", () => { + const exp = experiment({ targeting: { trafficPercentage: 0 } }); + const assignment = assignVariant(exp, visitor()); + expect(assignment.isControl).toBe(true); + expect(assignment.excluded).toBe(true); + }); + + it("excludes visitors who do not match geo targeting", () => { + const exp = experiment({ targeting: { trafficPercentage: 100, geo: ["GB"] } }); + const assignment = assignVariant(exp, visitor({ country: "US" })); + expect(assignment.excluded).toBe(true); + expect(assignment.isControl).toBe(true); + }); + + it("ignores variants that are not ready", () => { + const exp = experiment({ + trafficSplit: { control: 0, variants: { "variant-a": 50, "variant-ghost": 50 } }, + variants: [{ variantId: "variant-a", name: "Variant A" }] + }); + for (let i = 0; i < 200; i++) { + const assignment = assignVariant(exp, visitor({ visitorId: `v-${i}` })); + expect(assignment.variantId).not.toBe("variant-ghost"); + } + }); +}); + +describe("matchesTargeting", () => { + it("matches when no geo/device restriction is set", () => { + expect(matchesTargeting({ trafficPercentage: 100 }, visitor())).toBe(true); + }); + + it("matches device targeting", () => { + expect( + matchesTargeting( + { trafficPercentage: 100, device: ["mobile"] }, + visitor({ device: "mobile" }) + ) + ).toBe(true); + expect( + matchesTargeting( + { trafficPercentage: 100, device: ["mobile"] }, + visitor({ device: "desktop" }) + ) + ).toBe(false); + }); +}); + +describe("forcedAssignment", () => { + it("forces a ready variant and marks it as not counted", () => { + const assignment = forcedAssignment(experiment(), "variant-a"); + expect(assignment).toEqual({ + variantId: "variant-a", + isControl: false, + excluded: false, + forced: true + }); + }); + + it("forces the control bucket", () => { + const assignment = forcedAssignment(experiment(), CONTROL_VARIANT_ID); + expect(assignment?.isControl).toBe(true); + expect(assignment?.forced).toBe(true); + }); + + it("returns null for an unknown variant", () => { + expect(forcedAssignment(experiment(), "nope")).toBeNull(); + }); +}); diff --git a/packages/website-builder-sdk/src/experiments/bucketing.ts b/packages/website-builder-sdk/src/experiments/bucketing.ts new file mode 100644 index 00000000000..45441279f70 --- /dev/null +++ b/packages/website-builder-sdk/src/experiments/bucketing.ts @@ -0,0 +1,136 @@ +import type { + ActiveExperiment, + ExperimentTargeting, + VariantAssignment, + VisitorContext +} from "./types.js"; + +/** + * cyrb53 — a fast, well-distributed non-cryptographic string hash. Deterministic across + * runtimes, which is what makes server-side bucketing stable for a given visitor + experiment. + */ +export const cyrb53 = (str: string, seed = 0): number => { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; + +/** Hash a seed to a stable number in the half-open interval [0, 1). */ +export const hashToUnit = (seed: string): number => { + return cyrb53(seed) / 9007199254740992; +}; + +/** + * Whether a visitor matches the experiment's geo/device targeting. Tenant scoping is implicit: + * the experiment id is unique within a tenant, so a visitor's bucket never crosses tenants. + */ +export const matchesTargeting = (targeting: ExperimentTargeting, ctx: VisitorContext): boolean => { + if (targeting.geo && targeting.geo.length > 0) { + if (!ctx.country || !targeting.geo.includes(ctx.country)) { + return false; + } + } + if (targeting.device && targeting.device.length > 0) { + if (!ctx.device || !targeting.device.includes(ctx.device)) { + return false; + } + } + return true; +}; + +/** + * Deterministically assign a visitor to the control bucket or a variant. + * + * 1. If geo/device targeting excludes the visitor → control, excluded (no exposure). + * 2. If the visitor falls outside the traffic percentage → control, excluded (no exposure). + * 3. Otherwise bucket among the control + variants by their weights, seeded by + * visitorId + experimentId so the result is stable and tenant-isolated. + */ +export const assignVariant = ( + experiment: ActiveExperiment, + ctx: VisitorContext +): VariantAssignment => { + const controlVariantId = experiment.controlVariantId; + const control: VariantAssignment = { + variantId: controlVariantId, + isControl: true, + excluded: true, + forced: false + }; + + if (!matchesTargeting(experiment.targeting, ctx)) { + return control; + } + + const trafficPercentage = experiment.targeting.trafficPercentage ?? 100; + const inclusion = hashToUnit(`${ctx.visitorId}:${experiment.experimentId}:inclusion`); + if (inclusion >= trafficPercentage / 100) { + return control; + } + + // Build the bucket list: control first, then every variant that is both ready (present in + // experiment.variants) and assigned a weight in the traffic split. + const readyVariantIds = new Set(experiment.variants.map(variant => variant.variantId)); + const buckets: Array<{ id: string; weight: number }> = [ + { id: controlVariantId, weight: Math.max(0, experiment.trafficSplit.control) } + ]; + for (const [variantId, weight] of Object.entries(experiment.trafficSplit.variants)) { + if (readyVariantIds.has(variantId) && weight > 0) { + buckets.push({ id: variantId, weight }); + } + } + + const totalWeight = buckets.reduce((sum, bucket) => sum + bucket.weight, 0); + if (totalWeight <= 0) { + return { variantId: controlVariantId, isControl: true, excluded: false, forced: false }; + } + + let roll = hashToUnit(`${ctx.visitorId}:${experiment.experimentId}`) * totalWeight; + for (const bucket of buckets) { + roll -= bucket.weight; + if (roll < 0) { + return { + variantId: bucket.id, + isControl: bucket.id === controlVariantId, + excluded: false, + forced: false + }; + } + } + + // Floating-point edge: fall back to the last bucket. + const last = buckets[buckets.length - 1]; + return { + variantId: last.id, + isControl: last.id === controlVariantId, + excluded: false, + forced: false + }; +}; + +/** + * A forced assignment for QA/review. Overrides bucketing and is never counted in results. + * Returns null when the forced id does not match the control or any ready variant. + */ +export const forcedAssignment = ( + experiment: ActiveExperiment, + forcedVariantId: string +): VariantAssignment | null => { + if (forcedVariantId === experiment.controlVariantId) { + return { variantId: forcedVariantId, isControl: true, excluded: false, forced: true }; + } + const isReady = experiment.variants.some(variant => variant.variantId === forcedVariantId); + if (!isReady) { + return null; + } + return { variantId: forcedVariantId, isControl: false, excluded: false, forced: true }; +}; diff --git a/packages/website-builder-sdk/src/experiments/index.ts b/packages/website-builder-sdk/src/experiments/index.ts new file mode 100644 index 00000000000..071623f4ca6 --- /dev/null +++ b/packages/website-builder-sdk/src/experiments/index.ts @@ -0,0 +1,5 @@ +export * from "./types.js"; +export * from "./bucketing.js"; +export * from "./analyticsProvider.js"; +export * from "./PostHogAnalyticsProvider.js"; +export * from "./render.js"; diff --git a/packages/website-builder-sdk/src/experiments/render.ts b/packages/website-builder-sdk/src/experiments/render.ts new file mode 100644 index 00000000000..ea3fdad2e5e --- /dev/null +++ b/packages/website-builder-sdk/src/experiments/render.ts @@ -0,0 +1,288 @@ +import { nanoid } from "nanoid"; +import type { PublicPage } from "~/types.js"; +import { getHeadersProvider } from "~/headersProvider.js"; +import { assignVariant, forcedAssignment } from "./bucketing.js"; +import { getAnalyticsProvider } from "./analyticsProvider.js"; +import type { + ActiveExperiment, + DeviceType, + VariantAssignment, + VariantContent, + VisitorContext +} from "./types.js"; + +/** The cookie that persists the stable visitor id used for deterministic bucketing. */ +export const DEFAULT_VISITOR_COOKIE = "wb_ab_vid"; + +/** Default query parameter used to force a variant for QA/review. */ +export const FORCED_VARIANT_PARAM = "wb-variant"; + +const DEFAULT_GEO_HEADERS = [ + "x-vercel-ip-country", + "cloudfront-viewer-country", + "cf-ipcountry", + "x-country-code" +]; + +/** The minimal data surface the render helper needs. ContentSdk satisfies this. */ +export interface ExperimentSdk { + getPage(path: string): Promise; + getPageExperiment(path: string): Promise; + getVariantContent(variantId: string): Promise; + /** Runtime kill-switch check. When omitted, the pause state is not consulted. */ + getExperimentPaused?(experimentId: string): Promise; +} + +export interface ExperimentCookie { + name: string; + value: string; + maxAge: number; +} + +export interface GetPageWithExperimentOptions { + /** Forced variant id (from the `?wb-variant=` query parameter). Overrides bucketing, never counted. */ + forcedVariantId?: string; + /** + * Request query parameters. When provided, the forced variant is read from + * `searchParams[FORCED_VARIANT_PARAM]` unless `forcedVariantId` is set explicitly. Convenient + * for framework handlers that already have the parsed query (e.g. Next.js `searchParams`). + */ + searchParams?: Record; + /** Explicit visitor id. When omitted, it is read from the cookie or generated. */ + visitorId?: string; + /** Explicit ISO country code. When omitted, it is read from common CDN geo headers. */ + country?: string; + /** Explicit device type. When omitted, it is derived from the user agent. */ + device?: DeviceType; + /** Header names to read the visitor country from, in priority order. */ + geoHeaders?: string[]; + /** Bucketing cookie name. Defaults to `wb_ab_vid`. */ + cookieName?: string; + /** Bucketing cookie max age in seconds. Defaults to one year. */ + cookieMaxAge?: number; + /** Set false to skip exposure emission (e.g. when consent has not been granted). */ + emitExposure?: boolean; + /** Visitor id generator. Defaults to nanoid. */ + generateVisitorId?: () => string; +} + +export interface ExperimentRenderResult { + /** The content to render server-side: the baseline (control) or a variant overlay. */ + page: PublicPage | null; + /** The active experiment, or null when there is none for the path. */ + experiment: ActiveExperiment | null; + /** The visitor's assignment, or null when no experiment applies. */ + assignment: VariantAssignment | null; + /** + * Set when the caller should persist the bucketing cookie (i.e. a visitor id was generated + * this request). Persisting it in a Route Handler / middleware keeps bucketing stable. + */ + cookie?: ExperimentCookie; +} + +const readCookie = (cookieHeader: string | null | undefined, name: string): string | undefined => { + if (!cookieHeader) { + return undefined; + } + for (const part of cookieHeader.split(";")) { + const index = part.indexOf("="); + if (index === -1) { + continue; + } + const key = part.slice(0, index).trim(); + if (key === name) { + return decodeURIComponent(part.slice(index + 1).trim()); + } + } + return undefined; +}; + +const detectDevice = (userAgent: string | null | undefined): DeviceType | undefined => { + if (!userAgent) { + return undefined; + } + if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) { + return "tablet"; + } + if (/Mobi|Android.+Mobile|iPhone|iPod|Windows Phone/i.test(userAgent)) { + return "mobile"; + } + return "desktop"; +}; + +/** + * Resolve request-time visitor context from request headers/cookies. Never uses a logged-in + * identity. Returns whether a visitor id was generated, so the caller can persist the cookie. + */ +export const resolveVisitorContext = async ( + options: GetPageWithExperimentOptions = {} +): Promise<{ context: VisitorContext; generatedVisitorId: boolean }> => { + const cookieName = options.cookieName ?? DEFAULT_VISITOR_COOKIE; + const generate = options.generateVisitorId ?? (() => nanoid()); + + let headers: Headers | undefined; + const provider = getHeadersProvider(); + if (provider) { + try { + headers = await provider(); + } catch { + headers = undefined; + } + } + + let visitorId = options.visitorId; + let generatedVisitorId = false; + if (!visitorId) { + visitorId = readCookie(headers?.get("cookie"), cookieName); + } + if (!visitorId) { + visitorId = generate(); + generatedVisitorId = true; + } + + let country = options.country; + if (!country && headers) { + for (const header of options.geoHeaders ?? DEFAULT_GEO_HEADERS) { + const value = headers.get(header); + if (value) { + country = value.toUpperCase(); + break; + } + } + } + + const device = options.device ?? detectDevice(headers?.get("user-agent")); + + return { context: { visitorId, country, device }, generatedVisitorId }; +}; + +/** Forced variant id from an explicit option, falling back to the query parameter. */ +const resolveForcedVariantId = (options: GetPageWithExperimentOptions): string | undefined => { + if (options.forcedVariantId) { + return options.forcedVariantId; + } + const raw = options.searchParams?.[FORCED_VARIANT_PARAM]; + return Array.isArray(raw) ? raw[0] : raw; +}; + +const overlayVariant = (baseline: PublicPage, variant: VariantContent): PublicPage => { + return { + ...baseline, + properties: variant.properties as PublicPage["properties"], + bindings: variant.bindings as PublicPage["bindings"], + elements: variant.elements as PublicPage["elements"], + extensions: variant.extensions, + metadata: variant.metadata + }; +}; + +/** + * Resolve and render the right page for a visitor, server-side, with no client-side content + * swap. + * + * Caching contract (important — handled here so projects do not reimplement it): + * - The baseline (control) is fetched via `getPage(path)` and caches per path. + * - The active experiment metadata is fetched via `getPageExperiment(path)` and caches per path. + * - A variant's content is fetched via `getVariantContent(variantId)` and caches per variant id. + * Every request therefore resolves to one of a small, fixed set of cacheable objects keyed by + * (path) or (variant id) — NEVER by the raw visitor id. Each active variant adds exactly one + * cacheable object per URL. + */ +export const getPageWithExperiment = async ( + sdk: ExperimentSdk, + path: string, + options: GetPageWithExperimentOptions = {} +): Promise => { + // Baseline content + active experiment. Both cache per path. + const [controlPage, experiment] = await Promise.all([ + sdk.getPage(path), + sdk.getPageExperiment(path) + ]); + + if (!experiment || !controlPage) { + return { page: controlPage, experiment: experiment ?? null, assignment: null }; + } + + // Only a running experiment serves variants. `getPageExperiment` already returns the active + // one, but guard defensively. + if (experiment.status !== "running") { + return { page: controlPage, experiment, assignment: null }; + } + + // Kill-switch: a paused experiment instantly reverts to the control. Consulted uncached by the + // data provider so it takes effect immediately while page/variant content stays cached. + if (sdk.getExperimentPaused) { + const paused = await sdk.getExperimentPaused(experiment.experimentId).catch(() => false); + if (paused) { + return { page: controlPage, experiment, assignment: null }; + } + } + + const forcedVariantId = resolveForcedVariantId(options); + + const { context, generatedVisitorId } = await resolveVisitorContext(options); + + let assignment: VariantAssignment | null = null; + if (forcedVariantId) { + assignment = forcedAssignment(experiment, forcedVariantId); + } + if (!assignment) { + assignment = assignVariant(experiment, context); + } + + let page = controlPage; + if (!assignment.isControl) { + const variant = await sdk.getVariantContent(assignment.variantId); + if (variant) { + page = overlayVariant(controlPage, variant); + } else { + // The variant disappeared (e.g. deleted) — fall back to the control safely. + assignment = { + variantId: experiment.controlVariantId, + isControl: true, + excluded: assignment.excluded, + forced: assignment.forced + }; + } + } + + // Emit a single canonical exposure event through the provider seam. Forced and excluded + // assignments are never reported. Analytics failures must never break rendering. + const shouldEmit = (options.emitExposure ?? true) && !assignment.excluded && !assignment.forced; + if (shouldEmit) { + const provider = getAnalyticsProvider(experiment.analytics.provider); + if (provider) { + const experimentKey = + typeof experiment.analytics.experimentKey === "string" + ? experiment.analytics.experimentKey + : undefined; + try { + await provider.emitExposure({ + experimentId: experiment.experimentId, + experimentKey, + variantId: assignment.variantId, + revisionId: experiment.revisionId, + tenantId: experiment.tenantId, + visitorId: context.visitorId, + pageId: experiment.pageEntryId, + path: experiment.path, + timestamp: new Date().toISOString() + }); + } catch { + // Swallow — exposure measurement must never affect the rendered response. + } + } + } + + const result: ExperimentRenderResult = { page, experiment, assignment }; + + if (generatedVisitorId) { + result.cookie = { + name: options.cookieName ?? DEFAULT_VISITOR_COOKIE, + value: context.visitorId, + maxAge: options.cookieMaxAge ?? 60 * 60 * 24 * 365 + }; + } + + return result; +}; diff --git a/packages/website-builder-sdk/src/experiments/types.ts b/packages/website-builder-sdk/src/experiments/types.ts new file mode 100644 index 00000000000..1f25bf46f33 --- /dev/null +++ b/packages/website-builder-sdk/src/experiments/types.ts @@ -0,0 +1,119 @@ +/** + * The reserved variant id for the control bucket. The control bucket renders the baseline + * revision content; it has no Variant object of its own. + */ +export const CONTROL_VARIANT_ID = "control"; + +export type DeviceType = "desktop" | "mobile" | "tablet"; + +export interface ExperimentTrafficSplit { + control: number; + variants: Record; +} + +export interface ExperimentTargeting { + trafficPercentage: number; + geo?: string[]; + device?: DeviceType[]; +} + +/** + * Provider-agnostic analytics configuration carried by an experiment. Adapters read this and + * map it to their own shape. No provider-specific field name appears here. + */ +export interface ExperimentAnalyticsConfig { + provider: string; + [key: string]: unknown; +} + +export interface ActiveExperimentVariant { + variantId: string; + name: string; +} + +/** + * A variant's full content snapshot, shaped for rendering. Overlaid onto the baseline page so + * the result is a complete, renderable page. + */ +export interface VariantContent { + id: string; + properties: Record; + bindings: Record; + elements: Record; + extensions: Record; + metadata: Record; +} + +/** + * The active experiment for a path as returned by the Website Builder API. This is the only + * experiment shape the render path deals with — it is provider-agnostic. + */ +export interface ActiveExperiment { + experimentId: string; + revisionId: string; + pageEntryId: string; + path: string; + status: string; + tenantId: string; + controlVariantId: string; + trafficSplit: ExperimentTrafficSplit; + targeting: ExperimentTargeting; + analytics: ExperimentAnalyticsConfig; + variants: ActiveExperimentVariant[]; +} + +/** + * Request-time context used to bucket a visitor. Derived server-side from request headers / + * cookies — never from a logged-in identity. + */ +export interface VisitorContext { + /** A stable visitor id (from the bucketing cookie, or a generated id). */ + visitorId: string; + /** ISO country code, if the CDN/edge provided one. */ + country?: string; + /** Device type, derived from the user agent. */ + device?: DeviceType; +} + +export interface VariantAssignment { + /** The assigned variant id, or the control variant id. */ + variantId: string; + /** True when the control bucket (baseline revision) was assigned. */ + isControl: boolean; + /** + * True when the visitor was not entered into the experiment (targeting or traffic + * percentage). The control is served and no exposure is emitted. + */ + excluded: boolean; + /** True when a forced-variant query parameter overrode bucketing (QA). Never reported. */ + forced: boolean; +} + +/** + * The single canonical exposure event. Each analytics provider adapter maps from this into its + * own event shape. No PostHog (or other provider) field names may appear here. + */ +export interface ExposureEvent { + experimentId: string; + /** Optional human-readable experiment key for provider mapping (from analytics config). */ + experimentKey?: string; + /** The assigned variant id (the control variant id for the control bucket). */ + variantId: string; + revisionId: string; + tenantId: string; + /** Stable distinct visitor id. */ + visitorId: string; + pageId: string; + path: string; + timestamp: string; +} + +/** + * The provider seam. A v2 GA4 adapter is a drop-in implementation of this interface — the data + * model, assignment logic, and render path never change. + */ +export interface AnalyticsProvider { + /** Provider id, matched against an experiment's analytics.provider. */ + readonly name: string; + emitExposure(event: ExposureEvent): Promise; +} diff --git a/packages/website-builder-sdk/src/index.ts b/packages/website-builder-sdk/src/index.ts index 30a43b2781d..49baea7c301 100644 --- a/packages/website-builder-sdk/src/index.ts +++ b/packages/website-builder-sdk/src/index.ts @@ -21,6 +21,7 @@ export * from "./createElement.js"; export * from "./registerComponentGroup.js"; export * from "./jsonPatch.js"; export * from "./headersProvider.js"; +export * from "./experiments/index.js"; export * from "./ComponentManifestToAstConverter.js"; export * from "./findMatchingAstNode.js"; export * from "./InheritanceProcessor.js"; diff --git a/packages/website-builder-sdk/src/types.ts b/packages/website-builder-sdk/src/types.ts index 1a84d8537f0..9a9948213b0 100644 --- a/packages/website-builder-sdk/src/types.ts +++ b/packages/website-builder-sdk/src/types.ts @@ -3,6 +3,7 @@ import type { BindingsApi } from "~/BindingsApi.js"; import type { ShorthandCssProperties } from "./types/ShorthandCssProperties.js"; import type { InputFactory } from "~/createInput.js"; import type { Breakpoint } from "~/types/WebsiteBuilderTheme.js"; +import type { ActiveExperiment, VariantContent } from "~/experiments/types.js"; export type { WebsiteBuilderTheme, Breakpoint } from "./types/WebsiteBuilderTheme.js"; type CSSProperties = CSS.Properties; @@ -449,6 +450,9 @@ export interface IDataProvider { getPageByPath(path: string, options?: GetPageOptions): Promise; getPageById(id: string, options?: GetPageOptions): Promise; listPages(options?: ListPagesOptions): Promise; + getPageExperiment(path: string): Promise; + getVariantContent(variantId: string): Promise; + getExperimentPaused(experimentId: string): Promise; } export interface IEnvironment {