draft
This commit is contained in:
216
internal/rpc/app/core.go
Normal file
216
internal/rpc/app/core.go
Normal 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{}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(©Item))
|
||||
}
|
||||
|
||||
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(©Plan))
|
||||
}
|
||||
|
||||
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(©Item))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
33
internal/rpc/app/test_wrappers_test.go
Normal file
33
internal/rpc/app/test_wrappers_test.go
Normal 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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user