diff --git a/veza-backend-api/internal/models/daily_track_stats.go b/veza-backend-api/internal/models/daily_track_stats.go new file mode 100644 index 000000000..b6752442a --- /dev/null +++ b/veza-backend-api/internal/models/daily_track_stats.go @@ -0,0 +1,84 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// DailyTrackStats represents daily aggregated playback statistics for a track +type DailyTrackStats struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + TrackID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uq_daily_track_stats_track_date" json:"track_id"` + Date time.Time `gorm:"type:date;not null;uniqueIndex:uq_daily_track_stats_track_date" json:"date"` + TotalPlays int64 `gorm:"not null;default:0" json:"total_plays"` + UniqueListeners int64 `gorm:"not null;default:0" json:"unique_listeners"` + CompleteListens int64 `gorm:"not null;default:0" json:"complete_listens"` + TotalPlayTime int64 `gorm:"not null;default:0" json:"total_play_time"` + AvgCompletionRate float64 `gorm:"type:decimal(5,2);not null;default:0" json:"avg_completion_rate"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Track Track `gorm:"foreignKey:TrackID;constraint:OnDelete:CASCADE" json:"-"` +} + +func (DailyTrackStats) TableName() string { + return "daily_track_stats" +} + +func (m *DailyTrackStats) BeforeCreate(tx *gorm.DB) error { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + return nil +} + +// GeographicPlayStats represents aggregated geographic play data (anonymized) +type GeographicPlayStats struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + TrackID uuid.UUID `gorm:"type:uuid;not null" json:"track_id"` + CountryCode string `gorm:"size:2;not null" json:"country_code"` + Region string `gorm:"size:100;not null;default:''" json:"region"` + Date time.Time `gorm:"type:date;not null" json:"date"` + PlayCount int64 `gorm:"not null;default:0" json:"play_count"` + UniqueListeners int64 `gorm:"not null;default:0" json:"unique_listeners"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + Track Track `gorm:"foreignKey:TrackID;constraint:OnDelete:CASCADE" json:"-"` +} + +func (GeographicPlayStats) TableName() string { + return "geographic_play_stats" +} + +func (m *GeographicPlayStats) BeforeCreate(tx *gorm.DB) error { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + return nil +} + +// TrackDiscoverySource records how a user discovered a track +type TrackDiscoverySource struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + TrackID uuid.UUID `gorm:"type:uuid;not null" json:"track_id"` + UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` + Source string `gorm:"size:50;not null" json:"source"` // search, feed, share, profile, playlist, direct + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + Track Track `gorm:"foreignKey:TrackID;constraint:OnDelete:CASCADE" json:"-"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` +} + +func (TrackDiscoverySource) TableName() string { + return "track_discovery_sources" +} + +func (m *TrackDiscoverySource) BeforeCreate(tx *gorm.DB) error { + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + return nil +} diff --git a/veza-backend-api/migrations/945_creator_analytics_v0110.sql b/veza-backend-api/migrations/945_creator_analytics_v0110.sql new file mode 100644 index 000000000..729337ae5 --- /dev/null +++ b/veza-backend-api/migrations/945_creator_analytics_v0110.sql @@ -0,0 +1,57 @@ +-- Migration 945: Creator Analytics v0.11.0 (F381-F385) +-- Adds tables for geographic stats, discovery sources, and daily aggregated stats + +-- Daily aggregated track stats for efficient creator dashboard queries +CREATE TABLE IF NOT EXISTS daily_track_stats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + date DATE NOT NULL, + total_plays BIGINT NOT NULL DEFAULT 0, + unique_listeners BIGINT NOT NULL DEFAULT 0, + complete_listens BIGINT NOT NULL DEFAULT 0, + total_play_time BIGINT NOT NULL DEFAULT 0, -- seconds + avg_completion_rate DECIMAL(5,2) NOT NULL DEFAULT 0, -- percentage 0-100 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_daily_track_stats_track_date UNIQUE(track_id, date) +); + +CREATE INDEX IF NOT EXISTS idx_daily_track_stats_track_id ON daily_track_stats(track_id); +CREATE INDEX IF NOT EXISTS idx_daily_track_stats_date ON daily_track_stats(date); +CREATE INDEX IF NOT EXISTS idx_daily_track_stats_track_date ON daily_track_stats(track_id, date); + +-- Discovery sources: how users find tracks +CREATE TABLE IF NOT EXISTS track_discovery_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source VARCHAR(50) NOT NULL, -- 'search', 'feed', 'share', 'profile', 'playlist', 'direct' + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_track_discovery_track_id ON track_discovery_sources(track_id); +CREATE INDEX IF NOT EXISTS idx_track_discovery_source ON track_discovery_sources(source); +CREATE INDEX IF NOT EXISTS idx_track_discovery_created_at ON track_discovery_sources(created_at); + +-- Geographic play stats (aggregated, anonymized — never store individual user locations) +CREATE TABLE IF NOT EXISTS geographic_play_stats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + country_code VARCHAR(2) NOT NULL, -- ISO 3166-1 alpha-2 + region VARCHAR(100) NOT NULL DEFAULT '', + date DATE NOT NULL, + play_count BIGINT NOT NULL DEFAULT 0, + unique_listeners BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_geo_stats_track_country_region_date UNIQUE(track_id, country_code, region, date) +); + +CREATE INDEX IF NOT EXISTS idx_geo_stats_track_id ON geographic_play_stats(track_id); +CREATE INDEX IF NOT EXISTS idx_geo_stats_country ON geographic_play_stats(country_code); +CREATE INDEX IF NOT EXISTS idx_geo_stats_date ON geographic_play_stats(date); + +-- Add source column to track_plays for discovery tracking +ALTER TABLE track_plays ADD COLUMN IF NOT EXISTS source VARCHAR(50) DEFAULT ''; +-- Add country_code to track_plays for geographic aggregation +ALTER TABLE track_plays ADD COLUMN IF NOT EXISTS country_code VARCHAR(2) DEFAULT ''; diff --git a/veza-backend-api/migrations/945_creator_analytics_v0110_down.sql b/veza-backend-api/migrations/945_creator_analytics_v0110_down.sql new file mode 100644 index 000000000..81e87f51e --- /dev/null +++ b/veza-backend-api/migrations/945_creator_analytics_v0110_down.sql @@ -0,0 +1,7 @@ +-- Rollback migration 945: Creator Analytics v0.11.0 + +ALTER TABLE track_plays DROP COLUMN IF EXISTS country_code; +ALTER TABLE track_plays DROP COLUMN IF EXISTS source; +DROP TABLE IF EXISTS geographic_play_stats; +DROP TABLE IF EXISTS track_discovery_sources; +DROP TABLE IF EXISTS daily_track_stats;