2
0
Files
gitcaddy-server/models/pages/experiment.go
logikonline 3a8bdd936c feat(pages): add A/B testing framework for landing pages
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
2026-03-07 12:39:42 -05:00

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,
})
}