This commit is contained in:
2026-03-26 13:02:43 +00:00
parent a689f8b9da
commit eb7b519e49
64 changed files with 7081 additions and 5572 deletions

216
internal/rpc/app/core.go Normal file
View File

@@ -0,0 +1,216 @@
package app
import (
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm"
"stream.api/internal/config"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
adminhandler "stream.api/internal/modules/admin"
adtemplatesmodule "stream.api/internal/modules/adtemplates"
authmodule "stream.api/internal/modules/auth"
"stream.api/internal/modules/common"
dashboardmodule "stream.api/internal/modules/dashboard"
domainsmodule "stream.api/internal/modules/domains"
jobsmodule "stream.api/internal/modules/jobs"
paymentsmodule "stream.api/internal/modules/payments"
playerconfigsmodule "stream.api/internal/modules/playerconfigs"
plansmodule "stream.api/internal/modules/plans"
usersmodule "stream.api/internal/modules/users"
videosmodule "stream.api/internal/modules/videos"
"stream.api/internal/video"
"stream.api/pkg/cache"
"stream.api/pkg/logger"
"stream.api/pkg/storage"
"stream.api/pkg/token"
)
const defaultGoogleUserInfoURL = common.DefaultGoogleUserInfoURL
type Services struct {
AuthServiceServer
AccountServiceServer
PreferencesServiceServer
UsageServiceServer
NotificationsServiceServer
DomainsServiceServer
AdTemplatesServiceServer
PlayerConfigsServiceServer
PlansServiceServer
PaymentsServiceServer
VideosServiceServer
AdminServiceServer
}
type appServices struct {
db *gorm.DB
logger logger.Logger
authenticator *middleware.Authenticator
tokenProvider token.Provider
cache cache.Cache
storageProvider storage.Provider
videoService *video.Service
agentRuntime video.AgentRuntime
googleOauth *oauth2.Config
googleStateTTL time.Duration
googleUserInfoURL string
frontendBaseURL string
runtime *common.Runtime
authModule *authmodule.Module
usersModule *usersmodule.Module
domainsModule *domainsmodule.Module
adTemplatesModule *adtemplatesmodule.Module
playerConfigsModule *playerconfigsmodule.Module
plansModule *plansmodule.Module
paymentsModule *paymentsmodule.Module
videosModule *videosmodule.Module
jobsModule *jobsmodule.Module
dashboardModule *dashboardmodule.Module
authHandler *authmodule.Handler
accountHandler *usersmodule.AccountHandler
preferencesHandler *usersmodule.PreferencesHandler
usageHandler *usersmodule.UsageHandler
notificationsHandler *usersmodule.NotificationsHandler
domainsHandler *domainsmodule.Handler
adTemplatesHandler *adtemplatesmodule.Handler
playerConfigsHandler *playerconfigsmodule.Handler
plansHandler *plansmodule.Handler
paymentsHandler *paymentsmodule.Handler
videosHandler *videosmodule.Handler
adminHandler *adminhandler.Handler
}
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *video.Service, agentRuntime video.AgentRuntime) *Services {
var storageProvider storage.Provider
if cfg != nil {
provider, err := storage.NewS3Provider(cfg)
if err != nil {
l.Error("Failed to initialize S3 provider for gRPC app services", "error", err)
} else {
storageProvider = provider
}
}
googleStateTTL := 10 * time.Minute
googleOauth := &oauth2.Config{}
frontendBaseURL := ""
trustedMarker := ""
if cfg != nil {
if cfg.Google.StateTTLMinute > 0 {
googleStateTTL = time.Duration(cfg.Google.StateTTLMinute) * time.Minute
}
googleOauth = &oauth2.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
RedirectURL: cfg.Google.RedirectURL,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
frontendBaseURL = cfg.Frontend.BaseURL
trustedMarker = cfg.Internal.Marker
}
service := &appServices{
db: db,
logger: l,
authenticator: middleware.NewAuthenticator(db, l, trustedMarker),
tokenProvider: t,
cache: c,
storageProvider: storageProvider,
videoService: videoService,
agentRuntime: agentRuntime,
googleOauth: googleOauth,
googleStateTTL: googleStateTTL,
googleUserInfoURL: defaultGoogleUserInfoURL,
frontendBaseURL: frontendBaseURL,
}
service.initModules()
return &Services{
AuthServiceServer: service.authHandler,
AccountServiceServer: service.accountHandler,
PreferencesServiceServer: service.preferencesHandler,
UsageServiceServer: service.usageHandler,
NotificationsServiceServer: service.notificationsHandler,
DomainsServiceServer: service.domainsHandler,
AdTemplatesServiceServer: service.adTemplatesHandler,
PlayerConfigsServiceServer: service.playerConfigsHandler,
PlansServiceServer: service.plansHandler,
PaymentsServiceServer: service.paymentsHandler,
VideosServiceServer: service.videosHandler,
AdminServiceServer: service.adminHandler,
}
}
type AuthServiceServer interface { appv1.AuthServiceServer }
type AccountServiceServer interface { appv1.AccountServiceServer }
type PreferencesServiceServer interface { appv1.PreferencesServiceServer }
type UsageServiceServer interface { appv1.UsageServiceServer }
type NotificationsServiceServer interface { appv1.NotificationsServiceServer }
type DomainsServiceServer interface { appv1.DomainsServiceServer }
type AdTemplatesServiceServer interface { appv1.AdTemplatesServiceServer }
type PlayerConfigsServiceServer interface { appv1.PlayerConfigsServiceServer }
type PlansServiceServer interface { appv1.PlansServiceServer }
type PaymentsServiceServer interface { appv1.PaymentsServiceServer }
type VideosServiceServer interface { appv1.VideosServiceServer }
type AdminServiceServer interface { appv1.AdminServiceServer }
func (s *appServices) initModules() {
s.runtime = common.NewRuntime(common.RuntimeOptions{
DB: s.db,
Logger: s.logger,
Authenticator: s.authenticator,
TokenProvider: s.tokenProvider,
Cache: s.cache,
GoogleOauth: s.googleOauth,
GoogleStateTTL: s.googleStateTTL,
GoogleUserInfoURL: s.googleUserInfoURL,
FrontendBaseURL: s.frontendBaseURL,
StorageProvider: func() storage.Provider {
return s.storageProvider
},
VideoService: func() *video.Service {
return s.videoService
},
AgentRuntime: func() video.AgentRuntime {
return s.agentRuntime
},
})
s.usersModule = usersmodule.New(s.runtime)
s.authModule = authmodule.New(s.runtime, s.usersModule)
s.domainsModule = domainsmodule.New(s.runtime)
s.adTemplatesModule = adtemplatesmodule.New(s.runtime)
s.playerConfigsModule = playerconfigsmodule.New(s.runtime)
s.plansModule = plansmodule.New(s.runtime)
s.paymentsModule = paymentsmodule.New(s.runtime)
s.videosModule = videosmodule.New(s.runtime)
s.jobsModule = jobsmodule.New(s.runtime)
s.dashboardModule = dashboardmodule.New(s.runtime)
s.authHandler = authmodule.NewHandler(s.authModule)
s.accountHandler = usersmodule.NewAccountHandler(s.usersModule)
s.preferencesHandler = usersmodule.NewPreferencesHandler(s.usersModule)
s.usageHandler = usersmodule.NewUsageHandler(s.usersModule)
s.notificationsHandler = usersmodule.NewNotificationsHandler(s.usersModule)
s.domainsHandler = domainsmodule.NewHandler(s.domainsModule)
s.adTemplatesHandler = adtemplatesmodule.NewHandler(s.adTemplatesModule)
s.playerConfigsHandler = playerconfigsmodule.NewHandler(s.playerConfigsModule)
s.plansHandler = plansmodule.NewHandler(s.plansModule)
s.paymentsHandler = paymentsmodule.NewHandler(s.paymentsModule)
s.videosHandler = videosmodule.NewHandler(s.videosModule)
s.adminHandler = adminhandler.NewHandler(s.dashboardModule, s.usersModule, s.videosModule, s.paymentsModule, s.plansModule, s.adTemplatesModule, s.playerConfigsModule, s.jobsModule)
}
var (
_ = model.Plan{}
)

View File

@@ -1,63 +0,0 @@
package app
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
)
type updatePreferencesInput struct {
EmailNotifications *bool
PushNotifications *bool
MarketingNotifications *bool
TelegramNotifications *bool
Language *string
Locale *string
}
func loadUserPreferences(ctx context.Context, db *gorm.DB, userID string) (*model.UserPreference, error) {
return model.FindOrCreateUserPreference(ctx, db, userID)
}
func updateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updatePreferencesInput) (*model.UserPreference, error) {
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
if err != nil {
l.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 := db.WithContext(ctx).Save(pref).Error; err != nil {
l.Error("Failed to save preferences", "error", err)
return nil, err
}
return pref, nil
}

View File

@@ -1,87 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/pkg/logger"
)
var (
errEmailRequired = errors.New("Email is required")
errEmailAlreadyRegistered = errors.New("Email already registered")
)
type updateProfileInput struct {
Username *string
Email *string
Language *string
Locale *string
}
func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updateProfileInput) (*model.User, error) {
updates := map[string]any{}
if req.Username != nil {
username := strings.TrimSpace(*req.Username)
updates["username"] = username
}
if req.Email != nil {
email := strings.TrimSpace(*req.Email)
if email == "" {
return nil, errEmailRequired
}
updates["email"] = email
}
if len(updates) > 0 {
if err := db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, errEmailAlreadyRegistered
}
l.Error("Failed to update user", "error", err)
return nil, err
}
}
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
if err != nil {
l.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 := db.WithContext(ctx).Save(pref).Error; err != nil {
l.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
}

View File

@@ -1,187 +0,0 @@
package app
import (
"context"
"errors"
"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"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := buildUserPayload(ctx, s.db, result.User)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return &appv1.GetMeResponse{User: toProtoUser(payload)}, nil
}
func (s *appServices) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) {
_, err := s.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 := buildUserPayload(ctx, s.db, user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return toProtoUser(payload), nil
}
func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
updatedUser, err := updateUserProfile(ctx, s.db, s.logger, result.UserID, updateProfileInput{
Username: req.Username,
Email: req.Email,
Language: req.Language,
Locale: req.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 := buildUserPayload(ctx, s.db, updatedUser)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return &appv1.UpdateMeResponse{User: toProtoUser(payload)}, nil
}
func (s *appServices) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
userID := result.UserID
if err := s.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 {
s.logger.Error("Failed to delete user", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete account")
}
return messageResponse("Account deleted successfully"), nil
}
func (s *appServices) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
userID := result.UserID
if err := s.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]interface{}{"storage_used": 0}).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to clear user data", "error", err)
return nil, status.Error(codes.Internal, "Failed to clear data")
}
return messageResponse("Data cleared successfully"), nil
}
func (s *appServices) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
pref, err := loadUserPreferences(ctx, s.db, result.UserID)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load preferences")
}
return &appv1.GetPreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
}
func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
pref, err := updateUserPreferences(ctx, s.db, s.logger, result.UserID, updatePreferencesInput{
EmailNotifications: req.EmailNotifications,
PushNotifications: req.PushNotifications,
MarketingNotifications: req.MarketingNotifications,
TelegramNotifications: req.TelegramNotifications,
Language: req.Language,
Locale: req.Locale,
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save preferences")
}
return &appv1.UpdatePreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
}
func (s *appServices) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := loadUsage(ctx, s.db, s.logger, result.User)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load usage")
}
return &appv1.GetUsageResponse{
UserId: payload.UserID,
TotalVideos: payload.TotalVideos,
TotalStorage: payload.TotalStorage,
}, nil
}

View File

@@ -1,735 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) ListAdminPayments(ctx context.Context, req *appv1.ListAdminPaymentsRequest) (*appv1.ListAdminPaymentsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
userID := strings.TrimSpace(req.GetUserId())
statusFilter := strings.TrimSpace(req.GetStatus())
db := s.db.WithContext(ctx).Model(&model.Payment{})
if userID != "" {
db = db.Where("user_id = ?", userID)
}
if statusFilter != "" {
db = db.Where("UPPER(status) = ?", strings.ToUpper(statusFilter))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments")
}
var payments []model.Payment
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&payments).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments")
}
items := make([]*appv1.AdminPayment, 0, len(payments))
for _, payment := range payments {
payload, err := s.buildAdminPayment(ctx, &payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments")
}
items = append(items, payload)
}
return &appv1.ListAdminPaymentsResponse{Payments: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetAdminPayment(ctx context.Context, req *appv1.GetAdminPaymentRequest) (*appv1.GetAdminPaymentResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Payment not found")
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Payment not found")
}
return nil, status.Error(codes.Internal, "Failed to get payment")
}
payload, err := s.buildAdminPayment(ctx, &payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get payment")
}
return &appv1.GetAdminPaymentResponse{Payment: payload}, nil
}
func (s *appServices) CreateAdminPayment(ctx context.Context, req *appv1.CreateAdminPaymentRequest) (*appv1.CreateAdminPaymentResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
userID := strings.TrimSpace(req.GetUserId())
planID := strings.TrimSpace(req.GetPlanId())
if userID == "" || planID == "" {
return nil, status.Error(codes.InvalidArgument, "User ID and plan ID are required")
}
if !isAllowedTermMonths(req.GetTermMonths()) {
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
}
paymentMethod := normalizePaymentMethod(req.GetPaymentMethod())
if paymentMethod == "" {
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
}
user, err := s.loadPaymentUserForAdmin(ctx, userID)
if err != nil {
return nil, err
}
planRecord, err := s.loadPaymentPlanForAdmin(ctx, planID)
if err != nil {
return nil, err
}
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
UserID: user.ID,
Plan: planRecord,
TermMonths: req.GetTermMonths(),
PaymentMethod: paymentMethod,
TopupAmount: req.TopupAmount,
})
if err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, status.Error(codes.Internal, "Failed to create payment")
}
payload, err := s.buildAdminPayment(ctx, resultValue.Payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create payment")
}
return &appv1.CreateAdminPaymentResponse{
Payment: payload,
Subscription: toProtoPlanSubscription(resultValue.Subscription),
WalletBalance: resultValue.WalletBalance,
InvoiceId: resultValue.InvoiceID,
}, nil
}
func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateAdminPaymentRequest) (*appv1.UpdateAdminPaymentResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Payment not found")
}
newStatus := strings.ToUpper(strings.TrimSpace(req.GetStatus()))
if newStatus == "" {
newStatus = "SUCCESS"
}
if newStatus != "SUCCESS" && newStatus != "FAILED" && newStatus != "PENDING" {
return nil, status.Error(codes.InvalidArgument, "Invalid payment status")
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Payment not found")
}
return nil, status.Error(codes.Internal, "Failed to update payment")
}
currentStatus := strings.ToUpper(normalizePaymentStatus(payment.Status))
if currentStatus != newStatus {
if (currentStatus == "FAILED" || currentStatus == "PENDING") && newStatus == "SUCCESS" {
return nil, status.Error(codes.InvalidArgument, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead")
}
payment.Status = model.StringPtr(newStatus)
if err := s.db.WithContext(ctx).Save(&payment).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update payment")
}
}
payload, err := s.buildAdminPayment(ctx, &payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update payment")
}
return &appv1.UpdateAdminPaymentResponse{Payment: payload}, nil
}
func (s *appServices) ListAdminPlans(ctx context.Context, _ *appv1.ListAdminPlansRequest) (*appv1.ListAdminPlansResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
var plans []model.Plan
if err := s.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list plans")
}
items := make([]*appv1.AdminPlan, 0, len(plans))
for i := range plans {
payload, err := s.buildAdminPlan(ctx, &plans[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list plans")
}
items = append(items, payload)
}
return &appv1.ListAdminPlansResponse{Plans: items}, nil
}
func (s *appServices) CreateAdminPlan(ctx context.Context, req *appv1.CreateAdminPlanRequest) (*appv1.CreateAdminPlanResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if msg := validateAdminPlanInput(req.GetName(), req.GetCycle(), req.GetPrice(), req.GetStorageLimit(), req.GetUploadLimit()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
plan := &model.Plan{
ID: uuid.New().String(),
Name: strings.TrimSpace(req.GetName()),
Description: nullableTrimmedStringPtr(req.Description),
Features: append([]string(nil), req.GetFeatures()...),
Price: req.GetPrice(),
Cycle: strings.TrimSpace(req.GetCycle()),
StorageLimit: req.GetStorageLimit(),
UploadLimit: req.GetUploadLimit(),
DurationLimit: 0,
QualityLimit: "",
IsActive: model.BoolPtr(req.GetIsActive()),
}
if err := s.db.WithContext(ctx).Create(plan).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to create plan")
}
payload, err := s.buildAdminPlan(ctx, plan)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create plan")
}
return &appv1.CreateAdminPlanResponse{Plan: payload}, nil
}
func (s *appServices) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdminPlanRequest) (*appv1.UpdateAdminPlanResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Plan not found")
}
if msg := validateAdminPlanInput(req.GetName(), req.GetCycle(), req.GetPrice(), req.GetStorageLimit(), req.GetUploadLimit()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var plan model.Plan
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found")
}
return nil, status.Error(codes.Internal, "Failed to update plan")
}
plan.Name = strings.TrimSpace(req.GetName())
plan.Description = nullableTrimmedStringPtr(req.Description)
plan.Features = append([]string(nil), req.GetFeatures()...)
plan.Price = req.GetPrice()
plan.Cycle = strings.TrimSpace(req.GetCycle())
plan.StorageLimit = req.GetStorageLimit()
plan.UploadLimit = req.GetUploadLimit()
plan.IsActive = model.BoolPtr(req.GetIsActive())
if err := s.db.WithContext(ctx).Save(&plan).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan")
}
payload, err := s.buildAdminPlan(ctx, &plan)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan")
}
return &appv1.UpdateAdminPlanResponse{Plan: payload}, nil
}
func (s *appServices) DeleteAdminPlan(ctx context.Context, req *appv1.DeleteAdminPlanRequest) (*appv1.DeleteAdminPlanResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Plan not found")
}
var plan model.Plan
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found")
}
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
var paymentCount int64
if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", id).Count(&paymentCount).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
var subscriptionCount int64
if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", id).Count(&subscriptionCount).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
if paymentCount > 0 || subscriptionCount > 0 {
inactive := false
if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", id).Update("is_active", inactive).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to deactivate plan")
}
return &appv1.DeleteAdminPlanResponse{Message: "Plan deactivated", Mode: "deactivated"}, nil
}
if err := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Plan{}).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
return &appv1.DeleteAdminPlanResponse{Message: "Plan deleted", Mode: "deleted"}, nil
}
func (s *appServices) ListAdminAdTemplates(ctx context.Context, req *appv1.ListAdminAdTemplatesRequest) (*appv1.ListAdminAdTemplatesResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(protoStringValue(req.Search))
userID := strings.TrimSpace(protoStringValue(req.UserId))
db := s.db.WithContext(ctx).Model(&model.AdTemplate{})
if search != "" {
like := "%" + search + "%"
db = db.Where("name ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
var templates []model.AdTemplate
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&templates).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
items := make([]*appv1.AdminAdTemplate, 0, len(templates))
for i := range templates {
payload, err := s.buildAdminAdTemplate(ctx, &templates[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
items = append(items, payload)
}
return &appv1.ListAdminAdTemplatesResponse{
Templates: items,
Total: total,
Page: page,
Limit: limit,
}, nil
}
func (s *appServices) GetAdminAdTemplate(ctx context.Context, req *appv1.GetAdminAdTemplateRequest) (*appv1.GetAdminAdTemplateResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
var item model.AdTemplate
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to load ad template")
}
payload, err := s.buildAdminAdTemplate(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load ad template")
}
return &appv1.GetAdminAdTemplateResponse{Template: payload}, nil
}
func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.CreateAdminAdTemplateRequest) (*appv1.CreateAdminAdTemplateResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
duration := req.Duration
if msg := validateAdminAdTemplateInput(req.GetUserId(), req.GetName(), req.GetVastTagUrl(), req.GetAdFormat(), duration); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).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 save ad template")
}
item := &model.AdTemplate{
ID: uuid.New().String(),
UserID: user.ID,
Name: strings.TrimSpace(req.GetName()),
Description: nullableTrimmedStringPtr(req.Description),
VastTagURL: strings.TrimSpace(req.GetVastTagUrl()),
AdFormat: model.StringPtr(normalizeAdFormat(req.GetAdFormat())),
Duration: duration,
IsActive: model.BoolPtr(req.GetIsActive()),
IsDefault: req.GetIsDefault(),
}
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
payload, err := s.buildAdminAdTemplate(ctx, item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.CreateAdminAdTemplateResponse{Template: payload}, nil
}
func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.UpdateAdminAdTemplateRequest) (*appv1.UpdateAdminAdTemplateResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
duration := req.Duration
if msg := validateAdminAdTemplateInput(req.GetUserId(), req.GetName(), req.GetVastTagUrl(), req.GetAdFormat(), duration); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).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 save ad template")
}
var item model.AdTemplate
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item.UserID = user.ID
item.Name = strings.TrimSpace(req.GetName())
item.Description = nullableTrimmedStringPtr(req.Description)
item.VastTagURL = strings.TrimSpace(req.GetVastTagUrl())
item.AdFormat = model.StringPtr(normalizeAdFormat(req.GetAdFormat()))
item.Duration = duration
item.IsActive = model.BoolPtr(req.GetIsActive())
item.IsDefault = req.GetIsDefault()
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
payload, err := s.buildAdminAdTemplate(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.UpdateAdminAdTemplateResponse{Template: payload}, nil
}
func (s *appServices) DeleteAdminAdTemplate(ctx context.Context, req *appv1.DeleteAdminAdTemplateRequest) (*appv1.MessageResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).Where("ad_id = ?", id).Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ?", id).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to delete ad template")
}
return &appv1.MessageResponse{Message: "Ad template deleted"}, nil
}
func (s *appServices) ListAdminPlayerConfigs(ctx context.Context, req *appv1.ListAdminPlayerConfigsRequest) (*appv1.ListAdminPlayerConfigsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(protoStringValue(req.Search))
userID := strings.TrimSpace(protoStringValue(req.UserId))
db := s.db.WithContext(ctx).Model(&model.PlayerConfig{})
if search != "" {
like := "%" + search + "%"
db = db.Where("name ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
var configs []model.PlayerConfig
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&configs).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
items := make([]*appv1.AdminPlayerConfig, 0, len(configs))
for i := range configs {
payload, err := s.buildAdminPlayerConfig(ctx, &configs[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
items = append(items, payload)
}
return &appv1.ListAdminPlayerConfigsResponse{
Configs: items,
Total: total,
Page: page,
Limit: limit,
}, nil
}
func (s *appServices) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAdminPlayerConfigRequest) (*appv1.GetAdminPlayerConfigResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
var item model.PlayerConfig
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
return nil, status.Error(codes.Internal, "Failed to load player config")
}
payload, err := s.buildAdminPlayerConfig(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load player config")
}
return &appv1.GetAdminPlayerConfigResponse{Config: payload}, nil
}
func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.CreateAdminPlayerConfigRequest) (*appv1.CreateAdminPlayerConfigResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).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 save player config")
}
item := &model.PlayerConfig{
ID: uuid.New().String(),
UserID: user.ID,
Name: strings.TrimSpace(req.GetName()),
Description: nullableTrimmedStringPtr(req.Description),
Autoplay: req.GetAutoplay(),
Loop: req.GetLoop(),
Muted: req.GetMuted(),
ShowControls: model.BoolPtr(req.GetShowControls()),
Pip: model.BoolPtr(req.GetPip()),
Airplay: model.BoolPtr(req.GetAirplay()),
Chromecast: model.BoolPtr(req.GetChromecast()),
IsActive: model.BoolPtr(req.GetIsActive()),
IsDefault: req.GetIsDefault(),
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
LogoURL: nullableTrimmedStringPtr(req.LogoUrl),
}
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
payload, err := s.buildAdminPlayerConfig(ctx, item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.CreateAdminPlayerConfigResponse{Config: payload}, nil
}
func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.UpdateAdminPlayerConfigRequest) (*appv1.UpdateAdminPlayerConfigResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).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 save player config")
}
var item model.PlayerConfig
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
return nil, status.Error(codes.Internal, "Failed to save player config")
}
item.UserID = user.ID
item.Name = strings.TrimSpace(req.GetName())
item.Description = nullableTrimmedStringPtr(req.Description)
item.Autoplay = req.GetAutoplay()
item.Loop = req.GetLoop()
item.Muted = req.GetMuted()
item.ShowControls = model.BoolPtr(req.GetShowControls())
item.Pip = model.BoolPtr(req.GetPip())
item.Airplay = model.BoolPtr(req.GetAirplay())
item.Chromecast = model.BoolPtr(req.GetChromecast())
item.IsActive = model.BoolPtr(req.GetIsActive())
item.IsDefault = req.GetIsDefault()
if req.EncrytionM3U8 != nil {
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
}
if req.LogoUrl != nil {
item.LogoURL = nullableTrimmedStringPtr(req.LogoUrl)
}
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
payload, err := s.buildAdminPlayerConfig(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.UpdateAdminPlayerConfigResponse{Config: payload}, nil
}
func (s *appServices) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.DeleteAdminPlayerConfigRequest) (*appv1.MessageResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
res := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.PlayerConfig{})
if res.Error != nil {
return nil, status.Error(codes.Internal, "Failed to delete player config")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Player config not found")
}
return &appv1.MessageResponse{Message: "Player config deleted"}, nil
}

View File

@@ -9,29 +9,23 @@ import (
"google.golang.org/grpc/metadata"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
func TestCreateAdminPayment(t *testing.T) {
t.Run("happy path admin", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newAdminClient(conn)
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
if err != nil {
t.Fatalf("CreateAdminPayment() error = %v", err)
}
@@ -41,8 +35,8 @@ func TestCreateAdminPayment(t *testing.T) {
if resp.Payment.UserId != user.ID {
t.Fatalf("payment user_id = %q, want %q", resp.Payment.UserId, user.ID)
}
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
}
if resp.Payment.GetWalletAmount() != 30 {
t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount())
@@ -64,12 +58,7 @@ func TestCreateAdminPayment(t *testing.T) {
client := newAdminClient(conn)
var trailer metadata.MD
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument)
if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" {
t.Fatal("expected x-error-body trailer")

View File

@@ -1,212 +0,0 @@
package app
import (
"context"
"encoding/json"
"errors"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/video"
)
func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJobsRequest) (*appv1.ListAdminJobsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
agentID := strings.TrimSpace(req.GetAgentId())
offset := int(req.GetOffset())
limit := int(req.GetLimit())
pageSize := int(req.GetPageSize())
useCursorPagination := req.Cursor != nil || pageSize > 0
var (
result *video.PaginatedJobs
err error
)
if useCursorPagination {
result, err = s.videoService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize)
} else if agentID != "" {
result, err = s.videoService.ListJobsByAgent(ctx, agentID, offset, limit)
} else {
result, err = s.videoService.ListJobs(ctx, offset, limit)
}
if err != nil {
if errors.Is(err, video.ErrInvalidJobCursor) {
return nil, status.Error(codes.InvalidArgument, "Invalid job cursor")
}
return nil, status.Error(codes.Internal, "Failed to list jobs")
}
jobs := make([]*appv1.AdminJob, 0, len(result.Jobs))
for _, job := range result.Jobs {
jobs = append(jobs, buildAdminJob(job))
}
response := &appv1.ListAdminJobsResponse{
Jobs: jobs,
Total: result.Total,
Offset: int32(result.Offset),
Limit: int32(result.Limit),
HasMore: result.HasMore,
PageSize: int32(result.PageSize),
}
if strings.TrimSpace(result.NextCursor) != "" {
response.NextCursor = &result.NextCursor
}
return response, nil
}
func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobRequest) (*appv1.GetAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
job, err := s.videoService.GetJob(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.Internal, "Failed to load job")
}
return &appv1.GetAdminJobResponse{Job: buildAdminJob(job)}, nil
}
func (s *appServices) GetAdminJobLogs(ctx context.Context, req *appv1.GetAdminJobLogsRequest) (*appv1.GetAdminJobLogsResponse, error) {
response, err := s.GetAdminJob(ctx, &appv1.GetAdminJobRequest{Id: req.GetId()})
if err != nil {
return nil, err
}
return &appv1.GetAdminJobLogsResponse{Logs: response.GetJob().GetLogs()}, nil
}
func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdminJobRequest) (*appv1.CreateAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
command := strings.TrimSpace(req.GetCommand())
if command == "" {
return nil, status.Error(codes.InvalidArgument, "Command is required")
}
image := strings.TrimSpace(req.GetImage())
if image == "" {
image = "alpine"
}
name := strings.TrimSpace(req.GetName())
if name == "" {
name = command
}
payload, err := json.Marshal(map[string]any{
"image": image,
"commands": []string{command},
"environment": req.GetEnv(),
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create job payload")
}
videoID := ""
if req.VideoId != nil {
videoID = strings.TrimSpace(req.GetVideoId())
}
job, err := s.videoService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit())
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create job")
}
return &appv1.CreateAdminJobResponse{Job: buildAdminJob(job)}, nil
}
func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdminJobRequest) (*appv1.CancelAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
if err := s.videoService.CancelJob(ctx, id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &appv1.CancelAdminJobResponse{Status: "cancelled", JobId: id}, nil
}
func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJobRequest) (*appv1.RetryAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
job, err := s.videoService.RetryJob(ctx, id)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &appv1.RetryAdminJobResponse{Job: buildAdminJob(job)}, nil
}
func (s *appServices) ListAdminAgents(ctx context.Context, _ *appv1.ListAdminAgentsRequest) (*appv1.ListAdminAgentsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.agentRuntime == nil {
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
items := s.agentRuntime.ListAgentsWithStats()
agents := make([]*appv1.AdminAgent, 0, len(items))
for _, item := range items {
agents = append(agents, buildAdminAgent(item))
}
return &appv1.ListAdminAgentsResponse{Agents: agents}, nil
}
func (s *appServices) RestartAdminAgent(ctx context.Context, req *appv1.RestartAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.agentRuntime == nil {
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
if !s.agentRuntime.SendCommand(strings.TrimSpace(req.GetId()), "restart") {
return nil, status.Error(codes.Unavailable, "Agent not active or command channel full")
}
return &appv1.AdminAgentCommandResponse{Status: "restart command sent"}, nil
}
func (s *appServices) UpdateAdminAgent(ctx context.Context, req *appv1.UpdateAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.agentRuntime == nil {
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
if !s.agentRuntime.SendCommand(strings.TrimSpace(req.GetId()), "update") {
return nil, status.Error(codes.Unavailable, "Agent not active or command channel full")
}
return &appv1.AdminAgentCommandResponse{Status: "update command sent"}, nil
}

View File

@@ -1,637 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"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) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
dashboard := &appv1.AdminDashboard{}
db := s.db.WithContext(ctx)
db.Model(&model.User{}).Count(&dashboard.TotalUsers)
db.Model(&model.Video{}).Count(&dashboard.TotalVideos)
db.Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Row().Scan(&dashboard.TotalStorageUsed)
db.Model(&model.Payment{}).Count(&dashboard.TotalPayments)
db.Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Row().Scan(&dashboard.TotalRevenue)
db.Model(&model.PlanSubscription{}).Where("expires_at > ?", time.Now()).Count(&dashboard.ActiveSubscriptions)
db.Model(&model.AdTemplate{}).Count(&dashboard.TotalAdTemplates)
today := time.Now().Truncate(24 * time.Hour)
db.Model(&model.User{}).Where("created_at >= ?", today).Count(&dashboard.NewUsersToday)
db.Model(&model.Video{}).Where("created_at >= ?", today).Count(&dashboard.NewVideosToday)
return &appv1.GetAdminDashboardResponse{Dashboard: dashboard}, nil
}
func (s *appServices) ListAdminUsers(ctx context.Context, req *appv1.ListAdminUsersRequest) (*appv1.ListAdminUsersResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(req.GetSearch())
role := strings.TrimSpace(req.GetRole())
db := s.db.WithContext(ctx).Model(&model.User{})
if search != "" {
like := "%" + search + "%"
db = db.Where("email ILIKE ? OR username ILIKE ?", like, like)
}
if 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([]*appv1.AdminUser, 0, len(users))
for _, user := range users {
payload, err := s.buildAdminUser(ctx, &user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list users")
}
items = append(items, payload)
}
return &appv1.ListAdminUsersResponse{Users: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserRequest) (*appv1.GetAdminUserResponse, 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")
}
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 get user")
}
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")
}
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 {
return nil, err
}
email := strings.TrimSpace(req.GetEmail())
password := req.GetPassword()
if email == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Email and password are required")
}
role := normalizeAdminRoleValue(req.GetRole())
if !isValidAdminRoleValue(role) {
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
}
planID := nullableTrimmedString(req.PlanId)
if err := s.ensurePlanExists(ctx, 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: nullableTrimmedString(req.Username),
Role: model.StringPtr(role),
PlanID: planID,
}
if err := s.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 := s.buildAdminUser(ctx, user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create user")
}
return &appv1.CreateAdminUserResponse{User: payload}, nil
}
func (s *appServices) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdminUserRequest) (*appv1.UpdateAdminUserResponse, error) {
adminResult, err := s.requireAdmin(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
updates := map[string]interface{}{}
if req.Email != nil {
email := strings.TrimSpace(req.GetEmail())
if email == "" {
return nil, status.Error(codes.InvalidArgument, "Email is required")
}
updates["email"] = email
}
if req.Username != nil {
updates["username"] = nullableTrimmedString(req.Username)
}
if req.Role != nil {
role := normalizeAdminRoleValue(req.GetRole())
if !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 req.PlanId != nil {
planID := nullableTrimmedString(req.PlanId)
if err := s.ensurePlanExists(ctx, planID); err != nil {
return nil, err
}
updates["plan_id"] = planID
}
if req.Password != nil {
if strings.TrimSpace(req.GetPassword()) == "" {
return nil, status.Error(codes.InvalidArgument, "Password must not be empty")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.GetPassword()), 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 := 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 user")
}
payload, err := s.buildAdminUser(ctx, &user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update user")
}
return &appv1.UpdateAdminUserResponse{User: payload}, nil
}
result := s.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 := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update user")
}
payload, err := s.buildAdminUser(ctx, &user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update user")
}
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 {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
if id == adminResult.UserID {
return nil, status.Error(codes.InvalidArgument, "Cannot change your own role")
}
role := normalizeAdminRoleValue(req.GetRole())
if !isValidAdminRoleValue(role) {
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
}
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("role", role)
if result.Error != nil {
return nil, status.Error(codes.Internal, "Failed to update role")
}
if result.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "User not found")
}
return &appv1.UpdateAdminUserRoleResponse{Message: "Role updated", Role: role}, nil
}
func (s *appServices) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdminUserRequest) (*appv1.MessageResponse, error) {
adminResult, err := s.requireAdmin(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
if id == adminResult.UserID {
return nil, status.Error(codes.InvalidArgument, "Cannot delete your own account")
}
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 find user")
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tables := []struct {
model interface{}
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 nil, status.Error(codes.Internal, "Failed to delete user")
}
return messageResponse("User deleted"), nil
}
func (s *appServices) ListAdminVideos(ctx context.Context, req *appv1.ListAdminVideosRequest) (*appv1.ListAdminVideosResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(req.GetSearch())
userID := strings.TrimSpace(req.GetUserId())
statusFilter := strings.TrimSpace(req.GetStatus())
db := s.db.WithContext(ctx).Model(&model.Video{})
if search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
if statusFilter != "" && !strings.EqualFold(statusFilter, "all") {
db = db.Where("status = ?", normalizeVideoStatusValue(statusFilter))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&videos).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
items := make([]*appv1.AdminVideo, 0, len(videos))
for _, video := range videos {
payload, err := s.buildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
items = append(items, payload)
}
return &appv1.ListAdminVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetAdminVideo(ctx context.Context, req *appv1.GetAdminVideoRequest) (*appv1.GetAdminVideoResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to get video")
}
payload, err := s.buildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get video")
}
return &appv1.GetAdminVideoResponse{Video: payload}, nil
}
func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdminVideoRequest) (*appv1.CreateAdminVideoResponse, error) {
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())
videoURL := strings.TrimSpace(req.GetUrl())
if userID == "" || title == "" || videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
}
if req.GetSize() < 0 {
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
}
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 {
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")
}
}
payload, err := s.buildAdminVideo(ctx, created.Video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create video")
}
return &appv1.CreateAdminVideoResponse{Video: payload}, nil
}
func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdminVideoRequest) (*appv1.UpdateAdminVideoResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
userID := strings.TrimSpace(req.GetUserId())
title := strings.TrimSpace(req.GetTitle())
videoURL := strings.TrimSpace(req.GetUrl())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
if userID == "" || title == "" || videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
}
if req.GetSize() < 0 {
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
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 update video")
}
oldSize := video.Size
oldUserID := video.UserID
statusValue := normalizeVideoStatusValue(req.GetStatus())
processingStatus := strings.ToUpper(statusValue)
video.UserID = user.ID
video.Name = title
video.Title = title
video.Description = nullableTrimmedString(req.Description)
video.URL = videoURL
video.Size = req.GetSize()
video.Duration = req.GetDuration()
video.Format = strings.TrimSpace(req.GetFormat())
video.Status = model.StringPtr(statusValue)
video.ProcessingStatus = model.StringPtr(processingStatus)
video.StorageType = model.StringPtr(detectStorageType(videoURL))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&video).Error; err != nil {
return err
}
if oldUserID == user.ID {
delta := video.Size - oldSize
if delta != 0 {
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used + ?, 0)", delta)).Error; err != nil {
return err
}
}
} else {
if err := tx.Model(&model.User{}).Where("id = ?", oldUserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", oldSize)).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, user.ID, nullableTrimmedString(req.AdTemplateId))
})
if err != nil {
if strings.Contains(err.Error(), "Ad template not found") {
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
payload, err := s.buildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update video")
}
return &appv1.UpdateAdminVideoResponse{Video: payload}, nil
}
func (s *appServices) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdminVideoRequest) (*appv1.MessageResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to find video")
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", video.UserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", video.Size)).Error
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete video")
}
return messageResponse("Video deleted"), nil
}

View File

@@ -1,316 +0,0 @@
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) {
email := strings.TrimSpace(req.GetEmail())
password := req.GetPassword()
if email == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Email and password are required")
}
u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil {
return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
}
if user.Password == nil || strings.TrimSpace(*user.Password) == "" {
return nil, status.Error(codes.Unauthenticated, "Please login with Google")
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil {
return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
}
if err := s.issueSessionCookies(ctx, user); err != nil {
return nil, err
}
payload, err := buildUserPayload(ctx, s.db, user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return &appv1.LoginResponse{User: toProtoUser(payload)}, nil
}
func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
email := strings.TrimSpace(req.GetEmail())
username := strings.TrimSpace(req.GetUsername())
password := req.GetPassword()
refUsername := strings.TrimSpace(req.GetRefUsername())
if email == "" || username == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Username, email and password are required")
}
u := query.User
count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count()
if err != nil {
s.logger.Error("Failed to check existing user", "error", err)
return nil, status.Error(codes.Internal, "Failed to register")
}
if count > 0 {
return nil, status.Error(codes.InvalidArgument, "Email already registered")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to register")
}
referrerID, err := s.resolveSignupReferrerID(ctx, refUsername, username)
if err != nil {
s.logger.Error("Failed to resolve signup referrer", "error", err)
return nil, status.Error(codes.Internal, "Failed to register")
}
role := "USER"
passwordHash := string(hashedPassword)
newUser := &model.User{
ID: uuid.New().String(),
Email: email,
Password: &passwordHash,
Username: &username,
Role: &role,
ReferredByUserID: referrerID,
ReferralEligible: model.BoolPtr(true),
}
if err := u.WithContext(ctx).Create(newUser); err != nil {
s.logger.Error("Failed to create user", "error", err)
return nil, status.Error(codes.Internal, "Failed to register")
}
payload, err := buildUserPayload(ctx, s.db, newUser)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return &appv1.RegisterResponse{User: toProtoUser(payload)}, nil
}
func (s *appServices) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
return messageResponse("Logged out"), nil
}
func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
currentPassword := req.GetCurrentPassword()
newPassword := req.GetNewPassword()
if currentPassword == "" || newPassword == "" {
return nil, status.Error(codes.InvalidArgument, "Current password and new password are required")
}
if currentPassword == newPassword {
return nil, status.Error(codes.InvalidArgument, "New password must be different")
}
if result.User.Password == nil || strings.TrimSpace(*result.User.Password) == "" {
return nil, status.Error(codes.InvalidArgument, "This account does not have a local password")
}
if err := bcrypt.CompareHashAndPassword([]byte(*result.User.Password), []byte(currentPassword)); err != nil {
return nil, status.Error(codes.InvalidArgument, "Current password is incorrect")
}
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to change password")
}
if _, err := query.User.WithContext(ctx).
Where(query.User.ID.Eq(result.UserID)).
Update(query.User.Password, string(newHash)); err != nil {
s.logger.Error("Failed to change password", "error", err)
return nil, status.Error(codes.Internal, "Failed to change password")
}
return messageResponse("Password changed successfully"), nil
}
func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) {
email := strings.TrimSpace(req.GetEmail())
if email == "" {
return nil, status.Error(codes.InvalidArgument, "Email is required")
}
u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil {
return messageResponse("If email exists, a reset link has been sent"), nil
}
tokenID := uuid.New().String()
if err := s.cache.Set(ctx, "reset_pw:"+tokenID, user.ID, 15*time.Minute); err != nil {
s.logger.Error("Failed to set reset token", "error", err)
return nil, status.Error(codes.Internal, "Try again later")
}
s.logger.Info("Generated password reset token", "email", email, "token", tokenID)
return messageResponse("If email exists, a reset link has been sent"), nil
}
func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) {
resetToken := strings.TrimSpace(req.GetToken())
newPassword := req.GetNewPassword()
if resetToken == "" || newPassword == "" {
return nil, status.Error(codes.InvalidArgument, "Token and new password are required")
}
userID, err := s.cache.Get(ctx, "reset_pw:"+resetToken)
if err != nil || strings.TrimSpace(userID) == "" {
return nil, status.Error(codes.InvalidArgument, "Invalid or expired token")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, status.Error(codes.Internal, "Internal error")
}
if _, err := query.User.WithContext(ctx).
Where(query.User.ID.Eq(userID)).
Update(query.User.Password, string(hashedPassword)); err != nil {
s.logger.Error("Failed to update password", "error", err)
return nil, status.Error(codes.Internal, "Failed to update password")
}
_ = s.cache.Del(ctx, "reset_pw:"+resetToken)
return messageResponse("Password reset successfully"), nil
}
func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) {
if err := s.authenticator.RequireInternalCall(ctx); err != nil {
return nil, err
}
if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" {
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
}
state, err := generateOAuthState()
if err != nil {
s.logger.Error("Failed to generate Google OAuth state", "error", err)
return nil, status.Error(codes.Internal, "Failed to start Google login")
}
if err := s.cache.Set(ctx, googleOAuthStateCacheKey(state), "1", s.googleStateTTL); err != nil {
s.logger.Error("Failed to persist Google OAuth state", "error", err)
return nil, status.Error(codes.Internal, "Failed to start Google login")
}
loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil
}
func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) {
if err := s.authenticator.RequireInternalCall(ctx); err != nil {
return nil, err
}
if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" {
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
}
code := strings.TrimSpace(req.GetCode())
if code == "" {
return nil, status.Error(codes.InvalidArgument, "Code is required")
}
tokenResp, err := s.googleOauth.Exchange(ctx, code)
if err != nil {
s.logger.Error("Failed to exchange Google OAuth token", "error", err)
return nil, status.Error(codes.Unauthenticated, "exchange_failed")
}
client := s.googleOauth.Client(ctx, tokenResp)
resp, err := client.Get(s.googleUserInfoURL)
if err != nil {
s.logger.Error("Failed to fetch Google user info", "error", err)
return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.logger.Error("Google user info returned non-200", "status", resp.StatusCode)
return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
}
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil {
s.logger.Error("Failed to decode Google user info", "error", err)
return nil, status.Error(codes.Internal, "userinfo_parse_failed")
}
email := strings.TrimSpace(strings.ToLower(googleUser.Email))
refUsername := strings.TrimSpace(req.GetRefUsername())
if email == "" {
return nil, status.Error(codes.InvalidArgument, "missing_email")
}
u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load Google user", "error", err)
return nil, status.Error(codes.Internal, "load_user_failed")
}
referrerID, resolveErr := s.resolveSignupReferrerID(ctx, refUsername, googleUser.Name)
if resolveErr != nil {
s.logger.Error("Failed to resolve Google signup referrer", "error", resolveErr)
return nil, status.Error(codes.Internal, "create_user_failed")
}
role := "USER"
user = &model.User{
ID: uuid.New().String(),
Email: email,
Username: stringPointerOrNil(googleUser.Name),
GoogleID: stringPointerOrNil(googleUser.ID),
Avatar: stringPointerOrNil(googleUser.Picture),
Role: &role,
ReferredByUserID: referrerID,
ReferralEligible: model.BoolPtr(true),
}
if err := u.WithContext(ctx).Create(user); err != nil {
s.logger.Error("Failed to create Google user", "error", err)
return nil, status.Error(codes.Internal, "create_user_failed")
}
} else {
updates := map[string]interface{}{}
if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" {
updates["google_id"] = googleUser.ID
}
if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" {
updates["avatar"] = googleUser.Picture
}
if user.Username == nil || strings.TrimSpace(*user.Username) == "" {
updates["username"] = googleUser.Name
}
if len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
s.logger.Error("Failed to update Google user", "error", err)
return nil, status.Error(codes.Internal, "update_user_failed")
}
user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First()
if err != nil {
s.logger.Error("Failed to reload Google user", "error", err)
return nil, status.Error(codes.Internal, "reload_user_failed")
}
}
}
if err := s.issueSessionCookies(ctx, user); err != nil {
return nil, status.Error(codes.Internal, "session_failed")
}
payload, err := buildUserPayload(ctx, s.db, user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return &appv1.CompleteGoogleLoginResponse{User: toProtoUser(payload)}, nil
}

View File

@@ -1,234 +0,0 @@
package app
import (
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm"
"stream.api/internal/config"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
"stream.api/internal/video"
"stream.api/pkg/cache"
"stream.api/pkg/logger"
"stream.api/pkg/storage"
"stream.api/pkg/token"
)
const adTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
const defaultGoogleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
const (
playerConfigFreePlanLimitMessage = "Free plan supports only 1 player config"
playerConfigFreePlanReconciliationMessage = "Delete extra player configs to continue managing player configs on the free plan"
)
const (
walletTransactionTypeTopup = "topup"
walletTransactionTypeSubscriptionDebit = "subscription_debit"
walletTransactionTypeReferralReward = "referral_reward"
paymentMethodWallet = "wallet"
paymentMethodTopup = "topup"
paymentKindSubscription = "subscription"
paymentKindWalletTopup = "wallet_topup"
defaultReferralRewardBps = int32(500)
)
var allowedTermMonths = map[int32]struct{}{
1: {},
3: {},
6: {},
12: {},
}
type Services struct {
AuthServiceServer
AccountServiceServer
PreferencesServiceServer
UsageServiceServer
NotificationsServiceServer
DomainsServiceServer
AdTemplatesServiceServer
PlayerConfigsServiceServer
PlansServiceServer
PaymentsServiceServer
VideosServiceServer
AdminServiceServer
}
type appServices struct {
appv1.UnimplementedAuthServiceServer
appv1.UnimplementedAccountServiceServer
appv1.UnimplementedPreferencesServiceServer
appv1.UnimplementedUsageServiceServer
appv1.UnimplementedNotificationsServiceServer
appv1.UnimplementedDomainsServiceServer
appv1.UnimplementedAdTemplatesServiceServer
appv1.UnimplementedPlayerConfigsServiceServer
appv1.UnimplementedPlansServiceServer
appv1.UnimplementedPaymentsServiceServer
appv1.UnimplementedVideosServiceServer
appv1.UnimplementedAdminServiceServer
db *gorm.DB
logger logger.Logger
authenticator *middleware.Authenticator
tokenProvider token.Provider
cache cache.Cache
storageProvider storage.Provider
videoService *video.Service
agentRuntime video.AgentRuntime
googleOauth *oauth2.Config
googleStateTTL time.Duration
googleUserInfoURL string
frontendBaseURL string
}
type paymentInvoiceDetails struct {
PlanName string
TermMonths *int32
PaymentMethod string
ExpiresAt *time.Time
WalletAmount float64
TopupAmount float64
}
type paymentExecutionInput struct {
UserID string
Plan *model.Plan
TermMonths int32
PaymentMethod string
TopupAmount *float64
}
type paymentExecutionResult struct {
Payment *model.Payment
Subscription *model.PlanSubscription
WalletBalance float64
InvoiceID string
}
type referralRewardResult struct {
Granted bool
Amount float64
}
type apiErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *video.Service, agentRuntime video.AgentRuntime) *Services {
var storageProvider storage.Provider
if cfg != nil {
provider, err := storage.NewS3Provider(cfg)
if err != nil {
l.Error("Failed to initialize S3 provider for gRPC app services", "error", err)
} else {
storageProvider = provider
}
}
googleStateTTL := 10 * time.Minute
googleOauth := &oauth2.Config{}
if cfg != nil {
if cfg.Google.StateTTLMinute > 0 {
googleStateTTL = time.Duration(cfg.Google.StateTTLMinute) * time.Minute
}
googleOauth = &oauth2.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
RedirectURL: cfg.Google.RedirectURL,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
}
frontendBaseURL := ""
if cfg != nil {
frontendBaseURL = cfg.Frontend.BaseURL
}
service := &appServices{
db: db,
logger: l,
authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker),
tokenProvider: t,
cache: c,
storageProvider: storageProvider,
videoService: videoService,
agentRuntime: agentRuntime,
googleOauth: googleOauth,
googleStateTTL: googleStateTTL,
googleUserInfoURL: defaultGoogleUserInfoURL,
frontendBaseURL: frontendBaseURL,
}
return &Services{
AuthServiceServer: service,
AccountServiceServer: service,
PreferencesServiceServer: service,
UsageServiceServer: service,
NotificationsServiceServer: service,
DomainsServiceServer: service,
AdTemplatesServiceServer: service,
PlayerConfigsServiceServer: service,
PlansServiceServer: service,
PaymentsServiceServer: service,
VideosServiceServer: service,
AdminServiceServer: service,
}
}
type AuthServiceServer interface {
appv1.AuthServiceServer
}
type AccountServiceServer interface {
appv1.AccountServiceServer
}
type PreferencesServiceServer interface {
appv1.PreferencesServiceServer
}
type UsageServiceServer interface {
appv1.UsageServiceServer
}
type NotificationsServiceServer interface {
appv1.NotificationsServiceServer
}
type DomainsServiceServer interface {
appv1.DomainsServiceServer
}
type AdTemplatesServiceServer interface {
appv1.AdTemplatesServiceServer
}
type PlayerConfigsServiceServer interface {
appv1.PlayerConfigsServiceServer
}
type PlansServiceServer interface {
appv1.PlansServiceServer
}
type PaymentsServiceServer interface {
appv1.PaymentsServiceServer
}
type VideosServiceServer interface {
appv1.VideosServiceServer
}
type AdminServiceServer interface {
appv1.AdminServiceServer
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,144 +8,77 @@ import (
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"stream.api/internal/database/model"
"stream.api/internal/modules/common"
paymentsmodule "stream.api/internal/modules/payments"
)
func TestValidatePaymentFunding(t *testing.T) {
baseInput := paymentExecutionInput{PaymentMethod: paymentMethodWallet}
baseInput := paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodWallet}
tests := []struct {
name string
input paymentExecutionInput
input paymentsmodule.ExecutionInput
totalAmount float64
walletBalance float64
wantTopup float64
wantCode codes.Code
wantMessage string
}{
{
name: "wallet đủ tiền",
input: baseInput,
totalAmount: 30,
walletBalance: 30,
wantTopup: 0,
},
{
name: "wallet thiếu tiền",
input: baseInput,
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Insufficient wallet balance",
},
{
name: "topup thiếu amount",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount is required when payment method is topup",
},
{
name: "topup amount <= 0",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(0)},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount must be greater than 0",
},
{
name: "topup amount nhỏ hơn shortfall",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(20)},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount must be greater than or equal to the required shortfall",
},
{
name: "topup hợp lệ",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(30)},
totalAmount: 50,
walletBalance: 20,
wantTopup: 30,
},
{name: "wallet đủ tiền", input: baseInput, totalAmount: 30, walletBalance: 30, wantTopup: 0},
{name: "wallet thiếu tiền", input: baseInput, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Insufficient wallet balance"},
{name: "topup thiếu amount", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount is required when payment method is topup"},
{name: "topup amount <= 0", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(0)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than 0"},
{name: "topup amount nhỏ hơn shortfall", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(20)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than or equal to the required shortfall"},
{name: "topup hợp lệ", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(30)}, totalAmount: 50, walletBalance: 20, wantTopup: 30},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := validatePaymentFunding(context.Background(), tt.input, tt.totalAmount, tt.walletBalance)
got, err := paymentsmodule.ValidatePaymentFunding(tt.input, tt.totalAmount, tt.walletBalance)
if tt.wantCode == codes.OK {
if err != nil {
t.Fatalf("validatePaymentFunding() error = %v", err)
t.Fatalf("ValidatePaymentFunding() error = %v", err)
}
if got != tt.wantTopup {
t.Fatalf("validatePaymentFunding() topup = %v, want %v", got, tt.wantTopup)
t.Fatalf("ValidatePaymentFunding() topup = %v, want %v", got, tt.wantTopup)
}
return
}
if err == nil {
t.Fatalf("validatePaymentFunding() error = nil, want %v", tt.wantCode)
t.Fatalf("ValidatePaymentFunding() error = nil, want %v", tt.wantCode)
}
if status.Code(err) != tt.wantCode {
t.Fatalf("validatePaymentFunding() code = %v, want %v", status.Code(err), tt.wantCode)
if validationErr, ok := err.(*paymentsmodule.PaymentValidationError); !ok || codes.Code(validationErr.GRPCCode) != tt.wantCode {
gotCode := codes.Unknown
if ok {
gotCode = codes.Code(validationErr.GRPCCode)
}
t.Fatalf("ValidatePaymentFunding() code = %v, want %v", gotCode, tt.wantCode)
}
if got := err.Error(); !strings.Contains(got, tt.wantMessage) {
t.Fatalf("validatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage)
t.Fatalf("ValidatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage)
}
})
}
}
func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{
ID: uuid.NewString(),
Email: "payer@example.com",
Role: ptrString("USER"),
StorageUsed: 0,
})
plan := seedTestPlan(t, db, model.Plan{
ID: uuid.NewString(),
Name: "Pro",
Price: 10,
Cycle: "monthly",
StorageLimit: 100,
UploadLimit: 10,
DurationLimit: 0,
QualityLimit: "1080p",
Features: []string{"priority"},
IsActive: ptrBool(true),
})
seedWalletTransaction(t, db, model.WalletTransaction{
ID: uuid.NewString(),
UserID: user.ID,
Type: walletTransactionTypeTopup,
Amount: 5,
Currency: ptrString("USD"),
Note: ptrString("Initial funds"),
})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Role: ptrString("USER"), StorageUsed: 0})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 10, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, DurationLimit: 0, QualityLimit: "1080p", Features: []string{"priority"}, IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD"), Note: ptrString("Initial funds")})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{
UserID: user.ID,
Plan: &plan,
TermMonths: 3,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: user.ID, Plan: &plan, TermMonths: 3, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
t.Fatalf("ExecutePaymentFlow() error = %v", err)
}
if result == nil || result.Payment == nil || result.Subscription == nil {
t.Fatalf("executePaymentFlow() returned incomplete result: %#v", result)
t.Fatalf("ExecutePaymentFlow() returned incomplete result: %#v", result)
}
if result.InvoiceID != buildInvoiceID(result.Payment.ID) {
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, buildInvoiceID(result.Payment.ID))
if result.InvoiceID != common.BuildInvoiceID(result.Payment.ID) {
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, common.BuildInvoiceID(result.Payment.ID))
}
if result.WalletBalance != 0 {
t.Fatalf("wallet balance = %v, want 0", result.WalletBalance)
@@ -158,8 +91,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if payment.PlanID == nil || *payment.PlanID != plan.ID {
t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID)
}
if normalizePaymentStatus(payment.Status) != "success" {
t.Fatalf("payment status = %q, want success", normalizePaymentStatus(payment.Status))
if common.NormalizePaymentStatus(payment.Status) != "success" {
t.Fatalf("payment status = %q, want success", common.NormalizePaymentStatus(payment.Status))
}
subscription := mustLoadSubscriptionByPayment(t, db, payment.ID)
@@ -172,8 +105,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if subscription.TermMonths != 3 {
t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths)
}
if subscription.PaymentMethod != paymentMethodTopup {
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, paymentMethodTopup)
if subscription.PaymentMethod != common.PaymentMethodTopup {
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, common.PaymentMethodTopup)
}
if subscription.WalletAmount != 30 {
t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount)
@@ -189,10 +122,10 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if len(walletTransactions) != 2 {
t.Fatalf("wallet transaction count = %d, want 2", len(walletTransactions))
}
if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != walletTransactionTypeTopup {
if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != common.WalletTransactionTypeTopup {
t.Fatalf("first wallet transaction = %#v, want topup amount 25", walletTransactions[0])
}
if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != walletTransactionTypeSubscriptionDebit {
if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != common.WalletTransactionTypeSubscriptionDebit {
t.Fatalf("second wallet transaction = %#v, want debit amount -30", walletTransactions[1])
}
@@ -226,8 +159,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if metadataPayload["payment_id"] != payment.ID {
t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID)
}
if metadataPayload["payment_method"] != paymentMethodTopup {
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], paymentMethodTopup)
if metadataPayload["payment_method"] != common.PaymentMethodTopup {
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], common.PaymentMethodTopup)
}
if metadataPayload["wallet_amount"] != 30.0 {
t.Fatalf("metadata wallet_amount = %v, want 30", metadataPayload["wallet_amount"])

View File

@@ -1,279 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
planID := strings.TrimSpace(req.GetPlanId())
if planID == "" {
return nil, status.Error(codes.InvalidArgument, "Plan ID is required")
}
if !isAllowedTermMonths(req.GetTermMonths()) {
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
}
paymentMethod := normalizePaymentMethod(req.GetPaymentMethod())
if paymentMethod == "" {
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
}
planRecord, err := s.loadPaymentPlanForUser(ctx, planID)
if err != nil {
return nil, err
}
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
UserID: result.UserID,
Plan: planRecord,
TermMonths: req.GetTermMonths(),
PaymentMethod: paymentMethod,
TopupAmount: req.TopupAmount,
})
if err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
s.logger.Error("Failed to create payment", "error", err)
return nil, status.Error(codes.Internal, "Failed to create payment")
}
return &appv1.CreatePaymentResponse{
Payment: toProtoPayment(resultValue.Payment),
Subscription: toProtoPlanSubscription(resultValue.Subscription),
WalletBalance: resultValue.WalletBalance,
InvoiceId: resultValue.InvoiceID,
Message: "Payment completed successfully",
}, nil
}
func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
type paymentHistoryRow struct {
ID string `gorm:"column:id"`
Amount float64 `gorm:"column:amount"`
Currency *string `gorm:"column:currency"`
Status *string `gorm:"column:status"`
PlanID *string `gorm:"column:plan_id"`
PlanName *string `gorm:"column:plan_name"`
InvoiceID string `gorm:"column:invoice_id"`
Kind string `gorm:"column:kind"`
TermMonths *int32 `gorm:"column:term_months"`
PaymentMethod *string `gorm:"column:payment_method"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
CreatedAt *time.Time `gorm:"column:created_at"`
}
baseQuery := `
WITH history AS (
SELECT
p.id AS id,
p.amount AS amount,
p.currency AS currency,
p.status AS status,
p.plan_id AS plan_id,
pl.name AS plan_name,
p.id AS invoice_id,
? AS kind,
ps.term_months AS term_months,
ps.payment_method AS payment_method,
ps.expires_at AS expires_at,
p.created_at AS created_at
FROM payment AS p
LEFT JOIN plan AS pl ON pl.id = p.plan_id
LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id
WHERE p.user_id = ?
UNION ALL
SELECT
wt.id AS id,
wt.amount AS amount,
wt.currency AS currency,
'SUCCESS' AS status,
NULL AS plan_id,
NULL AS plan_name,
wt.id AS invoice_id,
? AS kind,
NULL AS term_months,
NULL AS payment_method,
NULL AS expires_at,
wt.created_at AS created_at
FROM wallet_transactions AS wt
WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL
)
`
var total int64
if err := s.db.WithContext(ctx).
Raw(baseQuery+`SELECT COUNT(*) FROM history`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup).
Scan(&total).Error; err != nil {
s.logger.Error("Failed to count payment history", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
}
var rows []paymentHistoryRow
if err := s.db.WithContext(ctx).
Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup, limit, offset).
Scan(&rows).Error; err != nil {
s.logger.Error("Failed to fetch payment history", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
}
items := make([]*appv1.PaymentHistoryItem, 0, len(rows))
for _, row := range rows {
items = append(items, &appv1.PaymentHistoryItem{
Id: row.ID,
Amount: row.Amount,
Currency: normalizeCurrency(row.Currency),
Status: normalizePaymentStatus(row.Status),
PlanId: row.PlanID,
PlanName: row.PlanName,
InvoiceId: buildInvoiceID(row.InvoiceID),
Kind: row.Kind,
TermMonths: row.TermMonths,
PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod),
ExpiresAt: timeToProto(row.ExpiresAt),
CreatedAt: timeToProto(row.CreatedAt),
})
}
hasPrev := page > 1 && total > 0
hasNext := int64(offset)+int64(len(items)) < total
return &appv1.ListPaymentHistoryResponse{
Payments: items,
Total: total,
Page: page,
Limit: limit,
HasPrev: hasPrev,
HasNext: hasNext,
}, nil
}
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
amount := req.GetAmount()
if amount < 1 {
return nil, status.Error(codes.InvalidArgument, "Amount must be at least 1")
}
transaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: result.UserID,
Type: walletTransactionTypeTopup,
Amount: amount,
Currency: model.StringPtr("USD"),
Note: model.StringPtr(fmt.Sprintf("Wallet top-up of %.2f USD", amount)),
}
notification := &model.Notification{
ID: uuid.New().String(),
UserID: result.UserID,
Type: "billing.topup",
Title: "Wallet credited",
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount),
Metadata: model.StringPtr(mustMarshalJSON(map[string]any{
"wallet_transaction_id": transaction.ID,
"invoice_id": buildInvoiceID(transaction.ID),
})),
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
return err
}
if err := tx.Create(transaction).Error; err != nil {
return err
}
if err := tx.Create(notification).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to top up wallet", "error", err)
return nil, status.Error(codes.Internal, "Failed to top up wallet")
}
balance, err := model.GetWalletBalance(ctx, s.db, result.UserID)
if err != nil {
s.logger.Error("Failed to calculate wallet balance", "error", err)
return nil, status.Error(codes.Internal, "Failed to top up wallet")
}
return &appv1.TopupWalletResponse{
WalletTransaction: toProtoWalletTransaction(transaction),
WalletBalance: balance,
InvoiceId: buildInvoiceID(transaction.ID),
}, nil
}
func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Invoice not found")
}
paymentRecord, err := query.Payment.WithContext(ctx).
Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(result.UserID)).
First()
if err == nil {
invoiceText, filename, buildErr := s.buildPaymentInvoice(ctx, paymentRecord)
if buildErr != nil {
s.logger.Error("Failed to build payment invoice", "error", buildErr)
return nil, status.Error(codes.Internal, "Failed to download invoice")
}
return &appv1.DownloadInvoiceResponse{
Filename: filename,
ContentType: "text/plain; charset=utf-8",
Content: invoiceText,
}, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load payment invoice", "error", err)
return nil, status.Error(codes.Internal, "Failed to download invoice")
}
var topup model.WalletTransaction
if err := s.db.WithContext(ctx).
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, result.UserID, walletTransactionTypeTopup).
First(&topup).Error; err == nil {
return &appv1.DownloadInvoiceResponse{
Filename: buildInvoiceFilename(topup.ID),
ContentType: "text/plain; charset=utf-8",
Content: buildTopupInvoice(&topup),
}, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load topup invoice", "error", err)
return nil, status.Error(codes.Internal, "Failed to download invoice")
}
return nil, status.Error(codes.NotFound, "Invoice not found")
}

View File

@@ -13,24 +13,18 @@ import (
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
"stream.api/internal/modules/common"
)
func TestCreatePayment(t *testing.T) {
t.Run("plan không tồn tại", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: uuid.NewString(),
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: uuid.NewString(), TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
assertGRPCCode(t, err, codes.NotFound)
})
@@ -39,16 +33,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(false)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
assertGRPCCode(t, err, codes.InvalidArgument)
})
@@ -57,16 +45,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 2,
PaymentMethod: paymentMethodWallet,
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 2, PaymentMethod: common.PaymentMethodWallet})
assertGRPCCode(t, err, codes.InvalidArgument)
})
@@ -75,16 +57,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: "bank_transfer",
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: "bank_transfer"})
assertGRPCCode(t, err, codes.InvalidArgument)
})
@@ -93,18 +69,12 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 50, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
var trailer metadata.MD
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument)
body := firstTestMetadataValue(trailer, "x-error-body")
if body == "" {
@@ -120,29 +90,22 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(15),
})
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(15)})
if err != nil {
t.Fatalf("CreatePayment() error = %v", err)
}
if resp.Payment == nil || resp.Subscription == nil {
t.Fatalf("CreatePayment() response incomplete: %#v", resp)
}
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
}
if resp.Subscription.PaymentMethod != paymentMethodTopup {
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, paymentMethodTopup)
if resp.Subscription.PaymentMethod != common.PaymentMethodTopup {
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, common.PaymentMethodTopup)
}
if resp.Subscription.WalletAmount != 20 {
t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount)
@@ -153,7 +116,6 @@ func TestCreatePayment(t *testing.T) {
if resp.WalletBalance != 0 {
t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance)
}
payment := mustLoadPayment(t, db, resp.Payment.Id)
if payment.Amount != 20 {
t.Fatalf("payment amount = %v, want 20", payment.Amount)

View File

@@ -15,6 +15,7 @@ import (
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
"stream.api/internal/modules/common"
)
func TestPlayerConfigsPolicy(t *testing.T) {
@@ -50,8 +51,8 @@ func TestPlayerConfigsPolicy(t *testing.T) {
_, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Second"})
assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanLimitMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanLimitMessage)
if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanLimitMessage {
t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanLimitMessage)
}
})
@@ -107,8 +108,8 @@ func TestPlayerConfigsPolicy(t *testing.T) {
IsActive: ptrBool(true),
})
assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanReconciliationMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanReconciliationMessage)
if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanReconciliationMessage {
t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanReconciliationMessage)
}
_, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: second.ID})
@@ -212,7 +213,7 @@ func TestPlayerConfigsPolicy(t *testing.T) {
t.Fatalf("player config count = %d, want 1", len(items))
}
for _, message := range messages {
if message != playerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") {
if message != common.PlayerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") {
t.Fatalf("unexpected concurrent create error message: %q", message)
}
}

View File

@@ -3,13 +3,14 @@ package app
import (
"context"
"testing"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
paymentsmodule "stream.api/internal/modules/payments"
)
func TestRegisterReferralCapture(t *testing.T) {
@@ -18,12 +19,7 @@ func TestRegisterReferralCapture(t *testing.T) {
services := newTestAppServices(t, db)
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{
Username: "bob",
Email: "bob@example.com",
Password: "secret123",
RefUsername: ptrString("alice"),
})
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "bob", Email: "bob@example.com", Password: "secret123", RefUsername: ptrString("alice")})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
@@ -39,13 +35,7 @@ func TestRegisterReferralCapture(t *testing.T) {
t.Run("register với ref invalid hoặc self-ref vẫn tạo user", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{
Username: "selfie",
Email: "selfie@example.com",
Password: "secret123",
RefUsername: ptrString("selfie"),
})
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "selfie", Email: "selfie@example.com", Password: "secret123", RefUsername: ptrString("selfie")})
if err != nil {
t.Fatalf("Register() error = %v", err)
}
@@ -61,9 +51,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob")
referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob")
if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
}
if referrerID == nil || *referrerID != referrer.ID {
t.Fatalf("referrerID = %v, want %s", referrerID, referrer.ID)
@@ -73,9 +63,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
t.Run("invalid hoặc self-ref bị ignore", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
referrerID, err := services.resolveSignupReferrerID(context.Background(), "bob", "bob")
referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "bob", "bob")
if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
}
if referrerID != nil {
t.Fatalf("referrerID = %v, want nil", referrerID)
@@ -87,9 +77,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
services := newTestAppServices(t, db)
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "a@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "b@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob")
referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob")
if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
}
if referrerID != nil {
t.Fatalf("referrerID = %v, want nil", referrerID)
@@ -110,11 +100,10 @@ func TestReferralRewardFlow(t *testing.T) {
t.Run("first subscription thưởng 5 phần trăm", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
t.Fatalf("ExecutePaymentFlow() error = %v", err)
}
updatedReferee := mustLoadUser(t, db, referee.ID)
if updatedReferee.ReferralRewardPaymentID == nil || *updatedReferee.ReferralRewardPaymentID != result.Payment.ID {
@@ -138,12 +127,12 @@ func TestReferralRewardFlow(t *testing.T) {
t.Run("subscription thứ hai không thưởng lại", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("first executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")})
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("first ExecutePaymentFlow() error = %v", err)
}
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("second executePaymentFlow() error = %v", err)
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("second ExecutePaymentFlow() error = %v", err)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
@@ -174,9 +163,9 @@ func TestReferralRewardFlow(t *testing.T) {
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_eligible", false).Error; err != nil {
t.Fatalf("update referral_eligible: %v", err)
}
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("ExecutePaymentFlow() error = %v", err)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
@@ -192,9 +181,9 @@ func TestReferralRewardFlow(t *testing.T) {
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_reward_bps", 750).Error; err != nil {
t.Fatalf("update referral_reward_bps: %v", err)
}
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("ExecutePaymentFlow() error = %v", err)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil {
@@ -213,23 +202,10 @@ func TestUpdateAdminUserReferralSettings(t *testing.T) {
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("ExecutePaymentFlow() error = %v", err)
}
_, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{
Id: referee.ID,
RefUsername: ptrString("alice"),
})
_, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{Id: referee.ID, RefUsername: ptrString("alice")})
assertGRPCCode(t, err, codes.InvalidArgument)
}
func containsAny(value string, parts ...string) bool {
for _, part := range parts {
if part != "" && strings.Contains(value, part) {
return true
}
}
return false
}

View File

@@ -1,624 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var rows []model.Notification
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
s.logger.Error("Failed to list notifications", "error", err)
return nil, status.Error(codes.Internal, "Failed to load notifications")
}
items := make([]*appv1.Notification, 0, len(rows))
for _, row := range rows {
items = append(items, toProtoNotification(row))
}
return &appv1.ListNotificationsResponse{Notifications: items}, nil
}
func (s *appServices) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Notification not found")
}
res := s.db.WithContext(ctx).
Model(&model.Notification{}).
Where("id = ? AND user_id = ?", id, result.UserID).
Update("is_read", true)
if res.Error != nil {
s.logger.Error("Failed to update notification", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to update notification")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Notification not found")
}
return messageResponse("Notification updated"), nil
}
func (s *appServices) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).
Model(&model.Notification{}).
Where("user_id = ? AND is_read = ?", result.UserID, false).
Update("is_read", true).Error; err != nil {
s.logger.Error("Failed to mark all notifications as read", "error", err)
return nil, status.Error(codes.Internal, "Failed to update notifications")
}
return messageResponse("All notifications marked as read"), nil
}
func (s *appServices) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Notification not found")
}
res := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", id, result.UserID).
Delete(&model.Notification{})
if res.Error != nil {
s.logger.Error("Failed to delete notification", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to delete notification")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Notification not found")
}
return messageResponse("Notification deleted"), nil
}
func (s *appServices) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Where("user_id = ?", result.UserID).Delete(&model.Notification{}).Error; err != nil {
s.logger.Error("Failed to clear notifications", "error", err)
return nil, status.Error(codes.Internal, "Failed to clear notifications")
}
return messageResponse("All notifications deleted"), nil
}
func (s *appServices) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var rows []model.Domain
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
s.logger.Error("Failed to list domains", "error", err)
return nil, status.Error(codes.Internal, "Failed to load domains")
}
items := make([]*appv1.Domain, 0, len(rows))
for _, row := range rows {
item := row
items = append(items, toProtoDomain(&item))
}
return &appv1.ListDomainsResponse{Domains: items}, nil
}
func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
name := normalizeDomain(req.GetName())
if name == "" || !strings.Contains(name, ".") || strings.ContainsAny(name, "/ ") {
return nil, status.Error(codes.InvalidArgument, "Invalid domain")
}
var count int64
if err := s.db.WithContext(ctx).
Model(&model.Domain{}).
Where("user_id = ? AND name = ?", result.UserID, name).
Count(&count).Error; err != nil {
s.logger.Error("Failed to validate domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain")
}
if count > 0 {
return nil, status.Error(codes.InvalidArgument, "Domain already exists")
}
item := &model.Domain{
ID: uuid.New().String(),
UserID: result.UserID,
Name: name,
}
if err := s.db.WithContext(ctx).Create(item).Error; err != nil {
s.logger.Error("Failed to create domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain")
}
return &appv1.CreateDomainResponse{Domain: toProtoDomain(item)}, nil
}
func (s *appServices) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Domain not found")
}
res := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", id, result.UserID).
Delete(&model.Domain{})
if res.Error != nil {
s.logger.Error("Failed to delete domain", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to delete domain")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Domain not found")
}
return messageResponse("Domain deleted"), nil
}
func (s *appServices) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var items []model.AdTemplate
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
s.logger.Error("Failed to list ad templates", "error", err)
return nil, status.Error(codes.Internal, "Failed to load ad templates")
}
payload := make([]*appv1.AdTemplate, 0, len(items))
for _, item := range items {
copyItem := item
payload = append(payload, toProtoAdTemplate(&copyItem))
}
return &appv1.ListAdTemplatesResponse{Templates: payload}, nil
}
func (s *appServices) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := ensurePaidPlan(result.User); err != nil {
return nil, err
}
name := strings.TrimSpace(req.GetName())
vastURL := strings.TrimSpace(req.GetVastTagUrl())
if name == "" || vastURL == "" {
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
}
format := normalizeAdFormat(req.GetAdFormat())
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
}
item := &model.AdTemplate{
ID: uuid.New().String(),
UserID: result.UserID,
Name: name,
Description: nullableTrimmedString(req.Description),
VastTagURL: vastURL,
AdFormat: model.StringPtr(format),
Duration: int32PtrToInt64Ptr(req.Duration),
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
IsDefault: req.IsDefault != nil && *req.IsDefault,
}
if !adTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, result.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
s.logger.Error("Failed to create ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.CreateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil
}
func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := ensurePaidPlan(result.User); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
name := strings.TrimSpace(req.GetName())
vastURL := strings.TrimSpace(req.GetVastTagUrl())
if name == "" || vastURL == "" {
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
}
format := normalizeAdFormat(req.GetAdFormat())
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
}
var item model.AdTemplate
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
s.logger.Error("Failed to load ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item.Name = name
item.Description = nullableTrimmedString(req.Description)
item.VastTagURL = vastURL
item.AdFormat = model.StringPtr(format)
item.Duration = int32PtrToInt64Ptr(req.Duration)
if req.IsActive != nil {
item.IsActive = model.BoolPtr(*req.IsActive)
}
if req.IsDefault != nil {
item.IsDefault = *req.IsDefault
}
if !adTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, result.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
s.logger.Error("Failed to update ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.UpdateAdTemplateResponse{Template: toProtoAdTemplate(&item)}, nil
}
func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := ensurePaidPlan(result.User); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).
Where("user_id = ? AND ad_id = ?", result.UserID, id).
Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
s.logger.Error("Failed to delete ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete ad template")
}
return messageResponse("Ad template deleted"), nil
}
func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) {
if _, err := s.authenticate(ctx); err != nil {
return nil, err
}
var plans []model.Plan
if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil {
s.logger.Error("Failed to fetch plans", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch plans")
}
items := make([]*appv1.Plan, 0, len(plans))
for _, plan := range plans {
copyPlan := plan
items = append(items, toProtoPlan(&copyPlan))
}
return &appv1.ListPlansResponse{Plans: items}, nil
}
func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var items []model.PlayerConfig
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
s.logger.Error("Failed to list player configs", "error", err)
return nil, status.Error(codes.Internal, "Failed to load player configs")
}
payload := make([]*appv1.PlayerConfig, 0, len(items))
for _, item := range items {
copyItem := item
payload = append(payload, toProtoPlayerConfig(&copyItem))
}
return &appv1.ListPlayerConfigsResponse{Configs: payload}, nil
}
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(req.GetName())
if name == "" {
return nil, status.Error(codes.InvalidArgument, "Name is required")
}
item := &model.PlayerConfig{
ID: uuid.New().String(),
UserID: result.UserID,
Name: name,
Description: nullableTrimmedString(req.Description),
Autoplay: req.GetAutoplay(),
Loop: req.GetLoop(),
Muted: req.GetMuted(),
ShowControls: model.BoolPtr(req.GetShowControls()),
Pip: model.BoolPtr(req.GetPip()),
Airplay: model.BoolPtr(req.GetAirplay()),
Chromecast: model.BoolPtr(req.GetChromecast()),
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
IsDefault: req.IsDefault != nil && *req.IsDefault,
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
LogoURL: nullableTrimmedString(req.LogoUrl),
}
if !playerConfigIsActive(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := playerConfigActionAllowed(lockedUser, configCount, "create"); err != nil {
return err
}
if item.IsDefault {
if err := unsetDefaultPlayerConfigs(tx, result.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
if status.Code(err) != codes.Unknown {
return nil, err
}
s.logger.Error("Failed to create player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.CreatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil
}
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
name := strings.TrimSpace(req.GetName())
if name == "" {
return nil, status.Error(codes.InvalidArgument, "Name is required")
}
var item model.PlayerConfig
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
return err
}
action := "update"
wasActive := playerConfigIsActive(item.IsActive)
if req.IsActive != nil && *req.IsActive != wasActive {
action = "toggle-active"
}
if req.IsDefault != nil && *req.IsDefault {
action = "set-default"
}
if err := playerConfigActionAllowed(lockedUser, configCount, action); err != nil {
return err
}
item.Name = name
item.Description = nullableTrimmedString(req.Description)
item.Autoplay = req.GetAutoplay()
item.Loop = req.GetLoop()
item.Muted = req.GetMuted()
item.ShowControls = model.BoolPtr(req.GetShowControls())
item.Pip = model.BoolPtr(req.GetPip())
item.Airplay = model.BoolPtr(req.GetAirplay())
item.Chromecast = model.BoolPtr(req.GetChromecast())
if req.EncrytionM3U8 != nil {
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
}
if req.LogoUrl != nil {
item.LogoURL = nullableTrimmedString(req.LogoUrl)
}
if req.IsActive != nil {
item.IsActive = model.BoolPtr(*req.IsActive)
}
if req.IsDefault != nil {
item.IsDefault = *req.IsDefault
}
if !playerConfigIsActive(item.IsActive) {
item.IsDefault = false
}
if item.IsDefault {
if err := unsetDefaultPlayerConfigs(tx, result.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if status.Code(err) != codes.Unknown {
return nil, err
}
s.logger.Error("Failed to update player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(&item)}, nil
}
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := playerConfigActionAllowed(lockedUser, configCount, "delete"); err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.PlayerConfig{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if status.Code(err) != codes.Unknown {
return nil, err
}
s.logger.Error("Failed to delete player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete player config")
}
return messageResponse("Player config deleted"), nil
}

View File

@@ -1,277 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/video"
)
func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if s.storageProvider == nil {
return nil, status.Error(codes.FailedPrecondition, "Storage provider is not configured")
}
filename := strings.TrimSpace(req.GetFilename())
if filename == "" {
return nil, status.Error(codes.InvalidArgument, "Filename is required")
}
fileID := uuid.New().String()
key := fmt.Sprintf("videos/%s/%s-%s", result.UserID, fileID, filename)
uploadURL, err := s.storageProvider.GeneratePresignedURL(key, 15*time.Minute)
if err != nil {
s.logger.Error("Failed to generate upload URL", "error", err)
return nil, status.Error(codes.Internal, "Storage error")
}
return &appv1.GetUploadUrlResponse{UploadUrl: uploadURL, Key: key, FileId: fileID}, nil
}
func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
title := strings.TrimSpace(req.GetTitle())
if title == "" {
return nil, status.Error(codes.InvalidArgument, "Title is required")
}
videoURL := strings.TrimSpace(req.GetUrl())
if videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "URL is required")
}
description := strings.TrimSpace(req.GetDescription())
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
UserID: result.UserID,
Title: title,
Description: &description,
URL: videoURL,
Size: req.GetSize(),
Duration: req.GetDuration(),
Format: strings.TrimSpace(req.GetFormat()),
})
if err != nil {
s.logger.Error("Failed to create video", "error", err)
switch {
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 &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil
}
func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
page := req.GetPage()
if page < 1 {
page = 1
}
limit := req.GetLimit()
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
offset := int((page - 1) * limit)
db := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", result.UserID)
if search := strings.TrimSpace(req.GetSearch()); search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
}
if st := strings.TrimSpace(req.GetStatus()); st != "" && !strings.EqualFold(st, "all") {
db = db.Where("status = ?", normalizeVideoStatusValue(st))
}
var total int64
if err := db.Count(&total).Error; err != nil {
s.logger.Error("Failed to count videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&videos).Error; err != nil {
s.logger.Error("Failed to list videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
items := make([]*appv1.Video, 0, len(videos))
for i := range videos {
payload, err := s.buildVideo(ctx, &videos[i])
if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", videos[i].ID)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
items = append(items, payload)
}
return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
_ = s.db.WithContext(ctx).Model(&model.Video{}).
Where("id = ? AND user_id = ?", id, result.UserID).
UpdateColumn("views", gorm.Expr("views + ?", 1)).Error
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
s.logger.Error("Failed to fetch video", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch video")
}
payload, err := s.buildVideo(ctx, &video)
if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to fetch video")
}
return &appv1.GetVideoResponse{Video: payload}, nil
}
func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
updates := map[string]any{}
if title := strings.TrimSpace(req.GetTitle()); title != "" {
updates["name"] = title
updates["title"] = title
}
if req.Description != nil {
desc := strings.TrimSpace(req.GetDescription())
updates["description"] = nullableTrimmedString(&desc)
}
if urlValue := strings.TrimSpace(req.GetUrl()); urlValue != "" {
updates["url"] = urlValue
}
if req.Size > 0 {
updates["size"] = req.GetSize()
}
if req.Duration > 0 {
updates["duration"] = req.GetDuration()
}
if req.Format != nil {
updates["format"] = strings.TrimSpace(req.GetFormat())
}
if req.Status != nil {
updates["status"] = normalizeVideoStatusValue(req.GetStatus())
}
if len(updates) == 0 {
return nil, status.Error(codes.InvalidArgument, "No changes provided")
}
res := s.db.WithContext(ctx).
Model(&model.Video{}).
Where("id = ? AND user_id = ?", id, result.UserID).
Updates(updates)
if res.Error != nil {
s.logger.Error("Failed to update video", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to update video")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
s.logger.Error("Failed to reload video", "error", err)
return nil, status.Error(codes.Internal, "Failed to update video")
}
payload, err := s.buildVideo(ctx, &video)
if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to update video")
}
return &appv1.UpdateVideoResponse{Video: payload}, nil
}
func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
s.logger.Error("Failed to load video", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
if s.storageProvider != nil && shouldDeleteStoredObject(video.URL) {
if err := s.storageProvider.Delete(video.URL); err != nil {
if parsedKey := extractObjectKey(video.URL); parsedKey != "" && parsedKey != video.URL {
if deleteErr := s.storageProvider.Delete(parsedKey); deleteErr != nil {
s.logger.Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
} else {
s.logger.Error("Failed to delete video object", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
}
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("id = ?", result.UserID).
UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error
}); err != nil {
s.logger.Error("Failed to delete video", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
return messageResponse("Video deleted successfully"), nil
}

View File

@@ -0,0 +1,33 @@
package app
import (
"context"
appv1 "stream.api/internal/gen/proto/app/v1"
paymentsmodule "stream.api/internal/modules/payments"
playerconfigsmodule "stream.api/internal/modules/playerconfigs"
)
func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
return s.authModule.Register(ctx, req)
}
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
return paymentsmodule.NewHandler(s.paymentsModule).TopupWallet(ctx, req)
}
func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
return s.usersModule.UpdateAdminUserReferralSettings(ctx, req)
}
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
return playerconfigsmodule.NewHandler(s.playerConfigsModule).CreatePlayerConfig(ctx, req)
}
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
return playerconfigsmodule.NewHandler(s.playerConfigsModule).UpdatePlayerConfig(ctx, req)
}
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
return playerconfigsmodule.NewHandler(s.playerConfigsModule).DeletePlayerConfig(ctx, req)
}

View File

@@ -244,7 +244,7 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
db = newTestDB(t)
}
return &appServices{
services := &appServices{
db: db,
logger: testLogger{},
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
@@ -252,6 +252,8 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
tokenProvider: fakeTokenProvider{},
googleUserInfoURL: defaultGoogleUserInfoURL,
}
services.initModules()
return services
}
func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) {
@@ -260,18 +262,18 @@ func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, f
lis := bufconn.Listen(testBufDialerListenerSize)
server := grpc.NewServer()
Register(server, &Services{
AuthServiceServer: services,
AccountServiceServer: services,
PreferencesServiceServer: services,
UsageServiceServer: services,
NotificationsServiceServer: services,
DomainsServiceServer: services,
AdTemplatesServiceServer: services,
PlayerConfigsServiceServer: services,
PlansServiceServer: services,
PaymentsServiceServer: services,
VideosServiceServer: services,
AdminServiceServer: services,
AuthServiceServer: services.authHandler,
AccountServiceServer: services.accountHandler,
PreferencesServiceServer: services.preferencesHandler,
UsageServiceServer: services.usageHandler,
NotificationsServiceServer: services.notificationsHandler,
DomainsServiceServer: services.domainsHandler,
AdTemplatesServiceServer: services.adTemplatesHandler,
PlayerConfigsServiceServer: services.playerConfigsHandler,
PlansServiceServer: services.plansHandler,
PaymentsServiceServer: services.paymentsHandler,
VideosServiceServer: services.videosHandler,
AdminServiceServer: services.adminHandler,
})
go func() {

View File

@@ -1,29 +0,0 @@
package app
import (
"context"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
)
type usagePayload struct {
UserID string `json:"user_id"`
TotalVideos int64 `json:"total_videos"`
TotalStorage int64 `json:"total_storage"`
}
func loadUsage(ctx context.Context, db *gorm.DB, l logger.Logger, user *model.User) (*usagePayload, error) {
var totalVideos int64
if err := db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil {
l.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
}

View File

@@ -1,120 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type userPayload struct {
ID string `json:"id"`
Email string `json:"email"`
Username *string `json:"username,omitempty"`
Avatar *string `json:"avatar,omitempty"`
Role *string `json:"role,omitempty"`
GoogleID *string `json:"google_id,omitempty"`
StorageUsed int64 `json:"storage_used"`
PlanID *string `json:"plan_id,omitempty"`
PlanStartedAt *time.Time `json:"plan_started_at,omitempty"`
PlanExpiresAt *time.Time `json:"plan_expires_at,omitempty"`
PlanTermMonths *int32 `json:"plan_term_months,omitempty"`
PlanPaymentMethod *string `json:"plan_payment_method,omitempty"`
PlanExpiringSoon bool `json:"plan_expiring_soon"`
WalletBalance float64 `json:"wallet_balance"`
Language string `json:"language"`
Locale string `json:"locale"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*userPayload, error) {
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID)
if err != nil {
return nil, err
}
walletBalance, err := model.GetWalletBalance(ctx, db, user.ID)
if err != nil {
return nil, err
}
language := strings.TrimSpace(model.StringValue(pref.Language))
if language == "" {
language = "en"
}
locale := strings.TrimSpace(model.StringValue(pref.Locale))
if locale == "" {
locale = language
}
effectivePlanID := user.PlanID
var planStartedAt *time.Time
var planExpiresAt *time.Time
var planTermMonths *int32
var planPaymentMethod *string
planExpiringSoon := false
now := time.Now().UTC()
subscription, err := model.GetLatestPlanSubscription(ctx, db, user.ID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil {
startedAt := subscription.StartedAt.UTC()
expiresAt := subscription.ExpiresAt.UTC()
termMonths := subscription.TermMonths
paymentMethod := normalizePlanPaymentMethod(subscription.PaymentMethod)
planStartedAt = &startedAt
planExpiresAt = &expiresAt
planTermMonths = &termMonths
planPaymentMethod = &paymentMethod
if expiresAt.After(now) {
effectivePlanID = &subscription.PlanID
planExpiringSoon = isPlanExpiringSoon(expiresAt, now)
} else {
effectivePlanID = nil
}
}
return &userPayload{
ID: user.ID,
Email: user.Email,
Username: user.Username,
Avatar: user.Avatar,
Role: user.Role,
GoogleID: user.GoogleID,
StorageUsed: user.StorageUsed,
PlanID: effectivePlanID,
PlanStartedAt: planStartedAt,
PlanExpiresAt: planExpiresAt,
PlanTermMonths: planTermMonths,
PlanPaymentMethod: planPaymentMethod,
PlanExpiringSoon: planExpiringSoon,
WalletBalance: walletBalance,
Language: language,
Locale: locale,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
func normalizePlanPaymentMethod(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "topup":
return "topup"
default:
return "wallet"
}
}
func isPlanExpiringSoon(expiresAt time.Time, now time.Time) bool {
hoursUntilExpiry := expiresAt.Sub(now).Hours()
const thresholdHours = 7 * 24
return hoursUntilExpiry > 0 && hoursUntilExpiry <= thresholdHours
}