draft grpc

This commit is contained in:
2026-03-13 02:17:18 +00:00
parent ea2edbb9e0
commit 91e5e3542b
116 changed files with 44505 additions and 558 deletions

View File

@@ -0,0 +1,390 @@
//go:build ignore
// +build ignore
package admin
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
// "stream.api/internal/database/model"
"stream.api/internal/database/model"
"stream.api/pkg/response"
)
type AdminAdTemplatePayload struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description string `json:"description"`
VastTagURL string `json:"vast_tag_url"`
AdFormat string `json:"ad_format"`
Duration *int64 `json:"duration,omitempty"`
IsActive bool `json:"is_active"`
IsDefault bool `json:"is_default"`
OwnerEmail string `json:"owner_email,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type SaveAdminAdTemplateRequest struct {
UserID string `json:"user_id" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
VASTTagURL string `json:"vast_tag_url" binding:"required"`
AdFormat string `json:"ad_format"`
Duration *int64 `json:"duration"`
IsActive *bool `json:"is_active"`
IsDefault *bool `json:"is_default"`
}
func normalizeAdminAdFormat(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "mid-roll", "post-roll":
return strings.TrimSpace(strings.ToLower(value))
default:
return "pre-roll"
}
}
func unsetAdminDefaultTemplates(tx *gorm.DB, userID, excludeID string) error {
query := tx.Model(&model.AdTemplate{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func validateAdminAdTemplateRequest(req *SaveAdminAdTemplateRequest) string {
if strings.TrimSpace(req.UserID) == "" {
return "User ID is required"
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.VASTTagURL) == "" {
return "Name and VAST URL are required"
}
format := normalizeAdminAdFormat(req.AdFormat)
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
return "Duration is required for mid-roll templates"
}
return ""
}
func (h *Handler) buildAdminAdTemplatePayload(ctx *gin.Context, item model.AdTemplate, ownerEmail string) AdminAdTemplatePayload {
return AdminAdTemplatePayload{
ID: item.ID,
UserID: item.UserID,
Name: item.Name,
Description: adminStringValue(item.Description),
VastTagURL: item.VastTagURL,
AdFormat: adminStringValue(item.AdFormat),
Duration: item.Duration,
IsActive: adminBoolValue(item.IsActive, true),
IsDefault: item.IsDefault,
OwnerEmail: ownerEmail,
CreatedAt: adminFormatTime(item.CreatedAt),
UpdatedAt: adminFormatTime(item.UpdatedAt),
}
}
// @Summary List All Ad Templates
// @Description Get paginated list of all ad templates across users (admin only)
// @Tags admin
// @Produce json
// @Param page query int false "Page" default(1)
// @Param limit query int false "Limit" default(20)
// @Param user_id query string false "Filter by user ID"
// @Param search query string false "Search by name"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Router /admin/ad-templates [get]
// @Security BearerAuth
func (h *Handler) ListAdTemplates(c *gin.Context) {
ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
search := strings.TrimSpace(c.Query("search"))
userID := strings.TrimSpace(c.Query("user_id"))
db := h.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 {
response.Error(c, http.StatusInternalServerError, "Failed to list ad templates")
return
}
var templates []model.AdTemplate
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&templates).Error; err != nil {
h.logger.Error("Failed to list ad templates", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to list ad templates")
return
}
ownerIDs := map[string]bool{}
for _, t := range templates {
ownerIDs[t.UserID] = true
}
ownerEmails := map[string]string{}
if len(ownerIDs) > 0 {
ids := make([]string, 0, len(ownerIDs))
for id := range ownerIDs {
ids = append(ids, id)
}
var users []struct{ ID, Email string }
h.db.WithContext(ctx).Table("\"user\"").Select("id, email").Where("id IN ?", ids).Find(&users)
for _, u := range users {
ownerEmails[u.ID] = u.Email
}
}
result := make([]AdminAdTemplatePayload, 0, len(templates))
for _, t := range templates {
result = append(result, h.buildAdminAdTemplatePayload(c, t, ownerEmails[t.UserID]))
}
response.Success(c, gin.H{
"templates": result,
"total": total,
"page": page,
"limit": limit,
})
}
// @Summary Get Ad Template Detail
// @Description Get ad template detail (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "Ad Template ID"
// @Success 200 {object} response.Response
// @Router /admin/ad-templates/{id} [get]
// @Security BearerAuth
func (h *Handler) GetAdTemplate(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
var item model.AdTemplate
if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to load ad template")
return
}
ownerEmail := ""
var user model.User
if err := h.db.WithContext(c.Request.Context()).Select("id, email").Where("id = ?", item.UserID).First(&user).Error; err == nil {
ownerEmail = user.Email
}
response.Success(c, gin.H{"template": h.buildAdminAdTemplatePayload(c, item, ownerEmail)})
}
// @Summary Create Ad Template
// @Description Create an ad template for any user (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param request body SaveAdminAdTemplateRequest true "Ad template payload"
// @Success 201 {object} response.Response
// @Router /admin/ad-templates [post]
// @Security BearerAuth
func (h *Handler) CreateAdTemplate(c *gin.Context) {
var req SaveAdminAdTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if msg := validateAdminAdTemplateRequest(&req); msg != "" {
response.Error(c, http.StatusBadRequest, msg)
return
}
ctx := c.Request.Context()
var user model.User
if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusBadRequest, "User not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to save ad template")
return
}
item := &model.AdTemplate{
ID: uuid.New().String(),
UserID: user.ID,
Name: strings.TrimSpace(req.Name),
Description: adminStringPtr(req.Description),
VastTagURL: strings.TrimSpace(req.VASTTagURL),
AdFormat: model.StringPtr(normalizeAdminAdFormat(req.AdFormat)),
Duration: req.Duration,
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
IsDefault: req.IsDefault != nil && *req.IsDefault,
}
if !adminBoolValue(item.IsActive, true) {
item.IsDefault = false
}
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetAdminDefaultTemplates(tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
h.logger.Error("Failed to create ad template", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to save ad template")
return
}
response.Created(c, gin.H{"template": h.buildAdminAdTemplatePayload(c, *item, user.Email)})
}
// @Summary Update Ad Template
// @Description Update an ad template for any user (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Ad Template ID"
// @Param request body SaveAdminAdTemplateRequest true "Ad template payload"
// @Success 200 {object} response.Response
// @Router /admin/ad-templates/{id} [put]
// @Security BearerAuth
func (h *Handler) UpdateAdTemplate(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
var req SaveAdminAdTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if msg := validateAdminAdTemplateRequest(&req); msg != "" {
response.Error(c, http.StatusBadRequest, msg)
return
}
ctx := c.Request.Context()
var user model.User
if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusBadRequest, "User not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to save ad template")
return
}
var item model.AdTemplate
if err := h.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to save ad template")
return
}
item.UserID = user.ID
item.Name = strings.TrimSpace(req.Name)
item.Description = adminStringPtr(req.Description)
item.VastTagURL = strings.TrimSpace(req.VASTTagURL)
item.AdFormat = model.StringPtr(normalizeAdminAdFormat(req.AdFormat))
item.Duration = req.Duration
if req.IsActive != nil {
item.IsActive = model.BoolPtr(*req.IsActive)
}
if req.IsDefault != nil {
item.IsDefault = *req.IsDefault
}
if !adminBoolValue(item.IsActive, true) {
item.IsDefault = false
}
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetAdminDefaultTemplates(tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
h.logger.Error("Failed to update ad template", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to save ad template")
return
}
response.Success(c, gin.H{"template": h.buildAdminAdTemplatePayload(c, item, user.Email)})
}
// @Summary Delete Ad Template (Admin)
// @Description Delete any ad template by ID (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "Ad Template ID"
// @Success 200 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /admin/ad-templates/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeleteAdTemplate(c *gin.Context) {
id := c.Param("id")
err := h.db.WithContext(c.Request.Context()).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 err == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
h.logger.Error("Failed to delete ad template", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to delete ad template")
return
}
response.Success(c, gin.H{"message": "Ad template deleted"})
}

View File

@@ -0,0 +1,94 @@
//go:build ignore
// +build ignore
package admin
import (
"fmt"
"strings"
"time"
)
func adminFormatTime(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func adminFormatTimeValue(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func adminStringPtr(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func adminStringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func adminInt64Ptr(value *int64) *int64 {
if value == nil {
return nil
}
return value
}
func adminStringSlice(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
func adminStringSliceValue(values []string) []string {
if len(values) == 0 {
return []string{}
}
return append([]string(nil), values...)
}
func adminBoolValue(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func adminInvoiceID(id string) string {
trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "")
if len(trimmed) > 12 {
trimmed = trimmed[:12]
}
return "INV-" + strings.ToUpper(trimmed)
}
func adminTransactionID(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}

View File

@@ -0,0 +1,68 @@
//go:build ignore
// +build ignore
package admin
import (
"time"
"github.com/gin-gonic/gin"
"stream.api/internal/database/model"
"stream.api/pkg/response"
)
type DashboardPayload struct {
TotalUsers int64 `json:"total_users"`
TotalVideos int64 `json:"total_videos"`
TotalStorageUsed int64 `json:"total_storage_used"`
TotalPayments int64 `json:"total_payments"`
TotalRevenue float64 `json:"total_revenue"`
ActiveSubscriptions int64 `json:"active_subscriptions"`
TotalAdTemplates int64 `json:"total_ad_templates"`
NewUsersToday int64 `json:"new_users_today"`
NewVideosToday int64 `json:"new_videos_today"`
}
// @Summary Admin Dashboard
// @Description Get system-wide statistics for the admin dashboard
// @Tags admin
// @Produce json
// @Success 200 {object} response.Response{data=DashboardPayload}
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Router /admin/dashboard [get]
// @Security BearerAuth
func (h *Handler) Dashboard(c *gin.Context) {
ctx := c.Request.Context()
var payload DashboardPayload
h.db.WithContext(ctx).Model(&model.User{}).Count(&payload.TotalUsers)
h.db.WithContext(ctx).Model(&model.Video{}).Count(&payload.TotalVideos)
h.db.WithContext(ctx).Model(&model.User{}).
Select("COALESCE(SUM(storage_used), 0)").
Row().Scan(&payload.TotalStorageUsed)
h.db.WithContext(ctx).Model(&model.Payment{}).Count(&payload.TotalPayments)
h.db.WithContext(ctx).Model(&model.Payment{}).
Where("status = ?", "SUCCESS").
Select("COALESCE(SUM(amount), 0)").
Row().Scan(&payload.TotalRevenue)
h.db.WithContext(ctx).Model(&model.PlanSubscription{}).
Where("expires_at > ?", time.Now()).
Count(&payload.ActiveSubscriptions)
h.db.WithContext(ctx).Model(&model.AdTemplate{}).Count(&payload.TotalAdTemplates)
today := time.Now().Truncate(24 * time.Hour)
h.db.WithContext(ctx).Model(&model.User{}).
Where("created_at >= ?", today).
Count(&payload.NewUsersToday)
h.db.WithContext(ctx).Model(&model.Video{}).
Where("created_at >= ?", today).
Count(&payload.NewVideosToday)
response.Success(c, payload)
}

View File

@@ -0,0 +1,26 @@
//go:build ignore
// +build ignore
package admin
import (
"gorm.io/gorm"
runtimegrpc "stream.api/internal/video/runtime/grpc"
"stream.api/internal/video/runtime/services"
"stream.api/pkg/logger"
)
type RenderRuntime interface {
JobService() *services.JobService
AgentRuntime() *runtimegrpc.Server
}
type Handler struct {
logger logger.Logger
db *gorm.DB
runtime RenderRuntime
}
func NewHandler(l logger.Logger, db *gorm.DB, renderRuntime RenderRuntime) *Handler {
return &Handler{logger: l, db: db, runtime: renderRuntime}
}

View File

@@ -0,0 +1,521 @@
//go:build ignore
// +build ignore
package admin
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"stream.api/internal/database/model"
"stream.api/pkg/response"
)
const (
adminWalletTransactionTypeTopup = "topup"
adminWalletTransactionTypeSubscriptionDebit = "subscription_debit"
adminPaymentMethodWallet = "wallet"
adminPaymentMethodTopup = "topup"
)
var adminAllowedTermMonths = map[int32]struct{}{
1: {}, 3: {}, 6: {}, 12: {},
}
type AdminPaymentPayload struct {
ID string `json:"id"`
UserID string `json:"user_id"`
PlanID *string `json:"plan_id,omitempty"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
Provider string `json:"provider"`
TransactionID string `json:"transaction_id,omitempty"`
UserEmail string `json:"user_email,omitempty"`
PlanName string `json:"plan_name,omitempty"`
InvoiceID string `json:"invoice_id"`
TermMonths *int32 `json:"term_months,omitempty"`
PaymentMethod *string `json:"payment_method,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
WalletAmount *float64 `json:"wallet_amount,omitempty"`
TopupAmount *float64 `json:"topup_amount,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type CreateAdminPaymentRequest struct {
UserID string `json:"user_id" binding:"required"`
PlanID string `json:"plan_id" binding:"required"`
TermMonths int32 `json:"term_months" binding:"required"`
PaymentMethod string `json:"payment_method" binding:"required"`
TopupAmount *float64 `json:"topup_amount,omitempty"`
}
type UpdateAdminPaymentRequest struct {
Status string `json:"status" binding:"required"`
}
func normalizeAdminPaymentMethod(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case adminPaymentMethodWallet:
return adminPaymentMethodWallet
case adminPaymentMethodTopup:
return adminPaymentMethodTopup
default:
return ""
}
}
func normalizeAdminPaymentStatus(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "PENDING":
return "PENDING"
case "FAILED":
return "FAILED"
default:
return "SUCCESS"
}
}
func adminIsAllowedTermMonths(value int32) bool {
_, ok := adminAllowedTermMonths[value]
return ok
}
func (h *Handler) loadAdminPaymentPayload(ctx *gin.Context, payment model.Payment) (AdminPaymentPayload, error) {
payload := AdminPaymentPayload{
ID: payment.ID,
UserID: payment.UserID,
PlanID: payment.PlanID,
Amount: payment.Amount,
Currency: strings.ToUpper(adminStringValue(payment.Currency)),
Status: normalizeAdminPaymentStatus(adminStringValue(payment.Status)),
Provider: strings.ToUpper(adminStringValue(payment.Provider)),
TransactionID: adminStringValue(payment.TransactionID),
InvoiceID: adminInvoiceID(payment.ID),
CreatedAt: adminFormatTime(payment.CreatedAt),
UpdatedAt: adminFormatTimeValue(payment.UpdatedAt),
}
if payload.Currency == "" {
payload.Currency = "USD"
}
var user model.User
if err := h.db.WithContext(ctx.Request.Context()).Select("id, email").Where("id = ?", payment.UserID).First(&user).Error; err == nil {
payload.UserEmail = user.Email
}
if payment.PlanID != nil && strings.TrimSpace(*payment.PlanID) != "" {
var plan model.Plan
if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", *payment.PlanID).First(&plan).Error; err == nil {
payload.PlanName = plan.Name
}
}
var subscription model.PlanSubscription
if err := h.db.WithContext(ctx.Request.Context()).Where("payment_id = ?", payment.ID).Order("created_at DESC").First(&subscription).Error; err == nil {
payload.TermMonths = &subscription.TermMonths
method := subscription.PaymentMethod
payload.PaymentMethod = &method
expiresAt := subscription.ExpiresAt.UTC().Format(time.RFC3339)
payload.ExpiresAt = &expiresAt
payload.WalletAmount = &subscription.WalletAmount
payload.TopupAmount = &subscription.TopupAmount
}
return payload, nil
}
func (h *Handler) adminLockUserForUpdate(ctx *gin.Context, tx *gorm.DB, userID string) (*model.User, error) {
var user model.User
if err := tx.WithContext(ctx.Request.Context()).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (h *Handler) adminCreateSubscriptionPayment(ctx *gin.Context, req CreateAdminPaymentRequest) (*model.Payment, *model.PlanSubscription, float64, error) {
planID := strings.TrimSpace(req.PlanID)
userID := strings.TrimSpace(req.UserID)
paymentMethod := normalizeAdminPaymentMethod(req.PaymentMethod)
if paymentMethod == "" {
return nil, nil, 0, errors.New("Payment method must be wallet or topup")
}
if !adminIsAllowedTermMonths(req.TermMonths) {
return nil, nil, 0, errors.New("Term months must be one of 1, 3, 6, or 12")
}
var planRecord model.Plan
if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", planID).First(&planRecord).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, 0, errors.New("Plan not found")
}
return nil, nil, 0, err
}
if !adminBoolValue(planRecord.IsActive, true) {
return nil, nil, 0, errors.New("Plan is not active")
}
var user model.User
if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, 0, errors.New("User not found")
}
return nil, nil, 0, err
}
totalAmount := planRecord.Price * float64(req.TermMonths)
status := "SUCCESS"
provider := "INTERNAL"
currency := "USD"
transactionID := adminTransactionID("sub")
now := time.Now().UTC()
payment := &model.Payment{
ID: uuid.New().String(),
UserID: user.ID,
PlanID: &planRecord.ID,
Amount: totalAmount,
Currency: &currency,
Status: &status,
Provider: &provider,
TransactionID: &transactionID,
}
var subscription *model.PlanSubscription
var walletBalance float64
err := h.db.WithContext(ctx.Request.Context()).Transaction(func(tx *gorm.DB) error {
if _, err := h.adminLockUserForUpdate(ctx, tx, user.ID); err != nil {
return err
}
var currentSubscription model.PlanSubscription
hasCurrentSubscription := false
if err := tx.Where("user_id = ?", user.ID).Order("created_at DESC").First(&currentSubscription).Error; err == nil {
hasCurrentSubscription = true
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
baseExpiry := now
if hasCurrentSubscription && currentSubscription.ExpiresAt.After(baseExpiry) {
baseExpiry = currentSubscription.ExpiresAt.UTC()
}
newExpiry := baseExpiry.AddDate(0, int(req.TermMonths), 0)
currentWalletBalance, err := model.GetWalletBalance(ctx.Request.Context(), tx, user.ID)
if err != nil {
return err
}
shortfall := totalAmount - currentWalletBalance
if shortfall < 0 {
shortfall = 0
}
if paymentMethod == adminPaymentMethodWallet && shortfall > 0 {
return fmt.Errorf("Insufficient wallet balance")
}
topupAmount := 0.0
if paymentMethod == adminPaymentMethodTopup {
if req.TopupAmount == nil {
return fmt.Errorf("Top-up amount is required when payment method is topup")
}
topupAmount = *req.TopupAmount
if topupAmount <= 0 {
return fmt.Errorf("Top-up amount must be greater than 0")
}
if topupAmount < shortfall {
return fmt.Errorf("Top-up amount must be greater than or equal to the required shortfall")
}
}
if err := tx.Create(payment).Error; err != nil {
return err
}
if paymentMethod == adminPaymentMethodTopup {
topupTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: user.ID,
Type: adminWalletTransactionTypeTopup,
Amount: topupAmount,
Currency: model.StringPtr(currency),
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.TermMonths)),
PaymentID: &payment.ID,
PlanID: &planRecord.ID,
TermMonths: &req.TermMonths,
}
if err := tx.Create(topupTransaction).Error; err != nil {
return err
}
}
debitTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: user.ID,
Type: adminWalletTransactionTypeSubscriptionDebit,
Amount: -totalAmount,
Currency: model.StringPtr(currency),
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.TermMonths)),
PaymentID: &payment.ID,
PlanID: &planRecord.ID,
TermMonths: &req.TermMonths,
}
if err := tx.Create(debitTransaction).Error; err != nil {
return err
}
subscription = &model.PlanSubscription{
ID: uuid.New().String(),
UserID: user.ID,
PaymentID: payment.ID,
PlanID: planRecord.ID,
TermMonths: req.TermMonths,
PaymentMethod: paymentMethod,
WalletAmount: totalAmount,
TopupAmount: topupAmount,
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 := &model.Notification{
ID: uuid.New().String(),
UserID: user.ID,
Type: "billing.subscription",
Title: "Subscription activated",
Message: fmt.Sprintf("Your subscription to %s is active until %s.", planRecord.Name, subscription.ExpiresAt.UTC().Format("2006-01-02")),
Metadata: model.StringPtr("{}"),
}
if err := tx.Create(notification).Error; err != nil {
return err
}
walletBalance, err = model.GetWalletBalance(ctx.Request.Context(), tx, user.ID)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, nil, 0, err
}
return payment, subscription, walletBalance, nil
}
// @Summary List All Payments
// @Description Get paginated list of all payments across users (admin only)
// @Tags admin
// @Produce json
// @Param page query int false "Page" default(1)
// @Param limit query int false "Limit" default(20)
// @Param user_id query string false "Filter by user ID"
// @Param status query string false "Filter by status"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Router /admin/payments [get]
// @Security BearerAuth
func (h *Handler) ListPayments(c *gin.Context) {
ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
userID := strings.TrimSpace(c.Query("user_id"))
status := strings.TrimSpace(c.Query("status"))
db := h.db.WithContext(ctx).Model(&model.Payment{})
if userID != "" {
db = db.Where("user_id = ?", userID)
}
if status != "" {
db = db.Where("UPPER(status) = ?", strings.ToUpper(status))
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to list payments")
return
}
var payments []model.Payment
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&payments).Error; err != nil {
h.logger.Error("Failed to list payments", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to list payments")
return
}
result := make([]AdminPaymentPayload, 0, len(payments))
for _, p := range payments {
payload, err := h.loadAdminPaymentPayload(c, p)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to list payments")
return
}
result = append(result, payload)
}
response.Success(c, gin.H{
"payments": result,
"total": total,
"page": page,
"limit": limit,
})
}
// @Summary Get Payment Detail
// @Description Get payment detail (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "Payment ID"
// @Success 200 {object} response.Response
// @Router /admin/payments/{id} [get]
// @Security BearerAuth
func (h *Handler) GetPayment(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Payment not found")
return
}
var payment model.Payment
if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&payment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Payment not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to get payment")
return
}
payload, err := h.loadAdminPaymentPayload(c, payment)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to get payment")
return
}
response.Success(c, gin.H{"payment": payload})
}
// @Summary Create Payment
// @Description Create a model subscription charge for a user (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateAdminPaymentRequest true "Payment payload"
// @Success 201 {object} response.Response
// @Router /admin/payments [post]
// @Security BearerAuth
func (h *Handler) CreatePayment(c *gin.Context) {
var req CreateAdminPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
payment, subscription, walletBalance, err := h.adminCreateSubscriptionPayment(c, req)
if err != nil {
switch err.Error() {
case "User not found", "Plan not found", "Plan is not active", "Payment method must be wallet or topup", "Term months must be one of 1, 3, 6, or 12", "Insufficient wallet balance", "Top-up amount is required when payment method is topup", "Top-up amount must be greater than 0", "Top-up amount must be greater than or equal to the required shortfall":
response.Error(c, http.StatusBadRequest, err.Error())
return
default:
h.logger.Error("Failed to create admin payment", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create payment")
return
}
}
payload, err := h.loadAdminPaymentPayload(c, *payment)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to create payment")
return
}
response.Created(c, gin.H{
"payment": payload,
"subscription": subscription,
"wallet_balance": walletBalance,
"invoice_id": adminInvoiceID(payment.ID),
})
}
// @Summary Update Payment
// @Description Update payment status safely without hard delete (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Payment ID"
// @Param request body UpdateAdminPaymentRequest true "Payment update payload"
// @Success 200 {object} response.Response
// @Router /admin/payments/{id} [put]
// @Security BearerAuth
func (h *Handler) UpdatePayment(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Payment not found")
return
}
var req UpdateAdminPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
newStatus := normalizeAdminPaymentStatus(req.Status)
var payment model.Payment
if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&payment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Payment not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to update payment")
return
}
currentStatus := normalizeAdminPaymentStatus(adminStringValue(payment.Status))
if currentStatus != newStatus {
if (currentStatus == "FAILED" || currentStatus == "PENDING") && newStatus == "SUCCESS" {
response.Error(c, http.StatusBadRequest, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead")
return
}
payment.Status = &newStatus
if err := h.db.WithContext(c.Request.Context()).Save(&payment).Error; err != nil {
h.logger.Error("Failed to update payment", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to update payment")
return
}
}
payload, err := h.loadAdminPaymentPayload(c, payment)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to update payment")
return
}
response.Success(c, gin.H{"payment": payload})
}

302
internal/api/admin/plans.go Normal file
View File

@@ -0,0 +1,302 @@
//go:build ignore
// +build ignore
package admin
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/response"
)
type AdminPlanPayload struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Features []string `json:"features,omitempty"`
Price float64 `json:"price"`
Cycle string `json:"cycle"`
StorageLimit int64 `json:"storage_limit"`
UploadLimit int32 `json:"upload_limit"`
DurationLimit int32 `json:"duration_limit"`
QualityLimit string `json:"quality_limit"`
IsActive bool `json:"is_active"`
UserCount int64 `json:"user_count"`
PaymentCount int64 `json:"payment_count"`
SubscriptionCount int64 `json:"subscription_count"`
}
type SavePlanRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Features []string `json:"features"`
Price float64 `json:"price" binding:"required"`
Cycle string `json:"cycle" binding:"required"`
StorageLimit int64 `json:"storage_limit" binding:"required"`
UploadLimit int32 `json:"upload_limit" binding:"required"`
IsActive *bool `json:"is_active"`
}
func buildAdminPlanPayload(plan model.Plan, userCount, paymentCount, subscriptionCount int64) AdminPlanPayload {
return AdminPlanPayload{
ID: plan.ID,
Name: plan.Name,
Description: adminStringValue(plan.Description),
Features: adminStringSliceValue(plan.Features),
Price: plan.Price,
Cycle: plan.Cycle,
StorageLimit: plan.StorageLimit,
UploadLimit: plan.UploadLimit,
DurationLimit: plan.DurationLimit,
QualityLimit: plan.QualityLimit,
IsActive: adminBoolValue(plan.IsActive, true),
UserCount: userCount,
PaymentCount: paymentCount,
SubscriptionCount: subscriptionCount,
}
}
func (h *Handler) loadPlanUsageCounts(ctx *gin.Context, planID string) (int64, int64, int64, error) {
var userCount int64
if err := h.db.WithContext(ctx.Request.Context()).Model(&model.User{}).Where("plan_id = ?", planID).Count(&userCount).Error; err != nil {
return 0, 0, 0, err
}
var paymentCount int64
if err := h.db.WithContext(ctx.Request.Context()).Model(&model.Payment{}).Where("plan_id = ?", planID).Count(&paymentCount).Error; err != nil {
return 0, 0, 0, err
}
var subscriptionCount int64
if err := h.db.WithContext(ctx.Request.Context()).Model(&model.PlanSubscription{}).Where("plan_id = ?", planID).Count(&subscriptionCount).Error; err != nil {
return 0, 0, 0, err
}
return userCount, paymentCount, subscriptionCount, nil
}
func validatePlanRequest(req *SavePlanRequest) string {
if strings.TrimSpace(req.Name) == "" {
return "Name is required"
}
if strings.TrimSpace(req.Cycle) == "" {
return "Cycle is required"
}
if req.Price < 0 {
return "Price must be greater than or equal to 0"
}
if req.StorageLimit <= 0 {
return "Storage limit must be greater than 0"
}
if req.UploadLimit <= 0 {
return "Upload limit must be greater than 0"
}
return ""
}
// @Summary List Plans
// @Description Get all plans with usage counts (admin only)
// @Tags admin
// @Produce json
// @Success 200 {object} response.Response
// @Router /admin/plans [get]
// @Security BearerAuth
func (h *Handler) ListPlans(c *gin.Context) {
ctx := c.Request.Context()
var plans []model.Plan
if err := h.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil {
h.logger.Error("Failed to list plans", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to list plans")
return
}
result := make([]AdminPlanPayload, 0, len(plans))
for _, plan := range plans {
userCount, paymentCount, subscriptionCount, err := h.loadPlanUsageCounts(c, plan.ID)
if err != nil {
h.logger.Error("Failed to load plan usage", "error", err, "plan_id", plan.ID)
response.Error(c, http.StatusInternalServerError, "Failed to list plans")
return
}
result = append(result, buildAdminPlanPayload(plan, userCount, paymentCount, subscriptionCount))
}
response.Success(c, gin.H{"plans": result})
}
// @Summary Create Plan
// @Description Create a plan (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param request body SavePlanRequest true "Plan payload"
// @Success 201 {object} response.Response
// @Router /admin/plans [post]
// @Security BearerAuth
func (h *Handler) CreatePlan(c *gin.Context) {
var req SavePlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if msg := validatePlanRequest(&req); msg != "" {
response.Error(c, http.StatusBadRequest, msg)
return
}
plan := &model.Plan{
ID: uuid.New().String(),
Name: strings.TrimSpace(req.Name),
Description: adminStringPtr(req.Description),
Features: adminStringSlice(req.Features),
Price: req.Price,
Cycle: strings.TrimSpace(req.Cycle),
StorageLimit: req.StorageLimit,
UploadLimit: req.UploadLimit,
DurationLimit: 0,
QualityLimit: "",
IsActive: func() *bool {
value := true
if req.IsActive != nil {
value = *req.IsActive
}
return &value
}(),
}
if err := h.db.WithContext(c.Request.Context()).Create(plan).Error; err != nil {
h.logger.Error("Failed to create plan", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create plan")
return
}
response.Created(c, gin.H{"plan": buildAdminPlanPayload(*plan, 0, 0, 0)})
}
// @Summary Update Plan
// @Description Update a plan (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Plan ID"
// @Param request body SavePlanRequest true "Plan payload"
// @Success 200 {object} response.Response
// @Router /admin/plans/{id} [put]
// @Security BearerAuth
func (h *Handler) UpdatePlan(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Plan not found")
return
}
var req SavePlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if msg := validatePlanRequest(&req); msg != "" {
response.Error(c, http.StatusBadRequest, msg)
return
}
ctx := c.Request.Context()
var plan model.Plan
if err := h.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Plan not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to update plan")
return
}
plan.Name = strings.TrimSpace(req.Name)
plan.Description = adminStringPtr(req.Description)
plan.Features = adminStringSlice(req.Features)
plan.Price = req.Price
plan.Cycle = strings.TrimSpace(req.Cycle)
plan.StorageLimit = req.StorageLimit
plan.UploadLimit = req.UploadLimit
if req.IsActive != nil {
plan.IsActive = req.IsActive
}
if err := h.db.WithContext(ctx).Save(&plan).Error; err != nil {
h.logger.Error("Failed to update plan", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to update plan")
return
}
userCount, paymentCount, subscriptionCount, err := h.loadPlanUsageCounts(c, plan.ID)
if err != nil {
h.logger.Error("Failed to load plan usage", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to update plan")
return
}
response.Success(c, gin.H{"plan": buildAdminPlanPayload(plan, userCount, paymentCount, subscriptionCount)})
}
// @Summary Delete Plan
// @Description Delete a plan, or deactivate it if already used (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "Plan ID"
// @Success 200 {object} response.Response
// @Router /admin/plans/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeletePlan(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Plan not found")
return
}
ctx := c.Request.Context()
var plan model.Plan
if err := h.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Plan not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to delete plan")
return
}
var paymentCount int64
if err := h.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", id).Count(&paymentCount).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to delete plan")
return
}
var subscriptionCount int64
if err := h.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", id).Count(&subscriptionCount).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to delete plan")
return
}
if paymentCount > 0 || subscriptionCount > 0 {
inactive := false
if err := h.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", id).Update("is_active", inactive).Error; err != nil {
h.logger.Error("Failed to deactivate plan", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to deactivate plan")
return
}
response.Success(c, gin.H{"message": "Plan deactivated", "mode": "deactivated"})
return
}
if err := h.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Plan{}).Error; err != nil {
h.logger.Error("Failed to delete plan", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to delete plan")
return
}
response.Success(c, gin.H{"message": "Plan deleted", "mode": "deleted"})
}

View File

@@ -0,0 +1,218 @@
//go:build ignore
// +build ignore
package admin
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"stream.api/pkg/response"
)
type createJobRequest struct {
Command string `json:"command"`
Image string `json:"image"`
Env map[string]string `json:"env"`
Priority int `json:"priority"`
UserID string `json:"user_id"`
Name string `json:"name"`
TimeLimit int64 `json:"time_limit"`
}
// @Summary List render jobs
// @Description Returns paginated render jobs for admin management
// @Tags admin-render
// @Security BearerAuth
// @Produce json
// @Param offset query int false "Offset"
// @Param limit query int false "Limit"
// @Param agent_id query string false "Agent ID"
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /admin/jobs [get]
func (h *Handler) ListJobs(c *gin.Context) {
offset := parseInt(c.Query("offset"), 0)
limit := parseInt(c.Query("limit"), 20)
if agentID := c.Query("agent_id"); agentID != "" {
items, err := h.runtime.JobService().ListJobsByAgent(c.Request.Context(), agentID, offset, limit)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to list jobs")
return
}
response.Success(c, items)
return
}
items, err := h.runtime.JobService().ListJobs(c.Request.Context(), offset, limit)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to list jobs")
return
}
response.Success(c, items)
}
// @Summary Get render job detail
// @Description Returns a render job by ID
// @Tags admin-render
// @Security BearerAuth
// @Produce json
// @Param id path string true "Job ID"
// @Success 200 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /admin/jobs/{id} [get]
func (h *Handler) GetJob(c *gin.Context) {
job, err := h.runtime.JobService().GetJob(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, http.StatusNotFound, "Job not found")
return
}
response.Success(c, gin.H{"job": job})
}
// @Summary Get render job logs
// @Description Returns plain text logs for a render job
// @Tags admin-render
// @Security BearerAuth
// @Produce plain
// @Param id path string true "Job ID"
// @Success 200 {string} string
// @Failure 404 {object} response.Response
// @Router /admin/jobs/{id}/logs [get]
func (h *Handler) GetJobLogs(c *gin.Context) {
job, err := h.runtime.JobService().GetJob(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, http.StatusNotFound, "Job not found")
return
}
c.String(http.StatusOK, job.Logs)
}
// @Summary Create render job
// @Description Queues a new render job for agents
// @Tags admin-render
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param payload body createJobRequest true "Job payload"
// @Success 201 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /admin/jobs [post]
func (h *Handler) CreateJob(c *gin.Context) {
var req createJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if req.Command == "" {
response.Error(c, http.StatusBadRequest, "Command is required")
return
}
if req.Image == "" {
req.Image = "alpine"
}
if req.Name == "" {
req.Name = req.Command
}
payload, _ := json.Marshal(map[string]interface{}{"image": req.Image, "commands": []string{req.Command}, "environment": req.Env})
job, err := h.runtime.JobService().CreateJob(c.Request.Context(), req.UserID, req.Name, payload, req.Priority, req.TimeLimit)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to create job")
return
}
response.Created(c, gin.H{"job": job})
}
// @Summary Cancel render job
// @Description Cancels a pending or running render job
// @Tags admin-render
// @Security BearerAuth
// @Produce json
// @Param id path string true "Job ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/jobs/{id}/cancel [post]
func (h *Handler) CancelJob(c *gin.Context) {
if err := h.runtime.JobService().CancelJob(c.Request.Context(), c.Param("id")); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
response.Success(c, gin.H{"status": "cancelled", "job_id": c.Param("id")})
}
// @Summary Retry render job
// @Description Retries a failed or cancelled render job
// @Tags admin-render
// @Security BearerAuth
// @Produce json
// @Param id path string true "Job ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/jobs/{id}/retry [post]
func (h *Handler) RetryJob(c *gin.Context) {
job, err := h.runtime.JobService().RetryJob(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
response.Success(c, gin.H{"job": job})
}
// @Summary List connected render agents
// @Description Returns currently connected render agents and current runtime stats
// @Tags admin-render
// @Security BearerAuth
// @Produce json
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /admin/agents [get]
func (h *Handler) ListAgents(c *gin.Context) {
response.Success(c, gin.H{"agents": h.runtime.AgentRuntime().ListAgentsWithStats()})
}
// @Summary Restart connected render agent
// @Description Sends a restart command to a currently connected render agent
// @Tags admin-render
// @Security BearerAuth
// @Produce json
// @Param id path string true "Agent ID"
// @Success 200 {object} response.Response
// @Failure 503 {object} response.Response
// @Router /admin/agents/{id}/restart [post]
func (h *Handler) RestartAgent(c *gin.Context) {
if ok := h.runtime.AgentRuntime().SendCommand(c.Param("id"), "restart"); !ok {
response.Error(c, http.StatusServiceUnavailable, "Agent not active or command channel full")
return
}
response.Success(c, gin.H{"status": "restart command sent"})
}
// @Summary Update connected render agent
// @Description Sends an update command to a currently connected render agent
// @Tags admin-render
// @Security BearerAuth
// @Produce json
// @Param id path string true "Agent ID"
// @Success 200 {object} response.Response
// @Failure 503 {object} response.Response
// @Router /admin/agents/{id}/update [post]
func (h *Handler) UpdateAgent(c *gin.Context) {
if ok := h.runtime.AgentRuntime().SendCommand(c.Param("id"), "update"); !ok {
response.Error(c, http.StatusServiceUnavailable, "Agent not active or command channel full")
return
}
response.Success(c, gin.H{"status": "update command sent"})
}
func parseInt(value string, fallback int) int {
if value == "" {
return fallback
}
var result int
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
return fallback
}
return result
}

522
internal/api/admin/users.go Normal file
View File

@@ -0,0 +1,522 @@
//go:build ignore
// +build ignore
package admin
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/response"
)
type AdminUserPayload struct {
ID string `json:"id"`
Email string `json:"email"`
Username *string `json:"username"`
Avatar *string `json:"avatar"`
Role *string `json:"role"`
PlanID *string `json:"plan_id"`
PlanName string `json:"plan_name,omitempty"`
StorageUsed int64 `json:"storage_used"`
VideoCount int64 `json:"video_count"`
WalletBalance float64 `json:"wallet_balance"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type CreateAdminUserRequest struct {
Email string `json:"email" binding:"required,email"`
Username string `json:"username"`
Password string `json:"password" binding:"required,min=6"`
Role string `json:"role"`
PlanID *string `json:"plan_id"`
}
type UpdateAdminUserRequest struct {
Email *string `json:"email"`
Username *string `json:"username"`
Password *string `json:"password"`
Role *string `json:"role"`
PlanID *string `json:"plan_id"`
}
type UpdateUserRoleRequest struct {
Role string `json:"role" binding:"required"`
}
func normalizeAdminRole(value string) string {
role := strings.ToUpper(strings.TrimSpace(value))
if role == "" {
return "USER"
}
return role
}
func isValidAdminRole(role string) bool {
switch normalizeAdminRole(role) {
case "USER", "ADMIN", "BLOCK":
return true
default:
return false
}
}
func (h *Handler) ensurePlanExists(ctx *gin.Context, planID *string) error {
if planID == nil {
return nil
}
trimmed := strings.TrimSpace(*planID)
if trimmed == "" {
return nil
}
var count int64
if err := h.db.WithContext(ctx.Request.Context()).Model(&model.Plan{}).Where("id = ?", trimmed).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// @Summary List Users
// @Description Get paginated list of all users (admin only)
// @Tags admin
// @Produce json
// @Param page query int false "Page" default(1)
// @Param limit query int false "Limit" default(20)
// @Param search query string false "Search by email or username"
// @Param role query string false "Filter by role"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Router /admin/users [get]
// @Security BearerAuth
func (h *Handler) ListUsers(c *gin.Context) {
ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
search := strings.TrimSpace(c.Query("search"))
role := strings.TrimSpace(c.Query("role"))
db := h.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 {
h.logger.Error("Failed to count users", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to list users")
return
}
var users []model.User
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&users).Error; err != nil {
h.logger.Error("Failed to list users", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to list users")
return
}
planIDs := map[string]bool{}
for _, u := range users {
if u.PlanID != nil && strings.TrimSpace(*u.PlanID) != "" {
planIDs[*u.PlanID] = true
}
}
planNames := map[string]string{}
if len(planIDs) > 0 {
ids := make([]string, 0, len(planIDs))
for id := range planIDs {
ids = append(ids, id)
}
var plans []model.Plan
h.db.WithContext(ctx).Where("id IN ?", ids).Find(&plans)
for _, p := range plans {
planNames[p.ID] = p.Name
}
}
result := make([]AdminUserPayload, 0, len(users))
for _, u := range users {
payload := AdminUserPayload{
ID: u.ID,
Email: u.Email,
Username: u.Username,
Avatar: u.Avatar,
Role: u.Role,
PlanID: u.PlanID,
StorageUsed: u.StorageUsed,
CreatedAt: adminFormatTime(u.CreatedAt),
UpdatedAt: adminFormatTimeValue(u.UpdatedAt),
}
if u.PlanID != nil {
payload.PlanName = planNames[*u.PlanID]
}
h.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", u.ID).Count(&payload.VideoCount)
payload.WalletBalance, _ = model.GetWalletBalance(ctx, h.db, u.ID)
result = append(result, payload)
}
response.Success(c, gin.H{
"users": result,
"total": total,
"page": page,
"limit": limit,
})
}
// @Summary Create User
// @Description Create a user from admin panel (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateAdminUserRequest true "User payload"
// @Success 201 {object} response.Response
// @Router /admin/users [post]
// @Security BearerAuth
func (h *Handler) CreateUser(c *gin.Context) {
var req CreateAdminUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
role := normalizeAdminRole(req.Role)
if !isValidAdminRole(role) {
response.Error(c, http.StatusBadRequest, "Invalid role. Must be USER, ADMIN, or BLOCK")
return
}
if err := h.ensurePlanExists(c, req.PlanID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusBadRequest, "Plan not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to create user")
return
}
email := strings.TrimSpace(req.Email)
username := strings.TrimSpace(req.Username)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to hash password")
return
}
password := string(hashedPassword)
user := &model.User{
ID: uuid.New().String(),
Email: email,
Password: &password,
Username: adminStringPtr(username),
Role: &role,
PlanID: nil,
}
if req.PlanID != nil && strings.TrimSpace(*req.PlanID) != "" {
planID := strings.TrimSpace(*req.PlanID)
user.PlanID = &planID
}
if err := h.db.WithContext(c.Request.Context()).Create(user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
response.Error(c, http.StatusBadRequest, "Email already registered")
return
}
h.logger.Error("Failed to create user", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create user")
return
}
response.Created(c, gin.H{"user": user})
}
// @Summary Get User Detail
// @Description Get detailed info about a single user (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /admin/users/{id} [get]
// @Security BearerAuth
func (h *Handler) GetUser(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
var user model.User
if err := h.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "User not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to get user")
return
}
var videoCount int64
h.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", id).Count(&videoCount)
balance, _ := model.GetWalletBalance(ctx, h.db, id)
planName := ""
if user.PlanID != nil {
var plan model.Plan
if err := h.db.WithContext(ctx).Where("id = ?", *user.PlanID).First(&plan).Error; err == nil {
planName = plan.Name
}
}
var subscription *model.PlanSubscription
var sub model.PlanSubscription
if err := h.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&sub).Error; err == nil {
subscription = &sub
}
response.Success(c, gin.H{
"user": gin.H{
"id": user.ID,
"email": user.Email,
"username": user.Username,
"avatar": user.Avatar,
"role": user.Role,
"plan_id": user.PlanID,
"plan_name": planName,
"storage_used": user.StorageUsed,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
},
"video_count": videoCount,
"wallet_balance": balance,
"subscription": subscription,
})
}
// @Summary Update User
// @Description Update a user from admin panel (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param request body UpdateAdminUserRequest true "User payload"
// @Success 200 {object} response.Response
// @Router /admin/users/{id} [put]
// @Security BearerAuth
func (h *Handler) UpdateUser(c *gin.Context) {
id := c.Param("id")
currentUserID := c.GetString("userID")
var req UpdateAdminUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
updates := map[string]interface{}{}
if req.Email != nil {
email := strings.TrimSpace(*req.Email)
if email == "" {
response.Error(c, http.StatusBadRequest, "Email is required")
return
}
updates["email"] = email
}
if req.Username != nil {
updates["username"] = strings.TrimSpace(*req.Username)
}
if req.Role != nil {
role := normalizeAdminRole(*req.Role)
if !isValidAdminRole(role) {
response.Error(c, http.StatusBadRequest, "Invalid role. Must be USER, ADMIN, or BLOCK")
return
}
if id == currentUserID && role != "ADMIN" {
response.Error(c, http.StatusBadRequest, "Cannot change your own role")
return
}
updates["role"] = role
}
if req.PlanID != nil {
if err := h.ensurePlanExists(c, req.PlanID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusBadRequest, "Plan not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to update user")
return
}
trimmed := strings.TrimSpace(*req.PlanID)
if trimmed == "" {
updates["plan_id"] = nil
} else {
updates["plan_id"] = trimmed
}
}
if req.Password != nil {
if strings.TrimSpace(*req.Password) == "" {
response.Error(c, http.StatusBadRequest, "Password must not be empty")
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to hash password")
return
}
updates["password"] = string(hashedPassword)
}
if len(updates) == 0 {
response.Success(c, gin.H{"message": "No changes provided"})
return
}
result := h.db.WithContext(c.Request.Context()).Model(&model.User{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrDuplicatedKey) {
response.Error(c, http.StatusBadRequest, "Email already registered")
return
}
h.logger.Error("Failed to update user", "error", result.Error)
response.Error(c, http.StatusInternalServerError, "Failed to update user")
return
}
if result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "User not found")
return
}
var user model.User
if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&user).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to reload user")
return
}
response.Success(c, gin.H{"user": user})
}
// @Summary Update User Role
// @Description Change user role (admin only). Valid: USER, ADMIN, BLOCK
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param request body UpdateUserRoleRequest true "Role payload"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /admin/users/{id}/role [put]
// @Security BearerAuth
func (h *Handler) UpdateUserRole(c *gin.Context) {
id := c.Param("id")
currentUserID := c.GetString("userID")
if id == currentUserID {
response.Error(c, http.StatusBadRequest, "Cannot change your own role")
return
}
var req UpdateUserRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
role := normalizeAdminRole(req.Role)
if !isValidAdminRole(role) {
response.Error(c, http.StatusBadRequest, "Invalid role. Must be USER, ADMIN, or BLOCK")
return
}
result := h.db.WithContext(c.Request.Context()).Model(&model.User{}).Where("id = ?", id).Update("role", role)
if result.Error != nil {
h.logger.Error("Failed to update user role", "error", result.Error)
response.Error(c, http.StatusInternalServerError, "Failed to update role")
return
}
if result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "User not found")
return
}
response.Success(c, gin.H{"message": "Role updated", "role": role})
}
// @Summary Delete User
// @Description Delete a user and their data (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /admin/users/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeleteUser(c *gin.Context) {
id := c.Param("id")
currentUserID := c.GetString("userID")
if id == currentUserID {
response.Error(c, http.StatusBadRequest, "Cannot delete your own account")
return
}
var user model.User
if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "User not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to find user")
return
}
err := h.db.WithContext(c.Request.Context()).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 _, t := range tables {
if err := tx.Where(t.where, id).Delete(t.model).Error; err != nil {
return err
}
}
return tx.Where("id = ?", id).Delete(&model.User{}).Error
})
if err != nil {
h.logger.Error("Failed to delete user", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to delete user")
return
}
response.Success(c, gin.H{"message": "User deleted"})
}

View File

@@ -0,0 +1,477 @@
//go:build ignore
// +build ignore
package admin
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/response"
)
type AdminVideoPayload struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
URL string `json:"url"`
Status string `json:"status"`
Size int64 `json:"size"`
Duration int32 `json:"duration"`
Format string `json:"format"`
OwnerEmail string `json:"owner_email,omitempty"`
AdTemplateID *string `json:"ad_template_id,omitempty"`
AdTemplateName string `json:"ad_template_name,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type SaveAdminVideoRequest struct {
UserID string `json:"user_id" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description"`
URL string `json:"url" binding:"required"`
Size int64 `json:"size" binding:"required"`
Duration int32 `json:"duration"`
Format string `json:"format"`
Status string `json:"status"`
AdTemplateID *string `json:"ad_template_id,omitempty"`
}
func normalizeAdminVideoStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "processing", "pending":
return "processing"
case "failed", "error":
return "failed"
default:
return "ready"
}
}
func (h *Handler) loadAdminVideoPayload(ctx *gin.Context, video model.Video) (AdminVideoPayload, error) {
payload := AdminVideoPayload{
ID: video.ID,
UserID: video.UserID,
Title: video.Title,
Description: adminStringValue(video.Description),
URL: video.URL,
Status: adminStringValue(video.Status),
Size: video.Size,
Duration: video.Duration,
Format: video.Format,
CreatedAt: adminFormatTime(video.CreatedAt),
UpdatedAt: adminFormatTimeValue(video.UpdatedAt),
}
var user model.User
if err := h.db.WithContext(ctx.Request.Context()).Select("id, email").Where("id = ?", video.UserID).First(&user).Error; err == nil {
payload.OwnerEmail = user.Email
}
var adConfig model.VideoAdConfig
if err := h.db.WithContext(ctx.Request.Context()).Where("video_id = ?", video.ID).First(&adConfig).Error; err == nil {
payload.AdTemplateID = &adConfig.AdTemplateID
var template model.AdTemplate
if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", adConfig.AdTemplateID).First(&template).Error; err == nil {
payload.AdTemplateName = template.Name
}
}
return payload, nil
}
func (h *Handler) saveAdminVideoAdConfig(tx *gorm.DB, videoID, userID string, adTemplateID *string) error {
if adTemplateID == nil {
return nil
}
trimmed := strings.TrimSpace(*adTemplateID)
if trimmed == "" {
return tx.Where("video_id = ? AND user_id = ?", videoID, userID).Delete(&model.VideoAdConfig{}).Error
}
var template model.AdTemplate
if err := tx.Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &gin.Error{Err: errors.New("Ad template not found"), Type: gin.ErrorTypeBind}
}
return err
}
var existing model.VideoAdConfig
if err := tx.Where("video_id = ? AND user_id = ?", videoID, userID).First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&model.VideoAdConfig{
VideoID: videoID,
UserID: userID,
AdTemplateID: template.ID,
VastTagURL: template.VastTagURL,
AdFormat: template.AdFormat,
Duration: template.Duration,
}).Error
}
return err
}
existing.AdTemplateID = template.ID
existing.VastTagURL = template.VastTagURL
existing.AdFormat = template.AdFormat
existing.Duration = template.Duration
return tx.Save(&existing).Error
}
// @Summary List All Videos
// @Description Get paginated list of all videos across users (admin only)
// @Tags admin
// @Produce json
// @Param page query int false "Page" default(1)
// @Param limit query int false "Limit" default(20)
// @Param search query string false "Search by title"
// @Param user_id query string false "Filter by user ID"
// @Param status query string false "Filter by status"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Router /admin/videos [get]
// @Security BearerAuth
func (h *Handler) ListVideos(c *gin.Context) {
ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
search := strings.TrimSpace(c.Query("search"))
userID := strings.TrimSpace(c.Query("user_id"))
status := strings.TrimSpace(c.Query("status"))
db := h.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 status != "" && !strings.EqualFold(status, "all") {
db = db.Where("status = ?", normalizeAdminVideoStatus(status))
}
var total int64
if err := db.Count(&total).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to list videos")
return
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil {
h.logger.Error("Failed to list videos", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to list videos")
return
}
result := make([]AdminVideoPayload, 0, len(videos))
for _, v := range videos {
payload, err := h.loadAdminVideoPayload(c, v)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to list videos")
return
}
result = append(result, payload)
}
response.Success(c, gin.H{
"videos": result,
"total": total,
"page": page,
"limit": limit,
})
}
// @Summary Get Video Detail
// @Description Get video detail by ID (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "Video ID"
// @Success 200 {object} response.Response
// @Router /admin/videos/{id} [get]
// @Security BearerAuth
func (h *Handler) GetVideo(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
var video model.Video
if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to get video")
return
}
payload, err := h.loadAdminVideoPayload(c, video)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to get video")
return
}
response.Success(c, gin.H{"video": payload})
}
// @Summary Create Video
// @Description Create a model video record for a user (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param request body SaveAdminVideoRequest true "Video payload"
// @Success 201 {object} response.Response
// @Router /admin/videos [post]
// @Security BearerAuth
func (h *Handler) CreateVideo(c *gin.Context) {
var req SaveAdminVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(req.UserID) == "" || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.URL) == "" {
response.Error(c, http.StatusBadRequest, "User ID, title, and URL are required")
return
}
if req.Size < 0 {
response.Error(c, http.StatusBadRequest, "Size must be greater than or equal to 0")
return
}
ctx := c.Request.Context()
var user model.User
if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusBadRequest, "User not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to create video")
return
}
status := normalizeAdminVideoStatus(req.Status)
processingStatus := strings.ToUpper(status)
storageType := "WORKER"
video := &model.Video{
ID: uuid.New().String(),
UserID: user.ID,
Name: strings.TrimSpace(req.Title),
Title: strings.TrimSpace(req.Title),
Description: adminStringPtr(req.Description),
URL: strings.TrimSpace(req.URL),
Size: req.Size,
Duration: req.Duration,
Format: strings.TrimSpace(req.Format),
Status: &status,
ProcessingStatus: &processingStatus,
StorageType: &storageType,
}
if err := h.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
}
if err := h.saveAdminVideoAdConfig(tx, video.ID, user.ID, req.AdTemplateID); err != nil {
return err
}
return nil
}); err != nil {
if strings.Contains(err.Error(), "Ad template not found") {
response.Error(c, http.StatusBadRequest, "Ad template not found")
return
}
h.logger.Error("Failed to create video", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create video")
return
}
payload, err := h.loadAdminVideoPayload(c, *video)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to create video")
return
}
response.Created(c, gin.H{"video": payload})
}
// @Summary Update Video
// @Description Update video metadata and status (admin only)
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "Video ID"
// @Param request body SaveAdminVideoRequest true "Video payload"
// @Success 200 {object} response.Response
// @Router /admin/videos/{id} [put]
// @Security BearerAuth
func (h *Handler) UpdateVideo(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
var req SaveAdminVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(req.UserID) == "" || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.URL) == "" {
response.Error(c, http.StatusBadRequest, "User ID, title, and URL are required")
return
}
if req.Size < 0 {
response.Error(c, http.StatusBadRequest, "Size must be greater than or equal to 0")
return
}
ctx := c.Request.Context()
var video model.Video
if err := h.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to update video")
return
}
var user model.User
if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusBadRequest, "User not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to update video")
return
}
oldSize := video.Size
oldUserID := video.UserID
status := normalizeAdminVideoStatus(req.Status)
processingStatus := strings.ToUpper(status)
video.UserID = user.ID
video.Name = strings.TrimSpace(req.Title)
video.Title = strings.TrimSpace(req.Title)
video.Description = adminStringPtr(req.Description)
video.URL = strings.TrimSpace(req.URL)
video.Size = req.Size
video.Duration = req.Duration
video.Format = strings.TrimSpace(req.Format)
video.Status = &status
video.ProcessingStatus = &processingStatus
if err := h.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
}
}
if err := h.saveAdminVideoAdConfig(tx, video.ID, user.ID, req.AdTemplateID); err != nil {
return err
}
return nil
}); err != nil {
if strings.Contains(err.Error(), "Ad template not found") {
response.Error(c, http.StatusBadRequest, "Ad template not found")
return
}
h.logger.Error("Failed to update video", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to update video")
return
}
payload, err := h.loadAdminVideoPayload(c, video)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to update video")
return
}
response.Success(c, gin.H{"video": payload})
}
// @Summary Delete Video (Admin)
// @Description Delete any video by ID (admin only)
// @Tags admin
// @Produce json
// @Param id path string true "Video ID"
// @Success 200 {object} response.Response
// @Failure 404 {object} response.Response
// @Router /admin/videos/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeleteVideo(c *gin.Context) {
id := c.Param("id")
var video model.Video
if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&video).Error; err != nil {
if err == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
response.Error(c, http.StatusInternalServerError, "Failed to find video")
return
}
err := h.db.WithContext(c.Request.Context()).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 {
h.logger.Error("Failed to delete video", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
return
}
response.Success(c, gin.H{"message": "Video deleted"})
}