draft grpc

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,338 @@
//go:build ignore
// +build ignore
package adtemplates
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
"stream.api/pkg/response"
)
const upgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
type Handler struct {
logger logger.Logger
db *gorm.DB
}
type SaveAdTemplateRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
VASTTagURL string `json:"vast_tag_url" binding:"required"`
AdFormat string `json:"ad_format"`
Duration *int `json:"duration"`
IsActive *bool `json:"is_active"`
IsDefault *bool `json:"is_default"`
}
type TemplatePayload struct {
Template *model.AdTemplate `json:"template"`
}
type TemplateListPayload struct {
Templates []model.AdTemplate `json:"templates"`
}
func NewHandler(l logger.Logger, db *gorm.DB) *Handler {
return &Handler{logger: l, db: db}
}
// @Summary List Ad Templates
// @Description Get all VAST ad templates for the current user
// @Tags ad-templates
// @Produce json
// @Success 200 {object} response.Response{data=TemplateListPayload}
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /ad-templates [get]
// @Security BearerAuth
func (h *Handler) ListTemplates(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var items []model.AdTemplate
if err := h.db.WithContext(c.Request.Context()).
Where("user_id = ?", userID).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
h.logger.Error("Failed to list ad templates", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to load ad templates")
return
}
response.Success(c, gin.H{"templates": items})
}
// @Summary Create Ad Template
// @Description Create a VAST ad template for the current user
// @Tags ad-templates
// @Accept json
// @Produce json
// @Param request body SaveAdTemplateRequest true "Ad template payload"
// @Success 201 {object} response.Response{data=TemplatePayload}
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /ad-templates [post]
// @Security BearerAuth
func (h *Handler) CreateTemplate(c *gin.Context) {
h.saveTemplate(c, true)
}
// @Summary Update Ad Template
// @Description Update a VAST ad template for the current user
// @Tags ad-templates
// @Accept json
// @Produce json
// @Param id path string true "Ad Template ID"
// @Param request body SaveAdTemplateRequest true "Ad template payload"
// @Success 200 {object} response.Response{data=TemplatePayload}
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /ad-templates/{id} [put]
// @Security BearerAuth
func (h *Handler) UpdateTemplate(c *gin.Context) {
h.saveTemplate(c, false)
}
// @Summary Delete Ad Template
// @Description Delete a VAST ad template for the current user
// @Tags ad-templates
// @Produce json
// @Param id path string true "Ad Template ID"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 403 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /ad-templates/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeleteTemplate(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
if !requirePaidPlan(c) {
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
result := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("ad_template_id = ? AND user_id = ?", id, userID).
Delete(&model.VideoAdConfig{}).Error; err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, userID).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if result != nil {
if result == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
h.logger.Error("Failed to delete ad template", "error", result)
response.Error(c, http.StatusInternalServerError, "Failed to delete ad template")
return
}
response.Success(c, gin.H{"message": "Ad template deleted"})
}
func (h *Handler) saveTemplate(c *gin.Context, create bool) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
if !requirePaidPlan(c) {
return
}
var req SaveAdTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
name := strings.TrimSpace(req.Name)
vastURL := strings.TrimSpace(req.VASTTagURL)
if name == "" || vastURL == "" {
response.Error(c, http.StatusBadRequest, "Name and VAST URL are required")
return
}
format := normalizeAdFormat(req.AdFormat)
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
response.Error(c, http.StatusBadRequest, "Duration is required for mid-roll templates")
return
}
ctx := c.Request.Context()
if create {
item := &model.AdTemplate{
ID: uuid.New().String(),
UserID: userID,
Name: name,
Description: stringPointer(strings.TrimSpace(req.Description)),
VastTagURL: vastURL,
AdFormat: model.StringPtr(format),
Duration: intPtrToInt64Ptr(req.Duration),
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
IsDefault: req.IsDefault != nil && *req.IsDefault,
}
if !adTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, 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": item})
return
}
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(ctx).Where("id = ? AND user_id = ?", id, userID).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "Ad template not found")
return
}
h.logger.Error("Failed to load ad template", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to save ad template")
return
}
item.Name = name
item.Description = stringPointer(strings.TrimSpace(req.Description))
item.VastTagURL = vastURL
item.AdFormat = model.StringPtr(format)
item.Duration = intPtrToInt64Ptr(req.Duration)
if req.IsActive != nil {
item.IsActive = model.BoolPtr(*req.IsActive)
}
if req.IsDefault != nil {
item.IsDefault = *req.IsDefault
}
if !adTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, 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": item})
}
func requirePaidPlan(c *gin.Context) bool {
userValue, exists := c.Get("user")
if !exists {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return false
}
user, ok := userValue.(*model.User)
if !ok || user == nil {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return false
}
if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" {
response.Error(c, http.StatusForbidden, upgradeRequiredMessage)
return false
}
return true
}
func unsetDefaultTemplates(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 normalizeAdFormat(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 stringPointer(value string) *string {
if value == "" {
return nil
}
return &value
}
func intPtrToInt64Ptr(value *int) *int64 {
if value == nil {
return nil
}
converted := int64(*value)
return &converted
}
func adTemplateIsActive(value *bool) bool {
return value == nil || *value
}

View File

@@ -1,8 +1,17 @@
//go:build ignore
// +build ignore
package auth
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -10,6 +19,7 @@ import (
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm"
"stream.api/internal/config"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
@@ -20,18 +30,31 @@ import (
)
type handler struct {
cache cache.Cache
token token.Provider
logger logger.Logger
googleOauth *oauth2.Config
cache cache.Cache
token token.Provider
logger logger.Logger
db *gorm.DB
googleOauth *oauth2.Config
googleStateTTL time.Duration
frontendBaseURL string
googleFinalizePath string
}
// NewHandler creates a new instance of Handler
func NewHandler(c cache.Cache, t token.Provider, l logger.Logger, cfg *config.Config) AuthHandler {
func NewHandler(c cache.Cache, t token.Provider, l logger.Logger, cfg *config.Config, db *gorm.DB) AuthHandler {
stateTTL := time.Duration(cfg.Google.StateTTLMinute) * time.Minute
if stateTTL <= 0 {
stateTTL = 10 * time.Minute
}
return &handler{
cache: c,
token: t,
logger: l,
cache: c,
token: t,
logger: l,
db: db,
googleStateTTL: stateTTL,
frontendBaseURL: strings.TrimRight(cfg.Frontend.BaseURL, "/"),
googleFinalizePath: cfg.Frontend.GoogleAuthFinalizePath,
googleOauth: &oauth2.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
@@ -45,6 +68,16 @@ func NewHandler(c cache.Cache, t token.Provider, l logger.Logger, cfg *config.Co
}
}
// @Summary Login
// @Description Login with email and password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body LoginRequest true "Login payload"
// @Success 200 {object} response.Response{data=UserPayload}
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Router /auth/login [post]
func (h *handler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -70,10 +103,20 @@ func (h *handler) Login(c *gin.Context) {
return
}
h.generateAndSetTokens(c, user.ID, user.Email, *user.Role)
response.Success(c, gin.H{"user": user})
if err := h.generateAndSetTokens(c, user.ID, user.Email, safeRole(user.Role)); err != nil {
return
}
h.respondWithUserPayload(c, user)
}
// @Summary Logout
// @Description Logout user and clear cookies
// @Tags auth
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Router /auth/logout [post]
// @Security BearerAuth
func (h *handler) Logout(c *gin.Context) {
refreshToken, err := c.Cookie("refresh_token")
if err == nil {
@@ -90,6 +133,16 @@ func (h *handler) Logout(c *gin.Context) {
response.Success(c, "Logged out")
}
// @Summary Register
// @Description Register a new user
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "Registration payload"
// @Success 201 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /auth/register [post]
func (h *handler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -129,6 +182,16 @@ func (h *handler) Register(c *gin.Context) {
response.Created(c, "User registered")
}
// @Summary Forgot Password
// @Description Request password reset link
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ForgotPasswordRequest true "Forgot password payload"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /auth/forgot-password [post]
func (h *handler) ForgotPassword(c *gin.Context) {
var req ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -157,6 +220,16 @@ func (h *handler) ForgotPassword(c *gin.Context) {
response.Success(c, gin.H{"message": "If email exists, a reset link has been sent", "debug_token": tokenID})
}
// @Summary Reset Password
// @Description Reset password using token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ResetPasswordRequest true "Reset password payload"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /auth/reset-password [post]
func (h *handler) ResetPassword(c *gin.Context) {
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -193,27 +266,82 @@ func (h *handler) ResetPassword(c *gin.Context) {
response.Success(c, "Password reset successfully")
}
// @Summary Google Login
// @Description Redirect to Google for Login
// @Tags auth
// @Router /auth/google/login [get]
func (h *handler) LoginGoogle(c *gin.Context) {
url := h.googleOauth.AuthCodeURL("state", oauth2.AccessTypeOffline)
state, err := generateOAuthState()
if err != nil {
h.logger.Error("Failed to generate Google OAuth state", "error", err)
response.Fail(c, "Failed to start Google login")
return
}
if err := h.cache.Set(c.Request.Context(), googleOAuthStateCacheKey(state), "1", h.googleStateTTL); err != nil {
h.logger.Error("Failed to persist Google OAuth state", "error", err)
response.Fail(c, "Failed to start Google login")
return
}
url := h.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
c.Redirect(http.StatusTemporaryRedirect, url)
}
// @Summary Google Callback
// @Description Callback for Google Login
// @Tags auth
// @Success 307
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /auth/google/callback [get]
func (h *handler) GoogleCallback(c *gin.Context) {
code := c.Query("code")
if oauthError := strings.TrimSpace(c.Query("error")); oauthError != "" {
h.redirectToGoogleFinalize(c, "error", oauthError)
return
}
state := strings.TrimSpace(c.Query("state"))
if state == "" {
h.redirectToGoogleFinalize(c, "error", "missing_state")
return
}
cachedState, err := h.cache.Get(c.Request.Context(), googleOAuthStateCacheKey(state))
if err != nil || cachedState == "" {
h.redirectToGoogleFinalize(c, "error", "invalid_state")
return
}
_ = h.cache.Del(c.Request.Context(), googleOAuthStateCacheKey(state))
code := strings.TrimSpace(c.Query("code"))
if code == "" {
h.redirectToGoogleFinalize(c, "error", "missing_code")
return
}
tokenResp, err := h.googleOauth.Exchange(c.Request.Context(), code)
if err != nil {
response.Error(c, http.StatusBadRequest, "Failed to exchange token")
h.logger.Error("Failed to exchange Google OAuth token", "error", err)
h.redirectToGoogleFinalize(c, "error", "exchange_failed")
return
}
client := h.googleOauth.Client(c.Request.Context(), tokenResp)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil || resp.StatusCode != http.StatusOK {
response.Fail(c, "Failed to get user info")
if err != nil {
h.logger.Error("Failed to fetch Google user info", "error", err)
h.redirectToGoogleFinalize(c, "error", "userinfo_failed")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
h.logger.Error("Google user info returned non-200", "status", resp.StatusCode)
h.redirectToGoogleFinalize(c, "error", "userinfo_failed")
return
}
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -221,7 +349,13 @@ func (h *handler) GoogleCallback(c *gin.Context) {
Picture string `json:"picture"`
}
if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil {
response.Fail(c, "Failed to parse user info")
h.logger.Error("Failed to decode Google user info", "error", err)
h.redirectToGoogleFinalize(c, "error", "userinfo_parse_failed")
return
}
if strings.TrimSpace(googleUser.Email) == "" {
h.redirectToGoogleFinalize(c, "error", "missing_email")
return
}
@@ -232,29 +366,300 @@ func (h *handler) GoogleCallback(c *gin.Context) {
user = &model.User{
ID: uuid.New().String(),
Email: googleUser.Email,
Username: &googleUser.Name,
GoogleID: &googleUser.ID,
Avatar: &googleUser.Picture,
Username: stringPointerOrNil(googleUser.Name),
GoogleID: stringPointerOrNil(googleUser.ID),
Avatar: stringPointerOrNil(googleUser.Picture),
Role: &role,
}
if err := u.WithContext(c.Request.Context()).Create(user); err != nil {
response.Fail(c, "Failed to create user")
h.logger.Error("Failed to create Google user", "error", err)
h.redirectToGoogleFinalize(c, "error", "create_user_failed")
return
}
} else if user.GoogleID == nil || *user.GoogleID == "" {
u.WithContext(c.Request.Context()).Where(u.ID.Eq(user.ID)).Update(u.GoogleID, googleUser.ID)
} else {
updates := map[string]interface{}{}
if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" {
updates["google_id"] = googleUser.ID
}
if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" {
updates["avatar"] = googleUser.Picture
}
if user.Username == nil || strings.TrimSpace(*user.Username) == "" {
updates["username"] = googleUser.Name
}
if len(updates) > 0 {
if err := h.db.WithContext(c.Request.Context()).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
h.logger.Error("Failed to update Google user", "error", err)
h.redirectToGoogleFinalize(c, "error", "update_user_failed")
return
}
user, err = u.WithContext(c.Request.Context()).Where(u.ID.Eq(user.ID)).First()
if err != nil {
h.logger.Error("Failed to reload Google user", "error", err)
h.redirectToGoogleFinalize(c, "error", "reload_user_failed")
return
}
}
}
h.generateAndSetTokens(c, user.ID, user.Email, *user.Role)
response.Success(c, gin.H{"user": user})
if err := h.generateAndSetTokens(c, user.ID, user.Email, safeRole(user.Role)); err != nil {
h.redirectToGoogleFinalize(c, "error", "session_failed")
return
}
if h.frontendBaseURL == "" {
h.respondWithUserPayload(c, user)
return
}
h.redirectToGoogleFinalize(c, "success", "")
}
func (h *handler) generateAndSetTokens(c *gin.Context, userID, email, role string) {
// @Summary Get Current User
// @Description Get the authenticated user's profile payload
// @Tags auth
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Router /me [get]
// @Security BearerAuth
func (h *handler) GetMe(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
u := query.User
user, err := u.WithContext(c.Request.Context()).Where(u.ID.Eq(userID)).First()
if err != nil {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
h.respondWithUserPayload(c, user)
}
// @Summary Update Current User
// @Description Update the authenticated user's profile information
// @Tags auth
// @Accept json
// @Produce json
// @Param request body UpdateMeRequest true "Profile payload"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /me [put]
// @Security BearerAuth
func (h *handler) UpdateMe(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var req UpdateMeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
user, err := UpdateUserProfile(c.Request.Context(), h.db, h.logger, userID, UpdateProfileInput{
Username: req.Username,
Email: req.Email,
Language: req.Language,
Locale: req.Locale,
})
if err != nil {
switch {
case errors.Is(err, ErrEmailRequired):
response.Error(c, http.StatusBadRequest, err.Error())
case errors.Is(err, ErrEmailAlreadyRegistered):
response.Error(c, http.StatusBadRequest, err.Error())
default:
response.Fail(c, "Failed to update profile")
}
return
}
h.respondWithUserPayload(c, user)
}
// @Summary Change Password
// @Description Change the authenticated user's local password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ChangePasswordRequest true "Password payload"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /auth/change-password [post]
// @Security BearerAuth
func (h *handler) ChangePassword(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
u := query.User
user, err := u.WithContext(c.Request.Context()).Where(u.ID.Eq(userID)).First()
if err != nil {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
if user.Password == nil || strings.TrimSpace(*user.Password) == "" {
response.Error(c, http.StatusBadRequest, "This account does not have a local password")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(req.CurrentPassword)); err != nil {
response.Error(c, http.StatusBadRequest, "Current password is incorrect")
return
}
if req.CurrentPassword == req.NewPassword {
response.Error(c, http.StatusBadRequest, "New password must be different")
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
response.Fail(c, "Failed to hash password")
return
}
if _, err := u.WithContext(c.Request.Context()).Where(u.ID.Eq(userID)).Update(u.Password, string(hashedPassword)); err != nil {
h.logger.Error("Failed to change password", "error", err)
response.Fail(c, "Failed to change password")
return
}
response.Success(c, gin.H{"message": "Password changed successfully"})
}
// @Summary Clear My Data
// @Description Remove videos and settings-related resources for the authenticated user
// @Tags auth
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /me/clear-data [post]
// @Security BearerAuth
func (h *handler) ClearMyData(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
ctx := c.Request.Context()
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.VideoAdConfig{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{"storage_used": 0}).Error; err != nil {
return err
}
return nil
}); err != nil {
h.logger.Error("Failed to clear user data", "error", err)
response.Fail(c, "Failed to clear data")
return
}
response.Success(c, gin.H{"message": "Data cleared successfully"})
}
// @Summary Delete My Account
// @Description Permanently delete the authenticated user's account and related data
// @Tags auth
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /me [delete]
// @Security BearerAuth
func (h *handler) DeleteMe(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
ctx := c.Request.Context()
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.VideoAdConfig{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil {
return err
}
return nil
}); err != nil {
h.logger.Error("Failed to delete user", "error", err)
response.Fail(c, "Failed to delete account")
return
}
c.SetCookie("access_token", "", -1, "/", "", false, true)
c.SetCookie("refresh_token", "", -1, "/", "", false, true)
response.Success(c, gin.H{"message": "Account deleted successfully"})
}
func (h *handler) generateAndSetTokens(c *gin.Context, userID, email, role string) error {
td, err := h.token.GenerateTokenPair(userID, email, role)
if err != nil {
h.logger.Error("Token generation failed", "error", err)
response.Fail(c, "Error generating tokens")
return
return err
}
// Store Refresh UUID in Redis
@@ -262,9 +667,79 @@ func (h *handler) generateAndSetTokens(c *gin.Context, userID, email, role strin
if err != nil {
h.logger.Error("Session storage failed", "error", err)
response.Fail(c, "Error storing session")
return
return err
}
c.SetCookie("access_token", td.AccessToken, int(td.AtExpires-time.Now().Unix()), "/", "", false, true)
c.SetCookie("refresh_token", td.RefreshToken, int(td.RtExpires-time.Now().Unix()), "/", "", false, true)
return nil
}
func (h *handler) respondWithUserPayload(c *gin.Context, user *model.User) {
payload, err := BuildUserPayload(c.Request.Context(), h.db, user)
if err != nil {
h.logger.Error("Failed to build user payload", "error", err)
response.Fail(c, "Failed to build user payload")
return
}
response.Success(c, gin.H{"user": payload})
}
func safeRole(role *string) string {
if role == nil || strings.TrimSpace(*role) == "" {
return "USER"
}
return *role
}
func generateOAuthState() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func googleOAuthStateCacheKey(state string) string {
return "google_oauth_state:" + state
}
func stringPointerOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func (h *handler) redirectToGoogleFinalize(c *gin.Context, status, reason string) {
finalizeURL := h.googleFinalizeURL(status, reason)
if finalizeURL == "" {
response.Error(c, http.StatusBadRequest, reason)
return
}
c.Redirect(http.StatusTemporaryRedirect, finalizeURL)
}
func (h *handler) googleFinalizeURL(status, reason string) string {
if h.frontendBaseURL == "" {
return ""
}
finalizePath := h.googleFinalizePath
if strings.TrimSpace(finalizePath) == "" {
finalizePath = "/auth/google/finalize"
}
if !strings.HasPrefix(finalizePath, "/") {
finalizePath = "/" + finalizePath
}
values := url.Values{}
values.Set("status", status)
if strings.TrimSpace(reason) != "" {
values.Set("reason", reason)
}
return fmt.Sprintf("%s%s?%s", h.frontendBaseURL, finalizePath, values.Encode())
}

View File

@@ -1,3 +1,6 @@
//go:build ignore
// +build ignore
package auth
import "github.com/gin-gonic/gin"
@@ -11,6 +14,11 @@ type AuthHandler interface {
ResetPassword(c *gin.Context)
LoginGoogle(c *gin.Context)
GoogleCallback(c *gin.Context)
GetMe(c *gin.Context)
UpdateMe(c *gin.Context)
ChangePassword(c *gin.Context)
DeleteMe(c *gin.Context)
ClearMyData(c *gin.Context)
}
// LoginRequest defines the payload for login
@@ -36,3 +44,15 @@ type ResetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
type UpdateMeRequest struct {
Username *string `json:"username"`
Email *string `json:"email"`
Language *string `json:"language"`
Locale *string `json:"locale"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}

View File

@@ -0,0 +1,87 @@
package auth
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/pkg/logger"
)
var (
ErrEmailRequired = errors.New("Email is required")
ErrEmailAlreadyRegistered = errors.New("Email already registered")
)
type UpdateProfileInput struct {
Username *string
Email *string
Language *string
Locale *string
}
func UpdateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req UpdateProfileInput) (*model.User, error) {
updates := map[string]any{}
if req.Username != nil {
username := strings.TrimSpace(*req.Username)
updates["username"] = username
}
if req.Email != nil {
email := strings.TrimSpace(*req.Email)
if email == "" {
return nil, ErrEmailRequired
}
updates["email"] = email
}
if len(updates) > 0 {
if err := db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, ErrEmailAlreadyRegistered
}
l.Error("Failed to update user", "error", err)
return nil, err
}
}
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
if err != nil {
l.Error("Failed to load user preference", "error", err)
return nil, err
}
prefChanged := false
if req.Language != nil {
pref.Language = model.StringPtr(strings.TrimSpace(*req.Language))
prefChanged = true
}
if req.Locale != nil {
pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale))
prefChanged = true
}
if strings.TrimSpace(model.StringValue(pref.Language)) == "" {
pref.Language = model.StringPtr("en")
prefChanged = true
}
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" {
pref.Locale = model.StringPtr(model.StringValue(pref.Language))
prefChanged = true
}
if prefChanged {
if err := db.WithContext(ctx).Save(pref).Error; err != nil {
l.Error("Failed to save user preference", "error", err)
return nil, err
}
}
u := query.User
user, err := u.WithContext(ctx).Where(u.ID.Eq(userID)).First()
if err != nil {
return nil, err
}
return user, nil
}

View File

@@ -0,0 +1,33 @@
package auth
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Username string `json:"username" binding:"required"`
}
type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
}
type ResetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
type UpdateMeRequest struct {
Username *string `json:"username"`
Email *string `json:"email"`
Language *string `json:"language"`
Locale *string `json:"locale"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}

View File

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

View File

@@ -0,0 +1,166 @@
//go:build ignore
// +build ignore
package domains
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
"stream.api/pkg/response"
)
type Handler struct {
logger logger.Logger
db *gorm.DB
}
type CreateDomainRequest struct {
Name string `json:"name" binding:"required"`
}
func NewHandler(l logger.Logger, db *gorm.DB) *Handler {
return &Handler{logger: l, db: db}
}
// @Summary List Domains
// @Description Get all whitelisted domains for the current user
// @Tags domains
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /domains [get]
// @Security BearerAuth
func (h *Handler) ListDomains(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var items []model.Domain
if err := h.db.WithContext(c.Request.Context()).
Where("user_id = ?", userID).
Order("created_at DESC").
Find(&items).Error; err != nil {
h.logger.Error("Failed to list domains", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to load domains")
return
}
response.Success(c, gin.H{"domains": items})
}
// @Summary Create Domain
// @Description Add a domain to the current user's whitelist
// @Tags domains
// @Accept json
// @Produce json
// @Param request body CreateDomainRequest true "Domain payload"
// @Success 201 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /domains [post]
// @Security BearerAuth
func (h *Handler) CreateDomain(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var req CreateDomainRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
name := normalizeDomain(req.Name)
if name == "" || !strings.Contains(name, ".") || strings.ContainsAny(name, "/ ") {
response.Error(c, http.StatusBadRequest, "Invalid domain")
return
}
var count int64
if err := h.db.WithContext(c.Request.Context()).
Model(&model.Domain{}).
Where("user_id = ? AND name = ?", userID, name).
Count(&count).Error; err != nil {
h.logger.Error("Failed to validate domain", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create domain")
return
}
if count > 0 {
response.Error(c, http.StatusBadRequest, "Domain already exists")
return
}
item := &model.Domain{
ID: uuid.New().String(),
UserID: userID,
Name: name,
}
if err := h.db.WithContext(c.Request.Context()).Create(item).Error; err != nil {
h.logger.Error("Failed to create domain", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create domain")
return
}
response.Created(c, gin.H{"domain": item})
}
// @Summary Delete Domain
// @Description Remove a domain from the current user's whitelist
// @Tags domains
// @Produce json
// @Param id path string true "Domain ID"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /domains/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeleteDomain(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Domain not found")
return
}
result := h.db.WithContext(c.Request.Context()).
Where("id = ? AND user_id = ?", id, userID).
Delete(&model.Domain{})
if result.Error != nil {
h.logger.Error("Failed to delete domain", "error", result.Error)
response.Error(c, http.StatusInternalServerError, "Failed to delete domain")
return
}
if result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "Domain not found")
return
}
response.Success(c, gin.H{"message": "Domain deleted"})
}
func normalizeDomain(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
normalized = strings.TrimPrefix(normalized, "https://")
normalized = strings.TrimPrefix(normalized, "http://")
normalized = strings.TrimPrefix(normalized, "www.")
normalized = strings.TrimSuffix(normalized, "/")
return normalized
}

View File

@@ -0,0 +1,246 @@
//go:build ignore
// +build ignore
package notifications
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
"stream.api/pkg/response"
)
type Handler struct {
logger logger.Logger
db *gorm.DB
}
type NotificationItem struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Message string `json:"message"`
Read bool `json:"read"`
ActionURL string `json:"actionUrl,omitempty"`
ActionLabel string `json:"actionLabel,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func NewHandler(l logger.Logger, db *gorm.DB) *Handler {
return &Handler{logger: l, db: db}
}
// @Summary List Notifications
// @Description Get notifications for the current user
// @Tags notifications
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /notifications [get]
// @Security BearerAuth
func (h *Handler) ListNotifications(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var rows []model.Notification
if err := h.db.WithContext(c.Request.Context()).
Where("user_id = ?", userID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
h.logger.Error("Failed to list notifications", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to load notifications")
return
}
items := make([]NotificationItem, 0, len(rows))
for _, row := range rows {
items = append(items, mapNotification(row))
}
response.Success(c, gin.H{"notifications": items})
}
// @Summary Mark Notification Read
// @Description Mark a single notification as read for the current user
// @Tags notifications
// @Produce json
// @Param id path string true "Notification ID"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /notifications/{id}/read [post]
// @Security BearerAuth
func (h *Handler) MarkRead(c *gin.Context) {
h.updateReadState(c, true, false)
}
// @Summary Mark All Notifications Read
// @Description Mark all notifications as read for the current user
// @Tags notifications
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /notifications/read-all [post]
// @Security BearerAuth
func (h *Handler) MarkAllRead(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
if err := h.db.WithContext(c.Request.Context()).
Model(&model.Notification{}).
Where("user_id = ? AND is_read = ?", userID, false).
Update("is_read", true).Error; err != nil {
h.logger.Error("Failed to mark all notifications as read", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to update notifications")
return
}
response.Success(c, gin.H{"message": "All notifications marked as read"})
}
// @Summary Delete Notification
// @Description Delete a single notification for the current user
// @Tags notifications
// @Produce json
// @Param id path string true "Notification ID"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /notifications/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeleteNotification(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Notification not found")
return
}
result := h.db.WithContext(c.Request.Context()).
Where("id = ? AND user_id = ?", id, userID).
Delete(&model.Notification{})
if result.Error != nil {
h.logger.Error("Failed to delete notification", "error", result.Error)
response.Error(c, http.StatusInternalServerError, "Failed to delete notification")
return
}
if result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "Notification not found")
return
}
response.Success(c, gin.H{"message": "Notification deleted"})
}
// @Summary Clear Notifications
// @Description Delete all notifications for the current user
// @Tags notifications
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /notifications [delete]
// @Security BearerAuth
func (h *Handler) ClearNotifications(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
if err := h.db.WithContext(c.Request.Context()).Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
h.logger.Error("Failed to clear notifications", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to clear notifications")
return
}
response.Success(c, gin.H{"message": "All notifications deleted"})
}
func (h *Handler) updateReadState(c *gin.Context, value bool, silentNotFound bool) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Notification not found")
return
}
result := h.db.WithContext(c.Request.Context()).
Model(&model.Notification{}).
Where("id = ? AND user_id = ?", id, userID).
Update("is_read", value)
if result.Error != nil {
h.logger.Error("Failed to update notification", "error", result.Error)
response.Error(c, http.StatusInternalServerError, "Failed to update notification")
return
}
if result.RowsAffected == 0 && !silentNotFound {
response.Error(c, http.StatusNotFound, "Notification not found")
return
}
response.Success(c, gin.H{"message": "Notification updated"})
}
func mapNotification(item model.Notification) NotificationItem {
createdAt := time.Time{}
if item.CreatedAt != nil {
createdAt = item.CreatedAt.UTC()
}
return NotificationItem{
ID: item.ID,
Type: normalizeType(item.Type),
Title: item.Title,
Message: item.Message,
Read: item.IsRead,
ActionURL: model.StringValue(item.ActionURL),
ActionLabel: model.StringValue(item.ActionLabel),
CreatedAt: createdAt,
}
}
func normalizeType(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
switch {
case strings.Contains(lower, "video"):
return "video"
case strings.Contains(lower, "payment"), strings.Contains(lower, "billing"):
return "payment"
case strings.Contains(lower, "warning"):
return "warning"
case strings.Contains(lower, "error"):
return "error"
case strings.Contains(lower, "success"):
return "success"
case strings.Contains(lower, "system"):
return "system"
default:
return "info"
}
}

View File

@@ -1,10 +1,21 @@
//go:build ignore
// +build ignore
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"stream.api/internal/config"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
@@ -12,20 +23,70 @@ import (
"stream.api/pkg/response"
)
const (
walletTransactionTypeTopup = "topup"
walletTransactionTypeSubscriptionDebit = "subscription_debit"
paymentMethodWallet = "wallet"
paymentMethodTopup = "topup"
paymentKindSubscription = "subscription"
paymentKindWalletTopup = "wallet_topup"
)
var allowedTermMonths = map[int32]struct{}{
1: {},
3: {},
6: {},
12: {},
}
type Handler struct {
logger logger.Logger
cfg *config.Config
db *gorm.DB
}
func NewHandler(l logger.Logger, cfg *config.Config) PaymentHandler {
type paymentRow struct {
ID string `gorm:"column:id"`
Amount float64 `gorm:"column:amount"`
Currency *string `gorm:"column:currency"`
Status *string `gorm:"column:status"`
PlanID *string `gorm:"column:plan_id"`
PlanName *string `gorm:"column:plan_name"`
TermMonths *int32 `gorm:"column:term_months"`
PaymentMethod *string `gorm:"column:payment_method"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
CreatedAt *time.Time `gorm:"column:created_at"`
}
type paymentInvoiceDetails struct {
PlanName string
TermMonths *int32
PaymentMethod string
ExpiresAt *time.Time
WalletAmount float64
TopupAmount float64
}
type paymentError struct {
Code int
Message string
Data interface{}
}
func (e *paymentError) Error() string {
return e.Message
}
func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB) PaymentHandler {
return &Handler{
logger: l,
cfg: cfg,
db: db,
}
}
// @Summary Create Payment
// @Description Create a new payment
// @Description Create a new payment for buying or renewing a plan
// @Tags payment
// @Accept json
// @Produce json
@@ -33,6 +94,7 @@ func NewHandler(l logger.Logger, cfg *config.Config) PaymentHandler {
// @Success 201 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /payments [post]
// @Security BearerAuth
@@ -49,27 +111,696 @@ func (h *Handler) CreatePayment(c *gin.Context) {
return
}
// In a real scenario, we would contact Stripe/PayPal here to create a session
// For now, we just create a "PENDING" payment record.
status := "PENDING"
provider := "STRIPE"
payment := &model.Payment{
ID: uuid.New().String(),
UserID: userID,
PlanID: &req.PlanID,
Amount: req.Amount,
Status: &status,
Provider: &provider, // Defaulting to Stripe for this example
planID := strings.TrimSpace(req.PlanID)
if planID == "" {
response.Error(c, http.StatusBadRequest, "Plan ID is required")
return
}
if !isAllowedTermMonths(req.TermMonths) {
response.Error(c, http.StatusBadRequest, "Term months must be one of 1, 3, 6, or 12")
return
}
p := query.Payment
if err := p.WithContext(c.Request.Context()).Create(payment); err != nil {
paymentMethod := normalizePaymentMethod(req.PaymentMethod)
if paymentMethod == "" {
response.Error(c, http.StatusBadRequest, "Payment method must be wallet or topup")
return
}
ctx := c.Request.Context()
var planRecord model.Plan
if err := h.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Plan not found")
return
}
h.logger.Error("Failed to load plan", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create payment")
return
}
if planRecord.IsActive == nil || !*planRecord.IsActive {
response.Error(c, http.StatusBadRequest, "Plan is not active")
return
}
totalAmount := planRecord.Price * float64(req.TermMonths)
if totalAmount < 0 {
response.Error(c, http.StatusBadRequest, "Amount must be greater than or equal to 0")
return
}
status := "SUCCESS"
provider := "INTERNAL"
currency := normalizeCurrency(nil)
transactionID := buildTransactionID("sub")
now := time.Now().UTC()
payment := &model.Payment{
ID: uuid.New().String(),
UserID: userID,
PlanID: &planRecord.ID,
Amount: totalAmount,
Currency: &currency,
Status: &status,
Provider: &provider,
TransactionID: &transactionID,
}
invoiceID := buildInvoiceID(payment.ID)
var subscription *model.PlanSubscription
var walletBalance float64
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := lockUserForUpdate(ctx, tx, userID); err != nil {
return err
}
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
baseExpiry := now
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
baseExpiry = currentSubscription.ExpiresAt.UTC()
}
newExpiry := baseExpiry.AddDate(0, int(req.TermMonths), 0)
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, userID)
if err != nil {
return err
}
shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
if paymentMethod == paymentMethodWallet && shortfall > 0 {
return &paymentError{
Code: http.StatusBadRequest,
Message: "Insufficient wallet balance",
Data: gin.H{
"payment_method": paymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
},
}
}
topupAmount := 0.0
if paymentMethod == paymentMethodTopup {
if req.TopupAmount == nil {
return &paymentError{
Code: http.StatusBadRequest,
Message: "Top-up amount is required when payment method is topup",
Data: gin.H{
"payment_method": paymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
},
}
}
topupAmount = maxFloat(*req.TopupAmount, 0)
if topupAmount <= 0 {
return &paymentError{
Code: http.StatusBadRequest,
Message: "Top-up amount must be greater than 0",
Data: gin.H{
"payment_method": paymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
},
}
}
if topupAmount < shortfall {
return &paymentError{
Code: http.StatusBadRequest,
Message: "Top-up amount must be greater than or equal to the required shortfall",
Data: gin.H{
"payment_method": paymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
"topup_amount": topupAmount,
},
}
}
}
if err := tx.Create(payment).Error; err != nil {
return err
}
walletUsedAmount := totalAmount
if paymentMethod == paymentMethodTopup {
topupTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: userID,
Type: walletTransactionTypeTopup,
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: userID,
Type: walletTransactionTypeSubscriptionDebit,
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: userID,
PaymentID: payment.ID,
PlanID: planRecord.ID,
TermMonths: req.TermMonths,
PaymentMethod: paymentMethod,
WalletAmount: walletUsedAmount,
TopupAmount: topupAmount,
StartedAt: now,
ExpiresAt: newExpiry,
}
if err := tx.Create(subscription).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", userID).
Update("plan_id", planRecord.ID).Error; err != nil {
return err
}
notification := buildSubscriptionNotification(userID, payment.ID, invoiceID, &planRecord, subscription)
if err := tx.Create(notification).Error; err != nil {
return err
}
walletBalance, err = model.GetWalletBalance(ctx, tx, userID)
if err != nil {
return err
}
return nil
})
if err != nil {
var paymentErr *paymentError
if errors.As(err, &paymentErr) {
c.AbortWithStatusJSON(paymentErr.Code, response.Response{
Code: paymentErr.Code,
Message: paymentErr.Message,
Data: paymentErr.Data,
})
return
}
h.logger.Error("Failed to create payment", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to create payment")
return
}
response.Created(c, gin.H{"payment": payment, "message": "Payment initiated"})
response.Created(c, gin.H{
"payment": payment,
"subscription": subscription,
"wallet_balance": walletBalance,
"invoice_id": invoiceID,
"message": "Payment completed successfully",
})
}
// @Summary List Payment History
// @Description Get payment history for the current user
// @Tags payment
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /payments/history [get]
// @Security BearerAuth
func (h *Handler) ListPaymentHistory(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var rows []paymentRow
if err := h.db.WithContext(c.Request.Context()).
Table("payment AS p").
Select("p.id, p.amount, p.currency, p.status, p.plan_id, pl.name AS plan_name, ps.term_months, ps.payment_method, ps.expires_at, p.created_at").
Joins("LEFT JOIN plan AS pl ON pl.id = p.plan_id").
Joins("LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id").
Where("p.user_id = ?", userID).
Order("p.created_at DESC").
Scan(&rows).Error; err != nil {
h.logger.Error("Failed to fetch payment history", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to fetch payment history")
return
}
items := make([]PaymentHistoryItem, 0, len(rows))
for _, row := range rows {
items = append(items, PaymentHistoryItem{
ID: row.ID,
Amount: row.Amount,
Currency: normalizeCurrency(row.Currency),
Status: normalizePaymentStatus(row.Status),
PlanID: row.PlanID,
PlanName: row.PlanName,
InvoiceID: buildInvoiceID(row.ID),
Kind: paymentKindSubscription,
TermMonths: row.TermMonths,
PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod),
ExpiresAt: row.ExpiresAt,
CreatedAt: row.CreatedAt,
})
}
var topups []model.WalletTransaction
if err := h.db.WithContext(c.Request.Context()).
Where("user_id = ? AND type = ? AND payment_id IS NULL", userID, walletTransactionTypeTopup).
Order("created_at DESC").
Find(&topups).Error; err != nil {
h.logger.Error("Failed to fetch wallet topups", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to fetch payment history")
return
}
for _, topup := range topups {
createdAt := topup.CreatedAt
items = append(items, PaymentHistoryItem{
ID: topup.ID,
Amount: topup.Amount,
Currency: normalizeCurrency(topup.Currency),
Status: "success",
InvoiceID: buildInvoiceID(topup.ID),
Kind: paymentKindWalletTopup,
CreatedAt: createdAt,
})
}
sortPaymentHistory(items)
response.Success(c, gin.H{"payments": items})
}
// @Summary Top Up Wallet
// @Description Add funds to wallet balance for the current user
// @Tags payment
// @Accept json
// @Produce json
// @Param request body TopupWalletRequest true "Topup Info"
// @Success 201 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /wallet/topups [post]
// @Security BearerAuth
func (h *Handler) TopupWallet(c *gin.Context) {
var req TopupWalletRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
amount := req.Amount
if amount < 1 {
response.Error(c, http.StatusBadRequest, "Amount must be at least 1")
return
}
transaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: userID,
Type: walletTransactionTypeTopup,
Amount: amount,
Currency: model.StringPtr("USD"),
Note: model.StringPtr(fmt.Sprintf("Wallet top-up of %.2f USD", amount)),
}
notification := &model.Notification{
ID: uuid.New().String(),
UserID: userID,
Type: "billing.topup",
Title: "Wallet credited",
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount),
Metadata: model.StringPtr(mustMarshalJSON(gin.H{
"wallet_transaction_id": transaction.ID,
"invoice_id": buildInvoiceID(transaction.ID),
})),
}
if err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
if _, err := lockUserForUpdate(c.Request.Context(), tx, userID); err != nil {
return err
}
if err := tx.Create(transaction).Error; err != nil {
return err
}
if err := tx.Create(notification).Error; err != nil {
return err
}
return nil
}); err != nil {
h.logger.Error("Failed to top up wallet", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to top up wallet")
return
}
balance, err := model.GetWalletBalance(c.Request.Context(), h.db, userID)
if err != nil {
h.logger.Error("Failed to calculate wallet balance", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to top up wallet")
return
}
response.Created(c, gin.H{
"wallet_transaction": transaction,
"wallet_balance": balance,
"invoice_id": buildInvoiceID(transaction.ID),
})
}
// @Summary Download Invoice
// @Description Download invoice text for a payment or wallet top-up
// @Tags payment
// @Produce plain
// @Param id path string true "Payment ID"
// @Success 200 {string} string
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /payments/{id}/invoice [get]
// @Security BearerAuth
func (h *Handler) DownloadInvoice(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
response.Error(c, http.StatusNotFound, "Invoice not found")
return
}
ctx := c.Request.Context()
paymentRecord, err := query.Payment.WithContext(ctx).
Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(userID)).
First()
if err == nil {
invoiceText, filename, buildErr := h.buildPaymentInvoice(ctx, paymentRecord)
if buildErr != nil {
h.logger.Error("Failed to build payment invoice", "error", buildErr)
response.Error(c, http.StatusInternalServerError, "Failed to download invoice")
return
}
serveInvoiceText(c, filename, invoiceText)
return
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
h.logger.Error("Failed to load payment invoice", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to download invoice")
return
}
var topup model.WalletTransaction
if err := h.db.WithContext(ctx).
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, userID, walletTransactionTypeTopup).
First(&topup).Error; err == nil {
invoiceText := buildTopupInvoice(&topup)
serveInvoiceText(c, buildInvoiceFilename(topup.ID), invoiceText)
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
h.logger.Error("Failed to load topup invoice", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to download invoice")
return
}
response.Error(c, http.StatusNotFound, "Invoice not found")
}
func normalizePaymentStatus(status *string) string {
value := strings.ToLower(strings.TrimSpace(stringValue(status)))
switch value {
case "success", "succeeded", "paid":
return "success"
case "failed", "error", "canceled", "cancelled":
return "failed"
case "pending", "processing":
return "pending"
default:
if value == "" {
return "success"
}
return value
}
}
func normalizeCurrency(currency *string) string {
value := strings.ToUpper(strings.TrimSpace(stringValue(currency)))
if value == "" {
return "USD"
}
return value
}
func normalizePaymentMethod(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case paymentMethodWallet:
return paymentMethodWallet
case paymentMethodTopup:
return paymentMethodTopup
default:
return ""
}
}
func normalizeOptionalPaymentMethod(value *string) *string {
normalized := normalizePaymentMethod(stringValue(value))
if normalized == "" {
return nil
}
return &normalized
}
func buildInvoiceID(id string) string {
trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "")
if len(trimmed) > 12 {
trimmed = trimmed[:12]
}
return "INV-" + strings.ToUpper(trimmed)
}
func buildTransactionID(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
func buildInvoiceFilename(id string) string {
return fmt.Sprintf("invoice-%s.txt", id)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func sortPaymentHistory(items []PaymentHistoryItem) {
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
left := time.Time{}
right := time.Time{}
if items[i].CreatedAt != nil {
left = *items[i].CreatedAt
}
if items[j].CreatedAt != nil {
right = *items[j].CreatedAt
}
if right.After(left) {
items[i], items[j] = items[j], items[i]
}
}
}
}
func serveInvoiceText(c *gin.Context, filename string, content string) {
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
c.String(http.StatusOK, content)
}
func (h *Handler) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) {
details, err := h.loadPaymentInvoiceDetails(ctx, paymentRecord)
if err != nil {
return "", "", err
}
createdAt := formatOptionalTimestamp(paymentRecord.CreatedAt)
lines := []string{
"Stream API Invoice",
fmt.Sprintf("Invoice ID: %s", buildInvoiceID(paymentRecord.ID)),
fmt.Sprintf("Payment ID: %s", paymentRecord.ID),
fmt.Sprintf("User ID: %s", paymentRecord.UserID),
fmt.Sprintf("Plan: %s", details.PlanName),
fmt.Sprintf("Amount: %.2f %s", paymentRecord.Amount, normalizeCurrency(paymentRecord.Currency)),
fmt.Sprintf("Status: %s", strings.ToUpper(normalizePaymentStatus(paymentRecord.Status))),
fmt.Sprintf("Provider: %s", strings.ToUpper(stringValue(paymentRecord.Provider))),
fmt.Sprintf("Payment Method: %s", strings.ToUpper(details.PaymentMethod)),
fmt.Sprintf("Transaction ID: %s", stringValue(paymentRecord.TransactionID)),
}
if details.TermMonths != nil {
lines = append(lines, fmt.Sprintf("Term: %d month(s)", *details.TermMonths))
}
if details.ExpiresAt != nil {
lines = append(lines, fmt.Sprintf("Valid Until: %s", details.ExpiresAt.UTC().Format(time.RFC3339)))
}
if details.WalletAmount > 0 {
lines = append(lines, fmt.Sprintf("Wallet Applied: %.2f %s", details.WalletAmount, normalizeCurrency(paymentRecord.Currency)))
}
if details.TopupAmount > 0 {
lines = append(lines, fmt.Sprintf("Top-up Added: %.2f %s", details.TopupAmount, normalizeCurrency(paymentRecord.Currency)))
}
lines = append(lines, fmt.Sprintf("Created At: %s", createdAt))
return strings.Join(lines, "\n"), buildInvoiceFilename(paymentRecord.ID), nil
}
func buildTopupInvoice(transaction *model.WalletTransaction) string {
createdAt := formatOptionalTimestamp(transaction.CreatedAt)
return strings.Join([]string{
"Stream API Wallet Top-up Invoice",
fmt.Sprintf("Invoice ID: %s", buildInvoiceID(transaction.ID)),
fmt.Sprintf("Wallet Transaction ID: %s", transaction.ID),
fmt.Sprintf("User ID: %s", transaction.UserID),
fmt.Sprintf("Amount: %.2f %s", transaction.Amount, normalizeCurrency(transaction.Currency)),
"Status: SUCCESS",
fmt.Sprintf("Type: %s", strings.ToUpper(transaction.Type)),
fmt.Sprintf("Note: %s", model.StringValue(transaction.Note)),
fmt.Sprintf("Created At: %s", createdAt),
}, "\n")
}
func (h *Handler) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) {
details := &paymentInvoiceDetails{
PlanName: "Unknown plan",
PaymentMethod: paymentMethodWallet,
}
if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" {
var planRecord model.Plan
if err := h.db.WithContext(ctx).Where("id = ?", *paymentRecord.PlanID).First(&planRecord).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
} else {
details.PlanName = planRecord.Name
}
}
var subscription model.PlanSubscription
if err := h.db.WithContext(ctx).
Where("payment_id = ?", paymentRecord.ID).
Order("created_at DESC").
First(&subscription).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return details, nil
}
details.TermMonths = &subscription.TermMonths
details.PaymentMethod = normalizePaymentMethod(subscription.PaymentMethod)
if details.PaymentMethod == "" {
details.PaymentMethod = paymentMethodWallet
}
details.ExpiresAt = &subscription.ExpiresAt
details.WalletAmount = subscription.WalletAmount
details.TopupAmount = subscription.TopupAmount
return details, nil
}
func buildSubscriptionNotification(userID string, paymentID string, invoiceID string, planRecord *model.Plan, subscription *model.PlanSubscription) *model.Notification {
return &model.Notification{
ID: uuid.New().String(),
UserID: userID,
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(mustMarshalJSON(gin.H{
"payment_id": paymentID,
"invoice_id": invoiceID,
"plan_id": planRecord.ID,
"term_months": subscription.TermMonths,
"payment_method": subscription.PaymentMethod,
"wallet_amount": subscription.WalletAmount,
"topup_amount": subscription.TopupAmount,
"plan_expires_at": subscription.ExpiresAt.UTC().Format(time.RFC3339),
})),
}
}
func isAllowedTermMonths(value int32) bool {
_, ok := allowedTermMonths[value]
return ok
}
func lockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) {
var user model.User
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", userID).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func maxFloat(left float64, right float64) float64 {
if left > right {
return left
}
return right
}
func formatOptionalTimestamp(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func mustMarshalJSON(value interface{}) string {
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}

View File

@@ -0,0 +1,12 @@
package payment
type CreatePaymentRequest struct {
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 TopupWalletRequest struct {
Amount float64 `json:"amount" binding:"required"`
}

View File

@@ -1,3 +1,6 @@
//go:build ignore
// +build ignore
package payment
import "github.com/gin-gonic/gin"
@@ -5,10 +8,19 @@ import "github.com/gin-gonic/gin"
// PaymentHandler defines the interface for payment operations
type PaymentHandler interface {
CreatePayment(c *gin.Context)
ListPaymentHistory(c *gin.Context)
TopupWallet(c *gin.Context)
DownloadInvoice(c *gin.Context)
}
// CreatePaymentRequest defines the payload for creating a payment
type CreatePaymentRequest struct {
PlanID string `json:"plan_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 TopupWalletRequest struct {
Amount float64 `json:"amount" binding:"required"`
}

View File

@@ -0,0 +1,18 @@
package payment
import "time"
type PaymentHistoryItem struct {
ID string `json:"id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
PlanID *string `json:"plan_id,omitempty"`
PlanName *string `json:"plan_name,omitempty"`
InvoiceID string `json:"invoice_id"`
Kind string `json:"kind"`
TermMonths *int32 `json:"term_months,omitempty"`
PaymentMethod *string `json:"payment_method,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}

View File

@@ -1,11 +1,15 @@
//go:build ignore
// +build ignore
package plan
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"stream.api/internal/config"
"stream.api/internal/database/query"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
"stream.api/pkg/response"
)
@@ -13,12 +17,14 @@ import (
type Handler struct {
logger logger.Logger
cfg *config.Config
db *gorm.DB
}
func NewHandler(l logger.Logger, cfg *config.Config) PlanHandler {
func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB) PlanHandler {
return &Handler{
logger: l,
cfg: cfg,
db: db,
}
}
@@ -26,14 +32,13 @@ func NewHandler(l logger.Logger, cfg *config.Config) PlanHandler {
// @Description Get all active plans
// @Tags plan
// @Produce json
// @Success 200 {object} response.Response{data=[]model.Plan}
// @Success 200 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /plans [get]
// @Security BearerAuth
func (h *Handler) ListPlans(c *gin.Context) {
p := query.Plan
plans, err := p.WithContext(c.Request.Context()).Where(p.IsActive.Is(true)).Find()
if err != nil {
var plans []model.Plan
if err := h.db.WithContext(c.Request.Context()).Where("is_active = ?", true).Find(&plans).Error; err != nil {
h.logger.Error("Failed to fetch plans", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to fetch plans")
return

View File

@@ -1,3 +1,6 @@
//go:build ignore
// +build ignore
package plan
import "github.com/gin-gonic/gin"

View File

@@ -0,0 +1,112 @@
//go:build ignore
// +build ignore
package preferences
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"stream.api/pkg/logger"
"stream.api/pkg/response"
)
type Handler struct {
logger logger.Logger
db *gorm.DB
}
type SettingsPreferencesRequest struct {
EmailNotifications *bool `json:"email_notifications"`
PushNotifications *bool `json:"push_notifications"`
MarketingNotifications *bool `json:"marketing_notifications"`
TelegramNotifications *bool `json:"telegram_notifications"`
Autoplay *bool `json:"autoplay"`
Loop *bool `json:"loop"`
Muted *bool `json:"muted"`
ShowControls *bool `json:"show_controls"`
Pip *bool `json:"pip"`
Airplay *bool `json:"airplay"`
Chromecast *bool `json:"chromecast"`
Language *string `json:"language"`
Locale *string `json:"locale"`
}
func NewHandler(l logger.Logger, db *gorm.DB) *Handler {
return &Handler{logger: l, db: db}
}
// @Summary Get Preferences
// @Description Get notification, player, and locale preferences for the current user
// @Tags settings
// @Produce json
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /settings/preferences [get]
// @Security BearerAuth
func (h *Handler) GetPreferences(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
pref, err := LoadUserPreferences(c.Request.Context(), h.db, userID)
if err != nil {
h.logger.Error("Failed to load preferences", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to load preferences")
return
}
response.Success(c, gin.H{"preferences": pref})
}
// @Summary Update Preferences
// @Description Update notification, player, and locale preferences for the current user
// @Tags settings
// @Accept json
// @Produce json
// @Param request body SettingsPreferencesRequest true "Preferences payload"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /settings/preferences [put]
// @Security BearerAuth
func (h *Handler) UpdatePreferences(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
var req SettingsPreferencesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
pref, err := UpdateUserPreferences(c.Request.Context(), h.db, h.logger, userID, UpdateInput{
EmailNotifications: req.EmailNotifications,
PushNotifications: req.PushNotifications,
MarketingNotifications: req.MarketingNotifications,
TelegramNotifications: req.TelegramNotifications,
Autoplay: req.Autoplay,
Loop: req.Loop,
Muted: req.Muted,
ShowControls: req.ShowControls,
Pip: req.Pip,
Airplay: req.Airplay,
Chromecast: req.Chromecast,
Language: req.Language,
Locale: req.Locale,
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to save preferences")
return
}
response.Success(c, gin.H{"preferences": pref})
}

View File

@@ -0,0 +1,91 @@
package preferences
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
)
type UpdateInput struct {
EmailNotifications *bool
PushNotifications *bool
MarketingNotifications *bool
TelegramNotifications *bool
Autoplay *bool
Loop *bool
Muted *bool
ShowControls *bool
Pip *bool
Airplay *bool
Chromecast *bool
Language *string
Locale *string
}
func LoadUserPreferences(ctx context.Context, db *gorm.DB, userID string) (*model.UserPreference, error) {
return model.FindOrCreateUserPreference(ctx, db, userID)
}
func UpdateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req UpdateInput) (*model.UserPreference, error) {
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
if err != nil {
l.Error("Failed to load preferences", "error", err)
return nil, err
}
if req.EmailNotifications != nil {
pref.EmailNotifications = model.BoolPtr(*req.EmailNotifications)
}
if req.PushNotifications != nil {
pref.PushNotifications = model.BoolPtr(*req.PushNotifications)
}
if req.MarketingNotifications != nil {
pref.MarketingNotifications = *req.MarketingNotifications
}
if req.TelegramNotifications != nil {
pref.TelegramNotifications = *req.TelegramNotifications
}
if req.Autoplay != nil {
pref.Autoplay = *req.Autoplay
}
if req.Loop != nil {
pref.Loop = *req.Loop
}
if req.Muted != nil {
pref.Muted = *req.Muted
}
if req.ShowControls != nil {
pref.ShowControls = model.BoolPtr(*req.ShowControls)
}
if req.Pip != nil {
pref.Pip = model.BoolPtr(*req.Pip)
}
if req.Airplay != nil {
pref.Airplay = model.BoolPtr(*req.Airplay)
}
if req.Chromecast != nil {
pref.Chromecast = model.BoolPtr(*req.Chromecast)
}
if req.Language != nil {
pref.Language = model.StringPtr(strings.TrimSpace(*req.Language))
}
if req.Locale != nil {
pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale))
}
if strings.TrimSpace(model.StringValue(pref.Language)) == "" {
pref.Language = model.StringPtr("en")
}
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" {
pref.Locale = model.StringPtr(model.StringValue(pref.Language))
}
if err := db.WithContext(ctx).Save(pref).Error; err != nil {
l.Error("Failed to save preferences", "error", err)
return nil, err
}
return pref, nil
}

View File

@@ -0,0 +1,63 @@
//go:build ignore
// +build ignore
package usage
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
"stream.api/pkg/response"
)
type Handler struct {
logger logger.Logger
db *gorm.DB
}
func NewHandler(l logger.Logger, db *gorm.DB) UsageHandler {
return &Handler{
logger: l,
db: db,
}
}
// @Summary Get Usage
// @Description Get the authenticated user's total video count and total storage usage
// @Tags usage
// @Produce json
// @Success 200 {object} response.Response{data=UsagePayload}
// @Failure 401 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /usage [get]
// @Security BearerAuth
func (h *Handler) GetUsage(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
user, ok := c.Get("user")
if !ok {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
currentUser, ok := user.(*model.User)
if !ok || currentUser == nil || currentUser.ID != userID {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
payload, err := LoadUsage(c.Request.Context(), h.db, h.logger, currentUser)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Failed to load usage")
return
}
response.Success(c, payload)
}

View File

@@ -0,0 +1,11 @@
//go:build ignore
// +build ignore
package usage
import "github.com/gin-gonic/gin"
// UsageHandler defines the interface for usage operations
type UsageHandler interface {
GetUsage(c *gin.Context)
}

View File

@@ -0,0 +1,23 @@
package usage
import (
"context"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
)
func LoadUsage(ctx context.Context, db *gorm.DB, l logger.Logger, user *model.User) (*UsagePayload, error) {
var totalVideos int64
if err := db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil {
l.Error("Failed to count user videos", "error", err, "user_id", user.ID)
return nil, err
}
return &UsagePayload{
UserID: user.ID,
TotalVideos: totalVideos,
TotalStorage: user.StorageUsed,
}, nil
}

View File

@@ -0,0 +1,7 @@
package usage
type UsagePayload struct {
UserID string `json:"user_id"`
TotalVideos int64 `json:"total_videos"`
TotalStorage int64 `json:"total_storage"`
}

View File

@@ -1,16 +1,22 @@
//go:build ignore
// +build ignore
package video
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"stream.api/internal/config"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/pkg/logger"
"stream.api/pkg/response"
"stream.api/pkg/storage"
@@ -19,13 +25,22 @@ import (
type Handler struct {
logger logger.Logger
cfg *config.Config
db *gorm.DB
storage storage.Provider
}
func NewHandler(l logger.Logger, cfg *config.Config, s storage.Provider) VideoHandler {
type videoError struct {
Code int
Message string
}
func (e *videoError) Error() string { return e.Message }
func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB, s storage.Provider) VideoHandler {
return &Handler{
logger: l,
cfg: cfg,
db: db,
storage: s,
}
}
@@ -62,7 +77,7 @@ func (h *Handler) GetUploadURL(c *gin.Context) {
response.Success(c, gin.H{
"upload_url": url,
"key": key,
"file_id": fileID, // Temporary ID, actual video record ID might differ or be same
"file_id": fileID,
})
}
@@ -85,38 +100,83 @@ func (h *Handler) CreateVideo(c *gin.Context) {
}
userID := c.GetString("userID")
status := "PUBLIC"
storageType := "S3"
video := &model.Video{
ID: uuid.New().String(),
UserID: userID,
Name: req.Title,
Title: req.Title,
Description: &req.Description,
URL: req.URL,
Size: req.Size,
Duration: req.Duration,
Format: req.Format,
Status: &status,
StorageType: &storageType,
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
q := query.Q
err := q.Transaction(func(tx *query.Query) error {
if err := tx.Video.WithContext(c.Request.Context()).Create(video); err != nil {
title := strings.TrimSpace(req.Title)
if title == "" {
response.Error(c, http.StatusBadRequest, "Title is required")
return
}
videoURL := strings.TrimSpace(req.URL)
if videoURL == "" {
response.Error(c, http.StatusBadRequest, "URL is required")
return
}
status := "ready"
processingStatus := "READY"
storageType := detectStorageType(videoURL)
description := strings.TrimSpace(req.Description)
format := strings.TrimSpace(req.Format)
video := &model.Video{
ID: uuid.New().String(),
UserID: userID,
Name: title,
Title: title,
Description: stringPointer(description),
URL: videoURL,
Size: req.Size,
Duration: req.Duration,
Format: format,
Status: &status,
ProcessingStatus: &processingStatus,
StorageType: &storageType,
}
err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var defaultTemplate model.AdTemplate
hasDefaultTemplate := false
if err := tx.Where("user_id = ? AND is_default = ? AND is_active = ?", userID, true, true).
Order("updated_at DESC").
First(&defaultTemplate).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
} else {
hasDefaultTemplate = true
}
if err := tx.Create(video).Error; err != nil {
return err
}
// Atomic update: StorageUsed = StorageUsed + video.Size
// We use UpdateSimple with Add to ensure atomicity at database level: UPDATE users SET storage_used = storage_used + ?
if _, err := tx.User.WithContext(c.Request.Context()).
Where(tx.User.ID.Eq(userID)).
UpdateSimple(tx.User.StorageUsed.Add(video.Size)); err != nil {
if err := tx.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
return err
}
if hasDefaultTemplate {
videoAdConfig := &model.VideoAdConfig{
VideoID: video.ID,
UserID: userID,
AdTemplateID: defaultTemplate.ID,
VastTagURL: defaultTemplate.VastTagURL,
AdFormat: defaultTemplate.AdFormat,
Duration: defaultTemplate.Duration,
}
if err := tx.Create(videoAdConfig).Error; err != nil {
return err
}
}
return nil
})
@@ -140,17 +200,46 @@ func (h *Handler) CreateVideo(c *gin.Context) {
// @Router /videos [get]
// @Security BearerAuth
func (h *Handler) ListVideos(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
v := query.Video
videos, count, err := v.WithContext(c.Request.Context()).
Where(v.Status.Eq("PUBLIC")).
Order(v.CreatedAt.Desc()).
FindByPage(offset, limit)
search := strings.TrimSpace(c.Query("search"))
status := strings.TrimSpace(c.Query("status"))
if err != nil {
db := h.db.WithContext(c.Request.Context()).Model(&model.Video{}).Where("user_id = ?", userID)
if search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
}
if status != "" && !strings.EqualFold(status, "all") {
db = db.Where("status = ?", normalizeVideoStatus(status))
}
var total int64
if err := db.Count(&total).Error; err != nil {
h.logger.Error("Failed to count videos", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to fetch 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 fetch videos", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to fetch videos")
return
@@ -158,7 +247,7 @@ func (h *Handler) ListVideos(c *gin.Context) {
response.Success(c, gin.H{
"videos": videos,
"total": count,
"total": total,
"page": page,
"limit": limit,
})
@@ -174,19 +263,321 @@ func (h *Handler) ListVideos(c *gin.Context) {
// @Router /videos/{id} [get]
// @Security BearerAuth
func (h *Handler) GetVideo(c *gin.Context) {
id := c.Param("id")
v := query.Video
// Atomically increment views: UPDATE videos SET views = views + 1 WHERE id = ?
// We intentionally ignore errors here (like record not found) because the subsequent fetch will handle 404s,
// and we don't want to fail the read if writing the view count fails for some transient reason.
v.WithContext(c.Request.Context()).Where(v.ID.Eq(id)).UpdateSimple(v.Views.Add(1))
video, err := v.WithContext(c.Request.Context()).Where(v.ID.Eq(id)).First()
if err != nil {
response.Error(c, http.StatusNotFound, "Video not found")
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
response.Success(c, gin.H{"video": video})
id := c.Param("id")
h.db.WithContext(c.Request.Context()).Model(&model.Video{}).
Where("id = ? AND user_id = ?", id, userID).
UpdateColumn("views", gorm.Expr("views + ?", 1))
var video model.Video
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil {
if err == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
h.logger.Error("Failed to fetch video", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to fetch video")
return
}
result := gin.H{"video": &video}
var adConfig model.VideoAdConfig
if err := h.db.WithContext(c.Request.Context()).
Where("video_id = ? AND user_id = ?", id, userID).
First(&adConfig).Error; err == nil {
adPayload := VideoAdConfigPayload{
AdTemplateID: adConfig.AdTemplateID,
VASTTagURL: adConfig.VastTagURL,
AdFormat: model.StringValue(adConfig.AdFormat),
Duration: int64PtrToIntPtr(adConfig.Duration),
}
var template model.AdTemplate
if err := h.db.WithContext(c.Request.Context()).
Where("id = ? AND user_id = ?", adConfig.AdTemplateID, userID).
First(&template).Error; err == nil {
adPayload.TemplateName = template.Name
}
result["ad_config"] = adPayload
}
response.Success(c, result)
}
// @Summary Update Video
// @Description Update title and description for a video owned by the current user
// @Tags video
// @Accept json
// @Produce json
// @Param id path string true "Video ID"
// @Param request body UpdateVideoRequest true "Video payload"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /videos/{id} [put]
// @Security BearerAuth
func (h *Handler) UpdateVideo(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
id := c.Param("id")
var req UpdateVideoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
response.Error(c, http.StatusBadRequest, "Title is required")
return
}
description := strings.TrimSpace(req.Description)
ctx := c.Request.Context()
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Video{}).
Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{
"name": title,
"title": title,
"description": stringPointer(description),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
if req.AdTemplateID != nil {
templateID := strings.TrimSpace(*req.AdTemplateID)
if templateID == "" {
tx.Where("video_id = ? AND user_id = ?", id, userID).Delete(&model.VideoAdConfig{})
} else {
var template model.AdTemplate
if err := tx.Where("id = ? AND user_id = ?", templateID, userID).
First(&template).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &videoError{Code: http.StatusBadRequest, Message: "Ad template not found"}
}
return err
}
var existing model.VideoAdConfig
if err := tx.Where("video_id = ? AND user_id = ?", id, userID).
First(&existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
newConfig := &model.VideoAdConfig{
VideoID: id,
UserID: userID,
AdTemplateID: template.ID,
VastTagURL: template.VastTagURL,
AdFormat: template.AdFormat,
Duration: template.Duration,
}
return tx.Create(newConfig).Error
}
return err
}
existing.AdTemplateID = template.ID
existing.VastTagURL = template.VastTagURL
existing.AdFormat = template.AdFormat
existing.Duration = template.Duration
return tx.Save(&existing).Error
}
}
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
var ve *videoError
if errors.As(err, &ve) {
response.Error(c, ve.Code, ve.Message)
return
}
h.logger.Error("Failed to update video", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to update video")
return
}
var video model.Video
if err := h.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil {
h.logger.Error("Failed to reload video", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to update video")
return
}
resp := gin.H{"video": &video}
var adConfig model.VideoAdConfig
if err := h.db.WithContext(ctx).
Where("video_id = ? AND user_id = ?", id, userID).
First(&adConfig).Error; err == nil {
adPayload := VideoAdConfigPayload{
AdTemplateID: adConfig.AdTemplateID,
VASTTagURL: adConfig.VastTagURL,
AdFormat: model.StringValue(adConfig.AdFormat),
Duration: int64PtrToIntPtr(adConfig.Duration),
}
var template model.AdTemplate
if err := h.db.WithContext(ctx).
Where("id = ? AND user_id = ?", adConfig.AdTemplateID, userID).
First(&template).Error; err == nil {
adPayload.TemplateName = template.Name
}
resp["ad_config"] = adPayload
}
response.Success(c, resp)
}
// @Summary Delete Video
// @Description Delete a video owned by the current user
// @Tags video
// @Produce json
// @Param id path string true "Video ID"
// @Success 200 {object} response.Response
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /videos/{id} [delete]
// @Security BearerAuth
func (h *Handler) DeleteVideo(c *gin.Context) {
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
id := c.Param("id")
var video model.Video
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil {
if err == gorm.ErrRecordNotFound {
response.Error(c, http.StatusNotFound, "Video not found")
return
}
h.logger.Error("Failed to load video for deletion", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
return
}
if h.storage != nil && shouldDeleteStoredObject(video.URL) {
if err := h.storage.Delete(video.URL); err != nil {
parsedKey := extractObjectKey(video.URL)
if parsedKey != "" && parsedKey != video.URL {
if deleteErr := h.storage.Delete(parsedKey); deleteErr != nil {
h.logger.Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID)
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
return
}
} else {
h.logger.Error("Failed to delete video object", "error", err, "video_id", video.ID)
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
return
}
}
}
if err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("video_id = ? AND user_id = ?", video.ID, userID).Delete(&model.VideoAdConfig{}).Error; err != nil {
return err
}
if err := tx.Where("id = ? AND user_id = ?", video.ID, userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error; err != nil {
return err
}
return nil
}); err != nil {
h.logger.Error("Failed to delete video record", "error", err)
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
return
}
response.Success(c, gin.H{"message": "Video deleted successfully"})
}
func normalizeVideoStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "processing", "pending":
return "processing"
case "failed", "error":
return "failed"
default:
return "ready"
}
}
func detectStorageType(rawURL string) string {
if shouldDeleteStoredObject(rawURL) {
return "S3"
}
return "WORKER"
}
func shouldDeleteStoredObject(rawURL string) bool {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return false
}
parsed, err := url.Parse(trimmed)
if err != nil {
return !strings.HasPrefix(trimmed, "/")
}
return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/")
}
func extractObjectKey(rawURL string) string {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil {
return trimmed
}
if parsed.Scheme == "" && parsed.Host == "" {
return strings.TrimPrefix(parsed.Path, "/")
}
return strings.TrimPrefix(parsed.Path, "/")
}
func stringPointer(value string) *string {
if value == "" {
return nil
}
return &value
}
func int64PtrToIntPtr(value *int64) *int {
if value == nil {
return nil
}
converted := int(*value)
return &converted
}

View File

@@ -1,3 +1,6 @@
//go:build ignore
// +build ignore
package video
import "github.com/gin-gonic/gin"
@@ -8,6 +11,8 @@ type VideoHandler interface {
CreateVideo(c *gin.Context)
ListVideos(c *gin.Context)
GetVideo(c *gin.Context)
UpdateVideo(c *gin.Context)
DeleteVideo(c *gin.Context)
}
// UploadURLRequest defines the payload for requesting an upload URL
@@ -26,3 +31,17 @@ type CreateVideoRequest struct {
Duration int32 `json:"duration"` // Maybe client knows, or we process later
Format string `json:"format"`
}
type UpdateVideoRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
AdTemplateID *string `json:"ad_template_id,omitempty"`
}
type VideoAdConfigPayload struct {
AdTemplateID string `json:"ad_template_id"`
TemplateName string `json:"template_name,omitempty"`
VASTTagURL string `json:"vast_tag_url,omitempty"`
AdFormat string `json:"ad_format,omitempty"`
Duration *int `json:"duration,omitempty"`
}