draft grpc
This commit is contained in:
390
internal/api/admin/ad_templates.go
Normal file
390
internal/api/admin/ad_templates.go
Normal 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"})
|
||||
}
|
||||
94
internal/api/admin/common.go
Normal file
94
internal/api/admin/common.go
Normal 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())
|
||||
}
|
||||
68
internal/api/admin/dashboard.go
Normal file
68
internal/api/admin/dashboard.go
Normal 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)
|
||||
}
|
||||
26
internal/api/admin/handler.go
Normal file
26
internal/api/admin/handler.go
Normal 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}
|
||||
}
|
||||
521
internal/api/admin/payments.go
Normal file
521
internal/api/admin/payments.go
Normal 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: ¤cy,
|
||||
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(¤tSubscription).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
302
internal/api/admin/plans.go
Normal 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"})
|
||||
}
|
||||
218
internal/api/admin/render.go
Normal file
218
internal/api/admin/render.go
Normal 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
522
internal/api/admin/users.go
Normal 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"})
|
||||
}
|
||||
477
internal/api/admin/videos.go
Normal file
477
internal/api/admin/videos.go
Normal 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"})
|
||||
}
|
||||
Reference in New Issue
Block a user