458 lines
32 KiB
Go
458 lines
32 KiB
Go
package users
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/wrapperspb"
|
|
"gorm.io/gorm"
|
|
"stream.api/internal/database/model"
|
|
"stream.api/internal/database/query"
|
|
"stream.api/internal/modules/common"
|
|
)
|
|
|
|
var (
|
|
ErrEmailRequired = errors.New("Email is required")
|
|
ErrEmailAlreadyRegistered = errors.New("Email already registered")
|
|
)
|
|
|
|
type Module struct {
|
|
runtime *common.Runtime
|
|
}
|
|
|
|
type updateProfileInput struct {
|
|
Username *string
|
|
Email *string
|
|
Language *string
|
|
Locale *string
|
|
}
|
|
|
|
type updatePreferencesInput struct {
|
|
EmailNotifications *bool
|
|
PushNotifications *bool
|
|
MarketingNotifications *bool
|
|
TelegramNotifications *bool
|
|
Language *string
|
|
Locale *string
|
|
}
|
|
|
|
type usagePayload struct {
|
|
UserID string `json:"user_id"`
|
|
TotalVideos int64 `json:"total_videos"`
|
|
TotalStorage int64 `json:"total_storage"`
|
|
}
|
|
|
|
func New(runtime *common.Runtime) *Module {
|
|
return &Module{runtime: runtime}
|
|
}
|
|
|
|
func (m *Module) GetMe(ctx context.Context, userID string) (*UserView, error) {
|
|
result, err := m.runtime.Authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), result.User)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
|
}
|
|
return mapUserPayload(payload), nil
|
|
}
|
|
|
|
func (m *Module) GetUserByID(ctx context.Context, req *wrapperspb.StringValue) (*UserView, error) {
|
|
_, err := m.runtime.Authenticator().RequireTrustedMetadata(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u := query.User
|
|
user, err := u.WithContext(ctx).Where(u.ID.Eq(req.Value)).First()
|
|
if err != nil {
|
|
return nil, status.Error(codes.Unauthenticated, "Unauthorized")
|
|
}
|
|
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
|
}
|
|
return mapUserPayload(payload), nil
|
|
}
|
|
|
|
func (m *Module) UpdateMe(ctx context.Context, cmd UpdateProfileCommand) (*UserView, error) {
|
|
updatedUser, err := m.updateUserProfile(ctx, cmd.UserID, updateProfileInput{Username: cmd.Username, Email: cmd.Email, Language: cmd.Language, Locale: cmd.Locale})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ErrEmailRequired), errors.Is(err, ErrEmailAlreadyRegistered):
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
default:
|
|
return nil, status.Error(codes.Internal, "Failed to update profile")
|
|
}
|
|
}
|
|
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), updatedUser)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
|
}
|
|
return mapUserPayload(payload), nil
|
|
}
|
|
|
|
func (m *Module) DeleteMe(ctx context.Context, userID string) error {
|
|
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil { return err }
|
|
if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil { return err }
|
|
return nil
|
|
}); err != nil {
|
|
m.runtime.Logger().Error("Failed to delete user", "error", err)
|
|
return status.Error(codes.Internal, "Failed to delete account")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) ClearMyData(ctx context.Context, userID string) error {
|
|
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil { return err }
|
|
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil { return err }
|
|
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{"storage_used": 0}).Error; err != nil { return err }
|
|
return nil
|
|
}); err != nil {
|
|
m.runtime.Logger().Error("Failed to clear user data", "error", err)
|
|
return status.Error(codes.Internal, "Failed to clear data")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) GetPreferences(ctx context.Context, userID string) (*PreferencesView, error) {
|
|
pref, err := m.loadUserPreferences(ctx, userID)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to load preferences") }
|
|
return mapPreferences(pref), nil
|
|
}
|
|
|
|
func (m *Module) UpdatePreferences(ctx context.Context, cmd UpdatePreferencesCommand) (*PreferencesView, error) {
|
|
pref, err := m.updateUserPreferences(ctx, cmd.UserID, updatePreferencesInput{EmailNotifications: cmd.EmailNotifications, PushNotifications: cmd.PushNotifications, MarketingNotifications: cmd.MarketingNotifications, TelegramNotifications: cmd.TelegramNotifications, Language: cmd.Language, Locale: cmd.Locale})
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to save preferences") }
|
|
return mapPreferences(pref), nil
|
|
}
|
|
|
|
func (m *Module) GetUsage(ctx context.Context, user *model.User) (*UsageView, error) {
|
|
payload, err := m.loadUsage(ctx, user)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to load usage") }
|
|
return &UsageView{UserID: payload.UserID, TotalVideos: payload.TotalVideos, TotalStorage: payload.TotalStorage}, nil
|
|
}
|
|
|
|
func (m *Module) ListNotifications(ctx context.Context, userID string) (*ListNotificationsResult, error) {
|
|
var rows []model.Notification
|
|
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&rows).Error; err != nil { m.runtime.Logger().Error("Failed to list notifications", "error", err); return nil, status.Error(codes.Internal, "Failed to load notifications") }
|
|
items := make([]NotificationView, 0, len(rows))
|
|
for _, row := range rows { items = append(items, NotificationView{Notification: row}) }
|
|
return &ListNotificationsResult{Items: items}, nil
|
|
}
|
|
|
|
func (m *Module) MarkNotificationRead(ctx context.Context, cmd MarkNotificationCommand) error {
|
|
id := strings.TrimSpace(cmd.ID)
|
|
if id == "" { return status.Error(codes.NotFound, "Notification not found") }
|
|
res := m.runtime.DB().WithContext(ctx).Model(&model.Notification{}).Where("id = ? AND user_id = ?", id, cmd.UserID).Update("is_read", true)
|
|
if res.Error != nil { m.runtime.Logger().Error("Failed to update notification", "error", res.Error); return status.Error(codes.Internal, "Failed to update notification") }
|
|
if res.RowsAffected == 0 { return status.Error(codes.NotFound, "Notification not found") }
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
|
if err := m.runtime.DB().WithContext(ctx).Model(&model.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Update("is_read", true).Error; err != nil { m.runtime.Logger().Error("Failed to mark all notifications as read", "error", err); return status.Error(codes.Internal, "Failed to update notifications") }
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) DeleteNotification(ctx context.Context, cmd MarkNotificationCommand) error {
|
|
id := strings.TrimSpace(cmd.ID)
|
|
if id == "" { return status.Error(codes.NotFound, "Notification not found") }
|
|
res := m.runtime.DB().WithContext(ctx).Where("id = ? AND user_id = ?", id, cmd.UserID).Delete(&model.Notification{})
|
|
if res.Error != nil { m.runtime.Logger().Error("Failed to delete notification", "error", res.Error); return status.Error(codes.Internal, "Failed to delete notification") }
|
|
if res.RowsAffected == 0 { return status.Error(codes.NotFound, "Notification not found") }
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) ClearNotifications(ctx context.Context, userID string) error {
|
|
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { m.runtime.Logger().Error("Failed to clear notifications", "error", err); return status.Error(codes.Internal, "Failed to clear notifications") }
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) ResolveSignupReferrerID(ctx context.Context, refUsername string, newUsername string) (*string, error) {
|
|
trimmedRefUsername := strings.TrimSpace(refUsername)
|
|
if trimmedRefUsername == "" || strings.EqualFold(trimmedRefUsername, strings.TrimSpace(newUsername)) { return nil, nil }
|
|
referrer, err := m.resolveReferralUserByUsername(ctx, trimmedRefUsername)
|
|
if err != nil { return nil, err }
|
|
if referrer == nil { return nil, nil }
|
|
return &referrer.ID, nil
|
|
}
|
|
|
|
func (m *Module) LoadReferralUserByUsernameStrict(ctx context.Context, username string) (*model.User, error) {
|
|
trimmed := strings.TrimSpace(username)
|
|
if trimmed == "" { return nil, status.Error(codes.InvalidArgument, "Referral username is required") }
|
|
users, err := m.loadReferralUsersByUsername(ctx, trimmed)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to resolve referral user") }
|
|
if len(users) == 0 { return nil, status.Error(codes.InvalidArgument, "Referral user not found") }
|
|
if len(users) > 1 { return nil, status.Error(codes.InvalidArgument, "Referral username is ambiguous") }
|
|
return &users[0], nil
|
|
}
|
|
|
|
func (m *Module) EnsurePlanExists(ctx context.Context, planID *string) error {
|
|
if planID == nil { return nil }
|
|
trimmed := strings.TrimSpace(*planID)
|
|
if trimmed == "" { return nil }
|
|
var count int64
|
|
if err := m.runtime.DB().WithContext(ctx).Model(&model.Plan{}).Where("id = ?", trimmed).Count(&count).Error; err != nil { return status.Error(codes.Internal, "Failed to validate plan") }
|
|
if count == 0 { return status.Error(codes.InvalidArgument, "Plan not found") }
|
|
return nil
|
|
}
|
|
|
|
func (m *Module) ListAdminUsers(ctx context.Context, queryValue ListAdminUsersQuery) (*ListAdminUsersResult, error) {
|
|
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
|
|
page, limit, offset := common.AdminPageLimitOffset(queryValue.Page, queryValue.Limit)
|
|
limitInt := int(limit)
|
|
db := m.runtime.DB().WithContext(ctx).Model(&model.User{})
|
|
if search := strings.TrimSpace(queryValue.Search); search != "" { like := "%" + search + "%"; db = db.Where("email ILIKE ? OR username ILIKE ?", like, like) }
|
|
if role := strings.TrimSpace(queryValue.Role); role != "" { db = db.Where("UPPER(role) = ?", strings.ToUpper(role)) }
|
|
var total int64
|
|
if err := db.Count(&total).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to list users") }
|
|
var users []model.User
|
|
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&users).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to list users") }
|
|
items := make([]AdminUserView, 0, len(users))
|
|
for _, user := range users {
|
|
payload, err := m.buildAdminUser(ctx, &user)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to list users") }
|
|
items = append(items, payload)
|
|
}
|
|
return &ListAdminUsersResult{Items: items, Total: total, Page: page, Limit: limit}, nil
|
|
}
|
|
|
|
func (m *Module) GetAdminUser(ctx context.Context, queryValue GetAdminUserQuery) (*AdminUserDetailView, error) {
|
|
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
|
|
id := strings.TrimSpace(queryValue.ID)
|
|
if id == "" { return nil, status.Error(codes.NotFound, "User not found") }
|
|
var user model.User
|
|
if err := m.runtime.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 get user") }
|
|
var subscription *model.PlanSubscription
|
|
var subscriptionRecord model.PlanSubscription
|
|
if err := m.runtime.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") }
|
|
detail, err := m.buildAdminUserDetail(ctx, &user, subscription)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to get user") }
|
|
return &detail, nil
|
|
}
|
|
|
|
func (m *Module) CreateAdminUser(ctx context.Context, cmd CreateAdminUserCommand) (*AdminUserView, error) {
|
|
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
|
|
email := strings.TrimSpace(cmd.Email)
|
|
password := cmd.Password
|
|
if email == "" || password == "" { return nil, status.Error(codes.InvalidArgument, "Email and password are required") }
|
|
role := common.NormalizeAdminRoleValue(cmd.Role)
|
|
if !common.IsValidAdminRoleValue(role) { return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") }
|
|
if err := m.EnsurePlanExists(ctx, cmd.PlanID); err != nil { return nil, err }
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to hash password") }
|
|
user := &model.User{ID: uuid.New().String(), Email: email, Password: model.StringPtr(string(hashedPassword)), Username: common.NullableTrimmedString(cmd.Username), Role: model.StringPtr(role), PlanID: cmd.PlanID}
|
|
if err := m.runtime.DB().WithContext(ctx).Create(user).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, status.Error(codes.AlreadyExists, "Email already registered") }; return nil, status.Error(codes.Internal, "Failed to create user") }
|
|
payload, err := m.buildAdminUser(ctx, user)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to create user") }
|
|
return &payload, nil
|
|
}
|
|
|
|
func (m *Module) UpdateAdminUser(ctx context.Context, cmd UpdateAdminUserCommand) (*AdminUserView, error) {
|
|
adminResult, err := m.runtime.RequireAdmin(ctx)
|
|
if err != nil { return nil, err }
|
|
id := strings.TrimSpace(cmd.ID)
|
|
if id == "" { return nil, status.Error(codes.NotFound, "User not found") }
|
|
updates := map[string]any{}
|
|
if cmd.Patch.Email != nil { email := strings.TrimSpace(*cmd.Patch.Email); if email == "" { return nil, status.Error(codes.InvalidArgument, "Email is required") }; updates["email"] = email }
|
|
if cmd.Patch.Username != nil { updates["username"] = common.NullableTrimmedString(cmd.Patch.Username) }
|
|
if cmd.Patch.Role != nil { role := common.NormalizeAdminRoleValue(*cmd.Patch.Role); if !common.IsValidAdminRoleValue(role) { return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") }; if id == adminResult.UserID && role != "ADMIN" { return nil, status.Error(codes.InvalidArgument, "Cannot change your own role") }; updates["role"] = role }
|
|
if cmd.Patch.PlanID != nil { planID := *cmd.Patch.PlanID; if err := m.EnsurePlanExists(ctx, planID); err != nil { return nil, err }; updates["plan_id"] = planID }
|
|
if cmd.Patch.Password != nil { if strings.TrimSpace(*cmd.Patch.Password) == "" { return nil, status.Error(codes.InvalidArgument, "Password must not be empty") }; hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*cmd.Patch.Password), bcrypt.DefaultCost); if err != nil { return nil, status.Error(codes.Internal, "Failed to hash password") }; updates["password"] = string(hashedPassword) }
|
|
if len(updates) == 0 { var user model.User; if err := m.runtime.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 user") }; payload, err := m.buildAdminUser(ctx, &user); if err != nil { return nil, status.Error(codes.Internal, "Failed to update user") }; return &payload, nil }
|
|
result := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
|
|
if result.Error != nil { if errors.Is(result.Error, gorm.ErrDuplicatedKey) { return nil, status.Error(codes.AlreadyExists, "Email already registered") }; return nil, status.Error(codes.Internal, "Failed to update user") }
|
|
if result.RowsAffected == 0 { return nil, status.Error(codes.NotFound, "User not found") }
|
|
var user model.User
|
|
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to update user") }
|
|
payload, err := m.buildAdminUser(ctx, &user)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to update user") }
|
|
return &payload, nil
|
|
}
|
|
|
|
func (m *Module) UpdateAdminUserReferralSettings(ctx context.Context, cmd UpdateReferralSettingsCommand) (*AdminUserDetailView, error) {
|
|
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
|
|
id := strings.TrimSpace(cmd.ID)
|
|
if id == "" { return nil, status.Error(codes.NotFound, "User not found") }
|
|
if cmd.ClearReferrer != nil && *cmd.ClearReferrer && cmd.RefUsername != nil && strings.TrimSpace(*cmd.RefUsername) != "" { return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referrer at the same time") }
|
|
if cmd.ClearReferralRewardBps != nil && *cmd.ClearReferralRewardBps && cmd.ReferralRewardBps != nil { return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referral reward override at the same time") }
|
|
if cmd.ReferralRewardBps != nil { bps := *cmd.ReferralRewardBps; 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 := m.runtime.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 cmd.RefUsername != nil || (cmd.ClearReferrer != nil && *cmd.ClearReferrer) {
|
|
if common.ReferralRewardProcessed(&user) { return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted") }
|
|
if cmd.ClearReferrer != nil && *cmd.ClearReferrer { updates["referred_by_user_id"] = nil } else if cmd.RefUsername != nil { referrer, err := m.LoadReferralUserByUsernameStrict(ctx, *cmd.RefUsername); 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 cmd.ReferralEligible != nil { updates["referral_eligible"] = *cmd.ReferralEligible }
|
|
if cmd.ClearReferralRewardBps != nil && *cmd.ClearReferralRewardBps { updates["referral_reward_bps"] = nil } else if cmd.ReferralRewardBps != nil { updates["referral_reward_bps"] = *cmd.ReferralRewardBps }
|
|
if len(updates) > 0 { result := m.runtime.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 := m.runtime.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 := m.runtime.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 := m.buildAdminUserDetail(ctx, &user, subscription)
|
|
if err != nil { return nil, status.Error(codes.Internal, "Failed to update referral settings") }
|
|
return &payload, nil
|
|
}
|
|
|
|
func (m *Module) UpdateAdminUserRole(ctx context.Context, cmd UpdateUserRoleCommand) (string, error) {
|
|
adminResult, err := m.runtime.RequireAdmin(ctx)
|
|
if err != nil { return "", err }
|
|
id := strings.TrimSpace(cmd.ID)
|
|
if id == "" { return "", status.Error(codes.NotFound, "User not found") }
|
|
if id == adminResult.UserID { return "", status.Error(codes.InvalidArgument, "Cannot change your own role") }
|
|
role := common.NormalizeAdminRoleValue(cmd.Role)
|
|
if !common.IsValidAdminRoleValue(role) { return "", status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") }
|
|
result := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("role", role)
|
|
if result.Error != nil { return "", status.Error(codes.Internal, "Failed to update role") }
|
|
if result.RowsAffected == 0 { return "", status.Error(codes.NotFound, "User not found") }
|
|
return role, nil
|
|
}
|
|
|
|
func (m *Module) DeleteAdminUser(ctx context.Context, cmd DeleteAdminUserCommand) error {
|
|
adminResult, err := m.runtime.RequireAdmin(ctx)
|
|
if err != nil { return err }
|
|
id := strings.TrimSpace(cmd.ID)
|
|
if id == "" { return status.Error(codes.NotFound, "User not found") }
|
|
if id == adminResult.UserID { return status.Error(codes.InvalidArgument, "Cannot delete your own account") }
|
|
var user model.User
|
|
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return status.Error(codes.NotFound, "User not found") }; return status.Error(codes.Internal, "Failed to find user") }
|
|
err = m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { tables := []struct { model any; where string }{{&model.AdTemplate{}, "user_id = ?"}, {&model.Notification{}, "user_id = ?"}, {&model.Domain{}, "user_id = ?"}, {&model.WalletTransaction{}, "user_id = ?"}, {&model.PlanSubscription{}, "user_id = ?"}, {&model.UserPreference{}, "user_id = ?"}, {&model.Video{}, "user_id = ?"}, {&model.Payment{}, "user_id = ?"}}; for _, item := range tables { if err := tx.Where(item.where, id).Delete(item.model).Error; err != nil { return err } }; return tx.Where("id = ?", id).Delete(&model.User{}).Error })
|
|
if err != nil { return status.Error(codes.Internal, "Failed to delete user") }
|
|
return nil
|
|
}
|
|
|
|
func mapUserPayload(payload *common.UserPayload) *UserView {
|
|
if payload == nil { return nil }
|
|
return &UserView{ID: payload.ID, Email: payload.Email, Username: payload.Username, Avatar: payload.Avatar, Role: payload.Role, GoogleID: payload.GoogleID, StorageUsed: payload.StorageUsed, PlanID: payload.PlanID, PlanStartedAt: payload.PlanStartedAt, PlanExpiresAt: payload.PlanExpiresAt, PlanTermMonths: payload.PlanTermMonths, PlanPaymentMethod: payload.PlanPaymentMethod, PlanExpiringSoon: payload.PlanExpiringSoon, WalletBalance: payload.WalletBalance, Language: payload.Language, Locale: payload.Locale, CreatedAt: payload.CreatedAt, UpdatedAt: payload.UpdatedAt}
|
|
}
|
|
|
|
func mapPreferences(pref *model.UserPreference) *PreferencesView {
|
|
if pref == nil { return nil }
|
|
return &PreferencesView{EmailNotifications: pref.EmailNotifications != nil && *pref.EmailNotifications, PushNotifications: pref.PushNotifications != nil && *pref.PushNotifications, MarketingNotifications: pref.MarketingNotifications, TelegramNotifications: pref.TelegramNotifications, Language: model.StringValue(pref.Language), Locale: model.StringValue(pref.Locale)}
|
|
}
|
|
|
|
func (m *Module) updateUserProfile(ctx context.Context, userID string, req updateProfileInput) (*model.User, error) {
|
|
updates := map[string]any{}
|
|
if req.Username != nil { updates["username"] = strings.TrimSpace(*req.Username) }
|
|
if req.Email != nil { email := strings.TrimSpace(*req.Email); if email == "" { return nil, ErrEmailRequired }; updates["email"] = email }
|
|
if len(updates) > 0 { if err := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, ErrEmailAlreadyRegistered }; m.runtime.Logger().Error("Failed to update user", "error", err); return nil, err } }
|
|
pref, err := model.FindOrCreateUserPreference(ctx, m.runtime.DB(), userID)
|
|
if err != nil { m.runtime.Logger().Error("Failed to load user preference", "error", err); return nil, err }
|
|
prefChanged := false
|
|
if req.Language != nil { pref.Language = model.StringPtr(strings.TrimSpace(*req.Language)); prefChanged = true }
|
|
if req.Locale != nil { pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale)); prefChanged = true }
|
|
if strings.TrimSpace(model.StringValue(pref.Language)) == "" { pref.Language = model.StringPtr("en"); prefChanged = true }
|
|
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" { pref.Locale = model.StringPtr(model.StringValue(pref.Language)); prefChanged = true }
|
|
if prefChanged { if err := m.runtime.DB().WithContext(ctx).Save(pref).Error; err != nil { m.runtime.Logger().Error("Failed to save user preference", "error", err); return nil, err } }
|
|
u := query.User
|
|
user, err := u.WithContext(ctx).Where(u.ID.Eq(userID)).First()
|
|
if err != nil { return nil, err }
|
|
return user, nil
|
|
}
|
|
|
|
func (m *Module) loadUserPreferences(ctx context.Context, userID string) (*model.UserPreference, error) { return model.FindOrCreateUserPreference(ctx, m.runtime.DB(), userID) }
|
|
|
|
func (m *Module) updateUserPreferences(ctx context.Context, userID string, req updatePreferencesInput) (*model.UserPreference, error) {
|
|
pref, err := model.FindOrCreateUserPreference(ctx, m.runtime.DB(), userID)
|
|
if err != nil { m.runtime.Logger().Error("Failed to load preferences", "error", err); return nil, err }
|
|
if req.EmailNotifications != nil { pref.EmailNotifications = model.BoolPtr(*req.EmailNotifications) }
|
|
if req.PushNotifications != nil { pref.PushNotifications = model.BoolPtr(*req.PushNotifications) }
|
|
if req.MarketingNotifications != nil { pref.MarketingNotifications = *req.MarketingNotifications }
|
|
if req.TelegramNotifications != nil { pref.TelegramNotifications = *req.TelegramNotifications }
|
|
if req.Language != nil { pref.Language = model.StringPtr(strings.TrimSpace(*req.Language)) }
|
|
if req.Locale != nil { pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale)) }
|
|
if strings.TrimSpace(model.StringValue(pref.Language)) == "" { pref.Language = model.StringPtr("en") }
|
|
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" { pref.Locale = model.StringPtr(model.StringValue(pref.Language)) }
|
|
if err := m.runtime.DB().WithContext(ctx).Save(pref).Error; err != nil { m.runtime.Logger().Error("Failed to save preferences", "error", err); return nil, err }
|
|
return pref, nil
|
|
}
|
|
|
|
func (m *Module) loadUsage(ctx context.Context, user *model.User) (*usagePayload, error) {
|
|
var totalVideos int64
|
|
if err := m.runtime.DB().WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil { m.runtime.Logger().Error("Failed to count user videos", "error", err, "user_id", user.ID); return nil, err }
|
|
return &usagePayload{UserID: user.ID, TotalVideos: totalVideos, TotalStorage: user.StorageUsed}, nil
|
|
}
|
|
|
|
func (m *Module) buildReferralShareLink(username *string) *string {
|
|
trimmed := strings.TrimSpace(common.StringValue(username))
|
|
if trimmed == "" { return nil }
|
|
path := "/ref/" + trimmed
|
|
base := strings.TrimRight(strings.TrimSpace(m.runtime.FrontendBaseURL()), "/")
|
|
if base == "" { return &path }
|
|
link := base + path
|
|
return &link
|
|
}
|
|
|
|
func (m *Module) loadReferralUsersByUsername(ctx context.Context, username string) ([]model.User, error) {
|
|
trimmed := strings.TrimSpace(username)
|
|
if trimmed == "" { return nil, nil }
|
|
var users []model.User
|
|
if err := m.runtime.DB().WithContext(ctx).Where("LOWER(username) = LOWER(?)", trimmed).Order("created_at ASC, id ASC").Limit(2).Find(&users).Error; err != nil { return nil, err }
|
|
return users, nil
|
|
}
|
|
|
|
func (m *Module) resolveReferralUserByUsername(ctx context.Context, username string) (*model.User, error) {
|
|
users, err := m.loadReferralUsersByUsername(ctx, username)
|
|
if err != nil { return nil, err }
|
|
if len(users) != 1 { return nil, nil }
|
|
return &users[0], nil
|
|
}
|
|
|
|
func (m *Module) buildAdminUser(ctx context.Context, user *model.User) (AdminUserView, error) {
|
|
if user == nil { return AdminUserView{}, nil }
|
|
videoCount, err := m.loadAdminUserVideoCount(ctx, user.ID); if err != nil { return AdminUserView{}, err }
|
|
walletBalance, err := model.GetWalletBalance(ctx, m.runtime.DB(), user.ID); if err != nil { return AdminUserView{}, err }
|
|
planName, err := m.loadAdminPlanName(ctx, user.PlanID); if err != nil { return AdminUserView{}, err }
|
|
return AdminUserView{ID: user.ID, Email: user.Email, Username: common.NullableTrimmedString(user.Username), Avatar: common.NullableTrimmedString(user.Avatar), Role: common.NullableTrimmedString(user.Role), PlanID: common.NullableTrimmedString(user.PlanID), PlanName: planName, StorageUsed: user.StorageUsed, VideoCount: videoCount, WalletBalance: walletBalance, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt}, nil
|
|
}
|
|
|
|
func (m *Module) buildAdminUserDetail(ctx context.Context, user *model.User, subscription *model.PlanSubscription) (AdminUserDetailView, error) {
|
|
payload, err := m.buildAdminUser(ctx, user); if err != nil { return AdminUserDetailView{}, err }
|
|
referral, err := m.buildAdminUserReferralInfo(ctx, user); if err != nil { return AdminUserDetailView{}, err }
|
|
return AdminUserDetailView{User: payload, Subscription: subscription, Referral: referral}, nil
|
|
}
|
|
|
|
func (m *Module) buildAdminUserReferralInfo(ctx context.Context, user *model.User) (*AdminUserReferralInfoView, error) {
|
|
if user == nil { return nil, nil }
|
|
var referrer *ReferralUserSummaryView
|
|
if user.ReferredByUserID != nil && strings.TrimSpace(*user.ReferredByUserID) != "" { loadedReferrer, err := m.loadReferralUserSummary(ctx, strings.TrimSpace(*user.ReferredByUserID)); if err != nil { return nil, err }; referrer = loadedReferrer }
|
|
bps := common.EffectiveReferralRewardBps(user.ReferralRewardBps)
|
|
return &AdminUserReferralInfoView{Referrer: referrer, ReferralEligible: common.ReferralUserEligible(user), EffectiveRewardPercent: common.ReferralRewardBpsToPercent(bps), RewardOverridePercent: func() *float64 { if user.ReferralRewardBps == nil { return nil }; value := common.ReferralRewardBpsToPercent(*user.ReferralRewardBps); return &value }(), ShareLink: m.buildReferralShareLink(user.Username), RewardGranted: common.ReferralRewardProcessed(user), RewardGrantedAt: user.ReferralRewardGrantedAt, RewardPaymentID: common.NullableTrimmedString(user.ReferralRewardPaymentID), RewardAmount: user.ReferralRewardAmount}, nil
|
|
}
|
|
|
|
func (m *Module) loadAdminUserVideoCount(ctx context.Context, userID string) (int64, error) { var videoCount int64; if err := m.runtime.DB().WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", userID).Count(&videoCount).Error; err != nil { return 0, err }; return videoCount, nil }
|
|
|
|
func (m *Module) loadReferralUserSummary(ctx context.Context, userID string) (*ReferralUserSummaryView, error) {
|
|
if strings.TrimSpace(userID) == "" { return nil, nil }
|
|
var user model.User
|
|
if err := m.runtime.DB().WithContext(ctx).Select("id, email, username").Where("id = ?", userID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil }; return nil, err }
|
|
return &ReferralUserSummaryView{ID: user.ID, Email: user.Email, Username: common.NullableTrimmedString(user.Username)}, nil
|
|
}
|
|
|
|
func (m *Module) loadAdminPlanName(ctx context.Context, planID *string) (*string, error) {
|
|
if planID == nil || strings.TrimSpace(*planID) == "" { return nil, nil }
|
|
var plan model.Plan
|
|
if err := m.runtime.DB().WithContext(ctx).Select("id, name").Where("id = ?", *planID).First(&plan).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil }; return nil, err }
|
|
return common.NullableTrimmedString(&plan.Name), nil
|
|
}
|