Implement comprehensive A/B testing system for landing page optimization: - Database models for experiments, variants, and events - AI-powered variant generation and analysis - Visitor tracking with conversion metrics - Experiment lifecycle management (draft/active/paused/completed) - Email notifications for experiment results - Cron job for automated experiment monitoring - UI for viewing experiment results and statistics
273 lines
8.6 KiB
Go
273 lines
8.6 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package pages
|
|
|
|
import (
|
|
"context"
|
|
|
|
"code.gitcaddy.com/server/v3/models/db"
|
|
"code.gitcaddy.com/server/v3/modules/timeutil"
|
|
)
|
|
|
|
// ExperimentStatus represents the status of an A/B test experiment.
|
|
type ExperimentStatus string
|
|
|
|
const (
|
|
ExperimentStatusDraft ExperimentStatus = "draft"
|
|
ExperimentStatusActive ExperimentStatus = "active"
|
|
ExperimentStatusPaused ExperimentStatus = "paused"
|
|
ExperimentStatusCompleted ExperimentStatus = "completed"
|
|
ExperimentStatusApproved ExperimentStatus = "approved"
|
|
)
|
|
|
|
func init() {
|
|
db.RegisterModel(new(PageExperiment))
|
|
db.RegisterModel(new(PageVariant))
|
|
db.RegisterModel(new(PageEvent))
|
|
}
|
|
|
|
// PageExperiment tracks an A/B test on a landing page.
|
|
type PageExperiment struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
|
Name string `xorm:"VARCHAR(255) NOT NULL"`
|
|
Status ExperimentStatus `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"`
|
|
CreatedByAI bool `xorm:"NOT NULL DEFAULT true"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
|
EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
|
|
|
Variants []*PageVariant `xorm:"-"`
|
|
}
|
|
|
|
// TableName returns the table name for PageExperiment.
|
|
func (e *PageExperiment) TableName() string {
|
|
return "page_experiment"
|
|
}
|
|
|
|
// PageVariant is one arm of an A/B test experiment.
|
|
type PageVariant struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
ExperimentID int64 `xorm:"INDEX NOT NULL"`
|
|
Name string `xorm:"VARCHAR(255) NOT NULL"`
|
|
IsControl bool `xorm:"NOT NULL DEFAULT false"`
|
|
Weight int `xorm:"NOT NULL DEFAULT 50"`
|
|
ConfigOverride string `xorm:"TEXT"`
|
|
Impressions int64 `xorm:"NOT NULL DEFAULT 0"`
|
|
Conversions int64 `xorm:"NOT NULL DEFAULT 0"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
|
}
|
|
|
|
// TableName returns the table name for PageVariant.
|
|
func (v *PageVariant) TableName() string {
|
|
return "page_variant"
|
|
}
|
|
|
|
// ConversionRate returns the conversion rate for this variant.
|
|
func (v *PageVariant) ConversionRate() float64 {
|
|
if v.Impressions == 0 {
|
|
return 0
|
|
}
|
|
return float64(v.Conversions) / float64(v.Impressions)
|
|
}
|
|
|
|
// PageEvent tracks visitor interactions with a landing page.
|
|
type PageEvent struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
|
VariantID int64 `xorm:"INDEX DEFAULT 0"`
|
|
ExperimentID int64 `xorm:"INDEX DEFAULT 0"`
|
|
VisitorID string `xorm:"VARCHAR(64) INDEX"`
|
|
EventType string `xorm:"VARCHAR(32) NOT NULL"`
|
|
EventData string `xorm:"TEXT"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
|
}
|
|
|
|
// TableName returns the table name for PageEvent.
|
|
func (e *PageEvent) TableName() string {
|
|
return "page_event"
|
|
}
|
|
|
|
// Valid event types
|
|
const (
|
|
EventTypeImpression = "impression"
|
|
EventTypeCTAClick = "cta_click"
|
|
EventTypeScrollDepth = "scroll_depth"
|
|
EventTypeClick = "click"
|
|
EventTypeStar = "star"
|
|
EventTypeFork = "fork"
|
|
EventTypeClone = "clone"
|
|
)
|
|
|
|
// CreateExperiment creates a new experiment.
|
|
func CreateExperiment(ctx context.Context, exp *PageExperiment) error {
|
|
_, err := db.GetEngine(ctx).Insert(exp)
|
|
return err
|
|
}
|
|
|
|
// GetExperimentByID returns an experiment by ID.
|
|
func GetExperimentByID(ctx context.Context, id int64) (*PageExperiment, error) {
|
|
exp := new(PageExperiment)
|
|
has, err := db.GetEngine(ctx).ID(id).Get(exp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !has {
|
|
return nil, nil
|
|
}
|
|
return exp, nil
|
|
}
|
|
|
|
// GetExperimentsByRepoID returns all experiments for a repository.
|
|
func GetExperimentsByRepoID(ctx context.Context, repoID int64) ([]*PageExperiment, error) {
|
|
experiments := make([]*PageExperiment, 0, 10)
|
|
return experiments, db.GetEngine(ctx).Where("repo_id = ?", repoID).
|
|
Desc("created_unix").Find(&experiments)
|
|
}
|
|
|
|
// GetActiveExperimentByRepoID returns the currently active experiment for a repo, if any.
|
|
func GetActiveExperimentByRepoID(ctx context.Context, repoID int64) (*PageExperiment, error) {
|
|
exp := new(PageExperiment)
|
|
has, err := db.GetEngine(ctx).
|
|
Where("repo_id = ? AND status = ?", repoID, ExperimentStatusActive).
|
|
Get(exp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !has {
|
|
return nil, nil
|
|
}
|
|
return exp, nil
|
|
}
|
|
|
|
// GetAllActiveExperiments returns all active experiments across all repos.
|
|
func GetAllActiveExperiments(ctx context.Context) ([]*PageExperiment, error) {
|
|
experiments := make([]*PageExperiment, 0, 50)
|
|
return experiments, db.GetEngine(ctx).
|
|
Where("status = ?", ExperimentStatusActive).
|
|
Find(&experiments)
|
|
}
|
|
|
|
// UpdateExperiment updates an experiment.
|
|
func UpdateExperiment(ctx context.Context, exp *PageExperiment) error {
|
|
_, err := db.GetEngine(ctx).ID(exp.ID).AllCols().Update(exp)
|
|
return err
|
|
}
|
|
|
|
// UpdateExperimentStatus updates just the status of an experiment.
|
|
func UpdateExperimentStatus(ctx context.Context, id int64, status ExperimentStatus) error {
|
|
_, err := db.GetEngine(ctx).ID(id).Cols("status").
|
|
Update(&PageExperiment{Status: status})
|
|
return err
|
|
}
|
|
|
|
// CreateVariant creates a new variant for an experiment.
|
|
func CreateVariant(ctx context.Context, variant *PageVariant) error {
|
|
_, err := db.GetEngine(ctx).Insert(variant)
|
|
return err
|
|
}
|
|
|
|
// GetVariantByID returns a variant by ID.
|
|
func GetVariantByID(ctx context.Context, id int64) (*PageVariant, error) {
|
|
variant := new(PageVariant)
|
|
has, err := db.GetEngine(ctx).ID(id).Get(variant)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !has {
|
|
return nil, nil
|
|
}
|
|
return variant, nil
|
|
}
|
|
|
|
// GetVariantsByExperimentID returns all variants for an experiment.
|
|
func GetVariantsByExperimentID(ctx context.Context, experimentID int64) ([]*PageVariant, error) {
|
|
variants := make([]*PageVariant, 0, 5)
|
|
return variants, db.GetEngine(ctx).
|
|
Where("experiment_id = ?", experimentID).
|
|
Find(&variants)
|
|
}
|
|
|
|
// IncrementVariantImpressions increments the impression counter for a variant.
|
|
func IncrementVariantImpressions(ctx context.Context, variantID int64) error {
|
|
_, err := db.GetEngine(ctx).Exec(
|
|
"UPDATE `page_variant` SET impressions = impressions + 1 WHERE id = ?", variantID)
|
|
return err
|
|
}
|
|
|
|
// IncrementVariantConversions increments the conversion counter for a variant.
|
|
func IncrementVariantConversions(ctx context.Context, variantID int64) error {
|
|
_, err := db.GetEngine(ctx).Exec(
|
|
"UPDATE `page_variant` SET conversions = conversions + 1 WHERE id = ?", variantID)
|
|
return err
|
|
}
|
|
|
|
// CreatePageEvent records a visitor event.
|
|
func CreatePageEvent(ctx context.Context, event *PageEvent) error {
|
|
_, err := db.GetEngine(ctx).Insert(event)
|
|
return err
|
|
}
|
|
|
|
// GetEventCountByVariant returns event counts grouped by event type for a variant.
|
|
func GetEventCountByVariant(ctx context.Context, variantID int64) (map[string]int64, error) {
|
|
type countResult struct {
|
|
EventType string `xorm:"event_type"`
|
|
Count int64 `xorm:"cnt"`
|
|
}
|
|
results := make([]countResult, 0)
|
|
err := db.GetEngine(ctx).Table("page_event").
|
|
Select("event_type, COUNT(*) as cnt").
|
|
Where("variant_id = ?", variantID).
|
|
GroupBy("event_type").
|
|
Find(&results)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
counts := make(map[string]int64, len(results))
|
|
for _, r := range results {
|
|
counts[r.EventType] = r.Count
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
// GetEventCountsByExperiment returns event counts for all variants in an experiment.
|
|
func GetEventCountsByExperiment(ctx context.Context, experimentID int64) (map[int64]map[string]int64, error) {
|
|
type countResult struct {
|
|
VariantID int64 `xorm:"variant_id"`
|
|
EventType string `xorm:"event_type"`
|
|
Count int64 `xorm:"cnt"`
|
|
}
|
|
results := make([]countResult, 0)
|
|
err := db.GetEngine(ctx).Table("page_event").
|
|
Select("variant_id, event_type, COUNT(*) as cnt").
|
|
Where("experiment_id = ?", experimentID).
|
|
GroupBy("variant_id, event_type").
|
|
Find(&results)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
counts := make(map[int64]map[string]int64)
|
|
for _, r := range results {
|
|
if counts[r.VariantID] == nil {
|
|
counts[r.VariantID] = make(map[string]int64)
|
|
}
|
|
counts[r.VariantID][r.EventType] = r.Count
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
// RecordRepoAction records a repo action (star, fork, clone) as a page event
|
|
// if the repo has an active experiment.
|
|
func RecordRepoAction(ctx context.Context, repoID int64, eventType string) {
|
|
exp, err := GetActiveExperimentByRepoID(ctx, repoID)
|
|
if err != nil || exp == nil {
|
|
return
|
|
}
|
|
_ = CreatePageEvent(ctx, &PageEvent{
|
|
RepoID: repoID,
|
|
ExperimentID: exp.ID,
|
|
EventType: eventType,
|
|
})
|
|
}
|