Files
stream.api/internal/modules/adtemplates/module.go
2026-03-26 13:02:43 +00:00

365 lines
14 KiB
Go

package adtemplates
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/modules/common"
)
type Module struct {
runtime *common.Runtime
}
func New(runtime *common.Runtime) *Module {
return &Module{runtime: runtime}
}
func (m *Module) ListAdTemplates(ctx context.Context, queryValue ListAdTemplatesQuery) (*ListAdTemplatesResult, error) {
var items []model.AdTemplate
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", queryValue.UserID).Order("is_default DESC").Order("created_at DESC").Find(&items).Error; err != nil {
m.runtime.Logger().Error("Failed to list ad templates", "error", err)
return nil, status.Error(codes.Internal, "Failed to load ad templates")
}
result := &ListAdTemplatesResult{Items: make([]AdTemplateView, 0, len(items))}
for i := range items {
result.Items = append(result.Items, AdTemplateView{Template: &items[i]})
}
return result, nil
}
func (m *Module) CreateAdTemplate(ctx context.Context, cmd CreateAdTemplateCommand) (*AdTemplateView, error) {
name := strings.TrimSpace(cmd.Name)
vastURL := strings.TrimSpace(cmd.VastTagURL)
if name == "" || vastURL == "" {
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
}
format := common.NormalizeAdFormat(cmd.AdFormat)
if format == "mid-roll" && (cmd.Duration == nil || *cmd.Duration <= 0) {
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
}
item := &model.AdTemplate{
ID: uuid.New().String(),
UserID: cmd.UserID,
Name: name,
Description: common.NullableTrimmedString(cmd.Description),
VastTagURL: vastURL,
AdFormat: model.StringPtr(format),
Duration: common.Int32PtrToInt64Ptr(cmd.Duration),
IsActive: model.BoolPtr(cmd.IsActive == nil || *cmd.IsActive),
IsDefault: cmd.IsDefault != nil && *cmd.IsDefault,
}
if !common.AdTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := common.UnsetDefaultTemplates(tx, cmd.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
m.runtime.Logger().Error("Failed to create ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &AdTemplateView{Template: item}, nil
}
func (m *Module) UpdateAdTemplate(ctx context.Context, cmd UpdateAdTemplateCommand) (*AdTemplateView, error) {
if cmd.ID == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
name := strings.TrimSpace(cmd.Name)
vastURL := strings.TrimSpace(cmd.VastTagURL)
if name == "" || vastURL == "" {
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
}
format := common.NormalizeAdFormat(cmd.AdFormat)
if format == "mid-roll" && (cmd.Duration == nil || *cmd.Duration <= 0) {
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
}
var item model.AdTemplate
if err := m.runtime.DB().WithContext(ctx).Where("id = ? AND user_id = ?", cmd.ID, cmd.UserID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
m.runtime.Logger().Error("Failed to load ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item.Name = name
item.Description = common.NullableTrimmedString(cmd.Description)
item.VastTagURL = vastURL
item.AdFormat = model.StringPtr(format)
item.Duration = common.Int32PtrToInt64Ptr(cmd.Duration)
if cmd.IsActive != nil {
item.IsActive = model.BoolPtr(*cmd.IsActive)
}
if cmd.IsDefault != nil {
item.IsDefault = *cmd.IsDefault
}
if !common.AdTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := common.UnsetDefaultTemplates(tx, cmd.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
m.runtime.Logger().Error("Failed to update ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &AdTemplateView{Template: &item}, nil
}
func (m *Module) DeleteAdTemplate(ctx context.Context, cmd DeleteAdTemplateCommand) error {
if cmd.ID == "" {
return status.Error(codes.NotFound, "Ad template not found")
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).Where("user_id = ? AND ad_id = ?", cmd.UserID, cmd.ID).Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", cmd.ID, cmd.UserID).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return status.Error(codes.NotFound, "Ad template not found")
}
m.runtime.Logger().Error("Failed to delete ad template", "error", err)
return status.Error(codes.Internal, "Failed to delete ad template")
}
return nil
}
func (m *Module) ListAdminAdTemplates(ctx context.Context, queryValue ListAdminAdTemplatesQuery) (*ListAdminAdTemplatesResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := common.AdminPageLimitOffset(queryValue.Page, queryValue.Limit)
limitInt := int(limit)
search := strings.TrimSpace(common.ProtoStringValue(queryValue.Search))
userID := strings.TrimSpace(common.ProtoStringValue(queryValue.UserID))
db := m.runtime.DB().WithContext(ctx).Model(&model.AdTemplate{})
if search != "" {
like := "%" + search + "%"
db = db.Where("name ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
var templates []model.AdTemplate
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&templates).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
items := make([]AdminAdTemplateView, 0, len(templates))
for i := range templates {
view, err := m.buildAdminAdTemplate(ctx, &templates[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
items = append(items, view)
}
return &ListAdminAdTemplatesResult{Items: items, Total: total, Page: page, Limit: limit}, nil
}
func (m *Module) GetAdminAdTemplate(ctx context.Context, queryValue GetAdminAdTemplateQuery) (*AdminAdTemplateView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if queryValue.ID == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
var item model.AdTemplate
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", queryValue.ID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to load ad template")
}
payload, err := m.buildAdminAdTemplate(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load ad template")
}
return &payload, nil
}
func (m *Module) CreateAdminAdTemplate(ctx context.Context, cmd CreateAdminAdTemplateCommand) (*AdminAdTemplateView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if msg := validateAdminAdTemplateInput(cmd.UserID, cmd.Name, cmd.VastTagURL, cmd.AdFormat, cmd.Duration); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", strings.TrimSpace(cmd.UserID)).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item := &model.AdTemplate{ID: uuid.New().String(), UserID: user.ID, Name: strings.TrimSpace(cmd.Name), Description: common.NullableTrimmedStringPtr(cmd.Description), VastTagURL: strings.TrimSpace(cmd.VastTagURL), AdFormat: model.StringPtr(common.NormalizeAdFormat(cmd.AdFormat)), Duration: cmd.Duration, IsActive: model.BoolPtr(cmd.IsActive), IsDefault: cmd.IsDefault}
if !common.BoolValue(item.IsActive) {
item.IsDefault = false
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := common.UnsetDefaultTemplates(tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
payload, err := m.buildAdminAdTemplate(ctx, item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &payload, nil
}
func (m *Module) UpdateAdminAdTemplate(ctx context.Context, cmd UpdateAdminAdTemplateCommand) (*AdminAdTemplateView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if cmd.ID == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
if msg := validateAdminAdTemplateInput(cmd.UserID, cmd.Name, cmd.VastTagURL, cmd.AdFormat, cmd.Duration); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", strings.TrimSpace(cmd.UserID)).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
var item model.AdTemplate
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", cmd.ID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item.UserID = user.ID
item.Name = strings.TrimSpace(cmd.Name)
item.Description = common.NullableTrimmedStringPtr(cmd.Description)
item.VastTagURL = strings.TrimSpace(cmd.VastTagURL)
item.AdFormat = model.StringPtr(common.NormalizeAdFormat(cmd.AdFormat))
item.Duration = cmd.Duration
item.IsActive = model.BoolPtr(cmd.IsActive)
item.IsDefault = cmd.IsDefault
if !common.BoolValue(item.IsActive) {
item.IsDefault = false
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := common.UnsetDefaultTemplates(tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
payload, err := m.buildAdminAdTemplate(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &payload, nil
}
func (m *Module) DeleteAdminAdTemplate(ctx context.Context, cmd DeleteAdminAdTemplateCommand) error {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return err
}
if cmd.ID == "" {
return status.Error(codes.NotFound, "Ad template not found")
}
err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).Where("ad_id = ?", cmd.ID).Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ?", cmd.ID).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return status.Error(codes.NotFound, "Ad template not found")
}
return status.Error(codes.Internal, "Failed to delete ad template")
}
return nil
}
func (m *Module) buildAdminAdTemplate(ctx context.Context, item *model.AdTemplate) (AdminAdTemplateView, error) {
if item == nil {
return AdminAdTemplateView{}, nil
}
var createdAt *string
if item.CreatedAt != nil {
formatted := item.CreatedAt.UTC().Format(time.RFC3339)
createdAt = &formatted
}
updated := item.UpdatedAt.UTC().Format(time.RFC3339)
updatedAt := &updated
ownerEmail, err := m.loadAdminUserEmail(ctx, item.UserID)
if err != nil {
return AdminAdTemplateView{}, err
}
return AdminAdTemplateView{ID: item.ID, UserID: item.UserID, Name: item.Name, Description: common.NullableTrimmedString(item.Description), VastTagURL: item.VastTagURL, AdFormat: common.StringValue(item.AdFormat), Duration: item.Duration, IsActive: common.BoolValue(item.IsActive), IsDefault: item.IsDefault, OwnerEmail: ownerEmail, CreatedAt: createdAt, UpdatedAt: updatedAt}, nil
}
func (m *Module) loadAdminUserEmail(ctx context.Context, userID string) (*string, error) {
var user model.User
if err := m.runtime.DB().WithContext(ctx).Select("id, email").Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return common.NullableTrimmedString(&user.Email), nil
}
func validateAdminAdTemplateInput(userID, name, vastTagURL, adFormat string, duration *int64) string {
if strings.TrimSpace(userID) == "" {
return "User ID is required"
}
if strings.TrimSpace(name) == "" || strings.TrimSpace(vastTagURL) == "" {
return "Name and VAST URL are required"
}
format := common.NormalizeAdFormat(adFormat)
if format == "mid-roll" && (duration == nil || *duration <= 0) {
return "Duration is required for mid-roll templates"
}
return ""
}