draft grpc
This commit is contained in:
20
internal/rpc/app/register.go
Normal file
20
internal/rpc/app/register.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"google.golang.org/grpc"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
)
|
||||
|
||||
func Register(server grpc.ServiceRegistrar, services *Services) {
|
||||
appv1.RegisterAuthServiceServer(server, services.AuthServiceServer)
|
||||
appv1.RegisterAccountServiceServer(server, services.AccountServiceServer)
|
||||
appv1.RegisterPreferencesServiceServer(server, services.PreferencesServiceServer)
|
||||
appv1.RegisterUsageServiceServer(server, services.UsageServiceServer)
|
||||
appv1.RegisterNotificationsServiceServer(server, services.NotificationsServiceServer)
|
||||
appv1.RegisterDomainsServiceServer(server, services.DomainsServiceServer)
|
||||
appv1.RegisterAdTemplatesServiceServer(server, services.AdTemplatesServiceServer)
|
||||
appv1.RegisterPlansServiceServer(server, services.PlansServiceServer)
|
||||
appv1.RegisterPaymentsServiceServer(server, services.PaymentsServiceServer)
|
||||
appv1.RegisterVideosServiceServer(server, services.VideosServiceServer)
|
||||
appv1.RegisterAdminServiceServer(server, services.AdminServiceServer)
|
||||
}
|
||||
185
internal/rpc/app/service_account.go
Normal file
185
internal/rpc/app/service_account.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"gorm.io/gorm"
|
||||
authapi "stream.api/internal/api/auth"
|
||||
preferencesapi "stream.api/internal/api/preferences"
|
||||
usageapi "stream.api/internal/api/usage"
|
||||
"stream.api/internal/database/model"
|
||||
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 := authapi.BuildUserPayload(ctx, s.db, result.User)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||
}
|
||||
return &appv1.GetMeResponse{User: toProtoUserPayload(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 := authapi.UpdateUserProfile(ctx, s.db, s.logger, result.UserID, authapi.UpdateProfileInput{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Language: req.Language,
|
||||
Locale: req.Locale,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, authapi.ErrEmailRequired), errors.Is(err, authapi.ErrEmailAlreadyRegistered):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
default:
|
||||
return nil, status.Error(codes.Internal, "Failed to update profile")
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := authapi.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.VideoAdConfig{}).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.VideoAdConfig{}).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 := preferencesapi.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 := preferencesapi.UpdateUserPreferences(ctx, s.db, s.logger, result.UserID, preferencesapi.UpdateInput{
|
||||
EmailNotifications: req.EmailNotifications,
|
||||
PushNotifications: req.PushNotifications,
|
||||
MarketingNotifications: req.MarketingNotifications,
|
||||
TelegramNotifications: req.TelegramNotifications,
|
||||
Autoplay: req.Autoplay,
|
||||
Loop: req.Loop,
|
||||
Muted: req.Muted,
|
||||
ShowControls: req.ShowControls,
|
||||
Pip: req.Pip,
|
||||
Airplay: req.Airplay,
|
||||
Chromecast: req.Chromecast,
|
||||
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 := usageapi.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
|
||||
}
|
||||
672
internal/rpc/app/service_admin_finance_catalog.go
Normal file
672
internal/rpc/app/service_admin_finance_catalog.go
Normal file
@@ -0,0 +1,672 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
|
||||
var planRecord model.Plan
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "Plan not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
if planRecord.IsActive == nil || !*planRecord.IsActive {
|
||||
return nil, status.Error(codes.InvalidArgument, "Plan is not active")
|
||||
}
|
||||
|
||||
totalAmount := planRecord.Price * float64(req.GetTermMonths())
|
||||
statusValue := "SUCCESS"
|
||||
provider := "INTERNAL"
|
||||
currency := normalizeCurrency(nil)
|
||||
transactionID := buildTransactionID("sub")
|
||||
now := time.Now().UTC()
|
||||
|
||||
paymentRecord := &model.Payment{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
Amount: totalAmount,
|
||||
Currency: ¤cy,
|
||||
Status: &statusValue,
|
||||
Provider: &provider,
|
||||
TransactionID: &transactionID,
|
||||
}
|
||||
invoiceID := buildInvoiceID(paymentRecord.ID)
|
||||
var subscription *model.PlanSubscription
|
||||
var walletBalance float64
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockUserForUpdate(ctx, tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, user.ID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
baseExpiry := now
|
||||
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
|
||||
baseExpiry = currentSubscription.ExpiresAt.UTC()
|
||||
}
|
||||
newExpiry := baseExpiry.AddDate(0, int(req.GetTermMonths()), 0)
|
||||
|
||||
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
|
||||
if paymentMethod == paymentMethodWallet && shortfall > 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Insufficient wallet balance", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
|
||||
topupAmount := 0.0
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
if req.TopupAmount == nil {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount is required when payment method is topup", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
topupAmount = maxFloat(req.GetTopupAmount(), 0)
|
||||
if topupAmount <= 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than 0", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
if topupAmount < shortfall {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than or equal to the required shortfall", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
"topup_amount": topupAmount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Create(paymentRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletUsedAmount := totalAmount
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
topupTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
Type: walletTransactionTypeTopup,
|
||||
Amount: maxFloat(req.GetTopupAmount(), 0),
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(topupTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
debitTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
Type: walletTransactionTypeSubscriptionDebit,
|
||||
Amount: -totalAmount,
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(debitTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscription = &model.PlanSubscription{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
PaymentID: paymentRecord.ID,
|
||||
PlanID: planRecord.ID,
|
||||
TermMonths: req.GetTermMonths(),
|
||||
PaymentMethod: paymentMethod,
|
||||
WalletAmount: walletUsedAmount,
|
||||
TopupAmount: maxFloat(req.GetTopupAmount(), 0),
|
||||
StartedAt: now,
|
||||
ExpiresAt: newExpiry,
|
||||
}
|
||||
if err := tx.Create(subscription).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("plan_id", planRecord.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification := buildSubscriptionNotification(user.ID, paymentRecord.ID, invoiceID, &planRecord, subscription)
|
||||
if err := tx.Create(notification).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletBalance, err = model.GetWalletBalance(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
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, paymentRecord)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
return &appv1.CreateAdminPaymentResponse{
|
||||
Payment: payload,
|
||||
Subscription: toProtoPlanSubscription(subscription),
|
||||
WalletBalance: walletBalance,
|
||||
InvoiceId: 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(normalizeAdminAdFormatValue(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(normalizeAdminAdFormatValue(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.Where("ad_template_id = ?", id).Delete(&model.VideoAdConfig{}).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
|
||||
}
|
||||
201
internal/rpc/app/service_admin_jobs_agents.go
Normal file
201
internal/rpc/app/service_admin_jobs_agents.go
Normal file
@@ -0,0 +1,201 @@
|
||||
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/runtime/services"
|
||||
)
|
||||
|
||||
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.jobService == nil {
|
||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
||||
}
|
||||
|
||||
offset := int(req.GetOffset())
|
||||
limit := int(req.GetLimit())
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
var (
|
||||
result *services.PaginatedJobs
|
||||
err error
|
||||
)
|
||||
if agentID := strings.TrimSpace(req.GetAgentId()); agentID != "" {
|
||||
result, err = s.jobService.ListJobsByAgent(ctx, agentID, offset, limit)
|
||||
} else {
|
||||
result, err = s.jobService.ListJobs(ctx, offset, limit)
|
||||
}
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
return &appv1.ListAdminJobsResponse{
|
||||
Jobs: jobs,
|
||||
Total: result.Total,
|
||||
Offset: int32(result.Offset),
|
||||
Limit: int32(result.Limit),
|
||||
HasMore: result.HasMore,
|
||||
}, 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.jobService == 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.jobService.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.jobService == 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")
|
||||
}
|
||||
|
||||
job, err := s.jobService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), 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.jobService == 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.jobService.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.jobService == 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.jobService.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
|
||||
}
|
||||
579
internal/rpc/app/service_admin_users_videos.go
Normal file
579
internal/rpc/app/service_admin_users_videos.go
Normal file
@@ -0,0 +1,579 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminUser(ctx, &user)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
||||
}
|
||||
|
||||
var subscription model.PlanSubscription
|
||||
var subscriptionPayload *appv1.PlanSubscription
|
||||
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscription).Error; err == nil {
|
||||
subscriptionPayload = toProtoPlanSubscription(&subscription)
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
||||
}
|
||||
|
||||
return &appv1.GetAdminUserResponse{User: &appv1.AdminUserDetail{User: payload, Subscription: subscriptionPayload}}, nil
|
||||
}
|
||||
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) 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.VideoAdConfig{}, "user_id = ?"},
|
||||
{&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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
}
|
||||
|
||||
statusValue := normalizeVideoStatusValue(req.GetStatus())
|
||||
processingStatus := strings.ToUpper(statusValue)
|
||||
storageType := detectStorageType(videoURL)
|
||||
video := &model.Video{
|
||||
ID: uuid.New().String(),
|
||||
UserID: user.ID,
|
||||
Name: title,
|
||||
Title: title,
|
||||
Description: nullableTrimmedString(req.Description),
|
||||
URL: videoURL,
|
||||
Size: req.GetSize(),
|
||||
Duration: req.GetDuration(),
|
||||
Format: strings.TrimSpace(req.GetFormat()),
|
||||
Status: model.StringPtr(statusValue),
|
||||
ProcessingStatus: model.StringPtr(processingStatus),
|
||||
StorageType: model.StringPtr(storageType),
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(video).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.saveAdminVideoAdConfig(ctx, tx, video.ID, user.ID, nullableTrimmedString(req.AdTemplateId))
|
||||
})
|
||||
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 create video")
|
||||
}
|
||||
|
||||
payload, err := s.buildAdminVideo(ctx, 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
|
||||
}
|
||||
}
|
||||
if oldUserID != user.ID {
|
||||
if err := tx.Model(&model.VideoAdConfig{}).Where("video_id = ?", video.ID).Update("user_id", user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.saveAdminVideoAdConfig(ctx, tx, video.ID, user.ID, nullableTrimmedString(req.AdTemplateId))
|
||||
})
|
||||
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("video_id = ?", video.ID).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
300
internal/rpc/app/service_auth.go
Normal file
300
internal/rpc/app/service_auth.go
Normal file
@@ -0,0 +1,300 @@
|
||||
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"
|
||||
authapi "stream.api/internal/api/auth"
|
||||
"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 := authapi.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()
|
||||
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")
|
||||
}
|
||||
|
||||
role := "USER"
|
||||
passwordHash := string(hashedPassword)
|
||||
newUser := &model.User{
|
||||
ID: uuid.New().String(),
|
||||
Email: email,
|
||||
Password: &passwordHash,
|
||||
Username: &username,
|
||||
Role: &role,
|
||||
}
|
||||
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 := authapi.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("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
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))
|
||||
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")
|
||||
}
|
||||
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,
|
||||
}
|
||||
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 := authapi.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
|
||||
}
|
||||
203
internal/rpc/app/service_core.go
Normal file
203
internal/rpc/app/service_core.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/config"
|
||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||
"stream.api/internal/middleware"
|
||||
videogrpc "stream.api/internal/video/runtime/grpc"
|
||||
"stream.api/internal/video/runtime/services"
|
||||
"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 (
|
||||
walletTransactionTypeTopup = "topup"
|
||||
walletTransactionTypeSubscriptionDebit = "subscription_debit"
|
||||
paymentMethodWallet = "wallet"
|
||||
paymentMethodTopup = "topup"
|
||||
paymentKindSubscription = "subscription"
|
||||
paymentKindWalletTopup = "wallet_topup"
|
||||
)
|
||||
|
||||
var allowedTermMonths = map[int32]struct{}{
|
||||
1: {},
|
||||
3: {},
|
||||
6: {},
|
||||
12: {},
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
AuthServiceServer
|
||||
AccountServiceServer
|
||||
PreferencesServiceServer
|
||||
UsageServiceServer
|
||||
NotificationsServiceServer
|
||||
DomainsServiceServer
|
||||
AdTemplatesServiceServer
|
||||
PlansServiceServer
|
||||
PaymentsServiceServer
|
||||
VideosServiceServer
|
||||
AdminServiceServer
|
||||
}
|
||||
|
||||
type appServices struct {
|
||||
appv1.UnimplementedAuthServiceServer
|
||||
appv1.UnimplementedAccountServiceServer
|
||||
appv1.UnimplementedPreferencesServiceServer
|
||||
appv1.UnimplementedUsageServiceServer
|
||||
appv1.UnimplementedNotificationsServiceServer
|
||||
appv1.UnimplementedDomainsServiceServer
|
||||
appv1.UnimplementedAdTemplatesServiceServer
|
||||
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
|
||||
jobService *services.JobService
|
||||
agentRuntime *videogrpc.Server
|
||||
googleOauth *oauth2.Config
|
||||
googleStateTTL time.Duration
|
||||
}
|
||||
|
||||
type paymentRow 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type paymentInvoiceDetails struct {
|
||||
PlanName string
|
||||
TermMonths *int32
|
||||
PaymentMethod string
|
||||
ExpiresAt *time.Time
|
||||
WalletAmount float64
|
||||
TopupAmount float64
|
||||
}
|
||||
|
||||
type apiErrorBody struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, jobService *services.JobService, agentRuntime *videogrpc.Server) *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,
|
||||
}
|
||||
}
|
||||
|
||||
service := &appServices{
|
||||
db: db,
|
||||
logger: l,
|
||||
authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker),
|
||||
tokenProvider: t,
|
||||
cache: c,
|
||||
storageProvider: storageProvider,
|
||||
jobService: jobService,
|
||||
agentRuntime: agentRuntime,
|
||||
googleOauth: googleOauth,
|
||||
googleStateTTL: googleStateTTL,
|
||||
}
|
||||
return &Services{
|
||||
AuthServiceServer: service,
|
||||
AccountServiceServer: service,
|
||||
PreferencesServiceServer: service,
|
||||
UsageServiceServer: service,
|
||||
NotificationsServiceServer: service,
|
||||
DomainsServiceServer: service,
|
||||
AdTemplatesServiceServer: 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 PlansServiceServer interface {
|
||||
appv1.PlansServiceServer
|
||||
}
|
||||
|
||||
type PaymentsServiceServer interface {
|
||||
appv1.PaymentsServiceServer
|
||||
}
|
||||
|
||||
type VideosServiceServer interface {
|
||||
appv1.VideosServiceServer
|
||||
}
|
||||
|
||||
type AdminServiceServer interface {
|
||||
appv1.AdminServiceServer
|
||||
}
|
||||
1100
internal/rpc/app/service_helpers.go
Normal file
1100
internal/rpc/app/service_helpers.go
Normal file
File diff suppressed because it is too large
Load Diff
414
internal/rpc/app/service_payments.go
Normal file
414
internal/rpc/app/service_payments.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"gorm.io/gorm"
|
||||
paymentapi "stream.api/internal/api/payment"
|
||||
"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")
|
||||
}
|
||||
|
||||
var planRecord model.Plan
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "Plan not found")
|
||||
}
|
||||
s.logger.Error("Failed to load plan", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
||||
}
|
||||
if planRecord.IsActive == nil || !*planRecord.IsActive {
|
||||
return nil, status.Error(codes.InvalidArgument, "Plan is not active")
|
||||
}
|
||||
|
||||
totalAmount := planRecord.Price * float64(req.GetTermMonths())
|
||||
if totalAmount < 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "Amount must be greater than or equal to 0")
|
||||
}
|
||||
|
||||
statusValue := "SUCCESS"
|
||||
provider := "INTERNAL"
|
||||
currency := normalizeCurrency(nil)
|
||||
transactionID := buildTransactionID("sub")
|
||||
now := time.Now().UTC()
|
||||
|
||||
paymentRecord := &model.Payment{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
PlanID: &planRecord.ID,
|
||||
Amount: totalAmount,
|
||||
Currency: ¤cy,
|
||||
Status: &statusValue,
|
||||
Provider: &provider,
|
||||
TransactionID: &transactionID,
|
||||
}
|
||||
|
||||
invoiceID := buildInvoiceID(paymentRecord.ID)
|
||||
var subscription *model.PlanSubscription
|
||||
var walletBalance float64
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, result.UserID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
baseExpiry := now
|
||||
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
|
||||
baseExpiry = currentSubscription.ExpiresAt.UTC()
|
||||
}
|
||||
newExpiry := baseExpiry.AddDate(0, int(req.GetTermMonths()), 0)
|
||||
|
||||
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, result.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
|
||||
|
||||
if paymentMethod == paymentMethodWallet && shortfall > 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Insufficient wallet balance", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
|
||||
topupAmount := 0.0
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
if req.TopupAmount == nil {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount is required when payment method is topup", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
|
||||
topupAmount = maxFloat(req.GetTopupAmount(), 0)
|
||||
if topupAmount <= 0 {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than 0", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
})
|
||||
}
|
||||
if topupAmount < shortfall {
|
||||
return statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Top-up amount must be greater than or equal to the required shortfall", map[string]interface{}{
|
||||
"payment_method": paymentMethod,
|
||||
"wallet_balance": currentWalletBalance,
|
||||
"total_amount": totalAmount,
|
||||
"shortfall": shortfall,
|
||||
"topup_amount": topupAmount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Create(paymentRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletUsedAmount := totalAmount
|
||||
|
||||
if paymentMethod == paymentMethodTopup {
|
||||
topupTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
Type: walletTransactionTypeTopup,
|
||||
Amount: topupAmount,
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(topupTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
debitTransaction := &model.WalletTransaction{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
Type: walletTransactionTypeSubscriptionDebit,
|
||||
Amount: -totalAmount,
|
||||
Currency: model.StringPtr(currency),
|
||||
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.GetTermMonths())),
|
||||
PaymentID: &paymentRecord.ID,
|
||||
PlanID: &planRecord.ID,
|
||||
TermMonths: int32Ptr(req.GetTermMonths()),
|
||||
}
|
||||
if err := tx.Create(debitTransaction).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscription = &model.PlanSubscription{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
PaymentID: paymentRecord.ID,
|
||||
PlanID: planRecord.ID,
|
||||
TermMonths: req.GetTermMonths(),
|
||||
PaymentMethod: paymentMethod,
|
||||
WalletAmount: walletUsedAmount,
|
||||
TopupAmount: topupAmount,
|
||||
StartedAt: now,
|
||||
ExpiresAt: newExpiry,
|
||||
}
|
||||
if err := tx.Create(subscription).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.User{}).
|
||||
Where("id = ?", result.UserID).
|
||||
Update("plan_id", planRecord.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification := buildSubscriptionNotification(result.UserID, paymentRecord.ID, invoiceID, &planRecord, subscription)
|
||||
if err := tx.Create(notification).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
walletBalance, err = model.GetWalletBalance(ctx, tx, result.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
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(paymentRecord),
|
||||
Subscription: toProtoPlanSubscription(subscription),
|
||||
WalletBalance: walletBalance,
|
||||
InvoiceId: invoiceID,
|
||||
Message: "Payment completed successfully",
|
||||
}, nil
|
||||
}
|
||||
func (s *appServices) ListPaymentHistory(ctx context.Context, _ *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
|
||||
result, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []paymentRow
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("payment AS p").
|
||||
Select("p.id, p.amount, p.currency, p.status, p.plan_id, pl.name AS plan_name, ps.term_months, ps.payment_method, ps.expires_at, p.created_at").
|
||||
Joins("LEFT JOIN plan AS pl ON pl.id = p.plan_id").
|
||||
Joins("LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id").
|
||||
Where("p.user_id = ?", result.UserID).
|
||||
Order("p.created_at DESC").
|
||||
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([]paymentapi.PaymentHistoryItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, paymentapi.PaymentHistoryItem{
|
||||
ID: row.ID,
|
||||
Amount: row.Amount,
|
||||
Currency: normalizeCurrency(row.Currency),
|
||||
Status: normalizePaymentStatus(row.Status),
|
||||
PlanID: row.PlanID,
|
||||
PlanName: row.PlanName,
|
||||
InvoiceID: buildInvoiceID(row.ID),
|
||||
Kind: paymentKindSubscription,
|
||||
TermMonths: row.TermMonths,
|
||||
PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod),
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var topups []model.WalletTransaction
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("user_id = ? AND type = ? AND payment_id IS NULL", result.UserID, walletTransactionTypeTopup).
|
||||
Order("created_at DESC").
|
||||
Find(&topups).Error; err != nil {
|
||||
s.logger.Error("Failed to fetch wallet topups", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
|
||||
}
|
||||
|
||||
for _, topup := range topups {
|
||||
createdAt := topup.CreatedAt
|
||||
items = append(items, paymentapi.PaymentHistoryItem{
|
||||
ID: topup.ID,
|
||||
Amount: topup.Amount,
|
||||
Currency: normalizeCurrency(topup.Currency),
|
||||
Status: "success",
|
||||
InvoiceID: buildInvoiceID(topup.ID),
|
||||
Kind: paymentKindWalletTopup,
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := time.Time{}
|
||||
right := time.Time{}
|
||||
if items[i].CreatedAt != nil {
|
||||
left = *items[i].CreatedAt
|
||||
}
|
||||
if items[j].CreatedAt != nil {
|
||||
right = *items[j].CreatedAt
|
||||
}
|
||||
return right.After(left)
|
||||
})
|
||||
|
||||
payload := make([]*appv1.PaymentHistoryItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
copyItem := item
|
||||
payload = append(payload, toProtoPaymentHistoryItem(©Item))
|
||||
}
|
||||
|
||||
return &appv1.ListPaymentHistoryResponse{Payments: payload}, 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]interface{}{
|
||||
"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 err != nil && !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")
|
||||
}
|
||||
390
internal/rpc/app/service_user_features.go
Normal file
390
internal/rpc/app/service_user_features.go
Normal file
@@ -0,0 +1,390 @@
|
||||
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.Where("ad_template_id = ? AND user_id = ?", id, result.UserID).
|
||||
Delete(&model.VideoAdConfig{}).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
|
||||
}
|
||||
274
internal/rpc/app/service_videos.go
Normal file
274
internal/rpc/app/service_videos.go
Normal file
@@ -0,0 +1,274 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
statusValue := "ready"
|
||||
processingStatus := "READY"
|
||||
storageType := detectStorageType(videoURL)
|
||||
description := strings.TrimSpace(req.GetDescription())
|
||||
format := strings.TrimSpace(req.GetFormat())
|
||||
|
||||
video := &model.Video{
|
||||
ID: uuid.New().String(),
|
||||
UserID: result.UserID,
|
||||
Name: title,
|
||||
Title: title,
|
||||
Description: nullableTrimmedString(&description),
|
||||
URL: videoURL,
|
||||
Size: req.GetSize(),
|
||||
Duration: req.GetDuration(),
|
||||
Format: format,
|
||||
Status: &statusValue,
|
||||
ProcessingStatus: &processingStatus,
|
||||
StorageType: &storageType,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(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 create video", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
||||
}
|
||||
|
||||
return &appv1.CreateVideoResponse{Video: toProtoVideo(video)}, 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 {
|
||||
items = append(items, toProtoVideo(&videos[i]))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
return &appv1.GetVideoResponse{Video: toProtoVideo(&video)}, 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")
|
||||
}
|
||||
|
||||
return &appv1.UpdateVideoResponse{Video: toProtoVideo(&video)}, 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("video_id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user