feat: Add player_configs feature and migrate user preferences

- Implemented player_configs table to store multiple player configurations per user.
- Migrated existing player settings from user_preferences to player_configs.
- Removed player-related columns from user_preferences.
- Added referral state fields to user for tracking referral rewards.
- Created migration scripts for database changes and data migration.
- Added test cases for app services and usage helpers.
- Introduced video job service interfaces and implementations.
This commit is contained in:
2026-03-24 16:08:36 +00:00
parent 91e5e3542b
commit e7fdd0e1ab
103 changed files with 9540 additions and 8446 deletions

View File

@@ -0,0 +1,110 @@
package model
import (
"context"
"strings"
"time"
"gorm.io/gorm"
)
// BoolPtr returns a pointer to the given bool value
func BoolPtr(b bool) *bool {
return &b
}
// StringPtr returns a pointer to the given string value
func StringPtr(s string) *string {
return &s
}
// StringValue returns the string value or empty string if nil
func StringValue(s *string) string {
if s == nil {
return ""
}
return *s
}
// BoolValue returns the bool value or false if nil
func BoolValue(b *bool) bool {
if b == nil {
return false
}
return *b
}
// Int64Ptr returns a pointer to the given int64 value
func Int64Ptr(i int64) *int64 {
return &i
}
// Float64Ptr returns a pointer to the given float64 value
func Float64Ptr(f float64) *float64 {
return &f
}
// TimePtr returns a pointer to the given time.Time value
func TimePtr(t time.Time) *time.Time {
return &t
}
// FindOrCreateUserPreference finds or creates a user preference record
func FindOrCreateUserPreference(ctx context.Context, db *gorm.DB, userID string) (*UserPreference, error) {
pref := &UserPreference{}
err := db.WithContext(ctx).
Where("user_id = ?", userID).
Attrs(&UserPreference{
Language: StringPtr("en"),
Locale: StringPtr("en"),
EmailNotifications: BoolPtr(true),
PushNotifications: BoolPtr(true),
MarketingNotifications: false,
TelegramNotifications: false,
}).
FirstOrCreate(pref).Error
// Handle race condition: if duplicate key error, fetch the existing record
if err != nil && strings.Contains(err.Error(), "duplicate key") {
err = db.WithContext(ctx).Where("user_id = ?", userID).First(pref).Error
}
return pref, err
}
// GetWalletBalance calculates the current wallet balance for a user
func GetWalletBalance(ctx context.Context, db *gorm.DB, userID string) (float64, error) {
var balance float64
if err := db.WithContext(ctx).
Model(&WalletTransaction{}).
Where("user_id = ?", userID).
Select("COALESCE(SUM(amount), 0)").
Scan(&balance).Error; err != nil {
return 0, err
}
return balance, nil
}
// GetLatestPlanSubscription finds the latest plan subscription for a user
func GetLatestPlanSubscription(ctx context.Context, db *gorm.DB, userID string) (*PlanSubscription, error) {
sub := &PlanSubscription{}
err := db.WithContext(ctx).
Where("user_id = ?", userID).
Order("expires_at DESC").
First(sub).Error
if err != nil {
return nil, err
}
return sub, nil
}
// IsSubscriptionExpiringSoon checks if subscription expires within threshold days
func IsSubscriptionExpiringSoon(sub *PlanSubscription, thresholdDays int) bool {
if sub == nil {
return false
}
now := time.Now()
hoursUntilExpiry := sub.ExpiresAt.Sub(now).Hours()
thresholdHours := float64(thresholdDays) * 24
return hoursUntilExpiry > 0 && hoursUntilExpiry <= thresholdHours
}

View File

@@ -1,138 +0,0 @@
package model
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
)
const (
defaultPreferenceLanguage = "en"
defaultPreferenceLocale = "en"
)
func DefaultUserPreference(userID string) *UserPreference {
return &UserPreference{
UserID: userID,
Language: StringPtr(defaultPreferenceLanguage),
Locale: StringPtr(defaultPreferenceLocale),
EmailNotifications: BoolPtr(true),
PushNotifications: BoolPtr(true),
MarketingNotifications: false,
TelegramNotifications: false,
Autoplay: false,
Loop: false,
Muted: false,
ShowControls: BoolPtr(true),
Pip: BoolPtr(true),
Airplay: BoolPtr(true),
Chromecast: BoolPtr(true),
}
}
func FindOrCreateUserPreference(ctx context.Context, db *gorm.DB, userID string) (*UserPreference, error) {
var pref UserPreference
if err := db.WithContext(ctx).Where("user_id = ?", userID).First(&pref).Error; err == nil {
normalizeUserPreferenceDefaults(&pref)
return &pref, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
pref = *DefaultUserPreference(userID)
if err := db.WithContext(ctx).Create(&pref).Error; err != nil {
return nil, err
}
return &pref, nil
}
func GetWalletBalance(ctx context.Context, db *gorm.DB, userID string) (float64, error) {
var balance float64
if err := db.WithContext(ctx).
Model(&WalletTransaction{}).
Where("user_id = ?", userID).
Select("COALESCE(SUM(amount), 0)").
Scan(&balance).Error; err != nil {
return 0, err
}
return balance, nil
}
func GetLatestPlanSubscription(ctx context.Context, db *gorm.DB, userID string) (*PlanSubscription, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return nil, gorm.ErrRecordNotFound
}
var subscription PlanSubscription
if err := db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Order("id DESC").
First(&subscription).Error; err != nil {
return nil, err
}
return &subscription, nil
}
func IsSubscriptionExpiringSoon(expiresAt time.Time, now time.Time) bool {
if expiresAt.IsZero() || !expiresAt.After(now) {
return false
}
return expiresAt.Sub(now) <= 7*24*time.Hour
}
func normalizeUserPreferenceDefaults(pref *UserPreference) {
if pref == nil {
return
}
if strings.TrimSpace(StringValue(pref.Language)) == "" {
pref.Language = StringPtr(defaultPreferenceLanguage)
}
if strings.TrimSpace(StringValue(pref.Locale)) == "" {
locale := StringValue(pref.Language)
if strings.TrimSpace(locale) == "" {
locale = defaultPreferenceLocale
}
pref.Locale = StringPtr(locale)
}
if pref.EmailNotifications == nil {
pref.EmailNotifications = BoolPtr(true)
}
if pref.PushNotifications == nil {
pref.PushNotifications = BoolPtr(true)
}
if pref.ShowControls == nil {
pref.ShowControls = BoolPtr(true)
}
if pref.Pip == nil {
pref.Pip = BoolPtr(true)
}
if pref.Airplay == nil {
pref.Airplay = BoolPtr(true)
}
if pref.Chromecast == nil {
pref.Chromecast = BoolPtr(true)
}
}
func StringPtr(value string) *string {
v := value
return &v
}
func BoolPtr(value bool) *bool {
v := value
return &v
}
func StringValue(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -0,0 +1,38 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
import (
"time"
)
const TableNamePlayerConfig = "player_configs"
// PlayerConfig mapped from table <player_configs>
type PlayerConfig struct {
ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
UserID string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:idx_player_configs_one_default_per_user,priority:1;index:idx_player_configs_user_default,priority:2;index:idx_player_configs_user_id,priority:1" json:"user_id"`
Name string `gorm:"column:name;type:text;not null" json:"name"`
Description *string `gorm:"column:description;type:text" json:"description"`
Autoplay bool `gorm:"column:autoplay;type:boolean;not null" json:"autoplay"`
Loop bool `gorm:"column:loop;type:boolean;not null" json:"loop"`
Muted bool `gorm:"column:muted;type:boolean;not null" json:"muted"`
ShowControls *bool `gorm:"column:show_controls;type:boolean;not null;default:true" json:"show_controls"`
Pip *bool `gorm:"column:pip;type:boolean;not null;default:true" json:"pip"`
Airplay *bool `gorm:"column:airplay;type:boolean;not null;default:true" json:"airplay"`
Chromecast *bool `gorm:"column:chromecast;type:boolean;not null;default:true" json:"chromecast"`
IsActive *bool `gorm:"column:is_active;type:boolean;not null;default:true" json:"is_active"`
IsDefault bool `gorm:"column:is_default;type:boolean;not null;index:idx_player_configs_is_default,priority:1;index:idx_player_configs_user_default,priority:1" json:"is_default"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
EncrytionM3u8 *bool `gorm:"column:encrytion_m3u8;type:boolean;not null;default:true" json:"encrytion_m3u8"`
LogoURL *string `gorm:"column:logo_url;type:character varying(500)" json:"logo_url"`
}
// TableName PlayerConfig's table name
func (*PlayerConfig) TableName() string {
return TableNamePlayerConfig
}

View File

@@ -12,18 +12,25 @@ const TableNameUser = "user"
// User mapped from table <user>
type User struct {
ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Email string `gorm:"column:email;type:text;not null;uniqueIndex:user_email_key,priority:1" json:"email"`
Password *string `gorm:"column:password;type:text" json:"-"`
Username *string `gorm:"column:username;type:text" json:"username"`
Avatar *string `gorm:"column:avatar;type:text" json:"avatar"`
Role *string `gorm:"column:role;type:character varying(20);not null;default:USER" json:"role"`
GoogleID *string `gorm:"column:google_id;type:text;uniqueIndex:user_google_id_key,priority:1" json:"google_id"`
StorageUsed int64 `gorm:"column:storage_used;type:bigint;not null" json:"storage_used"`
PlanID *string `gorm:"column:plan_id;type:uuid" json:"plan_id"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Email string `gorm:"column:email;type:text;not null;uniqueIndex:user_email_key,priority:1" json:"email"`
Password *string `gorm:"column:password;type:text" json:"-"`
Username *string `gorm:"column:username;type:text" json:"username"`
Avatar *string `gorm:"column:avatar;type:text" json:"avatar"`
Role *string `gorm:"column:role;type:character varying(20);not null;default:USER" json:"role"`
GoogleID *string `gorm:"column:google_id;type:text;uniqueIndex:user_google_id_key,priority:1" json:"google_id"`
StorageUsed int64 `gorm:"column:storage_used;type:bigint;not null" json:"storage_used"`
PlanID *string `gorm:"column:plan_id;type:uuid" json:"plan_id"`
ReferredByUserID *string `gorm:"column:referred_by_user_id;type:uuid;index:idx_user_referred_by_user_id,priority:1" json:"referred_by_user_id"`
ReferralEligible *bool `gorm:"column:referral_eligible;type:boolean;not null;default:true" json:"referral_eligible"`
ReferralRewardBps *int32 `gorm:"column:referral_reward_bps;type:integer" json:"referral_reward_bps"`
ReferralRewardGrantedAt *time.Time `gorm:"column:referral_reward_granted_at;type:timestamp with time zone" json:"referral_reward_granted_at"`
ReferralRewardPaymentID *string `gorm:"column:referral_reward_payment_id;type:uuid" json:"referral_reward_payment_id"`
ReferralRewardAmount *float64 `gorm:"column:referral_reward_amount;type:numeric(65,30)" json:"referral_reward_amount"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
TelegramID *string `gorm:"column:telegram_id;type:character varying" json:"telegram_id"`
}
// TableName User's table name

View File

@@ -19,16 +19,8 @@ type UserPreference struct {
PushNotifications *bool `gorm:"column:push_notifications;type:boolean;not null;default:true" json:"push_notifications"`
MarketingNotifications bool `gorm:"column:marketing_notifications;type:boolean;not null" json:"marketing_notifications"`
TelegramNotifications bool `gorm:"column:telegram_notifications;type:boolean;not null" json:"telegram_notifications"`
Autoplay bool `gorm:"column:autoplay;type:boolean;not null" json:"autoplay"`
Loop bool `gorm:"column:loop;type:boolean;not null" json:"loop"`
Muted bool `gorm:"column:muted;type:boolean;not null" json:"muted"`
ShowControls *bool `gorm:"column:show_controls;type:boolean;not null;default:true" json:"show_controls"`
Pip *bool `gorm:"column:pip;type:boolean;not null;default:true" json:"pip"`
Airplay *bool `gorm:"column:airplay;type:boolean;not null;default:true" json:"airplay"`
Chromecast *bool `gorm:"column:chromecast;type:boolean;not null;default:true" json:"chromecast"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp with time zone" json:"created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at;type:timestamp with time zone" json:"updated_at"`
EncrytionM3u8 bool `gorm:"column:encrytion_m3u8;type:boolean;not null" json:"encrytion_m3u8"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
}

View File

@@ -6,31 +6,35 @@ package model
import (
"time"
"gorm.io/datatypes"
)
const TableNameVideo = "video"
// Video mapped from table <video>
type Video struct {
ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Name string `gorm:"column:name;type:text;not null" json:"name"`
Title string `gorm:"column:title;type:text;not null" json:"title"`
Description *string `gorm:"column:description;type:text" json:"description"`
URL string `gorm:"column:url;type:text;not null" json:"url"`
Thumbnail *string `gorm:"column:thumbnail;type:text" json:"thumbnail"`
HlsToken *string `gorm:"column:hls_token;type:text" json:"hls_token"`
HlsPath *string `gorm:"column:hls_path;type:text" json:"hls_path"`
Duration int32 `gorm:"column:duration;type:integer;not null" json:"duration"`
Size int64 `gorm:"column:size;type:bigint;not null" json:"size"`
StorageType *string `gorm:"column:storage_type;type:character varying(20);not null;default:tiktok_avatar" json:"storage_type"`
Format string `gorm:"column:format;type:text;not null" json:"format"`
Status *string `gorm:"column:status;type:character varying(20);not null;default:PUBLIC" json:"status"`
ProcessingStatus *string `gorm:"column:processing_status;type:character varying(20);not null;default:PENDING" json:"processing_status"`
Views int32 `gorm:"column:views;type:integer;not null" json:"views"`
UserID string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Name string `gorm:"column:name;type:text;not null" json:"name"`
Title string `gorm:"column:title;type:text;not null" json:"title"`
Description *string `gorm:"column:description;type:text" json:"description"`
URL string `gorm:"column:url;type:text;not null" json:"url"`
Thumbnail *string `gorm:"column:thumbnail;type:text" json:"thumbnail"`
HlsToken *string `gorm:"column:hls_token;type:text" json:"hls_token"`
HlsPath *string `gorm:"column:hls_path;type:text" json:"hls_path"`
Duration int32 `gorm:"column:duration;type:integer;not null" json:"duration"`
Size int64 `gorm:"column:size;type:bigint;not null" json:"size"`
StorageType *string `gorm:"column:storage_type;type:character varying(20);not null;default:tiktok_avatar" json:"storage_type"`
Format string `gorm:"column:format;type:text;not null" json:"format"`
Status *string `gorm:"column:status;type:character varying(20);not null;default:PUBLIC" json:"status"`
ProcessingStatus *string `gorm:"column:processing_status;type:character varying(20);not null;default:PENDING" json:"processing_status"`
Views int32 `gorm:"column:views;type:integer;not null" json:"views"`
UserID string `gorm:"column:user_id;type:uuid;not null" json:"user_id"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
AdID *string `gorm:"column:ad_id;type:uuid" json:"ad_id"`
Metadata *datatypes.JSON `gorm:"column:metadata;type:jsonb" json:"metadata"`
}
// TableName Video's table name

View File

@@ -1,29 +0,0 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
import (
"time"
)
const TableNameVideoAdConfig = "video_ad_configs"
// VideoAdConfig mapped from table <video_ad_configs>
type VideoAdConfig struct {
VideoID string `gorm:"column:video_id;type:uuid;primaryKey" json:"video_id"`
UserID string `gorm:"column:user_id;type:uuid;not null;index:idx_video_ad_configs_user_id,priority:1" json:"user_id"`
AdTemplateID string `gorm:"column:ad_template_id;type:uuid;not null" json:"ad_template_id"`
VastTagURL string `gorm:"column:vast_tag_url;type:text;not null" json:"vast_tag_url"`
AdFormat *string `gorm:"column:ad_format;type:character varying(50);not null;default:pre-roll" json:"ad_format"`
Duration *int64 `gorm:"column:duration;type:bigint" json:"duration"`
CreatedAt *time.Time `gorm:"column:created_at;type:timestamp with time zone" json:"created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at;type:timestamp with time zone" json:"updated_at"`
Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"`
}
// TableName VideoAdConfig's table name
func (*VideoAdConfig) TableName() string {
return TableNameVideoAdConfig
}