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

@@ -13,6 +13,7 @@ import (
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/video"
)
func (s *appServices) GetAdminDashboard(ctx context.Context, _ *appv1.GetAdminDashboardRequest) (*appv1.GetAdminDashboardResponse, error) {
@@ -95,20 +96,19 @@ func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserR
return nil, status.Error(codes.Internal, "Failed to get user")
}
payload, err := s.buildAdminUser(ctx, &user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get user")
}
var subscription model.PlanSubscription
var subscriptionPayload *appv1.PlanSubscription
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscription).Error; err == nil {
subscriptionPayload = toProtoPlanSubscription(&subscription)
var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
subscription = &subscriptionRecord
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.Internal, "Failed to get user")
}
return &appv1.GetAdminUserResponse{User: &appv1.AdminUserDetail{User: payload, Subscription: subscriptionPayload}}, nil
detail, err := s.buildAdminUserDetail(ctx, &user, subscription)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get user")
}
return &appv1.GetAdminUserResponse{User: detail}, nil
}
func (s *appServices) CreateAdminUser(ctx context.Context, req *appv1.CreateAdminUserRequest) (*appv1.CreateAdminUserResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
@@ -243,6 +243,89 @@ func (s *appServices) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdmi
}
return &appv1.UpdateAdminUserResponse{User: payload}, nil
}
func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
if req.ClearReferrer != nil && req.GetClearReferrer() && req.RefUsername != nil && strings.TrimSpace(req.GetRefUsername()) != "" {
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referrer at the same time")
}
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() && req.ReferralRewardBps != nil {
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referral reward override at the same time")
}
if req.ReferralRewardBps != nil {
bps := req.GetReferralRewardBps()
if bps < 0 || bps > 10000 {
return nil, status.Error(codes.InvalidArgument, "Referral reward bps must be between 0 and 10000")
}
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
updates := map[string]any{}
if req.RefUsername != nil || (req.ClearReferrer != nil && req.GetClearReferrer()) {
if referralRewardProcessed(&user) {
return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted")
}
if req.ClearReferrer != nil && req.GetClearReferrer() {
updates["referred_by_user_id"] = nil
} else if req.RefUsername != nil {
referrer, err := s.loadReferralUserByUsernameStrict(ctx, req.GetRefUsername())
if err != nil {
return nil, err
}
if referrer.ID == user.ID {
return nil, status.Error(codes.InvalidArgument, "User cannot refer themselves")
}
updates["referred_by_user_id"] = referrer.ID
}
}
if req.ReferralEligible != nil {
updates["referral_eligible"] = req.GetReferralEligible()
}
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() {
updates["referral_reward_bps"] = nil
} else if req.ReferralRewardBps != nil {
updates["referral_reward_bps"] = req.GetReferralRewardBps()
}
if len(updates) > 0 {
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
if result.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "User not found")
}
}
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
subscription = &subscriptionRecord
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
payload, err := s.buildAdminUserDetail(ctx, &user, subscription)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
return &appv1.UpdateAdminUserReferralSettingsResponse{User: payload}, nil
}
func (s *appServices) UpdateAdminUserRole(ctx context.Context, req *appv1.UpdateAdminUserRoleRequest) (*appv1.UpdateAdminUserRoleResponse, error) {
adminResult, err := s.requireAdmin(ctx)
if err != nil {
@@ -299,7 +382,6 @@ func (s *appServices) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdmi
model interface{}
where string
}{
{&model.VideoAdConfig{}, "user_id = ?"},
{&model.AdTemplate{}, "user_id = ?"},
{&model.Notification{}, "user_id = ?"},
{&model.Domain{}, "user_id = ?"},
@@ -395,6 +477,9 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
userID := strings.TrimSpace(req.GetUserId())
title := strings.TrimSpace(req.GetTitle())
@@ -406,49 +491,30 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to create video")
}
statusValue := normalizeVideoStatusValue(req.GetStatus())
processingStatus := strings.ToUpper(statusValue)
storageType := detectStorageType(videoURL)
video := &model.Video{
ID: uuid.New().String(),
UserID: user.ID,
Name: title,
Title: title,
Description: nullableTrimmedString(req.Description),
URL: videoURL,
Size: req.GetSize(),
Duration: req.GetDuration(),
Format: strings.TrimSpace(req.GetFormat()),
Status: model.StringPtr(statusValue),
ProcessingStatus: model.StringPtr(processingStatus),
StorageType: model.StringPtr(storageType),
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(video).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
return err
}
return s.saveAdminVideoAdConfig(ctx, tx, video.ID, user.ID, nullableTrimmedString(req.AdTemplateId))
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
UserID: userID,
Title: title,
Description: req.Description,
URL: videoURL,
Size: req.GetSize(),
Duration: req.GetDuration(),
Format: strings.TrimSpace(req.GetFormat()),
AdTemplateID: nullableTrimmedString(req.AdTemplateId),
})
if err != nil {
if strings.Contains(err.Error(), "Ad template not found") {
switch {
case errors.Is(err, video.ErrUserNotFound):
return nil, status.Error(codes.InvalidArgument, "User not found")
case errors.Is(err, video.ErrAdTemplateNotFound):
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
case errors.Is(err, video.ErrJobServiceUnavailable):
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
default:
return nil, status.Error(codes.Internal, "Failed to create video")
}
return nil, status.Error(codes.Internal, "Failed to create video")
}
payload, err := s.buildAdminVideo(ctx, video)
payload, err := s.buildAdminVideo(ctx, created.Video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create video")
}
@@ -524,12 +590,7 @@ func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdm
return err
}
}
if oldUserID != user.ID {
if err := tx.Model(&model.VideoAdConfig{}).Where("video_id = ?", video.ID).Update("user_id", user.ID).Error; err != nil {
return err
}
}
return s.saveAdminVideoAdConfig(ctx, tx, video.ID, user.ID, nullableTrimmedString(req.AdTemplateId))
return s.saveAdminVideoAdConfig(ctx, tx, &video, user.ID, nullableTrimmedString(req.AdTemplateId))
})
if err != nil {
if strings.Contains(err.Error(), "Ad template not found") {
@@ -563,9 +624,6 @@ func (s *appServices) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdm
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("video_id = ?", video.ID).Delete(&model.VideoAdConfig{}).Error; err != nil {
return err
}
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
return err
}