This commit is contained in:
2026-03-26 13:02:43 +00:00
parent a689f8b9da
commit eb7b519e49
64 changed files with 7081 additions and 5572 deletions

BIN
bin/api

Binary file not shown.

View File

@@ -0,0 +1,213 @@
package admin
import (
"context"
appv1 "stream.api/internal/gen/proto/app/v1"
adtemplatesmodule "stream.api/internal/modules/adtemplates"
dashboardmodule "stream.api/internal/modules/dashboard"
jobsmodule "stream.api/internal/modules/jobs"
paymentsmodule "stream.api/internal/modules/payments"
playerconfigsmodule "stream.api/internal/modules/playerconfigs"
plansmodule "stream.api/internal/modules/plans"
usersmodule "stream.api/internal/modules/users"
videosmodule "stream.api/internal/modules/videos"
)
type Handler struct {
appv1.UnimplementedAdminServiceServer
dashboard *dashboardmodule.Module
users *usersmodule.Module
videos *videosmodule.Module
payments *paymentsmodule.Module
plans *plansmodule.Module
adtemplates *adtemplatesmodule.Module
playerconfigs *playerconfigsmodule.Module
jobs *jobsmodule.Module
}
var _ appv1.AdminServiceServer = (*Handler)(nil)
func NewHandler(dashboard *dashboardmodule.Module, users *usersmodule.Module, videos *videosmodule.Module, payments *paymentsmodule.Module, plans *plansmodule.Module, adtemplates *adtemplatesmodule.Module, playerconfigs *playerconfigsmodule.Module, jobs *jobsmodule.Module) *Handler {
return &Handler{dashboard: dashboard, users: users, videos: videos, payments: payments, plans: plans, adtemplates: adtemplates, playerconfigs: playerconfigs, jobs: jobs}
}
func (h *Handler) GetAdminDashboard(ctx context.Context, req *appv1.GetAdminDashboardRequest) (*appv1.GetAdminDashboardResponse, error) {
return h.dashboard.GetAdminDashboard(ctx, req)
}
func (h *Handler) ListAdminUsers(ctx context.Context, req *appv1.ListAdminUsersRequest) (*appv1.ListAdminUsersResponse, error) {
payload, err := h.users.ListAdminUsers(ctx, usersmodule.ListAdminUsersQuery{Page: req.GetPage(), Limit: req.GetLimit(), Search: req.GetSearch(), Role: req.GetRole()})
if err != nil { return nil, err }
items := make([]*appv1.AdminUser, 0, len(payload.Items))
for _, item := range payload.Items { items = append(items, usersmodule.presentAdminUser(item)) }
return &appv1.ListAdminUsersResponse{Users: items, Total: payload.Total, Page: payload.Page, Limit: payload.Limit}, nil
}
func (h *Handler) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserRequest) (*appv1.GetAdminUserResponse, error) {
payload, err := h.users.GetAdminUser(ctx, usersmodule.GetAdminUserQuery{ID: req.GetId()})
if err != nil { return nil, err }
return &appv1.GetAdminUserResponse{User: usersmodule.presentAdminUserDetail(*payload)}, nil
}
func (h *Handler) CreateAdminUser(ctx context.Context, req *appv1.CreateAdminUserRequest) (*appv1.CreateAdminUserResponse, error) {
payload, err := h.users.CreateAdminUser(ctx, usersmodule.CreateAdminUserCommand{Email: req.GetEmail(), Password: req.GetPassword(), Username: req.Username, Role: req.GetRole(), PlanID: common.NullableTrimmedString(req.PlanId)})
if err != nil { return nil, err }
return &appv1.CreateAdminUserResponse{User: usersmodule.presentAdminUser(*payload)}, nil
}
func (h *Handler) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdminUserRequest) (*appv1.UpdateAdminUserResponse, error) {
var planValue **string
if req.PlanId != nil {
plan := common.NullableTrimmedString(req.PlanId)
planValue = &plan
}
payload, err := h.users.UpdateAdminUser(ctx, usersmodule.UpdateAdminUserCommand{ID: req.GetId(), Patch: usersmodule.UserPatch{Email: req.Email, Username: req.Username, Role: req.Role, PlanID: planValue, Password: req.Password}})
if err != nil { return nil, err }
return &appv1.UpdateAdminUserResponse{User: usersmodule.presentAdminUser(*payload)}, nil
}
func (h *Handler) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
payload, err := h.users.UpdateAdminUserReferralSettings(ctx, usersmodule.UpdateReferralSettingsCommand{ID: req.GetId(), RefUsername: req.RefUsername, ClearReferrer: req.ClearReferrer, ReferralEligible: req.ReferralEligible, ReferralRewardBps: req.ReferralRewardBps, ClearReferralRewardBps: req.ClearReferralRewardBps})
if err != nil { return nil, err }
return &appv1.UpdateAdminUserReferralSettingsResponse{User: usersmodule.presentAdminUserDetail(*payload)}, nil
}
func (h *Handler) UpdateAdminUserRole(ctx context.Context, req *appv1.UpdateAdminUserRoleRequest) (*appv1.UpdateAdminUserRoleResponse, error) {
role, err := h.users.UpdateAdminUserRole(ctx, usersmodule.UpdateUserRoleCommand{ID: req.GetId(), Role: req.GetRole()})
if err != nil { return nil, err }
return &appv1.UpdateAdminUserRoleResponse{Message: "Role updated", Role: role}, nil
}
func (h *Handler) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdminUserRequest) (*appv1.MessageResponse, error) {
if err := h.users.DeleteAdminUser(ctx, usersmodule.DeleteAdminUserCommand{ID: req.GetId()}); err != nil { return nil, err }
return &appv1.MessageResponse{Message: "User deleted"}, nil
}
func (h *Handler) ListAdminVideos(ctx context.Context, req *appv1.ListAdminVideosRequest) (*appv1.ListAdminVideosResponse, error) {
return videosmodule.NewHandler(h.videos).ListAdminVideos(ctx, req)
}
func (h *Handler) GetAdminVideo(ctx context.Context, req *appv1.GetAdminVideoRequest) (*appv1.GetAdminVideoResponse, error) {
return videosmodule.NewHandler(h.videos).GetAdminVideo(ctx, req)
}
func (h *Handler) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdminVideoRequest) (*appv1.CreateAdminVideoResponse, error) {
return videosmodule.NewHandler(h.videos).CreateAdminVideo(ctx, req)
}
func (h *Handler) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdminVideoRequest) (*appv1.UpdateAdminVideoResponse, error) {
return videosmodule.NewHandler(h.videos).UpdateAdminVideo(ctx, req)
}
func (h *Handler) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdminVideoRequest) (*appv1.MessageResponse, error) {
return videosmodule.NewHandler(h.videos).DeleteAdminVideo(ctx, req)
}
func (h *Handler) ListAdminPayments(ctx context.Context, req *appv1.ListAdminPaymentsRequest) (*appv1.ListAdminPaymentsResponse, error) {
return paymentsmodule.NewHandler(h.payments).ListAdminPayments(ctx, req)
}
func (h *Handler) GetAdminPayment(ctx context.Context, req *appv1.GetAdminPaymentRequest) (*appv1.GetAdminPaymentResponse, error) {
return paymentsmodule.NewHandler(h.payments).GetAdminPayment(ctx, req)
}
func (h *Handler) CreateAdminPayment(ctx context.Context, req *appv1.CreateAdminPaymentRequest) (*appv1.CreateAdminPaymentResponse, error) {
return paymentsmodule.NewHandler(h.payments).CreateAdminPayment(ctx, req)
}
func (h *Handler) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateAdminPaymentRequest) (*appv1.UpdateAdminPaymentResponse, error) {
return paymentsmodule.NewHandler(h.payments).UpdateAdminPayment(ctx, req)
}
func (h *Handler) ListAdminPlans(ctx context.Context, req *appv1.ListAdminPlansRequest) (*appv1.ListAdminPlansResponse, error) {
return plansmodule.NewHandler(h.plans).ListAdminPlans(ctx, req)
}
func (h *Handler) CreateAdminPlan(ctx context.Context, req *appv1.CreateAdminPlanRequest) (*appv1.CreateAdminPlanResponse, error) {
return plansmodule.NewHandler(h.plans).CreateAdminPlan(ctx, req)
}
func (h *Handler) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdminPlanRequest) (*appv1.UpdateAdminPlanResponse, error) {
return plansmodule.NewHandler(h.plans).UpdateAdminPlan(ctx, req)
}
func (h *Handler) DeleteAdminPlan(ctx context.Context, req *appv1.DeleteAdminPlanRequest) (*appv1.DeleteAdminPlanResponse, error) {
return plansmodule.NewHandler(h.plans).DeleteAdminPlan(ctx, req)
}
func (h *Handler) ListAdminAdTemplates(ctx context.Context, req *appv1.ListAdminAdTemplatesRequest) (*appv1.ListAdminAdTemplatesResponse, error) {
return adtemplatesmodule.NewHandler(h.adtemplates).ListAdminAdTemplates(ctx, req)
}
func (h *Handler) GetAdminAdTemplate(ctx context.Context, req *appv1.GetAdminAdTemplateRequest) (*appv1.GetAdminAdTemplateResponse, error) {
return adtemplatesmodule.NewHandler(h.adtemplates).GetAdminAdTemplate(ctx, req)
}
func (h *Handler) CreateAdminAdTemplate(ctx context.Context, req *appv1.CreateAdminAdTemplateRequest) (*appv1.CreateAdminAdTemplateResponse, error) {
return adtemplatesmodule.NewHandler(h.adtemplates).CreateAdminAdTemplate(ctx, req)
}
func (h *Handler) UpdateAdminAdTemplate(ctx context.Context, req *appv1.UpdateAdminAdTemplateRequest) (*appv1.UpdateAdminAdTemplateResponse, error) {
return adtemplatesmodule.NewHandler(h.adtemplates).UpdateAdminAdTemplate(ctx, req)
}
func (h *Handler) DeleteAdminAdTemplate(ctx context.Context, req *appv1.DeleteAdminAdTemplateRequest) (*appv1.MessageResponse, error) {
return adtemplatesmodule.NewHandler(h.adtemplates).DeleteAdminAdTemplate(ctx, req)
}
func (h *Handler) ListAdminPlayerConfigs(ctx context.Context, req *appv1.ListAdminPlayerConfigsRequest) (*appv1.ListAdminPlayerConfigsResponse, error) {
return playerconfigsmodule.NewHandler(h.playerconfigs).ListAdminPlayerConfigs(ctx, req)
}
func (h *Handler) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAdminPlayerConfigRequest) (*appv1.GetAdminPlayerConfigResponse, error) {
return playerconfigsmodule.NewHandler(h.playerconfigs).GetAdminPlayerConfig(ctx, req)
}
func (h *Handler) CreateAdminPlayerConfig(ctx context.Context, req *appv1.CreateAdminPlayerConfigRequest) (*appv1.CreateAdminPlayerConfigResponse, error) {
return playerconfigsmodule.NewHandler(h.playerconfigs).CreateAdminPlayerConfig(ctx, req)
}
func (h *Handler) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.UpdateAdminPlayerConfigRequest) (*appv1.UpdateAdminPlayerConfigResponse, error) {
return playerconfigsmodule.NewHandler(h.playerconfigs).UpdateAdminPlayerConfig(ctx, req)
}
func (h *Handler) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.DeleteAdminPlayerConfigRequest) (*appv1.MessageResponse, error) {
return playerconfigsmodule.NewHandler(h.playerconfigs).DeleteAdminPlayerConfig(ctx, req)
}
func (h *Handler) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJobsRequest) (*appv1.ListAdminJobsResponse, error) {
return jobsmodule.NewHandler(h.jobs).ListAdminJobs(ctx, req)
}
func (h *Handler) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobRequest) (*appv1.GetAdminJobResponse, error) {
return jobsmodule.NewHandler(h.jobs).GetAdminJob(ctx, req)
}
func (h *Handler) GetAdminJobLogs(ctx context.Context, req *appv1.GetAdminJobLogsRequest) (*appv1.GetAdminJobLogsResponse, error) {
return jobsmodule.NewHandler(h.jobs).GetAdminJobLogs(ctx, req)
}
func (h *Handler) CreateAdminJob(ctx context.Context, req *appv1.CreateAdminJobRequest) (*appv1.CreateAdminJobResponse, error) {
return jobsmodule.NewHandler(h.jobs).CreateAdminJob(ctx, req)
}
func (h *Handler) CancelAdminJob(ctx context.Context, req *appv1.CancelAdminJobRequest) (*appv1.CancelAdminJobResponse, error) {
return jobsmodule.NewHandler(h.jobs).CancelAdminJob(ctx, req)
}
func (h *Handler) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJobRequest) (*appv1.RetryAdminJobResponse, error) {
return jobsmodule.NewHandler(h.jobs).RetryAdminJob(ctx, req)
}
func (h *Handler) ListAdminAgents(ctx context.Context, req *appv1.ListAdminAgentsRequest) (*appv1.ListAdminAgentsResponse, error) {
return jobsmodule.NewHandler(h.jobs).ListAdminAgents(ctx, req)
}
func (h *Handler) RestartAdminAgent(ctx context.Context, req *appv1.RestartAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
return jobsmodule.NewHandler(h.jobs).RestartAdminAgent(ctx, req)
}
func (h *Handler) UpdateAdminAgent(ctx context.Context, req *appv1.UpdateAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
return jobsmodule.NewHandler(h.jobs).UpdateAdminAgent(ctx, req)
}

View File

@@ -0,0 +1,151 @@
package adtemplates
import (
"context"
"strings"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
type Handler struct {
appv1.UnimplementedAdTemplatesServiceServer
module *Module
}
var _ appv1.AdTemplatesServiceServer = (*Handler)(nil)
func NewHandler(module *Module) *Handler { return &Handler{module: module} }
func (h *Handler) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.ListAdTemplates(ctx, ListAdTemplatesQuery{UserID: result.UserID})
if err != nil {
return nil, err
}
return presentListAdTemplatesResponse(payload), nil
}
func (h *Handler) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
if err := common.EnsurePaidPlan(result.User); err != nil {
return nil, err
}
payload, err := h.module.CreateAdTemplate(ctx, CreateAdTemplateCommand{
UserID: result.UserID,
Name: req.GetName(),
Description: req.Description,
VastTagURL: req.GetVastTagUrl(),
AdFormat: req.GetAdFormat(),
Duration: req.Duration,
IsActive: req.IsActive,
IsDefault: req.IsDefault,
})
if err != nil {
return nil, err
}
return presentCreateAdTemplateResponse(*payload), nil
}
func (h *Handler) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
if err := common.EnsurePaidPlan(result.User); err != nil {
return nil, err
}
payload, err := h.module.UpdateAdTemplate(ctx, UpdateAdTemplateCommand{
UserID: result.UserID,
ID: strings.TrimSpace(req.GetId()),
Name: req.GetName(),
Description: req.Description,
VastTagURL: req.GetVastTagUrl(),
AdFormat: req.GetAdFormat(),
Duration: req.Duration,
IsActive: req.IsActive,
IsDefault: req.IsDefault,
})
if err != nil {
return nil, err
}
return presentUpdateAdTemplateResponse(*payload), nil
}
func (h *Handler) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
if err := common.EnsurePaidPlan(result.User); err != nil {
return nil, err
}
if err := h.module.DeleteAdTemplate(ctx, DeleteAdTemplateCommand{UserID: result.UserID, ID: strings.TrimSpace(req.GetId())}); err != nil {
return nil, err
}
return &appv1.MessageResponse{Message: "Ad template deleted"}, nil
}
func (h *Handler) ListAdminAdTemplates(ctx context.Context, req *appv1.ListAdminAdTemplatesRequest) (*appv1.ListAdminAdTemplatesResponse, error) {
payload, err := h.module.ListAdminAdTemplates(ctx, ListAdminAdTemplatesQuery{Page: req.GetPage(), Limit: req.GetLimit(), Search: req.Search, UserID: req.UserId})
if err != nil {
return nil, err
}
return presentListAdminAdTemplatesResponse(payload), nil
}
func (h *Handler) GetAdminAdTemplate(ctx context.Context, req *appv1.GetAdminAdTemplateRequest) (*appv1.GetAdminAdTemplateResponse, error) {
payload, err := h.module.GetAdminAdTemplate(ctx, GetAdminAdTemplateQuery{ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, err
}
return presentGetAdminAdTemplateResponse(*payload), nil
}
func (h *Handler) CreateAdminAdTemplate(ctx context.Context, req *appv1.CreateAdminAdTemplateRequest) (*appv1.CreateAdminAdTemplateResponse, error) {
payload, err := h.module.CreateAdminAdTemplate(ctx, CreateAdminAdTemplateCommand{
UserID: strings.TrimSpace(req.GetUserId()),
Name: req.GetName(),
Description: req.Description,
VastTagURL: req.GetVastTagUrl(),
AdFormat: req.GetAdFormat(),
Duration: req.Duration,
IsActive: req.GetIsActive(),
IsDefault: req.GetIsDefault(),
})
if err != nil {
return nil, err
}
return presentCreateAdminAdTemplateResponse(*payload), nil
}
func (h *Handler) UpdateAdminAdTemplate(ctx context.Context, req *appv1.UpdateAdminAdTemplateRequest) (*appv1.UpdateAdminAdTemplateResponse, error) {
payload, err := h.module.UpdateAdminAdTemplate(ctx, UpdateAdminAdTemplateCommand{
ID: strings.TrimSpace(req.GetId()),
UserID: strings.TrimSpace(req.GetUserId()),
Name: req.GetName(),
Description: req.Description,
VastTagURL: req.GetVastTagUrl(),
AdFormat: req.GetAdFormat(),
Duration: req.Duration,
IsActive: req.GetIsActive(),
IsDefault: req.GetIsDefault(),
})
if err != nil {
return nil, err
}
return presentUpdateAdminAdTemplateResponse(*payload), nil
}
func (h *Handler) DeleteAdminAdTemplate(ctx context.Context, req *appv1.DeleteAdminAdTemplateRequest) (*appv1.MessageResponse, error) {
if err := h.module.DeleteAdminAdTemplate(ctx, DeleteAdminAdTemplateCommand{ID: strings.TrimSpace(req.GetId())}); err != nil {
return nil, err
}
return &appv1.MessageResponse{Message: "Ad template deleted"}, nil
}

View File

@@ -0,0 +1,364 @@
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 ""
}

View File

@@ -0,0 +1,85 @@
package adtemplates
import (
"time"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
"google.golang.org/protobuf/types/known/timestamppb"
)
func presentAdTemplate(view AdTemplateView) *appv1.AdTemplate {
return common.ToProtoAdTemplate(view.Template)
}
func presentListAdTemplatesResponse(result *ListAdTemplatesResult) *appv1.ListAdTemplatesResponse {
items := make([]*appv1.AdTemplate, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentAdTemplate(item))
}
return &appv1.ListAdTemplatesResponse{Templates: items}
}
func presentCreateAdTemplateResponse(view AdTemplateView) *appv1.CreateAdTemplateResponse {
return &appv1.CreateAdTemplateResponse{Template: presentAdTemplate(view)}
}
func presentUpdateAdTemplateResponse(view AdTemplateView) *appv1.UpdateAdTemplateResponse {
return &appv1.UpdateAdTemplateResponse{Template: presentAdTemplate(view)}
}
func presentAdminAdTemplate(view AdminAdTemplateView) *appv1.AdminAdTemplate {
return &appv1.AdminAdTemplate{
Id: view.ID,
UserId: view.UserID,
Name: view.Name,
Description: view.Description,
VastTagUrl: view.VastTagURL,
AdFormat: view.AdFormat,
Duration: view.Duration,
IsActive: view.IsActive,
IsDefault: view.IsDefault,
OwnerEmail: view.OwnerEmail,
CreatedAt: parseRFC3339ToProto(view.CreatedAt),
UpdatedAt: parseRFC3339ToProto(view.UpdatedAt),
}
}
func presentListAdminAdTemplatesResponse(result *ListAdminAdTemplatesResult) *appv1.ListAdminAdTemplatesResponse {
items := make([]*appv1.AdminAdTemplate, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentAdminAdTemplate(item))
}
return &appv1.ListAdminAdTemplatesResponse{Templates: items, Total: result.Total, Page: result.Page, Limit: result.Limit}
}
func presentGetAdminAdTemplateResponse(view AdminAdTemplateView) *appv1.GetAdminAdTemplateResponse {
return &appv1.GetAdminAdTemplateResponse{Template: presentAdminAdTemplate(view)}
}
func presentCreateAdminAdTemplateResponse(view AdminAdTemplateView) *appv1.CreateAdminAdTemplateResponse {
return &appv1.CreateAdminAdTemplateResponse{Template: presentAdminAdTemplate(view)}
}
func presentUpdateAdminAdTemplateResponse(view AdminAdTemplateView) *appv1.UpdateAdminAdTemplateResponse {
return &appv1.UpdateAdminAdTemplateResponse{Template: presentAdminAdTemplate(view)}
}
func parseRFC3339ToProto(value *string) *timestamppb.Timestamp {
if value == nil || *value == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, *value)
if err != nil {
return nil
}
return timestamppb.New(parsed.UTC())
}
func parseRFC3339ToTimePointer(value *time.Time) *string {
if value == nil {
return nil
}
formatted := value.UTC().Format(time.RFC3339)
return &formatted
}

View File

@@ -0,0 +1,103 @@
package adtemplates
import "stream.api/internal/database/model"
type AdTemplateView struct {
Template *model.AdTemplate
}
type ListAdTemplatesQuery struct {
UserID string
}
type ListAdTemplatesResult struct {
Items []AdTemplateView
}
type CreateAdTemplateCommand struct {
UserID string
Name string
Description *string
VastTagURL string
AdFormat string
Duration *int32
IsActive *bool
IsDefault *bool
}
type UpdateAdTemplateCommand struct {
UserID string
ID string
Name string
Description *string
VastTagURL string
AdFormat string
Duration *int32
IsActive *bool
IsDefault *bool
}
type DeleteAdTemplateCommand struct {
UserID string
ID string
}
type AdminAdTemplateView struct {
ID string
UserID string
Name string
Description *string
VastTagURL string
AdFormat string
Duration *int64
IsActive bool
IsDefault bool
OwnerEmail *string
CreatedAt *string
UpdatedAt *string
}
type ListAdminAdTemplatesQuery struct {
Page int32
Limit int32
Search *string
UserID *string
}
type ListAdminAdTemplatesResult struct {
Items []AdminAdTemplateView
Total int64
Page int32
Limit int32
}
type GetAdminAdTemplateQuery struct {
ID string
}
type CreateAdminAdTemplateCommand struct {
UserID string
Name string
Description *string
VastTagURL string
AdFormat string
Duration *int64
IsActive bool
IsDefault bool
}
type UpdateAdminAdTemplateCommand struct {
ID string
UserID string
Name string
Description *string
VastTagURL string
AdFormat string
Duration *int64
IsActive bool
IsDefault bool
}
type DeleteAdminAdTemplateCommand struct {
ID string
}

View File

@@ -0,0 +1,50 @@
package auth
import (
"context"
appv1 "stream.api/internal/gen/proto/app/v1"
)
type Handler struct {
appv1.UnimplementedAuthServiceServer
module *Module
}
var _ appv1.AuthServiceServer = (*Handler)(nil)
func NewHandler(module *Module) *Handler {
return &Handler{module: module}
}
func (h *Handler) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) {
return h.module.Login(ctx, req)
}
func (h *Handler) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
return h.module.Register(ctx, req)
}
func (h *Handler) Logout(ctx context.Context, req *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
return h.module.Logout(ctx, req)
}
func (h *Handler) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) {
return h.module.ChangePassword(ctx, req)
}
func (h *Handler) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) {
return h.module.ForgotPassword(ctx, req)
}
func (h *Handler) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) {
return h.module.ResetPassword(ctx, req)
}
func (h *Handler) GetGoogleLoginUrl(ctx context.Context, req *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) {
return h.module.GetGoogleLoginURL(ctx, req)
}
func (h *Handler) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) {
return h.module.CompleteGoogleLogin(ctx, req)
}

View File

@@ -1,4 +1,4 @@
package app package auth
import ( import (
"context" "context"
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -17,15 +16,25 @@ import (
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/database/query" "stream.api/internal/database/query"
appv1 "stream.api/internal/gen/proto/app/v1" appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
usersmodule "stream.api/internal/modules/users"
) )
func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) { type Module struct {
runtime *common.Runtime
users *usersmodule.Module
}
func New(runtime *common.Runtime, users *usersmodule.Module) *Module {
return &Module{runtime: runtime, users: users}
}
func (m *Module) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) {
email := strings.TrimSpace(req.GetEmail()) email := strings.TrimSpace(req.GetEmail())
password := req.GetPassword() password := req.GetPassword()
if email == "" || password == "" { if email == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Email and password are required") return nil, status.Error(codes.InvalidArgument, "Email and password are required")
} }
u := query.User u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil { if err != nil {
@@ -37,18 +46,17 @@ func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil {
return nil, status.Error(codes.Unauthenticated, "Invalid credentials") return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
} }
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
if err := s.issueSessionCookies(ctx, user); err != nil {
return nil, err return nil, err
} }
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
payload, err := buildUserPayload(ctx, s.db, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return &appv1.LoginResponse{User: toProtoUser(payload)}, nil return &appv1.LoginResponse{User: common.ToProtoUser(payload)}, nil
} }
func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
func (m *Module) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
email := strings.TrimSpace(req.GetEmail()) email := strings.TrimSpace(req.GetEmail())
username := strings.TrimSpace(req.GetUsername()) username := strings.TrimSpace(req.GetUsername())
password := req.GetPassword() password := req.GetPassword()
@@ -56,55 +64,44 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest)
if email == "" || username == "" || password == "" { if email == "" || username == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Username, email and password are required") return nil, status.Error(codes.InvalidArgument, "Username, email and password are required")
} }
u := query.User u := query.User
count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count() count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count()
if err != nil { if err != nil {
s.logger.Error("Failed to check existing user", "error", err) m.runtime.Logger().Error("Failed to check existing user", "error", err)
return nil, status.Error(codes.Internal, "Failed to register") return nil, status.Error(codes.Internal, "Failed to register")
} }
if count > 0 { if count > 0 {
return nil, status.Error(codes.InvalidArgument, "Email already registered") return nil, status.Error(codes.InvalidArgument, "Email already registered")
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to register") return nil, status.Error(codes.Internal, "Failed to register")
} }
referrerID, err := m.users.ResolveSignupReferrerID(ctx, refUsername, username)
referrerID, err := s.resolveSignupReferrerID(ctx, refUsername, username)
if err != nil { if err != nil {
s.logger.Error("Failed to resolve signup referrer", "error", err) m.runtime.Logger().Error("Failed to resolve signup referrer", "error", err)
return nil, status.Error(codes.Internal, "Failed to register") return nil, status.Error(codes.Internal, "Failed to register")
} }
role := "USER" role := "USER"
passwordHash := string(hashedPassword) passwordHash := string(hashedPassword)
newUser := &model.User{ newUser := &model.User{ID: uuid.New().String(), Email: email, Password: &passwordHash, Username: &username, Role: &role, ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true)}
ID: uuid.New().String(),
Email: email,
Password: &passwordHash,
Username: &username,
Role: &role,
ReferredByUserID: referrerID,
ReferralEligible: model.BoolPtr(true),
}
if err := u.WithContext(ctx).Create(newUser); err != nil { if err := u.WithContext(ctx).Create(newUser); err != nil {
s.logger.Error("Failed to create user", "error", err) m.runtime.Logger().Error("Failed to create user", "error", err)
return nil, status.Error(codes.Internal, "Failed to register") return nil, status.Error(codes.Internal, "Failed to register")
} }
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), newUser)
payload, err := buildUserPayload(ctx, s.db, newUser)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return &appv1.RegisterResponse{User: toProtoUser(payload)}, nil return &appv1.RegisterResponse{User: common.ToProtoUser(payload)}, nil
} }
func (s *appServices) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
return messageResponse("Logged out"), nil func (m *Module) Logout(context.Context, *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
return common.MessageResponse("Logged out"), nil
} }
func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) func (m *Module) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) {
result, err := m.runtime.Authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -126,191 +123,154 @@ func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePassw
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to change password") return nil, status.Error(codes.Internal, "Failed to change password")
} }
if _, err := query.User.WithContext(ctx). if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(result.UserID)).Update(query.User.Password, string(newHash)); err != nil {
Where(query.User.ID.Eq(result.UserID)). m.runtime.Logger().Error("Failed to change password", "error", err)
Update(query.User.Password, string(newHash)); err != nil {
s.logger.Error("Failed to change password", "error", err)
return nil, status.Error(codes.Internal, "Failed to change password") return nil, status.Error(codes.Internal, "Failed to change password")
} }
return messageResponse("Password changed successfully"), nil return common.MessageResponse("Password changed successfully"), nil
} }
func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) {
func (m *Module) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) {
email := strings.TrimSpace(req.GetEmail()) email := strings.TrimSpace(req.GetEmail())
if email == "" { if email == "" {
return nil, status.Error(codes.InvalidArgument, "Email is required") return nil, status.Error(codes.InvalidArgument, "Email is required")
} }
u := query.User u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil { if err != nil {
return messageResponse("If email exists, a reset link has been sent"), nil return common.MessageResponse("If email exists, a reset link has been sent"), nil
} }
tokenID := uuid.New().String() tokenID := uuid.New().String()
if err := s.cache.Set(ctx, "reset_pw:"+tokenID, user.ID, 15*time.Minute); err != nil { if err := m.runtime.Cache().Set(ctx, "reset_pw:"+tokenID, user.ID, 15*60*1000000000); err != nil {
s.logger.Error("Failed to set reset token", "error", err) m.runtime.Logger().Error("Failed to set reset token", "error", err)
return nil, status.Error(codes.Internal, "Try again later") return nil, status.Error(codes.Internal, "Try again later")
} }
m.runtime.Logger().Info("Generated password reset token", "email", email, "token", tokenID)
s.logger.Info("Generated password reset token", "email", email, "token", tokenID) return common.MessageResponse("If email exists, a reset link has been sent"), nil
return messageResponse("If email exists, a reset link has been sent"), nil
} }
func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) {
func (m *Module) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) {
resetToken := strings.TrimSpace(req.GetToken()) resetToken := strings.TrimSpace(req.GetToken())
newPassword := req.GetNewPassword() newPassword := req.GetNewPassword()
if resetToken == "" || newPassword == "" { if resetToken == "" || newPassword == "" {
return nil, status.Error(codes.InvalidArgument, "Token and new password are required") return nil, status.Error(codes.InvalidArgument, "Token and new password are required")
} }
userID, err := m.runtime.Cache().Get(ctx, "reset_pw:"+resetToken)
userID, err := s.cache.Get(ctx, "reset_pw:"+resetToken)
if err != nil || strings.TrimSpace(userID) == "" { if err != nil || strings.TrimSpace(userID) == "" {
return nil, status.Error(codes.InvalidArgument, "Invalid or expired token") return nil, status.Error(codes.InvalidArgument, "Invalid or expired token")
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Internal error") return nil, status.Error(codes.Internal, "Internal error")
} }
if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(userID)).Update(query.User.Password, string(hashedPassword)); err != nil {
if _, err := query.User.WithContext(ctx). m.runtime.Logger().Error("Failed to update password", "error", err)
Where(query.User.ID.Eq(userID)).
Update(query.User.Password, string(hashedPassword)); err != nil {
s.logger.Error("Failed to update password", "error", err)
return nil, status.Error(codes.Internal, "Failed to update password") return nil, status.Error(codes.Internal, "Failed to update password")
} }
_ = m.runtime.Cache().Del(ctx, "reset_pw:"+resetToken)
_ = s.cache.Del(ctx, "reset_pw:"+resetToken) return common.MessageResponse("Password reset successfully"), nil
return messageResponse("Password reset successfully"), nil
} }
func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) {
if err := s.authenticator.RequireInternalCall(ctx); err != nil { func (m *Module) GetGoogleLoginURL(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) {
if err := m.runtime.Authenticator().RequireInternalCall(ctx); err != nil {
return nil, err return nil, err
} }
if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" { googleOauth := m.runtime.GoogleOauth()
if googleOauth == nil || strings.TrimSpace(googleOauth.ClientID) == "" || strings.TrimSpace(googleOauth.RedirectURL) == "" {
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured") return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
} }
state, err := common.GenerateOAuthState()
state, err := generateOAuthState()
if err != nil { if err != nil {
s.logger.Error("Failed to generate Google OAuth state", "error", err) m.runtime.Logger().Error("Failed to generate Google OAuth state", "error", err)
return nil, status.Error(codes.Internal, "Failed to start Google login") return nil, status.Error(codes.Internal, "Failed to start Google login")
} }
if err := m.runtime.Cache().Set(ctx, common.GoogleOAuthStateCacheKey(state), "1", m.runtime.GoogleStateTTL()); err != nil {
if err := s.cache.Set(ctx, googleOAuthStateCacheKey(state), "1", s.googleStateTTL); err != nil { m.runtime.Logger().Error("Failed to persist Google OAuth state", "error", err)
s.logger.Error("Failed to persist Google OAuth state", "error", err)
return nil, status.Error(codes.Internal, "Failed to start Google login") return nil, status.Error(codes.Internal, "Failed to start Google login")
} }
loginURL := googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil
} }
func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) {
if err := s.authenticator.RequireInternalCall(ctx); err != nil { func (m *Module) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) {
if err := m.runtime.Authenticator().RequireInternalCall(ctx); err != nil {
return nil, err return nil, err
} }
if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" { googleOauth := m.runtime.GoogleOauth()
if googleOauth == nil || strings.TrimSpace(googleOauth.ClientID) == "" || strings.TrimSpace(googleOauth.RedirectURL) == "" {
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured") return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
} }
code := strings.TrimSpace(req.GetCode()) code := strings.TrimSpace(req.GetCode())
if code == "" { if code == "" {
return nil, status.Error(codes.InvalidArgument, "Code is required") return nil, status.Error(codes.InvalidArgument, "Code is required")
} }
tokenResp, err := googleOauth.Exchange(ctx, code)
tokenResp, err := s.googleOauth.Exchange(ctx, code)
if err != nil { if err != nil {
s.logger.Error("Failed to exchange Google OAuth token", "error", err) m.runtime.Logger().Error("Failed to exchange Google OAuth token", "error", err)
return nil, status.Error(codes.Unauthenticated, "exchange_failed") return nil, status.Error(codes.Unauthenticated, "exchange_failed")
} }
client := googleOauth.Client(ctx, tokenResp)
client := s.googleOauth.Client(ctx, tokenResp) resp, err := client.Get(m.runtime.GoogleUserInfoURL())
resp, err := client.Get(s.googleUserInfoURL)
if err != nil { if err != nil {
s.logger.Error("Failed to fetch Google user info", "error", err) m.runtime.Logger().Error("Failed to fetch Google user info", "error", err)
return nil, status.Error(codes.Unauthenticated, "userinfo_failed") return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
s.logger.Error("Google user info returned non-200", "status", resp.StatusCode) m.runtime.Logger().Error("Google user info returned non-200", "status", resp.StatusCode)
return nil, status.Error(codes.Unauthenticated, "userinfo_failed") return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
} }
var googleUser struct { ID, Email, Name, Picture string }
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil { if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil {
s.logger.Error("Failed to decode Google user info", "error", err) m.runtime.Logger().Error("Failed to decode Google user info", "error", err)
return nil, status.Error(codes.Internal, "userinfo_parse_failed") return nil, status.Error(codes.Internal, "userinfo_parse_failed")
} }
email := strings.TrimSpace(strings.ToLower(googleUser.Email)) email := strings.TrimSpace(strings.ToLower(googleUser.Email))
refUsername := strings.TrimSpace(req.GetRefUsername()) refUsername := strings.TrimSpace(req.GetRefUsername())
if email == "" { if email == "" {
return nil, status.Error(codes.InvalidArgument, "missing_email") return nil, status.Error(codes.InvalidArgument, "missing_email")
} }
u := query.User u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil { if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load Google user", "error", err) m.runtime.Logger().Error("Failed to load Google user", "error", err)
return nil, status.Error(codes.Internal, "load_user_failed") return nil, status.Error(codes.Internal, "load_user_failed")
} }
referrerID, resolveErr := s.resolveSignupReferrerID(ctx, refUsername, googleUser.Name) referrerID, resolveErr := m.users.ResolveSignupReferrerID(ctx, refUsername, googleUser.Name)
if resolveErr != nil { if resolveErr != nil {
s.logger.Error("Failed to resolve Google signup referrer", "error", resolveErr) m.runtime.Logger().Error("Failed to resolve Google signup referrer", "error", resolveErr)
return nil, status.Error(codes.Internal, "create_user_failed") return nil, status.Error(codes.Internal, "create_user_failed")
} }
role := "USER" role := "USER"
user = &model.User{ user = &model.User{ID: uuid.New().String(), Email: email, Username: common.StringPointerOrNil(googleUser.Name), GoogleID: common.StringPointerOrNil(googleUser.ID), Avatar: common.StringPointerOrNil(googleUser.Picture), Role: &role, ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true)}
ID: uuid.New().String(),
Email: email,
Username: stringPointerOrNil(googleUser.Name),
GoogleID: stringPointerOrNil(googleUser.ID),
Avatar: stringPointerOrNil(googleUser.Picture),
Role: &role,
ReferredByUserID: referrerID,
ReferralEligible: model.BoolPtr(true),
}
if err := u.WithContext(ctx).Create(user); err != nil { if err := u.WithContext(ctx).Create(user); err != nil {
s.logger.Error("Failed to create Google user", "error", err) m.runtime.Logger().Error("Failed to create Google user", "error", err)
return nil, status.Error(codes.Internal, "create_user_failed") return nil, status.Error(codes.Internal, "create_user_failed")
} }
} else { } else {
updates := map[string]interface{}{} updates := map[string]any{}
if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" { if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" { updates["google_id"] = googleUser.ID }
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 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 len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { if err := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
s.logger.Error("Failed to update Google user", "error", err) m.runtime.Logger().Error("Failed to update Google user", "error", err)
return nil, status.Error(codes.Internal, "update_user_failed") return nil, status.Error(codes.Internal, "update_user_failed")
} }
user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First() user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First()
if err != nil { if err != nil {
s.logger.Error("Failed to reload Google user", "error", err) m.runtime.Logger().Error("Failed to reload Google user", "error", err)
return nil, status.Error(codes.Internal, "reload_user_failed") return nil, status.Error(codes.Internal, "reload_user_failed")
} }
} }
} }
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
if err := s.issueSessionCookies(ctx, user); err != nil {
return nil, status.Error(codes.Internal, "session_failed") return nil, status.Error(codes.Internal, "session_failed")
} }
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
payload, err := buildUserPayload(ctx, s.db, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return &appv1.CompleteGoogleLoginResponse{User: toProtoUser(payload)}, nil return &appv1.CompleteGoogleLoginResponse{User: common.ToProtoUser(payload)}, nil
} }

View File

@@ -0,0 +1,19 @@
package auth
import "stream.api/internal/database/model"
type LoginCommand struct {
Email string
Password string
}
type RegisterCommand struct {
Email string
Username string
Password string
RefUsername string
}
type ChangePasswordCommand struct {
User *model.User
}

View File

@@ -0,0 +1,753 @@
package common
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
videodomain "stream.api/internal/video/runtime/domain"
runtimeservices "stream.api/internal/video/runtime/services"
)
type APIErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func AdminPageLimitOffset(pageValue int32, limitValue int32) (int32, int32, int) {
page := pageValue
if page < 1 {
page = 1
}
limit := limitValue
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := int((page - 1) * limit)
return page, limit, offset
}
func BuildAdminJob(job *videodomain.Job) *appv1.AdminJob {
if job == nil {
return nil
}
return &appv1.AdminJob{
Id: job.ID,
Status: string(job.Status),
Priority: int32(job.Priority),
UserId: job.UserID,
Name: job.Name,
TimeLimit: job.TimeLimit,
InputUrl: job.InputURL,
OutputUrl: job.OutputURL,
TotalDuration: job.TotalDuration,
CurrentTime: job.CurrentTime,
Progress: job.Progress,
AgentId: job.AgentID,
Logs: job.Logs,
Config: job.Config,
Cancelled: job.Cancelled,
RetryCount: int32(job.RetryCount),
MaxRetries: int32(job.MaxRetries),
CreatedAt: timestamppb.New(job.CreatedAt),
UpdatedAt: timestamppb.New(job.UpdatedAt),
VideoId: StringPointerOrNil(job.VideoID),
}
}
func BuildAdminAgent(agent *runtimeservices.AgentWithStats) *appv1.AdminAgent {
if agent == nil || agent.Agent == nil {
return nil
}
return &appv1.AdminAgent{
Id: agent.ID,
Name: agent.Name,
Platform: agent.Platform,
Backend: agent.Backend,
Version: agent.Version,
Capacity: agent.Capacity,
Status: string(agent.Status),
Cpu: agent.CPU,
Ram: agent.RAM,
LastHeartbeat: timestamppb.New(agent.LastHeartbeat),
CreatedAt: timestamppb.New(agent.CreatedAt),
UpdatedAt: timestamppb.New(agent.UpdatedAt),
}
}
func NormalizeAdminRoleValue(value string) string {
role := strings.ToUpper(strings.TrimSpace(value))
if role == "" {
return "USER"
}
return role
}
func IsValidAdminRoleValue(role string) bool {
switch NormalizeAdminRoleValue(role) {
case "USER", "ADMIN", "BLOCK":
return true
default:
return false
}
}
func ReferralUserEligible(user *model.User) bool {
if user == nil || user.ReferralEligible == nil {
return true
}
return *user.ReferralEligible
}
func EffectiveReferralRewardBps(value *int32) int32 {
if value == nil {
return DefaultReferralRewardBps
}
if *value < 0 {
return 0
}
if *value > 10000 {
return 10000
}
return *value
}
func ReferralRewardBpsToPercent(value int32) float64 {
return float64(value) / 100
}
func ReferralRewardProcessed(user *model.User) bool {
if user == nil {
return false
}
if user.ReferralRewardGrantedAt != nil {
return true
}
if user.ReferralRewardPaymentID != nil && strings.TrimSpace(*user.ReferralRewardPaymentID) != "" {
return true
}
return false
}
func BoolValue(value *bool) bool {
return value != nil && *value
}
func StringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func NullableTrimmedStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func NullableTrimmedString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func Int32PtrToInt64Ptr(value *int32) *int64 {
if value == nil {
return nil
}
converted := int64(*value)
return &converted
}
func Int64PtrToInt32Ptr(value *int64) *int32 {
if value == nil {
return nil
}
converted := int32(*value)
return &converted
}
func Int32Ptr(value int32) *int32 {
return &value
}
func ProtoStringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func SafeRole(role *string) string {
if role == nil || strings.TrimSpace(*role) == "" {
return "USER"
}
return *role
}
func StringPointerOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func TimeToProto(value *time.Time) *timestamppb.Timestamp {
if value == nil {
return nil
}
return timestamppb.New(value.UTC())
}
func MaxFloat(left, 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 any) string {
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}
func NormalizeNotificationType(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"
}
}
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
}
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 AdTemplateIsActive(value *bool) bool {
return value == nil || *value
}
func PlayerConfigIsActive(value *bool) bool {
return value == nil || *value
}
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 UnsetDefaultPlayerConfigs(tx *gorm.DB, userID, excludeID string) error {
query := tx.Model(&model.PlayerConfig{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func NormalizePaymentStatus(statusValue *string) string {
value := strings.ToLower(strings.TrimSpace(StringValue(statusValue)))
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 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 StatusErrorWithBody(ctx context.Context, grpcCode codes.Code, httpCode int, message string, data any) error {
body := APIErrorBody{Code: httpCode, Message: message, Data: data}
encoded, err := json.Marshal(body)
if err == nil {
_ = grpc.SetTrailer(ctx, metadata.Pairs("x-error-body", string(encoded)))
}
return status.Error(grpcCode, message)
}
func IsAllowedTermMonths(value int32) bool {
_, ok := AllowedTermMonths[value]
return ok
}
func LockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) {
if tx.Dialector.Name() == "sqlite" {
res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", userID)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
}
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 MessageResponse(message string) *appv1.MessageResponse {
return &appv1.MessageResponse{Message: message}
}
func EnsurePaidPlan(user *model.User) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" {
return status.Error(codes.PermissionDenied, AdTemplateUpgradeRequiredMessage)
}
return nil
}
func PlayerConfigActionAllowed(user *model.User, configCount int64, action string) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID != nil && strings.TrimSpace(*user.PlanID) != "" {
return nil
}
switch action {
case "create":
if configCount > 0 {
return status.Error(codes.FailedPrecondition, PlayerConfigFreePlanLimitMessage)
}
case "update", "set-default", "toggle-active":
if configCount > 1 {
return status.Error(codes.FailedPrecondition, PlayerConfigFreePlanReconciliationMessage)
}
}
return nil
}
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 ToProtoVideo(item *model.Video, jobID ...string) *appv1.Video {
if item == nil {
return nil
}
statusValue := StringValue(item.Status)
if statusValue == "" {
statusValue = "ready"
}
var linkedJobID *string
if len(jobID) > 0 {
linkedJobID = StringPointerOrNil(jobID[0])
}
return &appv1.Video{
Id: item.ID,
UserId: item.UserID,
Title: item.Title,
Description: item.Description,
Url: item.URL,
Status: strings.ToLower(statusValue),
Size: item.Size,
Duration: item.Duration,
Format: item.Format,
Thumbnail: item.Thumbnail,
ProcessingStatus: item.ProcessingStatus,
StorageType: item.StorageType,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
JobId: linkedJobID,
}
}
func NormalizeVideoStatusValue(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
}
return strings.TrimPrefix(parsed.Path, "/")
}
func ToProtoUser(user *UserPayload) *appv1.User {
if user == nil {
return nil
}
return &appv1.User{
Id: user.ID,
Email: user.Email,
Username: user.Username,
Avatar: user.Avatar,
Role: user.Role,
GoogleId: user.GoogleID,
StorageUsed: user.StorageUsed,
PlanId: user.PlanID,
PlanStartedAt: TimeToProto(user.PlanStartedAt),
PlanExpiresAt: TimeToProto(user.PlanExpiresAt),
PlanTermMonths: user.PlanTermMonths,
PlanPaymentMethod: user.PlanPaymentMethod,
PlanExpiringSoon: user.PlanExpiringSoon,
WalletBalance: user.WalletBalance,
Language: user.Language,
Locale: user.Locale,
CreatedAt: TimeToProto(user.CreatedAt),
UpdatedAt: timestamppb.New(user.UpdatedAt),
}
}
func ToProtoPreferences(pref *model.UserPreference) *appv1.Preferences {
if pref == nil {
return nil
}
return &appv1.Preferences{
EmailNotifications: BoolValue(pref.EmailNotifications),
PushNotifications: BoolValue(pref.PushNotifications),
MarketingNotifications: pref.MarketingNotifications,
TelegramNotifications: pref.TelegramNotifications,
Language: model.StringValue(pref.Language),
Locale: model.StringValue(pref.Locale),
}
}
func ToProtoNotification(item model.Notification) *appv1.Notification {
return &appv1.Notification{
Id: item.ID,
Type: NormalizeNotificationType(item.Type),
Title: item.Title,
Message: item.Message,
Read: item.IsRead,
ActionUrl: item.ActionURL,
ActionLabel: item.ActionLabel,
CreatedAt: TimeToProto(item.CreatedAt),
}
}
func ToProtoDomain(item *model.Domain) *appv1.Domain {
if item == nil {
return nil
}
return &appv1.Domain{
Id: item.ID,
Name: item.Name,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}
func ToProtoAdTemplate(item *model.AdTemplate) *appv1.AdTemplate {
if item == nil {
return nil
}
return &appv1.AdTemplate{
Id: item.ID,
Name: item.Name,
Description: item.Description,
VastTagUrl: item.VastTagURL,
AdFormat: model.StringValue(item.AdFormat),
Duration: Int64PtrToInt32Ptr(item.Duration),
IsActive: BoolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}
func ToProtoPlayerConfig(item *model.PlayerConfig) *appv1.PlayerConfig {
if item == nil {
return nil
}
return &appv1.PlayerConfig{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: BoolValue(item.ShowControls),
Pip: BoolValue(item.Pip),
Airplay: BoolValue(item.Airplay),
Chromecast: BoolValue(item.Chromecast),
IsActive: BoolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(&item.UpdatedAt),
EncrytionM3U8: BoolValue(item.EncrytionM3u8),
LogoUrl: NullableTrimmedString(item.LogoURL),
}
}
func ToProtoAdminPlayerConfig(item *model.PlayerConfig, ownerEmail *string) *appv1.AdminPlayerConfig {
if item == nil {
return nil
}
return &appv1.AdminPlayerConfig{
Id: item.ID,
UserId: item.UserID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: BoolValue(item.ShowControls),
Pip: BoolValue(item.Pip),
Airplay: BoolValue(item.Airplay),
Chromecast: BoolValue(item.Chromecast),
IsActive: BoolValue(item.IsActive),
IsDefault: item.IsDefault,
OwnerEmail: ownerEmail,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(&item.UpdatedAt),
EncrytionM3U8: BoolValue(item.EncrytionM3u8),
LogoUrl: NullableTrimmedString(item.LogoURL),
}
}
func ToProtoPlan(item *model.Plan) *appv1.Plan {
if item == nil {
return nil
}
return &appv1.Plan{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Price: item.Price,
Cycle: item.Cycle,
StorageLimit: item.StorageLimit,
UploadLimit: item.UploadLimit,
DurationLimit: item.DurationLimit,
QualityLimit: item.QualityLimit,
Features: item.Features,
IsActive: BoolValue(item.IsActive),
}
}
func ToProtoPayment(item *model.Payment) *appv1.Payment {
if item == nil {
return nil
}
return &appv1.Payment{
Id: item.ID,
UserId: item.UserID,
PlanId: item.PlanID,
Amount: item.Amount,
Currency: NormalizeCurrency(item.Currency),
Status: NormalizePaymentStatus(item.Status),
Provider: strings.ToUpper(StringValue(item.Provider)),
TransactionId: item.TransactionID,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
}
}
func ToProtoPlanSubscription(item *model.PlanSubscription) *appv1.PlanSubscription {
if item == nil {
return nil
}
return &appv1.PlanSubscription{
Id: item.ID,
UserId: item.UserID,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
PaymentMethod: item.PaymentMethod,
WalletAmount: item.WalletAmount,
TopupAmount: item.TopupAmount,
StartedAt: timestamppb.New(item.StartedAt.UTC()),
ExpiresAt: timestamppb.New(item.ExpiresAt.UTC()),
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}
func ToProtoWalletTransaction(item *model.WalletTransaction) *appv1.WalletTransaction {
if item == nil {
return nil
}
return &appv1.WalletTransaction{
Id: item.ID,
UserId: item.UserID,
Type: item.Type,
Amount: item.Amount,
Currency: NormalizeCurrency(item.Currency),
Note: item.Note,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
CreatedAt: TimeToProto(item.CreatedAt),
UpdatedAt: TimeToProto(item.UpdatedAt),
}
}

View File

@@ -0,0 +1,187 @@
package common
import (
"context"
"net/http"
"strings"
"time"
"golang.org/x/oauth2"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/middleware"
videodomain "stream.api/internal/video"
"stream.api/pkg/cache"
"stream.api/pkg/logger"
"stream.api/pkg/storage"
"stream.api/pkg/token"
)
const (
AdTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
DefaultGoogleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
PlayerConfigFreePlanLimitMessage = "Free plan supports only 1 player config"
PlayerConfigFreePlanReconciliationMessage = "Delete extra player configs to continue managing player configs on the free plan"
WalletTransactionTypeTopup = "topup"
WalletTransactionTypeSubscriptionDebit = "subscription_debit"
WalletTransactionTypeReferralReward = "referral_reward"
PaymentMethodWallet = "wallet"
PaymentMethodTopup = "topup"
PaymentKindSubscription = "subscription"
PaymentKindWalletTopup = "wallet_topup"
DefaultReferralRewardBps = int32(500)
)
var AllowedTermMonths = map[int32]struct{}{
1: {},
3: {},
6: {},
12: {},
}
type RuntimeOptions struct {
DB *gorm.DB
Logger logger.Logger
Authenticator *middleware.Authenticator
TokenProvider token.Provider
Cache cache.Cache
GoogleOauth *oauth2.Config
GoogleStateTTL time.Duration
GoogleUserInfoURL string
FrontendBaseURL string
StorageProvider func() storage.Provider
VideoService func() *videodomain.Service
AgentRuntime func() videodomain.AgentRuntime
}
type Runtime struct {
db *gorm.DB
logger logger.Logger
authenticator *middleware.Authenticator
tokenProvider token.Provider
cache cache.Cache
googleOauth *oauth2.Config
googleStateTTL time.Duration
googleUserInfoURL string
frontendBaseURL string
storageProvider func() storage.Provider
videoService func() *videodomain.Service
agentRuntime func() videodomain.AgentRuntime
}
func NewRuntime(opts RuntimeOptions) *Runtime {
googleStateTTL := opts.GoogleStateTTL
if googleStateTTL <= 0 {
googleStateTTL = 10 * time.Minute
}
googleUserInfoURL := strings.TrimSpace(opts.GoogleUserInfoURL)
if googleUserInfoURL == "" {
googleUserInfoURL = DefaultGoogleUserInfoURL
}
return &Runtime{
db: opts.DB,
logger: opts.Logger,
authenticator: opts.Authenticator,
tokenProvider: opts.TokenProvider,
cache: opts.Cache,
googleOauth: opts.GoogleOauth,
googleStateTTL: googleStateTTL,
googleUserInfoURL: googleUserInfoURL,
frontendBaseURL: strings.TrimSpace(opts.FrontendBaseURL),
storageProvider: opts.StorageProvider,
videoService: opts.VideoService,
agentRuntime: opts.AgentRuntime,
}
}
func (r *Runtime) DB() *gorm.DB { return r.db }
func (r *Runtime) Logger() logger.Logger { return r.logger }
func (r *Runtime) Authenticator() *middleware.Authenticator { return r.authenticator }
func (r *Runtime) TokenProvider() token.Provider { return r.tokenProvider }
func (r *Runtime) Cache() cache.Cache { return r.cache }
func (r *Runtime) GoogleOauth() *oauth2.Config { return r.googleOauth }
func (r *Runtime) GoogleStateTTL() time.Duration { return r.googleStateTTL }
func (r *Runtime) GoogleUserInfoURL() string { return r.googleUserInfoURL }
func (r *Runtime) FrontendBaseURL() string { return r.frontendBaseURL }
func (r *Runtime) StorageProvider() storage.Provider {
if r == nil || r.storageProvider == nil {
return nil
}
return r.storageProvider()
}
func (r *Runtime) VideoService() *videodomain.Service {
if r == nil || r.videoService == nil {
return nil
}
return r.videoService()
}
func (r *Runtime) AgentRuntime() videodomain.AgentRuntime {
if r == nil || r.agentRuntime == nil {
return nil
}
return r.agentRuntime()
}
func (r *Runtime) Authenticate(ctx context.Context) (*middleware.AuthResult, error) {
if r == nil || r.authenticator == nil {
return nil, status.Error(codes.Unauthenticated, "Unauthorized")
}
return r.authenticator.Authenticate(ctx)
}
func (r *Runtime) RequireAdmin(ctx context.Context) (*middleware.AuthResult, error) {
result, err := r.Authenticate(ctx)
if err != nil {
return nil, err
}
if result.User == nil || result.User.Role == nil || strings.ToUpper(strings.TrimSpace(*result.User.Role)) != "ADMIN" {
return nil, status.Error(codes.PermissionDenied, "Admin access required")
}
return result, nil
}
func (r *Runtime) IssueSessionCookies(ctx context.Context, user *model.User) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if r == nil || r.tokenProvider == nil || r.cache == nil {
return status.Error(codes.Internal, "Error storing session")
}
tokenPair, err := r.tokenProvider.GenerateTokenPair(user.ID, user.Email, SafeRole(user.Role))
if err != nil {
if r.logger != nil {
r.logger.Error("Token generation failed", "error", err)
}
return status.Error(codes.Internal, "Error generating tokens")
}
if err := r.cache.Set(ctx, "refresh_uuid:"+tokenPair.RefreshUUID, user.ID, time.Until(time.Unix(tokenPair.RtExpires, 0))); err != nil {
if r.logger != nil {
r.logger.Error("Session storage failed", "error", err)
}
return status.Error(codes.Internal, "Error storing session")
}
if err := grpc.SetHeader(ctx, metadata.Pairs(
"set-cookie", BuildTokenCookie("access_token", tokenPair.AccessToken, int(tokenPair.AtExpires-time.Now().Unix())),
"set-cookie", BuildTokenCookie("refresh_token", tokenPair.RefreshToken, int(tokenPair.RtExpires-time.Now().Unix())),
)); err != nil && r.logger != nil {
r.logger.Error("Failed to set gRPC auth headers", "error", err)
}
return nil
}
func BuildTokenCookie(name string, value string, maxAge int) string {
return (&http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
}).String()
}

View File

@@ -1,4 +1,4 @@
package app package common
import ( import (
"context" "context"
@@ -10,7 +10,7 @@ import (
"stream.api/internal/database/model" "stream.api/internal/database/model"
) )
type userPayload struct { type UserPayload struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Username *string `json:"username,omitempty"` Username *string `json:"username,omitempty"`
@@ -31,7 +31,7 @@ type userPayload struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*userPayload, error) { func BuildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*UserPayload, error) {
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID) pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -82,7 +82,7 @@ func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*user
} }
} }
return &userPayload{ return &UserPayload{
ID: user.ID, ID: user.ID,
Email: user.Email, Email: user.Email,
Username: user.Username, Username: user.Username,

View File

@@ -0,0 +1,37 @@
package dashboard
import (
"context"
"time"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
type Module struct {
runtime *common.Runtime
}
func New(runtime *common.Runtime) *Module {
return &Module{runtime: runtime}
}
func (m *Module) GetAdminDashboard(ctx context.Context, _ *appv1.GetAdminDashboardRequest) (*appv1.GetAdminDashboardResponse, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
dashboard := &appv1.AdminDashboard{}
db := m.runtime.DB().WithContext(ctx)
db.Model(&model.User{}).Count(&dashboard.TotalUsers)
db.Model(&model.Video{}).Count(&dashboard.TotalVideos)
db.Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Row().Scan(&dashboard.TotalStorageUsed)
db.Model(&model.Payment{}).Count(&dashboard.TotalPayments)
db.Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Row().Scan(&dashboard.TotalRevenue)
db.Model(&model.PlanSubscription{}).Where("expires_at > ?", time.Now()).Count(&dashboard.ActiveSubscriptions)
db.Model(&model.AdTemplate{}).Count(&dashboard.TotalAdTemplates)
today := time.Now().Truncate(24 * time.Hour)
db.Model(&model.User{}).Where("created_at >= ?", today).Count(&dashboard.NewUsersToday)
db.Model(&model.Video{}).Where("created_at >= ?", today).Count(&dashboard.NewVideosToday)
return &appv1.GetAdminDashboardResponse{Dashboard: dashboard}, nil
}

View File

@@ -0,0 +1,55 @@
package domains
import (
"context"
appv1 "stream.api/internal/gen/proto/app/v1"
)
type Handler struct {
appv1.UnimplementedDomainsServiceServer
module *Module
}
var _ appv1.DomainsServiceServer = (*Handler)(nil)
func NewHandler(module *Module) *Handler { return &Handler{module: module} }
func (h *Handler) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.ListDomains(ctx, ListDomainsQuery{UserID: result.UserID})
if err != nil {
return nil, err
}
return presentListDomainsResponse(payload), nil
}
func (h *Handler) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.CreateDomain(ctx, CreateDomainCommand{UserID: result.UserID, Name: req.GetName()})
if err != nil {
return nil, err
}
return presentCreateDomainResponse(*payload), nil
}
func (h *Handler) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
if err := h.module.DeleteDomain(ctx, DeleteDomainCommand{UserID: result.UserID, ID: req.GetId()}); err != nil {
return nil, err
}
return commonMessage("Domain deleted"), nil
}
func commonMessage(message string) *appv1.MessageResponse {
return &appv1.MessageResponse{Message: message}
}

View File

@@ -0,0 +1,69 @@
package domains
import (
"context"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"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) ListDomains(ctx context.Context, queryValue ListDomainsQuery) (*ListDomainsResult, error) {
var rows []model.Domain
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", queryValue.UserID).Order("created_at DESC").Find(&rows).Error; err != nil {
m.runtime.Logger().Error("Failed to list domains", "error", err)
return nil, status.Error(codes.Internal, "Failed to load domains")
}
items := make([]DomainView, 0, len(rows))
for i := range rows {
items = append(items, DomainView{Domain: &rows[i]})
}
return &ListDomainsResult{Items: items}, nil
}
func (m *Module) CreateDomain(ctx context.Context, cmd CreateDomainCommand) (*DomainView, error) {
name := common.NormalizeDomain(cmd.Name)
if name == "" || !strings.Contains(name, ".") || strings.ContainsAny(name, "/ ") {
return nil, status.Error(codes.InvalidArgument, "Invalid domain")
}
var count int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.Domain{}).Where("user_id = ? AND name = ?", cmd.UserID, name).Count(&count).Error; err != nil {
m.runtime.Logger().Error("Failed to validate domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain")
}
if count > 0 {
return nil, status.Error(codes.InvalidArgument, "Domain already exists")
}
item := &model.Domain{ID: uuid.New().String(), UserID: cmd.UserID, Name: name}
if err := m.runtime.DB().WithContext(ctx).Create(item).Error; err != nil {
m.runtime.Logger().Error("Failed to create domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain")
}
return &DomainView{Domain: item}, nil
}
func (m *Module) DeleteDomain(ctx context.Context, cmd DeleteDomainCommand) error {
if cmd.ID == "" {
return status.Error(codes.NotFound, "Domain not found")
}
res := m.runtime.DB().WithContext(ctx).Where("id = ? AND user_id = ?", cmd.ID, cmd.UserID).Delete(&model.Domain{})
if res.Error != nil {
m.runtime.Logger().Error("Failed to delete domain", "error", res.Error)
return status.Error(codes.Internal, "Failed to delete domain")
}
if res.RowsAffected == 0 {
return status.Error(codes.NotFound, "Domain not found")
}
return nil
}

View File

@@ -0,0 +1,18 @@
package domains
import (
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
func presentListDomainsResponse(result *ListDomainsResult) *appv1.ListDomainsResponse {
items := make([]*appv1.Domain, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, common.ToProtoDomain(item.Domain))
}
return &appv1.ListDomainsResponse{Domains: items}
}
func presentCreateDomainResponse(view DomainView) *appv1.CreateDomainResponse {
return &appv1.CreateDomainResponse{Domain: common.ToProtoDomain(view.Domain)}
}

View File

@@ -0,0 +1,25 @@
package domains
import "stream.api/internal/database/model"
type ListDomainsQuery struct {
UserID string
}
type CreateDomainCommand struct {
UserID string
Name string
}
type DeleteDomainCommand struct {
UserID string
ID string
}
type DomainView struct {
Domain *model.Domain
}
type ListDomainsResult struct {
Items []DomainView
}

View File

@@ -0,0 +1,87 @@
package jobs
import (
"context"
"strings"
appv1 "stream.api/internal/gen/proto/app/v1"
)
type Handler struct {
module *Module
}
func NewHandler(module *Module) *Handler { return &Handler{module: module} }
func (h *Handler) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJobsRequest) (*appv1.ListAdminJobsResponse, error) {
useCursorPagination := req.Cursor != nil || int(req.GetPageSize()) > 0
result, err := h.module.ListAdminJobs(ctx, ListAdminJobsQuery{AgentID: strings.TrimSpace(req.GetAgentId()), Offset: int(req.GetOffset()), Limit: int(req.GetLimit()), Cursor: req.Cursor, PageSize: int(req.GetPageSize()), UseCursorPagination: useCursorPagination})
if err != nil {
return nil, err
}
return presentListAdminJobsResponse(result), nil
}
func (h *Handler) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobRequest) (*appv1.GetAdminJobResponse, error) {
job, err := h.module.GetAdminJob(ctx, GetAdminJobQuery{ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, err
}
return presentGetAdminJobResponse(job), nil
}
func (h *Handler) GetAdminJobLogs(ctx context.Context, req *appv1.GetAdminJobLogsRequest) (*appv1.GetAdminJobLogsResponse, error) {
response, err := h.GetAdminJob(ctx, &appv1.GetAdminJobRequest{Id: req.GetId()})
if err != nil {
return nil, err
}
return &appv1.GetAdminJobLogsResponse{Logs: response.GetJob().GetLogs()}, nil
}
func (h *Handler) CreateAdminJob(ctx context.Context, req *appv1.CreateAdminJobRequest) (*appv1.CreateAdminJobResponse, error) {
job, err := h.module.CreateAdminJob(ctx, CreateAdminJobCommand{Command: strings.TrimSpace(req.GetCommand()), Image: strings.TrimSpace(req.GetImage()), Name: strings.TrimSpace(req.GetName()), UserID: strings.TrimSpace(req.GetUserId()), VideoID: req.VideoId, Env: req.GetEnv(), Priority: int(req.GetPriority()), TimeLimit: req.GetTimeLimit()})
if err != nil {
return nil, err
}
return presentCreateAdminJobResponse(job), nil
}
func (h *Handler) CancelAdminJob(ctx context.Context, req *appv1.CancelAdminJobRequest) (*appv1.CancelAdminJobResponse, error) {
result, err := h.module.CancelAdminJob(ctx, CancelAdminJobCommand{ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, err
}
return presentCancelAdminJobResponse(result), nil
}
func (h *Handler) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJobRequest) (*appv1.RetryAdminJobResponse, error) {
job, err := h.module.RetryAdminJob(ctx, RetryAdminJobCommand{ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, err
}
return presentRetryAdminJobResponse(job), nil
}
func (h *Handler) ListAdminAgents(ctx context.Context, _ *appv1.ListAdminAgentsRequest) (*appv1.ListAdminAgentsResponse, error) {
items, err := h.module.ListAdminAgents(ctx)
if err != nil {
return nil, err
}
return presentListAdminAgentsResponse(items), nil
}
func (h *Handler) RestartAdminAgent(ctx context.Context, req *appv1.RestartAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
statusValue, err := h.module.RestartAdminAgent(ctx, AgentCommand{ID: strings.TrimSpace(req.GetId()), Command: "restart", Success: "restart command sent"})
if err != nil {
return nil, err
}
return presentAgentCommandResponse(statusValue), nil
}
func (h *Handler) UpdateAdminAgent(ctx context.Context, req *appv1.UpdateAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
statusValue, err := h.module.UpdateAdminAgent(ctx, AgentCommand{ID: strings.TrimSpace(req.GetId()), Command: "update", Success: "update command sent"})
if err != nil {
return nil, err
}
return presentAgentCommandResponse(statusValue), nil
}

View File

@@ -0,0 +1,184 @@
package jobs
import (
"context"
"encoding/json"
"errors"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/modules/common"
videodomain "stream.api/internal/video"
)
type Module struct {
runtime *common.Runtime
}
func New(runtime *common.Runtime) *Module {
return &Module{runtime: runtime}
}
func (m *Module) ListAdminJobs(ctx context.Context, queryValue ListAdminJobsQuery) (*ListAdminJobsResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
videoService := m.runtime.VideoService()
if videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
var (
result *videodomain.PaginatedJobs
err error
)
cursor := ""
if queryValue.Cursor != nil {
cursor = *queryValue.Cursor
}
if queryValue.UseCursorPagination {
result, err = videoService.ListJobsByCursor(ctx, queryValue.AgentID, cursor, queryValue.PageSize)
} else if queryValue.AgentID != "" {
result, err = videoService.ListJobsByAgent(ctx, queryValue.AgentID, queryValue.Offset, queryValue.Limit)
} else {
result, err = videoService.ListJobs(ctx, queryValue.Offset, queryValue.Limit)
}
if err != nil {
if errors.Is(err, videodomain.ErrInvalidJobCursor) {
return nil, status.Error(codes.InvalidArgument, "Invalid job cursor")
}
return nil, status.Error(codes.Internal, "Failed to list jobs")
}
var nextCursor *string
if strings.TrimSpace(result.NextCursor) != "" {
value := result.NextCursor
nextCursor = &value
}
return &ListAdminJobsResult{Jobs: result.Jobs, Total: result.Total, Offset: result.Offset, Limit: result.Limit, HasMore: result.HasMore, PageSize: result.PageSize, NextCursor: nextCursor}, nil
}
func (m *Module) GetAdminJob(ctx context.Context, queryValue GetAdminJobQuery) (*videodomain.Job, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
videoService := m.runtime.VideoService()
if videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
if queryValue.ID == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
job, err := videoService.GetJob(ctx, queryValue.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.Internal, "Failed to load job")
}
return job, nil
}
func (m *Module) CreateAdminJob(ctx context.Context, cmd CreateAdminJobCommand) (*videodomain.Job, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
videoService := m.runtime.VideoService()
if videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
if cmd.Command == "" {
return nil, status.Error(codes.InvalidArgument, "Command is required")
}
image := strings.TrimSpace(cmd.Image)
if image == "" {
image = "alpine"
}
name := strings.TrimSpace(cmd.Name)
if name == "" {
name = cmd.Command
}
payload, err := json.Marshal(map[string]any{"image": image, "commands": []string{cmd.Command}, "environment": cmd.Env})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create job payload")
}
videoID := ""
if cmd.VideoID != nil {
videoID = strings.TrimSpace(*cmd.VideoID)
}
job, err := videoService.CreateJob(ctx, strings.TrimSpace(cmd.UserID), videoID, name, payload, cmd.Priority, cmd.TimeLimit)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create job")
}
return job, nil
}
func (m *Module) CancelAdminJob(ctx context.Context, cmd CancelAdminJobCommand) (*CancelAdminJobResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
videoService := m.runtime.VideoService()
if videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
if cmd.ID == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
if err := videoService.CancelJob(ctx, cmd.ID); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &CancelAdminJobResult{Status: "cancelled", JobID: cmd.ID}, nil
}
func (m *Module) RetryAdminJob(ctx context.Context, cmd RetryAdminJobCommand) (*videodomain.Job, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
videoService := m.runtime.VideoService()
if videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
if cmd.ID == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
job, err := videoService.RetryJob(ctx, cmd.ID)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return job, nil
}
func (m *Module) ListAdminAgents(ctx context.Context) ([]*videodomain.AgentWithStats, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
agentRuntime := m.runtime.AgentRuntime()
if agentRuntime == nil {
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
return agentRuntime.ListAgentsWithStats(), nil
}
func (m *Module) RestartAdminAgent(ctx context.Context, cmd AgentCommand) (string, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return "", err
}
agentRuntime := m.runtime.AgentRuntime()
if agentRuntime == nil {
return "", status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
if !agentRuntime.SendCommand(strings.TrimSpace(cmd.ID), cmd.Command) {
return "", status.Error(codes.Unavailable, "Agent not active or command channel full")
}
return cmd.Success, nil
}
func (m *Module) UpdateAdminAgent(ctx context.Context, cmd AgentCommand) (string, error) {
return m.RestartAdminAgent(ctx, cmd)
}

View File

@@ -0,0 +1,54 @@
package jobs
import (
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
videodomain "stream.api/internal/video"
)
func presentListAdminJobsResponse(result *ListAdminJobsResult) *appv1.ListAdminJobsResponse {
jobs := make([]*appv1.AdminJob, 0, len(result.Jobs))
for _, job := range result.Jobs {
jobs = append(jobs, common.BuildAdminJob(job))
}
response := &appv1.ListAdminJobsResponse{
Jobs: jobs,
Total: result.Total,
Offset: int32(result.Offset),
Limit: int32(result.Limit),
HasMore: result.HasMore,
PageSize: int32(result.PageSize),
}
if result.NextCursor != nil {
response.NextCursor = result.NextCursor
}
return response
}
func presentGetAdminJobResponse(job *videodomain.Job) *appv1.GetAdminJobResponse {
return &appv1.GetAdminJobResponse{Job: common.BuildAdminJob(job)}
}
func presentCreateAdminJobResponse(job *videodomain.Job) *appv1.CreateAdminJobResponse {
return &appv1.CreateAdminJobResponse{Job: common.BuildAdminJob(job)}
}
func presentCancelAdminJobResponse(result *CancelAdminJobResult) *appv1.CancelAdminJobResponse {
return &appv1.CancelAdminJobResponse{Status: result.Status, JobId: result.JobID}
}
func presentRetryAdminJobResponse(job *videodomain.Job) *appv1.RetryAdminJobResponse {
return &appv1.RetryAdminJobResponse{Job: common.BuildAdminJob(job)}
}
func presentListAdminAgentsResponse(items []*videodomain.AgentWithStats) *appv1.ListAdminAgentsResponse {
agents := make([]*appv1.AdminAgent, 0, len(items))
for _, item := range items {
agents = append(agents, common.BuildAdminAgent(item))
}
return &appv1.ListAdminAgentsResponse{Agents: agents}
}
func presentAgentCommandResponse(status string) *appv1.AdminAgentCommandResponse {
return &appv1.AdminAgentCommandResponse{Status: status}
}

View File

@@ -0,0 +1,56 @@
package jobs
import videodomain "stream.api/internal/video"
type ListAdminJobsQuery struct {
AgentID string
Offset int
Limit int
Cursor *string
PageSize int
UseCursorPagination bool
}
type ListAdminJobsResult struct {
Jobs []*videodomain.Job
Total int64
Offset int
Limit int
HasMore bool
PageSize int
NextCursor *string
}
type GetAdminJobQuery struct {
ID string
}
type CreateAdminJobCommand struct {
Command string
Image string
Name string
UserID string
VideoID *string
Env map[string]string
Priority int
TimeLimit int64
}
type CancelAdminJobCommand struct {
ID string
}
type CancelAdminJobResult struct {
Status string
JobID string
}
type RetryAdminJobCommand struct {
ID string
}
type AgentCommand struct {
ID string
Command string
Success string
}

View File

@@ -0,0 +1,33 @@
package payments
import (
"net/http"
"google.golang.org/grpc/codes"
"stream.api/internal/modules/common"
)
func newValidationError(message string, data map[string]any) *PaymentValidationError {
return &PaymentValidationError{
GRPCCode: int(codes.InvalidArgument),
HTTPCode: http.StatusBadRequest,
Message: message,
Data: data,
}
}
func (e *PaymentValidationError) Error() string {
if e == nil {
return ""
}
return e.Message
}
func (e *PaymentValidationError) apiBody() common.APIErrorBody {
return common.APIErrorBody{
Code: e.HTTPCode,
Message: e.Message,
Data: e.Data,
}
}

View File

@@ -0,0 +1,144 @@
package payments
import (
"context"
"encoding/json"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
type Handler struct {
appv1.UnimplementedPaymentsServiceServer
module *Module
}
var _ appv1.PaymentsServiceServer = (*Handler)(nil)
func NewHandler(module *Module) *Handler { return &Handler{module: module} }
func (h *Handler) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) {
authResult, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
planID := strings.TrimSpace(req.GetPlanId())
if planID == "" {
return nil, status.Error(codes.InvalidArgument, "Plan ID is required")
}
if !common.IsAllowedTermMonths(req.GetTermMonths()) {
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
}
paymentMethod := common.NormalizePaymentMethod(req.GetPaymentMethod())
if paymentMethod == "" {
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
}
result, err := h.module.CreatePayment(ctx, CreatePaymentCommand{UserID: authResult.UserID, PlanID: planID, TermMonths: req.GetTermMonths(), PaymentMethod: paymentMethod, TopupAmount: req.TopupAmount})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to create payment")
}
return presentCreatePaymentResponse(result), nil
}
func (h *Handler) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
authResult, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
result, err := h.module.ListPaymentHistory(ctx, PaymentHistoryQuery{UserID: authResult.UserID, Page: req.GetPage(), Limit: req.GetLimit()})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to fetch payment history")
}
return presentPaymentHistoryResponse(result), nil
}
func (h *Handler) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
authResult, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
result, err := h.module.TopupWallet(ctx, TopupWalletCommand{UserID: authResult.UserID, Amount: req.GetAmount()})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to top up wallet")
}
return presentTopupWalletResponse(result), nil
}
func (h *Handler) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) {
authResult, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
result, err := h.module.DownloadInvoice(ctx, DownloadInvoiceQuery{UserID: authResult.UserID, ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to download invoice")
}
return presentDownloadInvoiceResponse(result), nil
}
func (h *Handler) ListAdminPayments(ctx context.Context, req *appv1.ListAdminPaymentsRequest) (*appv1.ListAdminPaymentsResponse, error) {
result, err := h.module.ListAdminPayments(ctx, ListAdminPaymentsQuery{Page: req.GetPage(), Limit: req.GetLimit(), UserID: strings.TrimSpace(req.GetUserId()), StatusFilter: strings.TrimSpace(req.GetStatus())})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to list payments")
}
return presentListAdminPaymentsResponse(result), nil
}
func (h *Handler) GetAdminPayment(ctx context.Context, req *appv1.GetAdminPaymentRequest) (*appv1.GetAdminPaymentResponse, error) {
result, err := h.module.GetAdminPayment(ctx, GetAdminPaymentQuery{ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to get payment")
}
return presentGetAdminPaymentResponse(*result), nil
}
func (h *Handler) CreateAdminPayment(ctx context.Context, req *appv1.CreateAdminPaymentRequest) (*appv1.CreateAdminPaymentResponse, error) {
userID := strings.TrimSpace(req.GetUserId())
planID := strings.TrimSpace(req.GetPlanId())
if userID == "" || planID == "" {
return nil, status.Error(codes.InvalidArgument, "User ID and plan ID are required")
}
if !common.IsAllowedTermMonths(req.GetTermMonths()) {
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
}
paymentMethod := common.NormalizePaymentMethod(req.GetPaymentMethod())
if paymentMethod == "" {
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
}
result, err := h.module.CreateAdminPayment(ctx, CreateAdminPaymentCommand{UserID: userID, PlanID: planID, TermMonths: req.GetTermMonths(), PaymentMethod: paymentMethod, TopupAmount: req.TopupAmount})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to create payment")
}
return presentCreateAdminPaymentResponse(result), nil
}
func (h *Handler) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateAdminPaymentRequest) (*appv1.UpdateAdminPaymentResponse, error) {
result, err := h.module.UpdateAdminPayment(ctx, UpdateAdminPaymentCommand{ID: strings.TrimSpace(req.GetId()), NewStatus: req.GetStatus()})
if err != nil {
return nil, h.handleError(ctx, err, "Failed to update payment")
}
return presentUpdateAdminPaymentResponse(*result), nil
}
func (h *Handler) handleError(ctx context.Context, err error, fallback string) error {
if err == nil {
return nil
}
if validationErr, ok := err.(*PaymentValidationError); ok {
body := validationErr.apiBody()
if encoded, marshalErr := json.Marshal(body); marshalErr == nil {
_ = grpc.SetTrailer(ctx, metadata.Pairs("x-error-body", string(encoded)))
}
return status.Error(codes.Code(validationErr.GRPCCode), validationErr.Message)
}
if _, ok := status.FromError(err); ok {
return err
}
h.module.runtime.Logger().Error(fallback, "error", err)
return status.Error(codes.Internal, fallback)
}

View File

@@ -0,0 +1,656 @@
package payments
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/internal/modules/common"
)
type ExecutionInput struct {
UserID string
Plan *model.Plan
TermMonths int32
PaymentMethod string
TopupAmount *float64
}
type ExecutionResult struct {
Payment *model.Payment
Subscription *model.PlanSubscription
WalletBalance float64
InvoiceID string
}
type InvoiceDetails struct {
PlanName string
TermMonths *int32
PaymentMethod string
ExpiresAt *time.Time
WalletAmount float64
TopupAmount float64
}
type ReferralRewardResult struct {
Granted bool
Amount float64
}
type Module struct {
runtime *common.Runtime
}
func New(runtime *common.Runtime) *Module {
return &Module{runtime: runtime}
}
func (m *Module) CreatePayment(ctx context.Context, cmd CreatePaymentCommand) (*CreatePaymentResult, error) {
planRecord, err := m.LoadPaymentPlanForUser(ctx, cmd.PlanID)
if err != nil {
return nil, err
}
resultValue, err := m.ExecutePaymentFlow(ctx, ExecutionInput{
UserID: cmd.UserID,
Plan: planRecord,
TermMonths: cmd.TermMonths,
PaymentMethod: cmd.PaymentMethod,
TopupAmount: cmd.TopupAmount,
})
if err != nil {
return nil, err
}
return &CreatePaymentResult{
Payment: resultValue.Payment,
Subscription: resultValue.Subscription,
WalletBalance: resultValue.WalletBalance,
InvoiceID: resultValue.InvoiceID,
Message: "Payment completed successfully",
}, nil
}
func (m *Module) ListPaymentHistory(ctx context.Context, queryValue PaymentHistoryQuery) (*PaymentHistoryResult, error) {
page, limit, offset := common.AdminPageLimitOffset(queryValue.Page, queryValue.Limit)
type paymentHistoryRow 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"`
InvoiceID string `gorm:"column:invoice_id"`
Kind string `gorm:"column:kind"`
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"`
}
baseQuery := `
WITH history AS (
SELECT
p.id AS id,
p.amount AS amount,
p.currency AS currency,
p.status AS status,
p.plan_id AS plan_id,
pl.name AS plan_name,
p.id AS invoice_id,
? AS kind,
ps.term_months AS term_months,
ps.payment_method AS payment_method,
ps.expires_at AS expires_at,
p.created_at AS created_at
FROM payment AS p
LEFT JOIN plan AS pl ON pl.id = p.plan_id
LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id
WHERE p.user_id = ?
UNION ALL
SELECT
wt.id AS id,
wt.amount AS amount,
wt.currency AS currency,
'SUCCESS' AS status,
NULL AS plan_id,
NULL AS plan_name,
wt.id AS invoice_id,
? AS kind,
NULL AS term_months,
NULL AS payment_method,
NULL AS expires_at,
wt.created_at AS created_at
FROM wallet_transactions AS wt
WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL
)
`
var total int64
if err := m.runtime.DB().WithContext(ctx).
Raw(baseQuery+`SELECT COUNT(*) FROM history`, common.PaymentKindSubscription, queryValue.UserID, common.PaymentKindWalletTopup, queryValue.UserID, common.WalletTransactionTypeTopup).
Scan(&total).Error; err != nil {
return nil, err
}
var rows []paymentHistoryRow
if err := m.runtime.DB().WithContext(ctx).
Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, common.PaymentKindSubscription, queryValue.UserID, common.PaymentKindWalletTopup, queryValue.UserID, common.WalletTransactionTypeTopup, limit, offset).
Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]PaymentHistoryItem, 0, len(rows))
for _, row := range rows {
var expiresAt *string
if row.ExpiresAt != nil {
value := row.ExpiresAt.UTC().Format(time.RFC3339)
expiresAt = &value
}
var createdAt *string
if row.CreatedAt != nil {
value := row.CreatedAt.UTC().Format(time.RFC3339)
createdAt = &value
}
items = append(items, PaymentHistoryItem{
ID: row.ID,
Amount: row.Amount,
Currency: common.NormalizeCurrency(row.Currency),
Status: common.NormalizePaymentStatus(row.Status),
PlanID: row.PlanID,
PlanName: row.PlanName,
InvoiceID: common.BuildInvoiceID(row.InvoiceID),
Kind: row.Kind,
TermMonths: row.TermMonths,
PaymentMethod: common.NormalizeOptionalPaymentMethod(row.PaymentMethod),
ExpiresAt: expiresAt,
CreatedAt: createdAt,
})
}
hasPrev := page > 1 && total > 0
hasNext := int64(offset)+int64(len(items)) < total
return &PaymentHistoryResult{Items: items, Total: total, Page: page, Limit: limit, HasPrev: hasPrev, HasNext: hasNext}, nil
}
func (m *Module) TopupWallet(ctx context.Context, cmd TopupWalletCommand) (*TopupWalletResult, error) {
if cmd.Amount < 1 {
return nil, status.Error(codes.InvalidArgument, "Amount must be at least 1")
}
transaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: cmd.UserID,
Type: common.WalletTransactionTypeTopup,
Amount: cmd.Amount,
Currency: model.StringPtr("USD"),
Note: model.StringPtr(fmt.Sprintf("Wallet top-up of %.2f USD", cmd.Amount)),
}
notification := &model.Notification{
ID: uuid.New().String(),
UserID: cmd.UserID,
Type: "billing.topup",
Title: "Wallet credited",
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", cmd.Amount),
Metadata: model.StringPtr(common.MustMarshalJSON(map[string]any{
"wallet_transaction_id": transaction.ID,
"invoice_id": common.BuildInvoiceID(transaction.ID),
})),
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := common.LockUserForUpdate(ctx, tx, cmd.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 {
return nil, err
}
balance, err := model.GetWalletBalance(ctx, m.runtime.DB(), cmd.UserID)
if err != nil {
return nil, err
}
return &TopupWalletResult{WalletTransaction: transaction, WalletBalance: balance, InvoiceID: common.BuildInvoiceID(transaction.ID)}, nil
}
func (m *Module) DownloadInvoice(ctx context.Context, queryValue DownloadInvoiceQuery) (*DownloadInvoiceResult, error) {
if queryValue.ID == "" {
return nil, status.Error(codes.NotFound, "Invoice not found")
}
paymentRecord, err := query.Payment.WithContext(ctx).Where(query.Payment.ID.Eq(queryValue.ID), query.Payment.UserID.Eq(queryValue.UserID)).First()
if err == nil {
invoiceText, filename, buildErr := m.BuildPaymentInvoice(ctx, paymentRecord)
if buildErr != nil {
return nil, buildErr
}
return &DownloadInvoiceResult{Filename: filename, ContentType: "text/plain; charset=utf-8", Content: invoiceText}, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var topup model.WalletTransaction
if err := m.runtime.DB().WithContext(ctx).
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", queryValue.ID, queryValue.UserID, common.WalletTransactionTypeTopup).
First(&topup).Error; err == nil {
return &DownloadInvoiceResult{Filename: common.BuildInvoiceFilename(topup.ID), ContentType: "text/plain; charset=utf-8", Content: common.BuildTopupInvoice(&topup)}, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return nil, status.Error(codes.NotFound, "Invoice not found")
}
func (m *Module) LoadPaymentPlanForUser(ctx context.Context, planID string) (*model.Plan, error) {
var planRecord model.Plan
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found")
}
m.runtime.Logger().Error("Failed to load plan", "error", err)
return nil, status.Error(codes.Internal, "Failed to create payment")
}
if planRecord.IsActive == nil || !*planRecord.IsActive {
return nil, status.Error(codes.InvalidArgument, "Plan is not active")
}
return &planRecord, nil
}
func (m *Module) LoadPaymentPlanForAdmin(ctx context.Context, planID string) (*model.Plan, error) {
var planRecord model.Plan
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "Plan not found")
}
return nil, status.Error(codes.Internal, "Failed to create payment")
}
if planRecord.IsActive == nil || !*planRecord.IsActive {
return nil, status.Error(codes.InvalidArgument, "Plan is not active")
}
return &planRecord, nil
}
func (m *Module) LoadPaymentUserForAdmin(ctx context.Context, userID string) (*model.User, error) {
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to create payment")
}
return &user, nil
}
func (m *Module) ExecutePaymentFlow(ctx context.Context, input ExecutionInput) (*ExecutionResult, error) {
totalAmount := input.Plan.Price * float64(input.TermMonths)
if totalAmount < 0 {
return nil, status.Error(codes.InvalidArgument, "Amount must be greater than or equal to 0")
}
statusValue := "SUCCESS"
provider := "INTERNAL"
currency := common.NormalizeCurrency(nil)
transactionID := common.BuildTransactionID("sub")
now := time.Now().UTC()
paymentRecord := &model.Payment{ID: uuid.New().String(), UserID: input.UserID, PlanID: &input.Plan.ID, Amount: totalAmount, Currency: &currency, Status: &statusValue, Provider: &provider, TransactionID: &transactionID}
invoiceID := common.BuildInvoiceID(paymentRecord.ID)
result := &ExecutionResult{Payment: paymentRecord, InvoiceID: invoiceID}
err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := common.LockUserForUpdate(ctx, tx, input.UserID); err != nil {
return err
}
newExpiry, err := loadPaymentExpiry(ctx, tx, input.UserID, input.TermMonths, now)
if err != nil {
return err
}
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID)
if err != nil {
return err
}
validatedTopupAmount, err := ValidatePaymentFunding(input, totalAmount, currentWalletBalance)
if err != nil {
return err
}
if err := tx.Create(paymentRecord).Error; err != nil {
return err
}
if err := createPaymentWalletTransactions(tx, input, paymentRecord, totalAmount, validatedTopupAmount, currency); err != nil {
return err
}
subscription := buildPaymentSubscription(input, paymentRecord, totalAmount, validatedTopupAmount, now, newExpiry)
if err := tx.Create(subscription).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", input.UserID).Update("plan_id", input.Plan.ID).Error; err != nil {
return err
}
if err := tx.Create(buildSubscriptionNotification(input.UserID, paymentRecord.ID, invoiceID, input.Plan, subscription)).Error; err != nil {
return err
}
if _, err := m.MaybeGrantReferralReward(ctx, tx, input, paymentRecord, subscription); err != nil {
return err
}
walletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID)
if err != nil {
return err
}
result.Subscription = subscription
result.WalletBalance = walletBalance
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func loadPaymentExpiry(ctx context.Context, tx *gorm.DB, userID string, termMonths int32, now time.Time) (time.Time, error) {
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return time.Time{}, err
}
baseExpiry := now
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
baseExpiry = currentSubscription.ExpiresAt.UTC()
}
return baseExpiry.AddDate(0, int(termMonths), 0), nil
}
func ValidatePaymentFunding(input ExecutionInput, totalAmount, currentWalletBalance float64) (float64, error) {
shortfall := common.MaxFloat(totalAmount-currentWalletBalance, 0)
if input.PaymentMethod == common.PaymentMethodWallet && shortfall > 0 {
return 0, newValidationError("Insufficient wallet balance", map[string]any{
"payment_method": input.PaymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
})
}
if input.PaymentMethod != common.PaymentMethodTopup {
return 0, nil
}
if input.TopupAmount == nil {
return 0, newValidationError("Top-up amount is required when payment method is topup", map[string]any{
"payment_method": input.PaymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
})
}
topupAmount := common.MaxFloat(*input.TopupAmount, 0)
if topupAmount <= 0 {
return 0, newValidationError("Top-up amount must be greater than 0", map[string]any{
"payment_method": input.PaymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
})
}
if topupAmount < shortfall {
return 0, newValidationError("Top-up amount must be greater than or equal to the required shortfall", map[string]any{
"payment_method": input.PaymentMethod,
"wallet_balance": currentWalletBalance,
"total_amount": totalAmount,
"shortfall": shortfall,
"topup_amount": topupAmount,
})
}
return topupAmount, nil
}
func createPaymentWalletTransactions(tx *gorm.DB, input ExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, currency string) error {
if input.PaymentMethod == common.PaymentMethodTopup {
topupTransaction := &model.WalletTransaction{ID: uuid.New().String(), UserID: input.UserID, Type: common.WalletTransactionTypeTopup, Amount: topupAmount, Currency: model.StringPtr(currency), Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", input.Plan.Name, input.TermMonths)), PaymentID: &paymentRecord.ID, PlanID: &input.Plan.ID, TermMonths: common.Int32Ptr(input.TermMonths)}
if err := tx.Create(topupTransaction).Error; err != nil {
return err
}
}
debitTransaction := &model.WalletTransaction{ID: uuid.New().String(), UserID: input.UserID, Type: common.WalletTransactionTypeSubscriptionDebit, Amount: -totalAmount, Currency: model.StringPtr(currency), Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", input.Plan.Name, input.TermMonths)), PaymentID: &paymentRecord.ID, PlanID: &input.Plan.ID, TermMonths: common.Int32Ptr(input.TermMonths)}
return tx.Create(debitTransaction).Error
}
func buildPaymentSubscription(input ExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, now, newExpiry time.Time) *model.PlanSubscription {
return &model.PlanSubscription{ID: uuid.New().String(), UserID: input.UserID, PaymentID: paymentRecord.ID, PlanID: input.Plan.ID, TermMonths: input.TermMonths, PaymentMethod: input.PaymentMethod, WalletAmount: totalAmount, TopupAmount: topupAmount, StartedAt: now, ExpiresAt: newExpiry}
}
func buildSubscriptionNotification(userID, paymentID, 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(common.MustMarshalJSON(map[string]any{"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 buildReferralRewardNotification(userID string, rewardAmount float64, referee *model.User, paymentRecord *model.Payment) *model.Notification {
refereeLabel := strings.TrimSpace(referee.Email)
if username := strings.TrimSpace(common.StringValue(referee.Username)); username != "" {
refereeLabel = "@" + username
}
return &model.Notification{ID: uuid.New().String(), UserID: userID, Type: "billing.referral_reward", Title: "Referral reward granted", Message: fmt.Sprintf("You received %.2f USD from %s's first subscription.", rewardAmount, refereeLabel), Metadata: model.StringPtr(common.MustMarshalJSON(map[string]any{"payment_id": paymentRecord.ID, "referee_id": referee.ID, "amount": rewardAmount}))}
}
func (m *Module) MaybeGrantReferralReward(ctx context.Context, tx *gorm.DB, input ExecutionInput, paymentRecord *model.Payment, subscription *model.PlanSubscription) (*ReferralRewardResult, error) {
if paymentRecord == nil || subscription == nil || input.Plan == nil {
return &ReferralRewardResult{}, nil
}
if subscription.PaymentMethod != common.PaymentMethodWallet && subscription.PaymentMethod != common.PaymentMethodTopup {
return &ReferralRewardResult{}, nil
}
referee, err := common.LockUserForUpdate(ctx, tx, input.UserID)
if err != nil {
return nil, err
}
if referee.ReferredByUserID == nil || strings.TrimSpace(*referee.ReferredByUserID) == "" {
return &ReferralRewardResult{}, nil
}
if common.ReferralRewardProcessed(referee) {
return &ReferralRewardResult{}, nil
}
var subscriptionCount int64
if err := tx.WithContext(ctx).Model(&model.PlanSubscription{}).Where("user_id = ?", referee.ID).Count(&subscriptionCount).Error; err != nil {
return nil, err
}
if subscriptionCount != 1 {
return &ReferralRewardResult{}, nil
}
referrer, err := common.LockUserForUpdate(ctx, tx, strings.TrimSpace(*referee.ReferredByUserID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &ReferralRewardResult{}, nil
}
return nil, err
}
if referrer.ID == referee.ID || !common.ReferralUserEligible(referrer) {
return &ReferralRewardResult{}, nil
}
bps := common.EffectiveReferralRewardBps(referrer.ReferralRewardBps)
if bps <= 0 {
return &ReferralRewardResult{}, nil
}
baseAmount := input.Plan.Price * float64(input.TermMonths)
if baseAmount <= 0 {
return &ReferralRewardResult{}, nil
}
rewardAmount := baseAmount * float64(bps) / 10000
if rewardAmount <= 0 {
return &ReferralRewardResult{}, nil
}
currency := common.NormalizeCurrency(paymentRecord.Currency)
rewardTransaction := &model.WalletTransaction{ID: uuid.New().String(), UserID: referrer.ID, Type: common.WalletTransactionTypeReferralReward, Amount: rewardAmount, Currency: model.StringPtr(currency), Note: model.StringPtr(fmt.Sprintf("Referral reward for %s first subscription", referee.Email)), PaymentID: &paymentRecord.ID, PlanID: &input.Plan.ID}
if err := tx.Create(rewardTransaction).Error; err != nil {
return nil, err
}
if err := tx.Create(buildReferralRewardNotification(referrer.ID, rewardAmount, referee, paymentRecord)).Error; err != nil {
return nil, err
}
now := time.Now().UTC()
updates := map[string]any{"referral_reward_granted_at": now, "referral_reward_payment_id": paymentRecord.ID, "referral_reward_amount": rewardAmount}
if err := tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", referee.ID).Updates(updates).Error; err != nil {
return nil, err
}
referee.ReferralRewardGrantedAt = &now
referee.ReferralRewardPaymentID = &paymentRecord.ID
referee.ReferralRewardAmount = &rewardAmount
return &ReferralRewardResult{Granted: true, Amount: rewardAmount}, nil
}
func (m *Module) BuildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) {
details, err := m.LoadPaymentInvoiceDetails(ctx, paymentRecord)
if err != nil {
return "", "", err
}
createdAt := common.FormatOptionalTimestamp(paymentRecord.CreatedAt)
lines := []string{"Stream API Invoice", fmt.Sprintf("Invoice ID: %s", common.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, common.NormalizeCurrency(paymentRecord.Currency)), fmt.Sprintf("Status: %s", strings.ToUpper(common.NormalizePaymentStatus(paymentRecord.Status))), fmt.Sprintf("Provider: %s", strings.ToUpper(common.StringValue(paymentRecord.Provider))), fmt.Sprintf("Payment Method: %s", strings.ToUpper(details.PaymentMethod)), fmt.Sprintf("Transaction ID: %s", common.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, common.NormalizeCurrency(paymentRecord.Currency))) }
if details.TopupAmount > 0 { lines = append(lines, fmt.Sprintf("Top-up Added: %.2f %s", details.TopupAmount, common.NormalizeCurrency(paymentRecord.Currency))) }
lines = append(lines, fmt.Sprintf("Created At: %s", createdAt))
return strings.Join(lines, "\n"), common.BuildInvoiceFilename(paymentRecord.ID), nil
}
func (m *Module) LoadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*InvoiceDetails, error) {
details := &InvoiceDetails{PlanName: "Unknown plan", PaymentMethod: common.PaymentMethodWallet}
if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" {
var planRecord model.Plan
if err := m.runtime.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 := m.runtime.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
}
termMonths := subscription.TermMonths
details.TermMonths = &termMonths
details.PaymentMethod = common.NormalizePaymentMethod(subscription.PaymentMethod)
if details.PaymentMethod == "" { details.PaymentMethod = common.PaymentMethodWallet }
details.ExpiresAt = &subscription.ExpiresAt
details.WalletAmount = subscription.WalletAmount
details.TopupAmount = subscription.TopupAmount
return details, nil
}
func (m *Module) ListAdminPayments(ctx context.Context, queryValue ListAdminPaymentsQuery) (*ListAdminPaymentsResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := common.AdminPageLimitOffset(queryValue.Page, queryValue.Limit)
limitInt := int(limit)
db := m.runtime.DB().WithContext(ctx).Model(&model.Payment{})
if queryValue.UserID != "" { db = db.Where("user_id = ?", queryValue.UserID) }
if queryValue.StatusFilter != "" { db = db.Where("UPPER(status) = ?", strings.ToUpper(queryValue.StatusFilter)) }
var total int64
if err := db.Count(&total).Error; err != nil { return nil, err }
var payments []model.Payment
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&payments).Error; err != nil { return nil, err }
items := make([]AdminPaymentView, 0, len(payments))
for _, payment := range payments {
payload, err := m.BuildAdminPayment(ctx, &payment)
if err != nil { return nil, err }
items = append(items, payload)
}
return &ListAdminPaymentsResult{Items: items, Total: total, Page: page, Limit: limit}, nil
}
func (m *Module) GetAdminPayment(ctx context.Context, queryValue GetAdminPaymentQuery) (*AdminPaymentView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if queryValue.ID == "" { return nil, status.Error(codes.NotFound, "Payment not found") }
var payment model.Payment
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", queryValue.ID).First(&payment).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Payment not found") }; return nil, status.Error(codes.Internal, "Failed to get payment") }
payload, err := m.BuildAdminPayment(ctx, &payment)
if err != nil { return nil, status.Error(codes.Internal, "Failed to get payment") }
return &payload, nil
}
func (m *Module) CreateAdminPayment(ctx context.Context, cmd CreateAdminPaymentCommand) (*CreateAdminPaymentResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
user, err := m.LoadPaymentUserForAdmin(ctx, cmd.UserID)
if err != nil { return nil, err }
planRecord, err := m.LoadPaymentPlanForAdmin(ctx, cmd.PlanID)
if err != nil { return nil, err }
resultValue, err := m.ExecutePaymentFlow(ctx, ExecutionInput{UserID: user.ID, Plan: planRecord, TermMonths: cmd.TermMonths, PaymentMethod: cmd.PaymentMethod, TopupAmount: cmd.TopupAmount})
if err != nil { return nil, err }
payload, err := m.BuildAdminPayment(ctx, resultValue.Payment)
if err != nil { return nil, status.Error(codes.Internal, "Failed to create payment") }
return &CreateAdminPaymentResult{Payment: payload, Subscription: resultValue.Subscription, WalletBalance: resultValue.WalletBalance, InvoiceID: resultValue.InvoiceID}, nil
}
func (m *Module) UpdateAdminPayment(ctx context.Context, cmd UpdateAdminPaymentCommand) (*AdminPaymentView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
if cmd.ID == "" { return nil, status.Error(codes.NotFound, "Payment not found") }
newStatus := strings.ToUpper(strings.TrimSpace(cmd.NewStatus))
if newStatus == "" { newStatus = "SUCCESS" }
if newStatus != "SUCCESS" && newStatus != "FAILED" && newStatus != "PENDING" { return nil, status.Error(codes.InvalidArgument, "Invalid payment status") }
var payment model.Payment
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", cmd.ID).First(&payment).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Payment not found") }; return nil, status.Error(codes.Internal, "Failed to update payment") }
currentStatus := strings.ToUpper(common.NormalizePaymentStatus(payment.Status))
if currentStatus != newStatus {
if (currentStatus == "FAILED" || currentStatus == "PENDING") && newStatus == "SUCCESS" { return nil, status.Error(codes.InvalidArgument, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead") }
payment.Status = model.StringPtr(newStatus)
if err := m.runtime.DB().WithContext(ctx).Save(&payment).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to update payment") }
}
payload, err := m.BuildAdminPayment(ctx, &payment)
if err != nil { return nil, status.Error(codes.Internal, "Failed to update payment") }
return &payload, nil
}
func (m *Module) BuildAdminPayment(ctx context.Context, payment *model.Payment) (AdminPaymentView, error) {
if payment == nil { return AdminPaymentView{}, nil }
createdAt := payment.CreatedAt.UTC().Format(time.RFC3339)
updatedAt := payment.UpdatedAt.UTC().Format(time.RFC3339)
view := AdminPaymentView{ID: payment.ID, UserID: payment.UserID, PlanID: common.NullableTrimmedString(payment.PlanID), Amount: payment.Amount, Currency: common.NormalizeCurrency(payment.Currency), Status: common.NormalizePaymentStatus(payment.Status), Provider: strings.ToUpper(common.StringValue(payment.Provider)), TransactionID: common.NullableTrimmedString(payment.TransactionID), InvoiceID: payment.ID, CreatedAt: &createdAt, UpdatedAt: &updatedAt}
userEmail, err := m.loadAdminUserEmail(ctx, payment.UserID)
if err != nil { return AdminPaymentView{}, err }
view.UserEmail = userEmail
planName, err := m.loadAdminPlanName(ctx, payment.PlanID)
if err != nil { return AdminPaymentView{}, err }
view.PlanName = planName
termMonths, paymentMethod, expiresAt, walletAmount, topupAmount, err := m.loadAdminPaymentSubscriptionDetails(ctx, payment.ID)
if err != nil { return AdminPaymentView{}, err }
view.TermMonths = termMonths
view.PaymentMethod = paymentMethod
view.ExpiresAt = expiresAt
view.WalletAmount = walletAmount
view.TopupAmount = topupAmount
return view, 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 (m *Module) loadAdminPlanName(ctx context.Context, planID *string) (*string, error) {
if planID == nil || strings.TrimSpace(*planID) == "" { return nil, nil }
var plan model.Plan
if err := m.runtime.DB().WithContext(ctx).Select("id, name").Where("id = ?", *planID).First(&plan).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil }; return nil, err }
return common.NullableTrimmedString(&plan.Name), nil
}
func (m *Module) loadAdminPaymentSubscriptionDetails(ctx context.Context, paymentID string) (*int32, *string, *string, *float64, *float64, error) {
var subscription model.PlanSubscription
if err := m.runtime.DB().WithContext(ctx).Where("payment_id = ?", paymentID).Order("created_at DESC").First(&subscription).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, nil, nil, nil }; return nil, nil, nil, nil, nil, err }
termMonths := subscription.TermMonths
paymentMethod := common.NullableTrimmedString(&subscription.PaymentMethod)
expiresAt := subscription.ExpiresAt.UTC().Format(time.RFC3339)
walletAmount := subscription.WalletAmount
topupAmount := subscription.TopupAmount
return &termMonths, paymentMethod, common.NullableTrimmedString(&expiresAt), &walletAmount, &topupAmount, nil
}

View File

@@ -0,0 +1,122 @@
package payments
import (
"time"
"google.golang.org/protobuf/types/known/timestamppb"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
func presentCreatePaymentResponse(result *CreatePaymentResult) *appv1.CreatePaymentResponse {
return &appv1.CreatePaymentResponse{
Payment: common.ToProtoPayment(result.Payment),
Subscription: common.ToProtoPlanSubscription(result.Subscription),
WalletBalance: result.WalletBalance,
InvoiceId: result.InvoiceID,
Message: result.Message,
}
}
func presentPaymentHistoryResponse(result *PaymentHistoryResult) *appv1.ListPaymentHistoryResponse {
items := make([]*appv1.PaymentHistoryItem, 0, len(result.Items))
for _, row := range result.Items {
items = append(items, &appv1.PaymentHistoryItem{
Id: row.ID,
Amount: row.Amount,
Currency: row.Currency,
Status: row.Status,
PlanId: row.PlanID,
PlanName: row.PlanName,
InvoiceId: row.InvoiceID,
Kind: row.Kind,
TermMonths: row.TermMonths,
PaymentMethod: row.PaymentMethod,
ExpiresAt: parseRFC3339ToProto(row.ExpiresAt),
CreatedAt: parseRFC3339ToProto(row.CreatedAt),
})
}
return &appv1.ListPaymentHistoryResponse{
Payments: items,
Total: result.Total,
Page: result.Page,
Limit: result.Limit,
HasPrev: result.HasPrev,
HasNext: result.HasNext,
}
}
func presentTopupWalletResponse(result *TopupWalletResult) *appv1.TopupWalletResponse {
return &appv1.TopupWalletResponse{
WalletTransaction: common.ToProtoWalletTransaction(result.WalletTransaction),
WalletBalance: result.WalletBalance,
InvoiceId: result.InvoiceID,
}
}
func presentDownloadInvoiceResponse(result *DownloadInvoiceResult) *appv1.DownloadInvoiceResponse {
return &appv1.DownloadInvoiceResponse{
Filename: result.Filename,
ContentType: result.ContentType,
Content: result.Content,
}
}
func presentAdminPayment(view AdminPaymentView) *appv1.AdminPayment {
return &appv1.AdminPayment{
Id: view.ID,
UserId: view.UserID,
PlanId: view.PlanID,
Amount: view.Amount,
Currency: view.Currency,
Status: view.Status,
Provider: view.Provider,
TransactionId: view.TransactionID,
InvoiceId: view.InvoiceID,
CreatedAt: parseRFC3339ToProto(view.CreatedAt),
UpdatedAt: parseRFC3339ToProto(view.UpdatedAt),
UserEmail: view.UserEmail,
PlanName: view.PlanName,
TermMonths: view.TermMonths,
PaymentMethod: view.PaymentMethod,
ExpiresAt: view.ExpiresAt,
WalletAmount: view.WalletAmount,
TopupAmount: view.TopupAmount,
}
}
func presentListAdminPaymentsResponse(result *ListAdminPaymentsResult) *appv1.ListAdminPaymentsResponse {
items := make([]*appv1.AdminPayment, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentAdminPayment(item))
}
return &appv1.ListAdminPaymentsResponse{Payments: items, Total: result.Total, Page: result.Page, Limit: result.Limit}
}
func presentGetAdminPaymentResponse(view AdminPaymentView) *appv1.GetAdminPaymentResponse {
return &appv1.GetAdminPaymentResponse{Payment: presentAdminPayment(view)}
}
func presentCreateAdminPaymentResponse(result *CreateAdminPaymentResult) *appv1.CreateAdminPaymentResponse {
return &appv1.CreateAdminPaymentResponse{
Payment: presentAdminPayment(result.Payment),
Subscription: common.ToProtoPlanSubscription(result.Subscription),
WalletBalance: result.WalletBalance,
InvoiceId: result.InvoiceID,
}
}
func presentUpdateAdminPaymentResponse(view AdminPaymentView) *appv1.UpdateAdminPaymentResponse {
return &appv1.UpdateAdminPaymentResponse{Payment: presentAdminPayment(view)}
}
func parseRFC3339ToProto(value *string) *timestamppb.Timestamp {
if value == nil || *value == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, *value)
if err != nil {
return nil
}
return timestamppb.New(parsed.UTC())
}

View File

@@ -0,0 +1,137 @@
package payments
import "stream.api/internal/database/model"
type CreatePaymentCommand struct {
UserID string
PlanID string
TermMonths int32
PaymentMethod string
TopupAmount *float64
}
type CreatePaymentResult struct {
Payment *model.Payment
Subscription *model.PlanSubscription
WalletBalance float64
InvoiceID string
Message string
}
type PaymentHistoryQuery struct {
UserID string
Page int32
Limit int32
}
type PaymentHistoryItem struct {
ID string
Amount float64
Currency string
Status string
PlanID *string
PlanName *string
InvoiceID string
Kind string
TermMonths *int32
PaymentMethod *string
ExpiresAt *string
CreatedAt *string
}
type PaymentHistoryResult struct {
Items []PaymentHistoryItem
Total int64
Page int32
Limit int32
HasPrev bool
HasNext bool
}
type TopupWalletCommand struct {
UserID string
Amount float64
}
type TopupWalletResult struct {
WalletTransaction *model.WalletTransaction
WalletBalance float64
InvoiceID string
}
type DownloadInvoiceQuery struct {
UserID string
ID string
}
type DownloadInvoiceResult struct {
Filename string
ContentType string
Content string
}
type ListAdminPaymentsQuery struct {
Page int32
Limit int32
UserID string
StatusFilter string
}
type AdminPaymentView struct {
ID string
UserID string
PlanID *string
Amount float64
Currency string
Status string
Provider string
TransactionID *string
InvoiceID string
CreatedAt *string
UpdatedAt *string
UserEmail *string
PlanName *string
TermMonths *int32
PaymentMethod *string
ExpiresAt *string
WalletAmount *float64
TopupAmount *float64
}
type ListAdminPaymentsResult struct {
Items []AdminPaymentView
Total int64
Page int32
Limit int32
}
type GetAdminPaymentQuery struct {
ID string
}
type CreateAdminPaymentCommand struct {
UserID string
PlanID string
TermMonths int32
PaymentMethod string
TopupAmount *float64
}
type CreateAdminPaymentResult struct {
Payment AdminPaymentView
Subscription *model.PlanSubscription
WalletBalance float64
InvoiceID string
}
type UpdateAdminPaymentCommand struct {
ID string
NewStatus string
}
type PaymentValidationError struct {
GRPCCode int
HTTPCode int
Message string
Data map[string]any
}

View File

@@ -0,0 +1,56 @@
package plans
import (
"context"
appv1 "stream.api/internal/gen/proto/app/v1"
)
type Handler struct {
appv1.UnimplementedPlansServiceServer
module *Module
}
var _ appv1.PlansServiceServer = (*Handler)(nil)
func NewHandler(module *Module) *Handler { return &Handler{module: module} }
func (h *Handler) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) {
result, err := h.module.ListPlans(ctx)
if err != nil {
return nil, err
}
return presentListPlansResponse(result), nil
}
func (h *Handler) ListAdminPlans(ctx context.Context, _ *appv1.ListAdminPlansRequest) (*appv1.ListAdminPlansResponse, error) {
result, err := h.module.ListAdminPlans(ctx)
if err != nil {
return nil, err
}
return presentListAdminPlansResponse(result), nil
}
func (h *Handler) CreateAdminPlan(ctx context.Context, req *appv1.CreateAdminPlanRequest) (*appv1.CreateAdminPlanResponse, error) {
result, err := h.module.CreateAdminPlan(ctx, CreateAdminPlanCommand{Name: req.GetName(), Description: req.Description, Features: req.GetFeatures(), Price: req.GetPrice(), Cycle: req.GetCycle(), StorageLimit: req.GetStorageLimit(), UploadLimit: req.GetUploadLimit(), IsActive: req.GetIsActive()})
if err != nil {
return nil, err
}
return presentCreateAdminPlanResponse(*result), nil
}
func (h *Handler) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdminPlanRequest) (*appv1.UpdateAdminPlanResponse, error) {
result, err := h.module.UpdateAdminPlan(ctx, UpdateAdminPlanCommand{ID: req.GetId(), Name: req.GetName(), Description: req.Description, Features: req.GetFeatures(), Price: req.GetPrice(), Cycle: req.GetCycle(), StorageLimit: req.GetStorageLimit(), UploadLimit: req.GetUploadLimit(), IsActive: req.GetIsActive()})
if err != nil {
return nil, err
}
return presentUpdateAdminPlanResponse(*result), nil
}
func (h *Handler) DeleteAdminPlan(ctx context.Context, req *appv1.DeleteAdminPlanRequest) (*appv1.DeleteAdminPlanResponse, error) {
result, err := h.module.DeleteAdminPlan(ctx, DeleteAdminPlanCommand{ID: req.GetId()})
if err != nil {
return nil, err
}
return presentDeleteAdminPlanResponse(result), nil
}

View File

@@ -0,0 +1,191 @@
package plans
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/modules/common"
)
type Module struct {
runtime *common.Runtime
}
func New(runtime *common.Runtime) *Module {
return &Module{runtime: runtime}
}
func (m *Module) ListPlans(ctx context.Context) (*ListPlansResult, error) {
if _, err := m.runtime.Authenticate(ctx); err != nil {
return nil, err
}
var plans []model.Plan
if err := m.runtime.DB().WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil {
m.runtime.Logger().Error("Failed to fetch plans", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch plans")
}
items := make([]PlanView, 0, len(plans))
for i := range plans {
items = append(items, PlanView{Plan: &plans[i]})
}
return &ListPlansResult{Items: items}, nil
}
func (m *Module) ListAdminPlans(ctx context.Context) (*ListAdminPlansResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
var plans []model.Plan
if err := m.runtime.DB().WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list plans")
}
items := make([]AdminPlanView, 0, len(plans))
for i := range plans {
payload, err := m.BuildAdminPlan(ctx, &plans[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list plans")
}
items = append(items, payload)
}
return &ListAdminPlansResult{Items: items}, nil
}
func (m *Module) CreateAdminPlan(ctx context.Context, cmd CreateAdminPlanCommand) (*AdminPlanView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if msg := validateAdminPlanInput(cmd.Name, cmd.Cycle, cmd.Price, cmd.StorageLimit, cmd.UploadLimit); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
plan := &model.Plan{ID: uuid.New().String(), Name: strings.TrimSpace(cmd.Name), Description: common.NullableTrimmedStringPtr(cmd.Description), Features: append([]string(nil), cmd.Features...), Price: cmd.Price, Cycle: strings.TrimSpace(cmd.Cycle), StorageLimit: cmd.StorageLimit, UploadLimit: cmd.UploadLimit, DurationLimit: 0, QualityLimit: "", IsActive: model.BoolPtr(cmd.IsActive)}
if err := m.runtime.DB().WithContext(ctx).Create(plan).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to create plan")
}
payload, err := m.BuildAdminPlan(ctx, plan)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create plan")
}
return &payload, nil
}
func (m *Module) UpdateAdminPlan(ctx context.Context, cmd UpdateAdminPlanCommand) (*AdminPlanView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if cmd.ID == "" {
return nil, status.Error(codes.NotFound, "Plan not found")
}
if msg := validateAdminPlanInput(cmd.Name, cmd.Cycle, cmd.Price, cmd.StorageLimit, cmd.UploadLimit); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var plan model.Plan
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", cmd.ID).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found")
}
return nil, status.Error(codes.Internal, "Failed to update plan")
}
plan.Name = strings.TrimSpace(cmd.Name)
plan.Description = common.NullableTrimmedStringPtr(cmd.Description)
plan.Features = append([]string(nil), cmd.Features...)
plan.Price = cmd.Price
plan.Cycle = strings.TrimSpace(cmd.Cycle)
plan.StorageLimit = cmd.StorageLimit
plan.UploadLimit = cmd.UploadLimit
plan.IsActive = model.BoolPtr(cmd.IsActive)
if err := m.runtime.DB().WithContext(ctx).Save(&plan).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan")
}
payload, err := m.BuildAdminPlan(ctx, &plan)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan")
}
return &payload, nil
}
func (m *Module) DeleteAdminPlan(ctx context.Context, cmd DeleteAdminPlanCommand) (*DeleteAdminPlanResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if cmd.ID == "" {
return nil, status.Error(codes.NotFound, "Plan not found")
}
var plan model.Plan
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", cmd.ID).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found")
}
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
var paymentCount int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", cmd.ID).Count(&paymentCount).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
var subscriptionCount int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", cmd.ID).Count(&subscriptionCount).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
if paymentCount > 0 || subscriptionCount > 0 {
inactive := false
if err := m.runtime.DB().WithContext(ctx).Model(&model.Plan{}).Where("id = ?", cmd.ID).Update("is_active", inactive).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to deactivate plan")
}
return &DeleteAdminPlanResult{Message: "Plan deactivated", Mode: "deactivated"}, nil
}
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", cmd.ID).Delete(&model.Plan{}).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
return &DeleteAdminPlanResult{Message: "Plan deleted", Mode: "deleted"}, nil
}
func (m *Module) BuildAdminPlan(ctx context.Context, plan *model.Plan) (AdminPlanView, error) {
if plan == nil {
return AdminPlanView{}, nil
}
userCount, paymentCount, subscriptionCount, err := m.loadAdminPlanUsageCounts(ctx, plan.ID)
if err != nil {
return AdminPlanView{}, err
}
return AdminPlanView{ID: plan.ID, Name: plan.Name, Description: common.NullableTrimmedString(plan.Description), Features: append([]string(nil), plan.Features...), Price: plan.Price, Cycle: plan.Cycle, StorageLimit: plan.StorageLimit, UploadLimit: plan.UploadLimit, DurationLimit: int32(plan.DurationLimit), QualityLimit: plan.QualityLimit, IsActive: common.BoolValue(plan.IsActive), UserCount: userCount, PaymentCount: paymentCount, SubscriptionCount: subscriptionCount}, nil
}
func (m *Module) loadAdminPlanUsageCounts(ctx context.Context, planID string) (int64, int64, int64, error) {
var userCount int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("plan_id = ?", planID).Count(&userCount).Error; err != nil {
return 0, 0, 0, err
}
var paymentCount int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", planID).Count(&paymentCount).Error; err != nil {
return 0, 0, 0, err
}
var subscriptionCount int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", planID).Count(&subscriptionCount).Error; err != nil {
return 0, 0, 0, err
}
return userCount, paymentCount, subscriptionCount, nil
}
func validateAdminPlanInput(name, cycle string, price float64, storageLimit int64, uploadLimit int32) string {
if strings.TrimSpace(name) == "" {
return "Name is required"
}
if strings.TrimSpace(cycle) == "" {
return "Cycle is required"
}
if price < 0 {
return "Price must be greater than or equal to 0"
}
if storageLimit <= 0 {
return "Storage limit must be greater than 0"
}
if uploadLimit <= 0 {
return "Upload limit must be greater than 0"
}
return ""
}

View File

@@ -0,0 +1,62 @@
package plans
import appv1 "stream.api/internal/gen/proto/app/v1"
func presentListPlansResponse(result *ListPlansResult) *appv1.ListPlansResponse {
items := make([]*appv1.Plan, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, &appv1.Plan{
Id: item.Plan.ID,
Name: item.Plan.Name,
Description: item.Plan.Description,
Price: item.Plan.Price,
Cycle: item.Plan.Cycle,
StorageLimit: item.Plan.StorageLimit,
UploadLimit: item.Plan.UploadLimit,
DurationLimit: item.Plan.DurationLimit,
QualityLimit: item.Plan.QualityLimit,
Features: item.Plan.Features,
IsActive: item.Plan.IsActive != nil && *item.Plan.IsActive,
})
}
return &appv1.ListPlansResponse{Plans: items}
}
func presentAdminPlan(view AdminPlanView) *appv1.AdminPlan {
return &appv1.AdminPlan{
Id: view.ID,
Name: view.Name,
Description: view.Description,
Features: view.Features,
Price: view.Price,
Cycle: view.Cycle,
StorageLimit: view.StorageLimit,
UploadLimit: view.UploadLimit,
DurationLimit: view.DurationLimit,
QualityLimit: view.QualityLimit,
IsActive: view.IsActive,
UserCount: view.UserCount,
PaymentCount: view.PaymentCount,
SubscriptionCount: view.SubscriptionCount,
}
}
func presentListAdminPlansResponse(result *ListAdminPlansResult) *appv1.ListAdminPlansResponse {
items := make([]*appv1.AdminPlan, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentAdminPlan(item))
}
return &appv1.ListAdminPlansResponse{Plans: items}
}
func presentCreateAdminPlanResponse(view AdminPlanView) *appv1.CreateAdminPlanResponse {
return &appv1.CreateAdminPlanResponse{Plan: presentAdminPlan(view)}
}
func presentUpdateAdminPlanResponse(view AdminPlanView) *appv1.UpdateAdminPlanResponse {
return &appv1.UpdateAdminPlanResponse{Plan: presentAdminPlan(view)}
}
func presentDeleteAdminPlanResponse(result *DeleteAdminPlanResult) *appv1.DeleteAdminPlanResponse {
return &appv1.DeleteAdminPlanResponse{Message: result.Message, Mode: result.Mode}
}

View File

@@ -0,0 +1,64 @@
package plans
import "stream.api/internal/database/model"
type PlanView struct {
Plan *model.Plan
}
type ListPlansResult struct {
Items []PlanView
}
type AdminPlanView struct {
ID string
Name string
Description *string
Features []string
Price float64
Cycle string
StorageLimit int64
UploadLimit int32
DurationLimit int32
QualityLimit string
IsActive bool
UserCount int64
PaymentCount int64
SubscriptionCount int64
}
type ListAdminPlansResult struct {
Items []AdminPlanView
}
type CreateAdminPlanCommand struct {
Name string
Description *string
Features []string
Price float64
Cycle string
StorageLimit int64
UploadLimit int32
IsActive bool
}
type UpdateAdminPlanCommand struct {
ID string
Name string
Description *string
Features []string
Price float64
Cycle string
StorageLimit int64
UploadLimit int32
IsActive bool
}
type DeleteAdminPlanCommand struct {
ID string
}
type DeleteAdminPlanResult struct {
Message string
Mode string
}

View File

@@ -0,0 +1,103 @@
package playerconfigs
import (
"context"
"strings"
appv1 "stream.api/internal/gen/proto/app/v1"
)
type Handler struct {
appv1.UnimplementedPlayerConfigsServiceServer
module *Module
}
var _ appv1.PlayerConfigsServiceServer = (*Handler)(nil)
func NewHandler(module *Module) *Handler { return &Handler{module: module} }
func (h *Handler) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.ListPlayerConfigs(ctx, ListPlayerConfigsQuery{UserID: result.UserID})
if err != nil {
return nil, err
}
return presentListPlayerConfigsResponse(payload), nil
}
func (h *Handler) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.CreatePlayerConfig(ctx, CreatePlayerConfigCommand{UserID: result.UserID, Name: req.GetName(), Description: req.Description, Autoplay: req.GetAutoplay(), Loop: req.GetLoop(), Muted: req.GetMuted(), ShowControls: req.GetShowControls(), Pip: req.GetPip(), Airplay: req.GetAirplay(), Chromecast: req.GetChromecast(), IsActive: req.IsActive, IsDefault: req.IsDefault, EncrytionM3U8: req.EncrytionM3U8, LogoURL: req.LogoUrl})
if err != nil {
return nil, err
}
return presentCreatePlayerConfigResponse(*payload), nil
}
func (h *Handler) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.UpdatePlayerConfig(ctx, UpdatePlayerConfigCommand{UserID: result.UserID, ID: strings.TrimSpace(req.GetId()), Name: req.GetName(), Description: req.Description, Autoplay: req.GetAutoplay(), Loop: req.GetLoop(), Muted: req.GetMuted(), ShowControls: req.GetShowControls(), Pip: req.GetPip(), Airplay: req.GetAirplay(), Chromecast: req.GetChromecast(), IsActive: req.IsActive, IsDefault: req.IsDefault, EncrytionM3U8: req.EncrytionM3U8, LogoURL: req.LogoUrl})
if err != nil {
return nil, err
}
return presentUpdatePlayerConfigResponse(*payload), nil
}
func (h *Handler) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
if err := h.module.DeletePlayerConfig(ctx, DeletePlayerConfigCommand{UserID: result.UserID, ID: strings.TrimSpace(req.GetId())}); err != nil {
return nil, err
}
return &appv1.MessageResponse{Message: "Player config deleted"}, nil
}
func (h *Handler) ListAdminPlayerConfigs(ctx context.Context, req *appv1.ListAdminPlayerConfigsRequest) (*appv1.ListAdminPlayerConfigsResponse, error) {
payload, err := h.module.ListAdminPlayerConfigs(ctx, ListAdminPlayerConfigsQuery{Page: req.GetPage(), Limit: req.GetLimit(), Search: req.Search, UserID: req.UserId})
if err != nil {
return nil, err
}
return presentListAdminPlayerConfigsResponse(payload), nil
}
func (h *Handler) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAdminPlayerConfigRequest) (*appv1.GetAdminPlayerConfigResponse, error) {
payload, err := h.module.GetAdminPlayerConfig(ctx, GetAdminPlayerConfigQuery{ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, err
}
return presentGetAdminPlayerConfigResponse(*payload), nil
}
func (h *Handler) CreateAdminPlayerConfig(ctx context.Context, req *appv1.CreateAdminPlayerConfigRequest) (*appv1.CreateAdminPlayerConfigResponse, error) {
payload, err := h.module.CreateAdminPlayerConfig(ctx, CreateAdminPlayerConfigCommand{UserID: strings.TrimSpace(req.GetUserId()), Name: req.GetName(), Description: req.Description, Autoplay: req.GetAutoplay(), Loop: req.GetLoop(), Muted: req.GetMuted(), ShowControls: req.GetShowControls(), Pip: req.GetPip(), Airplay: req.GetAirplay(), Chromecast: req.GetChromecast(), IsActive: req.GetIsActive(), IsDefault: req.GetIsDefault(), EncrytionM3U8: req.EncrytionM3U8, LogoURL: req.LogoUrl})
if err != nil {
return nil, err
}
return presentCreateAdminPlayerConfigResponse(*payload), nil
}
func (h *Handler) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.UpdateAdminPlayerConfigRequest) (*appv1.UpdateAdminPlayerConfigResponse, error) {
payload, err := h.module.UpdateAdminPlayerConfig(ctx, UpdateAdminPlayerConfigCommand{ID: strings.TrimSpace(req.GetId()), UserID: strings.TrimSpace(req.GetUserId()), Name: req.GetName(), Description: req.Description, Autoplay: req.GetAutoplay(), Loop: req.GetLoop(), Muted: req.GetMuted(), ShowControls: req.GetShowControls(), Pip: req.GetPip(), Airplay: req.GetAirplay(), Chromecast: req.GetChromecast(), IsActive: req.GetIsActive(), IsDefault: req.GetIsDefault(), EncrytionM3U8: req.EncrytionM3U8, LogoURL: req.LogoUrl})
if err != nil {
return nil, err
}
return presentUpdateAdminPlayerConfigResponse(*payload), nil
}
func (h *Handler) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.DeleteAdminPlayerConfigRequest) (*appv1.MessageResponse, error) {
if err := h.module.DeleteAdminPlayerConfig(ctx, DeleteAdminPlayerConfigCommand{ID: strings.TrimSpace(req.GetId())}); err != nil {
return nil, err
}
return &appv1.MessageResponse{Message: "Player config deleted"}, nil
}

View File

@@ -0,0 +1,394 @@
package playerconfigs
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) ListPlayerConfigs(ctx context.Context, queryValue ListPlayerConfigsQuery) (*ListPlayerConfigsResult, error) {
var items []model.PlayerConfig
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 player configs", "error", err)
return nil, status.Error(codes.Internal, "Failed to load player configs")
}
result := &ListPlayerConfigsResult{Items: make([]PlayerConfigView, 0, len(items))}
for i := range items {
result.Items = append(result.Items, PlayerConfigView{Config: &items[i]})
}
return result, nil
}
func (m *Module) CreatePlayerConfig(ctx context.Context, cmd CreatePlayerConfigCommand) (*PlayerConfigView, error) {
name := strings.TrimSpace(cmd.Name)
if name == "" {
return nil, status.Error(codes.InvalidArgument, "Name is required")
}
item := &model.PlayerConfig{ID: uuid.New().String(), UserID: cmd.UserID, Name: name, Description: common.NullableTrimmedString(cmd.Description), Autoplay: cmd.Autoplay, Loop: cmd.Loop, Muted: cmd.Muted, ShowControls: model.BoolPtr(cmd.ShowControls), Pip: model.BoolPtr(cmd.Pip), Airplay: model.BoolPtr(cmd.Airplay), Chromecast: model.BoolPtr(cmd.Chromecast), IsActive: model.BoolPtr(cmd.IsActive == nil || *cmd.IsActive), IsDefault: cmd.IsDefault != nil && *cmd.IsDefault, EncrytionM3u8: model.BoolPtr(cmd.EncrytionM3U8 == nil || *cmd.EncrytionM3U8), LogoURL: common.NullableTrimmedString(cmd.LogoURL)}
if !common.PlayerConfigIsActive(item.IsActive) {
item.IsDefault = false
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := common.LockUserForUpdate(ctx, tx, cmd.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", cmd.UserID).Count(&configCount).Error; err != nil {
return err
}
if err := common.PlayerConfigActionAllowed(lockedUser, configCount, "create"); err != nil {
return err
}
if item.IsDefault {
if err := common.UnsetDefaultPlayerConfigs(tx, cmd.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
if status.Code(err) != codes.Unknown {
return nil, err
}
m.runtime.Logger().Error("Failed to create player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &PlayerConfigView{Config: item}, nil
}
func (m *Module) UpdatePlayerConfig(ctx context.Context, cmd UpdatePlayerConfigCommand) (*PlayerConfigView, error) {
if strings.TrimSpace(cmd.ID) == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
name := strings.TrimSpace(cmd.Name)
if name == "" {
return nil, status.Error(codes.InvalidArgument, "Name is required")
}
var item model.PlayerConfig
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := common.LockUserForUpdate(ctx, tx, cmd.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", cmd.UserID).Count(&configCount).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", cmd.ID, cmd.UserID).First(&item).Error; err != nil {
return err
}
action := "update"
wasActive := common.PlayerConfigIsActive(item.IsActive)
if cmd.IsActive != nil && *cmd.IsActive != wasActive {
action = "toggle-active"
}
if cmd.IsDefault != nil && *cmd.IsDefault {
action = "set-default"
}
if err := common.PlayerConfigActionAllowed(lockedUser, configCount, action); err != nil {
return err
}
item.Name = name
item.Description = common.NullableTrimmedString(cmd.Description)
item.Autoplay = cmd.Autoplay
item.Loop = cmd.Loop
item.Muted = cmd.Muted
item.ShowControls = model.BoolPtr(cmd.ShowControls)
item.Pip = model.BoolPtr(cmd.Pip)
item.Airplay = model.BoolPtr(cmd.Airplay)
item.Chromecast = model.BoolPtr(cmd.Chromecast)
if cmd.EncrytionM3U8 != nil {
item.EncrytionM3u8 = model.BoolPtr(*cmd.EncrytionM3U8)
}
if cmd.LogoURL != nil {
item.LogoURL = common.NullableTrimmedString(cmd.LogoURL)
}
if cmd.IsActive != nil {
item.IsActive = model.BoolPtr(*cmd.IsActive)
}
if cmd.IsDefault != nil {
item.IsDefault = *cmd.IsDefault
}
if !common.PlayerConfigIsActive(item.IsActive) {
item.IsDefault = false
}
if item.IsDefault {
if err := common.UnsetDefaultPlayerConfigs(tx, cmd.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if status.Code(err) != codes.Unknown {
return nil, err
}
m.runtime.Logger().Error("Failed to update player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &PlayerConfigView{Config: &item}, nil
}
func (m *Module) DeletePlayerConfig(ctx context.Context, cmd DeletePlayerConfigCommand) error {
if strings.TrimSpace(cmd.ID) == "" {
return status.Error(codes.NotFound, "Player config not found")
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := common.LockUserForUpdate(ctx, tx, cmd.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", cmd.UserID).Count(&configCount).Error; err != nil {
return err
}
if err := common.PlayerConfigActionAllowed(lockedUser, configCount, "delete"); err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", cmd.ID, cmd.UserID).Delete(&model.PlayerConfig{})
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, "Player config not found")
}
if status.Code(err) != codes.Unknown {
return err
}
m.runtime.Logger().Error("Failed to delete player config", "error", err)
return status.Error(codes.Internal, "Failed to delete player config")
}
return nil
}
func (m *Module) ListAdminPlayerConfigs(ctx context.Context, queryValue ListAdminPlayerConfigsQuery) (*ListAdminPlayerConfigsResult, 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.PlayerConfig{})
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 player configs")
}
var configs []model.PlayerConfig
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&configs).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
items := make([]AdminPlayerConfigView, 0, len(configs))
for i := range configs {
payload, err := m.buildAdminPlayerConfig(ctx, &configs[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
items = append(items, payload)
}
return &ListAdminPlayerConfigsResult{Items: items, Total: total, Page: page, Limit: limit}, nil
}
func (m *Module) GetAdminPlayerConfig(ctx context.Context, queryValue GetAdminPlayerConfigQuery) (*AdminPlayerConfigView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if strings.TrimSpace(queryValue.ID) == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
var item model.PlayerConfig
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, "Player config not found")
}
return nil, status.Error(codes.Internal, "Failed to load player config")
}
payload, err := m.buildAdminPlayerConfig(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load player config")
}
return &payload, nil
}
func (m *Module) CreateAdminPlayerConfig(ctx context.Context, cmd CreateAdminPlayerConfigCommand) (*AdminPlayerConfigView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if msg := validateAdminPlayerConfigInput(cmd.UserID, cmd.Name); 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 player config")
}
item := &model.PlayerConfig{ID: uuid.New().String(), UserID: user.ID, Name: strings.TrimSpace(cmd.Name), Description: common.NullableTrimmedStringPtr(cmd.Description), Autoplay: cmd.Autoplay, Loop: cmd.Loop, Muted: cmd.Muted, ShowControls: model.BoolPtr(cmd.ShowControls), Pip: model.BoolPtr(cmd.Pip), Airplay: model.BoolPtr(cmd.Airplay), Chromecast: model.BoolPtr(cmd.Chromecast), IsActive: model.BoolPtr(cmd.IsActive), IsDefault: cmd.IsDefault, EncrytionM3u8: model.BoolPtr(cmd.EncrytionM3U8 == nil || *cmd.EncrytionM3U8), LogoURL: common.NullableTrimmedStringPtr(cmd.LogoURL)}
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.UnsetDefaultPlayerConfigs(tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
payload, err := m.buildAdminPlayerConfig(ctx, item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &payload, nil
}
func (m *Module) UpdateAdminPlayerConfig(ctx context.Context, cmd UpdateAdminPlayerConfigCommand) (*AdminPlayerConfigView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
if strings.TrimSpace(cmd.ID) == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if msg := validateAdminPlayerConfigInput(cmd.UserID, cmd.Name); 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 player config")
}
var item model.PlayerConfig
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, "Player config not found")
}
return nil, status.Error(codes.Internal, "Failed to save player config")
}
item.UserID = user.ID
item.Name = strings.TrimSpace(cmd.Name)
item.Description = common.NullableTrimmedStringPtr(cmd.Description)
item.Autoplay = cmd.Autoplay
item.Loop = cmd.Loop
item.Muted = cmd.Muted
item.ShowControls = model.BoolPtr(cmd.ShowControls)
item.Pip = model.BoolPtr(cmd.Pip)
item.Airplay = model.BoolPtr(cmd.Airplay)
item.Chromecast = model.BoolPtr(cmd.Chromecast)
item.IsActive = model.BoolPtr(cmd.IsActive)
item.IsDefault = cmd.IsDefault
if cmd.EncrytionM3U8 != nil {
item.EncrytionM3u8 = model.BoolPtr(*cmd.EncrytionM3U8)
}
if cmd.LogoURL != nil {
item.LogoURL = common.NullableTrimmedStringPtr(cmd.LogoURL)
}
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.UnsetDefaultPlayerConfigs(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 player config")
}
payload, err := m.buildAdminPlayerConfig(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &payload, nil
}
func (m *Module) DeleteAdminPlayerConfig(ctx context.Context, cmd DeleteAdminPlayerConfigCommand) error {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return err
}
if strings.TrimSpace(cmd.ID) == "" {
return status.Error(codes.NotFound, "Player config not found")
}
res := m.runtime.DB().WithContext(ctx).Where("id = ?", cmd.ID).Delete(&model.PlayerConfig{})
if res.Error != nil {
return status.Error(codes.Internal, "Failed to delete player config")
}
if res.RowsAffected == 0 {
return status.Error(codes.NotFound, "Player config not found")
}
return nil
}
func (m *Module) buildAdminPlayerConfig(ctx context.Context, item *model.PlayerConfig) (AdminPlayerConfigView, error) {
if item == nil {
return AdminPlayerConfigView{}, nil
}
ownerEmail, err := m.loadAdminUserEmail(ctx, item.UserID)
if err != nil {
return AdminPlayerConfigView{}, err
}
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
return AdminPlayerConfigView{ID: item.ID, UserID: item.UserID, Name: item.Name, Description: item.Description, Autoplay: item.Autoplay, Loop: item.Loop, Muted: item.Muted, ShowControls: common.BoolValue(item.ShowControls), Pip: common.BoolValue(item.Pip), Airplay: common.BoolValue(item.Airplay), Chromecast: common.BoolValue(item.Chromecast), IsActive: common.BoolValue(item.IsActive), IsDefault: item.IsDefault, OwnerEmail: ownerEmail, CreatedAt: createdAt, UpdatedAt: updatedAt, EncrytionM3U8: common.BoolValue(item.EncrytionM3u8), LogoURL: common.NullableTrimmedString(item.LogoURL)}, 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 validateAdminPlayerConfigInput(userID, name string) string {
if strings.TrimSpace(userID) == "" {
return "User ID is required"
}
if strings.TrimSpace(name) == "" {
return "Name is required"
}
return ""
}

View File

@@ -0,0 +1,83 @@
package playerconfigs
import (
"time"
appv1 "stream.api/internal/gen/proto/app/v1"
"google.golang.org/protobuf/types/known/timestamppb"
"stream.api/internal/modules/common"
)
func presentPlayerConfig(view PlayerConfigView) *appv1.PlayerConfig {
return common.ToProtoPlayerConfig(view.Config)
}
func presentListPlayerConfigsResponse(result *ListPlayerConfigsResult) *appv1.ListPlayerConfigsResponse {
items := make([]*appv1.PlayerConfig, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentPlayerConfig(item))
}
return &appv1.ListPlayerConfigsResponse{Configs: items}
}
func presentCreatePlayerConfigResponse(view PlayerConfigView) *appv1.CreatePlayerConfigResponse {
return &appv1.CreatePlayerConfigResponse{Config: presentPlayerConfig(view)}
}
func presentUpdatePlayerConfigResponse(view PlayerConfigView) *appv1.UpdatePlayerConfigResponse {
return &appv1.UpdatePlayerConfigResponse{Config: presentPlayerConfig(view)}
}
func presentAdminPlayerConfig(view AdminPlayerConfigView) *appv1.AdminPlayerConfig {
return &appv1.AdminPlayerConfig{
Id: view.ID,
UserId: view.UserID,
Name: view.Name,
Description: view.Description,
Autoplay: view.Autoplay,
Loop: view.Loop,
Muted: view.Muted,
ShowControls: view.ShowControls,
Pip: view.Pip,
Airplay: view.Airplay,
Chromecast: view.Chromecast,
IsActive: view.IsActive,
IsDefault: view.IsDefault,
OwnerEmail: view.OwnerEmail,
CreatedAt: parseRFC3339ToProto(view.CreatedAt),
UpdatedAt: parseRFC3339ToProto(view.UpdatedAt),
EncrytionM3U8: view.EncrytionM3U8,
LogoUrl: view.LogoURL,
}
}
func presentListAdminPlayerConfigsResponse(result *ListAdminPlayerConfigsResult) *appv1.ListAdminPlayerConfigsResponse {
items := make([]*appv1.AdminPlayerConfig, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentAdminPlayerConfig(item))
}
return &appv1.ListAdminPlayerConfigsResponse{Configs: items, Total: result.Total, Page: result.Page, Limit: result.Limit}
}
func presentGetAdminPlayerConfigResponse(view AdminPlayerConfigView) *appv1.GetAdminPlayerConfigResponse {
return &appv1.GetAdminPlayerConfigResponse{Config: presentAdminPlayerConfig(view)}
}
func presentCreateAdminPlayerConfigResponse(view AdminPlayerConfigView) *appv1.CreateAdminPlayerConfigResponse {
return &appv1.CreateAdminPlayerConfigResponse{Config: presentAdminPlayerConfig(view)}
}
func presentUpdateAdminPlayerConfigResponse(view AdminPlayerConfigView) *appv1.UpdateAdminPlayerConfigResponse {
return &appv1.UpdateAdminPlayerConfigResponse{Config: presentAdminPlayerConfig(view)}
}
func parseRFC3339ToProto(value *string) *timestamppb.Timestamp {
if value == nil || *value == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, *value)
if err != nil {
return nil
}
return timestamppb.New(parsed.UTC())
}

View File

@@ -0,0 +1,133 @@
package playerconfigs
import "stream.api/internal/database/model"
type PlayerConfigView struct {
Config *model.PlayerConfig
}
type ListPlayerConfigsQuery struct {
UserID string
}
type ListPlayerConfigsResult struct {
Items []PlayerConfigView
}
type CreatePlayerConfigCommand struct {
UserID string
Name string
Description *string
Autoplay bool
Loop bool
Muted bool
ShowControls bool
Pip bool
Airplay bool
Chromecast bool
IsActive *bool
IsDefault *bool
EncrytionM3U8 *bool
LogoURL *string
}
type UpdatePlayerConfigCommand struct {
UserID string
ID string
Name string
Description *string
Autoplay bool
Loop bool
Muted bool
ShowControls bool
Pip bool
Airplay bool
Chromecast bool
IsActive *bool
IsDefault *bool
EncrytionM3U8 *bool
LogoURL *string
}
type DeletePlayerConfigCommand struct {
UserID string
ID string
}
type AdminPlayerConfigView struct {
ID string
UserID string
Name string
Description *string
Autoplay bool
Loop bool
Muted bool
ShowControls bool
Pip bool
Airplay bool
Chromecast bool
IsActive bool
IsDefault bool
OwnerEmail *string
CreatedAt *string
UpdatedAt *string
EncrytionM3U8 bool
LogoURL *string
}
type ListAdminPlayerConfigsQuery struct {
Page int32
Limit int32
Search *string
UserID *string
}
type ListAdminPlayerConfigsResult struct {
Items []AdminPlayerConfigView
Total int64
Page int32
Limit int32
}
type GetAdminPlayerConfigQuery struct {
ID string
}
type CreateAdminPlayerConfigCommand struct {
UserID string
Name string
Description *string
Autoplay bool
Loop bool
Muted bool
ShowControls bool
Pip bool
Airplay bool
Chromecast bool
IsActive bool
IsDefault bool
EncrytionM3U8 *bool
LogoURL *string
}
type UpdateAdminPlayerConfigCommand struct {
ID string
UserID string
Name string
Description *string
Autoplay bool
Loop bool
Muted bool
ShowControls bool
Pip bool
Airplay bool
Chromecast bool
IsActive bool
IsDefault bool
EncrytionM3U8 *bool
LogoURL *string
}
type DeleteAdminPlayerConfigCommand struct {
ID string
}

View File

@@ -0,0 +1,139 @@
package users
import (
"context"
"strings"
appv1 "stream.api/internal/gen/proto/app/v1"
"google.golang.org/protobuf/types/known/wrapperspb"
)
type AccountHandler struct {
appv1.UnimplementedAccountServiceServer
module *Module
}
type PreferencesHandler struct {
appv1.UnimplementedPreferencesServiceServer
module *Module
}
type UsageHandler struct {
appv1.UnimplementedUsageServiceServer
module *Module
}
type NotificationsHandler struct {
appv1.UnimplementedNotificationsServiceServer
module *Module
}
var _ appv1.AccountServiceServer = (*AccountHandler)(nil)
var _ appv1.PreferencesServiceServer = (*PreferencesHandler)(nil)
var _ appv1.UsageServiceServer = (*UsageHandler)(nil)
var _ appv1.NotificationsServiceServer = (*NotificationsHandler)(nil)
func NewAccountHandler(module *Module) *AccountHandler { return &AccountHandler{module: module} }
func NewPreferencesHandler(module *Module) *PreferencesHandler { return &PreferencesHandler{module: module} }
func NewUsageHandler(module *Module) *UsageHandler { return &UsageHandler{module: module} }
func NewNotificationsHandler(module *Module) *NotificationsHandler { return &NotificationsHandler{module: module} }
func (h *AccountHandler) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
payload, err := h.module.GetMe(ctx, result.UserID)
if err != nil { return nil, err }
return &appv1.GetMeResponse{User: presentUser(*payload)}, nil
}
func (h *AccountHandler) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) {
payload, err := h.module.GetUserByID(ctx, req)
if err != nil { return nil, err }
return presentUser(*payload), nil
}
func (h *AccountHandler) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
payload, err := h.module.UpdateMe(ctx, UpdateProfileCommand{UserID: result.UserID, Username: req.Username, Email: req.Email, Language: req.Language, Locale: req.Locale})
if err != nil { return nil, err }
return &appv1.UpdateMeResponse{User: presentUser(*payload)}, nil
}
func (h *AccountHandler) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
if err := h.module.DeleteMe(ctx, result.UserID); err != nil { return nil, err }
return commonMessage("Account deleted successfully"), nil
}
func (h *AccountHandler) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
if err := h.module.ClearMyData(ctx, result.UserID); err != nil { return nil, err }
return commonMessage("Data cleared successfully"), nil
}
func (h *PreferencesHandler) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
payload, err := h.module.GetPreferences(ctx, result.UserID)
if err != nil { return nil, err }
return &appv1.GetPreferencesResponse{Preferences: presentPreferences(*payload)}, nil
}
func (h *PreferencesHandler) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
payload, err := h.module.UpdatePreferences(ctx, UpdatePreferencesCommand{UserID: result.UserID, EmailNotifications: req.EmailNotifications, PushNotifications: req.PushNotifications, MarketingNotifications: req.MarketingNotifications, TelegramNotifications: req.TelegramNotifications, Language: req.Language, Locale: req.Locale})
if err != nil { return nil, err }
return &appv1.UpdatePreferencesResponse{Preferences: presentPreferences(*payload)}, nil
}
func (h *UsageHandler) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
payload, err := h.module.GetUsage(ctx, result.User)
if err != nil { return nil, err }
return &appv1.GetUsageResponse{UserId: payload.UserID, TotalVideos: payload.TotalVideos, TotalStorage: payload.TotalStorage}, nil
}
func (h *NotificationsHandler) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
payload, err := h.module.ListNotifications(ctx, result.UserID)
if err != nil { return nil, err }
items := make([]*appv1.Notification, 0, len(payload.Items))
for _, item := range payload.Items { items = append(items, presentNotification(item)) }
return &appv1.ListNotificationsResponse{Notifications: items}, nil
}
func (h *NotificationsHandler) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
if err := h.module.MarkNotificationRead(ctx, MarkNotificationCommand{UserID: result.UserID, ID: strings.TrimSpace(req.GetId())}); err != nil { return nil, err }
return commonMessage("Notification updated"), nil
}
func (h *NotificationsHandler) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
if err := h.module.MarkAllNotificationsRead(ctx, result.UserID); err != nil { return nil, err }
return commonMessage("All notifications marked as read"), nil
}
func (h *NotificationsHandler) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
if err := h.module.DeleteNotification(ctx, MarkNotificationCommand{UserID: result.UserID, ID: strings.TrimSpace(req.GetId())}); err != nil { return nil, err }
return commonMessage("Notification deleted"), nil
}
func (h *NotificationsHandler) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil { return nil, err }
if err := h.module.ClearNotifications(ctx, result.UserID); err != nil { return nil, err }
return commonMessage("All notifications deleted"), nil
}
func commonMessage(message string) *appv1.MessageResponse { return &appv1.MessageResponse{Message: message} }

View File

@@ -0,0 +1,457 @@
package users
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/wrapperspb"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/internal/modules/common"
)
var (
ErrEmailRequired = errors.New("Email is required")
ErrEmailAlreadyRegistered = errors.New("Email already registered")
)
type Module struct {
runtime *common.Runtime
}
type updateProfileInput struct {
Username *string
Email *string
Language *string
Locale *string
}
type updatePreferencesInput struct {
EmailNotifications *bool
PushNotifications *bool
MarketingNotifications *bool
TelegramNotifications *bool
Language *string
Locale *string
}
type usagePayload struct {
UserID string `json:"user_id"`
TotalVideos int64 `json:"total_videos"`
TotalStorage int64 `json:"total_storage"`
}
func New(runtime *common.Runtime) *Module {
return &Module{runtime: runtime}
}
func (m *Module) GetMe(ctx context.Context, userID string) (*UserView, error) {
result, err := m.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), result.User)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return mapUserPayload(payload), nil
}
func (m *Module) GetUserByID(ctx context.Context, req *wrapperspb.StringValue) (*UserView, error) {
_, err := m.runtime.Authenticator().RequireTrustedMetadata(ctx)
if err != nil {
return nil, err
}
u := query.User
user, err := u.WithContext(ctx).Where(u.ID.Eq(req.Value)).First()
if err != nil {
return nil, status.Error(codes.Unauthenticated, "Unauthorized")
}
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return mapUserPayload(payload), nil
}
func (m *Module) UpdateMe(ctx context.Context, cmd UpdateProfileCommand) (*UserView, error) {
updatedUser, err := m.updateUserProfile(ctx, cmd.UserID, updateProfileInput{Username: cmd.Username, Email: cmd.Email, Language: cmd.Language, Locale: cmd.Locale})
if err != nil {
switch {
case errors.Is(err, ErrEmailRequired), errors.Is(err, ErrEmailAlreadyRegistered):
return nil, status.Error(codes.InvalidArgument, err.Error())
default:
return nil, status.Error(codes.Internal, "Failed to update profile")
}
}
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), updatedUser)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return mapUserPayload(payload), nil
}
func (m *Module) DeleteMe(ctx context.Context, userID string) error {
if err := m.runtime.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.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 {
m.runtime.Logger().Error("Failed to delete user", "error", err)
return status.Error(codes.Internal, "Failed to delete account")
}
return nil
}
func (m *Module) ClearMyData(ctx context.Context, userID string) error {
if err := m.runtime.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.Video{}).Error; err != nil { return err }
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{"storage_used": 0}).Error; err != nil { return err }
return nil
}); err != nil {
m.runtime.Logger().Error("Failed to clear user data", "error", err)
return status.Error(codes.Internal, "Failed to clear data")
}
return nil
}
func (m *Module) GetPreferences(ctx context.Context, userID string) (*PreferencesView, error) {
pref, err := m.loadUserPreferences(ctx, userID)
if err != nil { return nil, status.Error(codes.Internal, "Failed to load preferences") }
return mapPreferences(pref), nil
}
func (m *Module) UpdatePreferences(ctx context.Context, cmd UpdatePreferencesCommand) (*PreferencesView, error) {
pref, err := m.updateUserPreferences(ctx, cmd.UserID, updatePreferencesInput{EmailNotifications: cmd.EmailNotifications, PushNotifications: cmd.PushNotifications, MarketingNotifications: cmd.MarketingNotifications, TelegramNotifications: cmd.TelegramNotifications, Language: cmd.Language, Locale: cmd.Locale})
if err != nil { return nil, status.Error(codes.Internal, "Failed to save preferences") }
return mapPreferences(pref), nil
}
func (m *Module) GetUsage(ctx context.Context, user *model.User) (*UsageView, error) {
payload, err := m.loadUsage(ctx, user)
if err != nil { return nil, status.Error(codes.Internal, "Failed to load usage") }
return &UsageView{UserID: payload.UserID, TotalVideos: payload.TotalVideos, TotalStorage: payload.TotalStorage}, nil
}
func (m *Module) ListNotifications(ctx context.Context, userID string) (*ListNotificationsResult, error) {
var rows []model.Notification
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&rows).Error; err != nil { m.runtime.Logger().Error("Failed to list notifications", "error", err); return nil, status.Error(codes.Internal, "Failed to load notifications") }
items := make([]NotificationView, 0, len(rows))
for _, row := range rows { items = append(items, NotificationView{Notification: row}) }
return &ListNotificationsResult{Items: items}, nil
}
func (m *Module) MarkNotificationRead(ctx context.Context, cmd MarkNotificationCommand) error {
id := strings.TrimSpace(cmd.ID)
if id == "" { return status.Error(codes.NotFound, "Notification not found") }
res := m.runtime.DB().WithContext(ctx).Model(&model.Notification{}).Where("id = ? AND user_id = ?", id, cmd.UserID).Update("is_read", true)
if res.Error != nil { m.runtime.Logger().Error("Failed to update notification", "error", res.Error); return status.Error(codes.Internal, "Failed to update notification") }
if res.RowsAffected == 0 { return status.Error(codes.NotFound, "Notification not found") }
return nil
}
func (m *Module) MarkAllNotificationsRead(ctx context.Context, userID string) error {
if err := m.runtime.DB().WithContext(ctx).Model(&model.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Update("is_read", true).Error; err != nil { m.runtime.Logger().Error("Failed to mark all notifications as read", "error", err); return status.Error(codes.Internal, "Failed to update notifications") }
return nil
}
func (m *Module) DeleteNotification(ctx context.Context, cmd MarkNotificationCommand) error {
id := strings.TrimSpace(cmd.ID)
if id == "" { return status.Error(codes.NotFound, "Notification not found") }
res := m.runtime.DB().WithContext(ctx).Where("id = ? AND user_id = ?", id, cmd.UserID).Delete(&model.Notification{})
if res.Error != nil { m.runtime.Logger().Error("Failed to delete notification", "error", res.Error); return status.Error(codes.Internal, "Failed to delete notification") }
if res.RowsAffected == 0 { return status.Error(codes.NotFound, "Notification not found") }
return nil
}
func (m *Module) ClearNotifications(ctx context.Context, userID string) error {
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { m.runtime.Logger().Error("Failed to clear notifications", "error", err); return status.Error(codes.Internal, "Failed to clear notifications") }
return nil
}
func (m *Module) ResolveSignupReferrerID(ctx context.Context, refUsername string, newUsername string) (*string, error) {
trimmedRefUsername := strings.TrimSpace(refUsername)
if trimmedRefUsername == "" || strings.EqualFold(trimmedRefUsername, strings.TrimSpace(newUsername)) { return nil, nil }
referrer, err := m.resolveReferralUserByUsername(ctx, trimmedRefUsername)
if err != nil { return nil, err }
if referrer == nil { return nil, nil }
return &referrer.ID, nil
}
func (m *Module) LoadReferralUserByUsernameStrict(ctx context.Context, username string) (*model.User, error) {
trimmed := strings.TrimSpace(username)
if trimmed == "" { return nil, status.Error(codes.InvalidArgument, "Referral username is required") }
users, err := m.loadReferralUsersByUsername(ctx, trimmed)
if err != nil { return nil, status.Error(codes.Internal, "Failed to resolve referral user") }
if len(users) == 0 { return nil, status.Error(codes.InvalidArgument, "Referral user not found") }
if len(users) > 1 { return nil, status.Error(codes.InvalidArgument, "Referral username is ambiguous") }
return &users[0], nil
}
func (m *Module) EnsurePlanExists(ctx context.Context, planID *string) error {
if planID == nil { return nil }
trimmed := strings.TrimSpace(*planID)
if trimmed == "" { return nil }
var count int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.Plan{}).Where("id = ?", trimmed).Count(&count).Error; err != nil { return status.Error(codes.Internal, "Failed to validate plan") }
if count == 0 { return status.Error(codes.InvalidArgument, "Plan not found") }
return nil
}
func (m *Module) ListAdminUsers(ctx context.Context, queryValue ListAdminUsersQuery) (*ListAdminUsersResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
page, limit, offset := common.AdminPageLimitOffset(queryValue.Page, queryValue.Limit)
limitInt := int(limit)
db := m.runtime.DB().WithContext(ctx).Model(&model.User{})
if search := strings.TrimSpace(queryValue.Search); search != "" { like := "%" + search + "%"; db = db.Where("email ILIKE ? OR username ILIKE ?", like, like) }
if role := strings.TrimSpace(queryValue.Role); role != "" { db = db.Where("UPPER(role) = ?", strings.ToUpper(role)) }
var total int64
if err := db.Count(&total).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to list users") }
var users []model.User
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&users).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to list users") }
items := make([]AdminUserView, 0, len(users))
for _, user := range users {
payload, err := m.buildAdminUser(ctx, &user)
if err != nil { return nil, status.Error(codes.Internal, "Failed to list users") }
items = append(items, payload)
}
return &ListAdminUsersResult{Items: items, Total: total, Page: page, Limit: limit}, nil
}
func (m *Module) GetAdminUser(ctx context.Context, queryValue GetAdminUserQuery) (*AdminUserDetailView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
id := strings.TrimSpace(queryValue.ID)
if id == "" { return nil, status.Error(codes.NotFound, "User not found") }
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "User not found") }; return nil, status.Error(codes.Internal, "Failed to get user") }
var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil { subscription = &subscriptionRecord } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.Internal, "Failed to get user") }
detail, err := m.buildAdminUserDetail(ctx, &user, subscription)
if err != nil { return nil, status.Error(codes.Internal, "Failed to get user") }
return &detail, nil
}
func (m *Module) CreateAdminUser(ctx context.Context, cmd CreateAdminUserCommand) (*AdminUserView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
email := strings.TrimSpace(cmd.Email)
password := cmd.Password
if email == "" || password == "" { return nil, status.Error(codes.InvalidArgument, "Email and password are required") }
role := common.NormalizeAdminRoleValue(cmd.Role)
if !common.IsValidAdminRoleValue(role) { return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") }
if err := m.EnsurePlanExists(ctx, cmd.PlanID); err != nil { return nil, err }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { return nil, status.Error(codes.Internal, "Failed to hash password") }
user := &model.User{ID: uuid.New().String(), Email: email, Password: model.StringPtr(string(hashedPassword)), Username: common.NullableTrimmedString(cmd.Username), Role: model.StringPtr(role), PlanID: cmd.PlanID}
if err := m.runtime.DB().WithContext(ctx).Create(user).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, status.Error(codes.AlreadyExists, "Email already registered") }; return nil, status.Error(codes.Internal, "Failed to create user") }
payload, err := m.buildAdminUser(ctx, user)
if err != nil { return nil, status.Error(codes.Internal, "Failed to create user") }
return &payload, nil
}
func (m *Module) UpdateAdminUser(ctx context.Context, cmd UpdateAdminUserCommand) (*AdminUserView, error) {
adminResult, err := m.runtime.RequireAdmin(ctx)
if err != nil { return nil, err }
id := strings.TrimSpace(cmd.ID)
if id == "" { return nil, status.Error(codes.NotFound, "User not found") }
updates := map[string]any{}
if cmd.Patch.Email != nil { email := strings.TrimSpace(*cmd.Patch.Email); if email == "" { return nil, status.Error(codes.InvalidArgument, "Email is required") }; updates["email"] = email }
if cmd.Patch.Username != nil { updates["username"] = common.NullableTrimmedString(cmd.Patch.Username) }
if cmd.Patch.Role != nil { role := common.NormalizeAdminRoleValue(*cmd.Patch.Role); if !common.IsValidAdminRoleValue(role) { return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") }; if id == adminResult.UserID && role != "ADMIN" { return nil, status.Error(codes.InvalidArgument, "Cannot change your own role") }; updates["role"] = role }
if cmd.Patch.PlanID != nil { planID := *cmd.Patch.PlanID; if err := m.EnsurePlanExists(ctx, planID); err != nil { return nil, err }; updates["plan_id"] = planID }
if cmd.Patch.Password != nil { if strings.TrimSpace(*cmd.Patch.Password) == "" { return nil, status.Error(codes.InvalidArgument, "Password must not be empty") }; hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*cmd.Patch.Password), bcrypt.DefaultCost); if err != nil { return nil, status.Error(codes.Internal, "Failed to hash password") }; updates["password"] = string(hashedPassword) }
if len(updates) == 0 { var user model.User; if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "User not found") }; return nil, status.Error(codes.Internal, "Failed to update user") }; payload, err := m.buildAdminUser(ctx, &user); if err != nil { return nil, status.Error(codes.Internal, "Failed to update user") }; return &payload, nil }
result := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
if result.Error != nil { if errors.Is(result.Error, gorm.ErrDuplicatedKey) { return nil, status.Error(codes.AlreadyExists, "Email already registered") }; return nil, status.Error(codes.Internal, "Failed to update user") }
if result.RowsAffected == 0 { return nil, status.Error(codes.NotFound, "User not found") }
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to update user") }
payload, err := m.buildAdminUser(ctx, &user)
if err != nil { return nil, status.Error(codes.Internal, "Failed to update user") }
return &payload, nil
}
func (m *Module) UpdateAdminUserReferralSettings(ctx context.Context, cmd UpdateReferralSettingsCommand) (*AdminUserDetailView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil { return nil, err }
id := strings.TrimSpace(cmd.ID)
if id == "" { return nil, status.Error(codes.NotFound, "User not found") }
if cmd.ClearReferrer != nil && *cmd.ClearReferrer && cmd.RefUsername != nil && strings.TrimSpace(*cmd.RefUsername) != "" { return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referrer at the same time") }
if cmd.ClearReferralRewardBps != nil && *cmd.ClearReferralRewardBps && cmd.ReferralRewardBps != nil { return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referral reward override at the same time") }
if cmd.ReferralRewardBps != nil { bps := *cmd.ReferralRewardBps; if bps < 0 || bps > 10000 { return nil, status.Error(codes.InvalidArgument, "Referral reward bps must be between 0 and 10000") } }
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "User not found") }; return nil, status.Error(codes.Internal, "Failed to update referral settings") }
updates := map[string]any{}
if cmd.RefUsername != nil || (cmd.ClearReferrer != nil && *cmd.ClearReferrer) {
if common.ReferralRewardProcessed(&user) { return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted") }
if cmd.ClearReferrer != nil && *cmd.ClearReferrer { updates["referred_by_user_id"] = nil } else if cmd.RefUsername != nil { referrer, err := m.LoadReferralUserByUsernameStrict(ctx, *cmd.RefUsername); if err != nil { return nil, err }; if referrer.ID == user.ID { return nil, status.Error(codes.InvalidArgument, "User cannot refer themselves") }; updates["referred_by_user_id"] = referrer.ID }
}
if cmd.ReferralEligible != nil { updates["referral_eligible"] = *cmd.ReferralEligible }
if cmd.ClearReferralRewardBps != nil && *cmd.ClearReferralRewardBps { updates["referral_reward_bps"] = nil } else if cmd.ReferralRewardBps != nil { updates["referral_reward_bps"] = *cmd.ReferralRewardBps }
if len(updates) > 0 { result := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates); if result.Error != nil { return nil, status.Error(codes.Internal, "Failed to update referral settings") }; if result.RowsAffected == 0 { return nil, status.Error(codes.NotFound, "User not found") } }
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { return nil, status.Error(codes.Internal, "Failed to update referral settings") }
var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription
if err := m.runtime.DB().WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil { subscription = &subscriptionRecord } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.Internal, "Failed to update referral settings") }
payload, err := m.buildAdminUserDetail(ctx, &user, subscription)
if err != nil { return nil, status.Error(codes.Internal, "Failed to update referral settings") }
return &payload, nil
}
func (m *Module) UpdateAdminUserRole(ctx context.Context, cmd UpdateUserRoleCommand) (string, error) {
adminResult, err := m.runtime.RequireAdmin(ctx)
if err != nil { return "", err }
id := strings.TrimSpace(cmd.ID)
if id == "" { return "", status.Error(codes.NotFound, "User not found") }
if id == adminResult.UserID { return "", status.Error(codes.InvalidArgument, "Cannot change your own role") }
role := common.NormalizeAdminRoleValue(cmd.Role)
if !common.IsValidAdminRoleValue(role) { return "", status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") }
result := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("role", role)
if result.Error != nil { return "", status.Error(codes.Internal, "Failed to update role") }
if result.RowsAffected == 0 { return "", status.Error(codes.NotFound, "User not found") }
return role, nil
}
func (m *Module) DeleteAdminUser(ctx context.Context, cmd DeleteAdminUserCommand) error {
adminResult, err := m.runtime.RequireAdmin(ctx)
if err != nil { return err }
id := strings.TrimSpace(cmd.ID)
if id == "" { return status.Error(codes.NotFound, "User not found") }
if id == adminResult.UserID { return status.Error(codes.InvalidArgument, "Cannot delete your own account") }
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return status.Error(codes.NotFound, "User not found") }; return status.Error(codes.Internal, "Failed to find user") }
err = m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { tables := []struct { model any; where string }{{&model.AdTemplate{}, "user_id = ?"}, {&model.Notification{}, "user_id = ?"}, {&model.Domain{}, "user_id = ?"}, {&model.WalletTransaction{}, "user_id = ?"}, {&model.PlanSubscription{}, "user_id = ?"}, {&model.UserPreference{}, "user_id = ?"}, {&model.Video{}, "user_id = ?"}, {&model.Payment{}, "user_id = ?"}}; for _, item := range tables { if err := tx.Where(item.where, id).Delete(item.model).Error; err != nil { return err } }; return tx.Where("id = ?", id).Delete(&model.User{}).Error })
if err != nil { return status.Error(codes.Internal, "Failed to delete user") }
return nil
}
func mapUserPayload(payload *common.UserPayload) *UserView {
if payload == nil { return nil }
return &UserView{ID: payload.ID, Email: payload.Email, Username: payload.Username, Avatar: payload.Avatar, Role: payload.Role, GoogleID: payload.GoogleID, StorageUsed: payload.StorageUsed, PlanID: payload.PlanID, PlanStartedAt: payload.PlanStartedAt, PlanExpiresAt: payload.PlanExpiresAt, PlanTermMonths: payload.PlanTermMonths, PlanPaymentMethod: payload.PlanPaymentMethod, PlanExpiringSoon: payload.PlanExpiringSoon, WalletBalance: payload.WalletBalance, Language: payload.Language, Locale: payload.Locale, CreatedAt: payload.CreatedAt, UpdatedAt: payload.UpdatedAt}
}
func mapPreferences(pref *model.UserPreference) *PreferencesView {
if pref == nil { return nil }
return &PreferencesView{EmailNotifications: pref.EmailNotifications != nil && *pref.EmailNotifications, PushNotifications: pref.PushNotifications != nil && *pref.PushNotifications, MarketingNotifications: pref.MarketingNotifications, TelegramNotifications: pref.TelegramNotifications, Language: model.StringValue(pref.Language), Locale: model.StringValue(pref.Locale)}
}
func (m *Module) updateUserProfile(ctx context.Context, userID string, req updateProfileInput) (*model.User, error) {
updates := map[string]any{}
if req.Username != nil { updates["username"] = strings.TrimSpace(*req.Username) }
if req.Email != nil { email := strings.TrimSpace(*req.Email); if email == "" { return nil, ErrEmailRequired }; updates["email"] = email }
if len(updates) > 0 { if err := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, ErrEmailAlreadyRegistered }; m.runtime.Logger().Error("Failed to update user", "error", err); return nil, err } }
pref, err := model.FindOrCreateUserPreference(ctx, m.runtime.DB(), userID)
if err != nil { m.runtime.Logger().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 := m.runtime.DB().WithContext(ctx).Save(pref).Error; err != nil { m.runtime.Logger().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
}
func (m *Module) loadUserPreferences(ctx context.Context, userID string) (*model.UserPreference, error) { return model.FindOrCreateUserPreference(ctx, m.runtime.DB(), userID) }
func (m *Module) updateUserPreferences(ctx context.Context, userID string, req updatePreferencesInput) (*model.UserPreference, error) {
pref, err := model.FindOrCreateUserPreference(ctx, m.runtime.DB(), userID)
if err != nil { m.runtime.Logger().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.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 := m.runtime.DB().WithContext(ctx).Save(pref).Error; err != nil { m.runtime.Logger().Error("Failed to save preferences", "error", err); return nil, err }
return pref, nil
}
func (m *Module) loadUsage(ctx context.Context, user *model.User) (*usagePayload, error) {
var totalVideos int64
if err := m.runtime.DB().WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil { m.runtime.Logger().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
}
func (m *Module) buildReferralShareLink(username *string) *string {
trimmed := strings.TrimSpace(common.StringValue(username))
if trimmed == "" { return nil }
path := "/ref/" + trimmed
base := strings.TrimRight(strings.TrimSpace(m.runtime.FrontendBaseURL()), "/")
if base == "" { return &path }
link := base + path
return &link
}
func (m *Module) loadReferralUsersByUsername(ctx context.Context, username string) ([]model.User, error) {
trimmed := strings.TrimSpace(username)
if trimmed == "" { return nil, nil }
var users []model.User
if err := m.runtime.DB().WithContext(ctx).Where("LOWER(username) = LOWER(?)", trimmed).Order("created_at ASC, id ASC").Limit(2).Find(&users).Error; err != nil { return nil, err }
return users, nil
}
func (m *Module) resolveReferralUserByUsername(ctx context.Context, username string) (*model.User, error) {
users, err := m.loadReferralUsersByUsername(ctx, username)
if err != nil { return nil, err }
if len(users) != 1 { return nil, nil }
return &users[0], nil
}
func (m *Module) buildAdminUser(ctx context.Context, user *model.User) (AdminUserView, error) {
if user == nil { return AdminUserView{}, nil }
videoCount, err := m.loadAdminUserVideoCount(ctx, user.ID); if err != nil { return AdminUserView{}, err }
walletBalance, err := model.GetWalletBalance(ctx, m.runtime.DB(), user.ID); if err != nil { return AdminUserView{}, err }
planName, err := m.loadAdminPlanName(ctx, user.PlanID); if err != nil { return AdminUserView{}, err }
return AdminUserView{ID: user.ID, Email: user.Email, Username: common.NullableTrimmedString(user.Username), Avatar: common.NullableTrimmedString(user.Avatar), Role: common.NullableTrimmedString(user.Role), PlanID: common.NullableTrimmedString(user.PlanID), PlanName: planName, StorageUsed: user.StorageUsed, VideoCount: videoCount, WalletBalance: walletBalance, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt}, nil
}
func (m *Module) buildAdminUserDetail(ctx context.Context, user *model.User, subscription *model.PlanSubscription) (AdminUserDetailView, error) {
payload, err := m.buildAdminUser(ctx, user); if err != nil { return AdminUserDetailView{}, err }
referral, err := m.buildAdminUserReferralInfo(ctx, user); if err != nil { return AdminUserDetailView{}, err }
return AdminUserDetailView{User: payload, Subscription: subscription, Referral: referral}, nil
}
func (m *Module) buildAdminUserReferralInfo(ctx context.Context, user *model.User) (*AdminUserReferralInfoView, error) {
if user == nil { return nil, nil }
var referrer *ReferralUserSummaryView
if user.ReferredByUserID != nil && strings.TrimSpace(*user.ReferredByUserID) != "" { loadedReferrer, err := m.loadReferralUserSummary(ctx, strings.TrimSpace(*user.ReferredByUserID)); if err != nil { return nil, err }; referrer = loadedReferrer }
bps := common.EffectiveReferralRewardBps(user.ReferralRewardBps)
return &AdminUserReferralInfoView{Referrer: referrer, ReferralEligible: common.ReferralUserEligible(user), EffectiveRewardPercent: common.ReferralRewardBpsToPercent(bps), RewardOverridePercent: func() *float64 { if user.ReferralRewardBps == nil { return nil }; value := common.ReferralRewardBpsToPercent(*user.ReferralRewardBps); return &value }(), ShareLink: m.buildReferralShareLink(user.Username), RewardGranted: common.ReferralRewardProcessed(user), RewardGrantedAt: user.ReferralRewardGrantedAt, RewardPaymentID: common.NullableTrimmedString(user.ReferralRewardPaymentID), RewardAmount: user.ReferralRewardAmount}, nil
}
func (m *Module) loadAdminUserVideoCount(ctx context.Context, userID string) (int64, error) { var videoCount int64; if err := m.runtime.DB().WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", userID).Count(&videoCount).Error; err != nil { return 0, err }; return videoCount, nil }
func (m *Module) loadReferralUserSummary(ctx context.Context, userID string) (*ReferralUserSummaryView, error) {
if strings.TrimSpace(userID) == "" { return nil, nil }
var user model.User
if err := m.runtime.DB().WithContext(ctx).Select("id, email, username").Where("id = ?", userID).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil }; return nil, err }
return &ReferralUserSummaryView{ID: user.ID, Email: user.Email, Username: common.NullableTrimmedString(user.Username)}, nil
}
func (m *Module) loadAdminPlanName(ctx context.Context, planID *string) (*string, error) {
if planID == nil || strings.TrimSpace(*planID) == "" { return nil, nil }
var plan model.Plan
if err := m.runtime.DB().WithContext(ctx).Select("id, name").Where("id = ?", *planID).First(&plan).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil }; return nil, err }
return common.NullableTrimmedString(&plan.Name), nil
}

View File

@@ -0,0 +1,93 @@
package users
import (
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
func presentUser(view UserView) *appv1.User {
return &appv1.User{
Id: view.ID,
Email: view.Email,
Username: view.Username,
Avatar: view.Avatar,
Role: view.Role,
GoogleId: view.GoogleID,
StorageUsed: view.StorageUsed,
PlanId: view.PlanID,
PlanStartedAt: common.TimeToProto(view.PlanStartedAt),
PlanExpiresAt: common.TimeToProto(view.PlanExpiresAt),
PlanTermMonths: view.PlanTermMonths,
PlanPaymentMethod: view.PlanPaymentMethod,
PlanExpiringSoon: view.PlanExpiringSoon,
WalletBalance: view.WalletBalance,
Language: view.Language,
Locale: view.Locale,
CreatedAt: common.TimeToProto(view.CreatedAt),
UpdatedAt: common.TimeToProto(&view.UpdatedAt),
}
}
func presentPreferences(view PreferencesView) *appv1.Preferences {
return &appv1.Preferences{
EmailNotifications: view.EmailNotifications,
PushNotifications: view.PushNotifications,
MarketingNotifications: view.MarketingNotifications,
TelegramNotifications: view.TelegramNotifications,
Language: view.Language,
Locale: view.Locale,
}
}
func presentNotification(view NotificationView) *appv1.Notification {
return common.ToProtoNotification(view.Notification)
}
func presentAdminUser(view AdminUserView) *appv1.AdminUser {
return &appv1.AdminUser{
Id: view.ID,
Email: view.Email,
Username: view.Username,
Avatar: view.Avatar,
Role: view.Role,
PlanId: view.PlanID,
PlanName: view.PlanName,
StorageUsed: view.StorageUsed,
VideoCount: view.VideoCount,
WalletBalance: view.WalletBalance,
CreatedAt: common.TimeToProto(view.CreatedAt),
UpdatedAt: common.TimeToProto(&view.UpdatedAt),
}
}
func presentReferralSummary(view *ReferralUserSummaryView) *appv1.ReferralUserSummary {
if view == nil {
return nil
}
return &appv1.ReferralUserSummary{Id: view.ID, Email: view.Email, Username: view.Username}
}
func presentAdminUserReferralInfo(view *AdminUserReferralInfoView) *appv1.AdminUserReferralInfo {
if view == nil {
return nil
}
return &appv1.AdminUserReferralInfo{
Referrer: presentReferralSummary(view.Referrer),
ReferralEligible: view.ReferralEligible,
EffectiveRewardPercent: view.EffectiveRewardPercent,
RewardOverridePercent: view.RewardOverridePercent,
ShareLink: view.ShareLink,
RewardGranted: view.RewardGranted,
RewardGrantedAt: common.TimeToProto(view.RewardGrantedAt),
RewardPaymentId: view.RewardPaymentID,
RewardAmount: view.RewardAmount,
}
}
func presentAdminUserDetail(view AdminUserDetailView) *appv1.AdminUserDetail {
return &appv1.AdminUserDetail{
User: presentAdminUser(view.User),
Subscription: common.ToProtoPlanSubscription(view.Subscription),
Referral: presentAdminUserReferralInfo(view.Referral),
}
}

View File

@@ -0,0 +1,173 @@
package users
import (
"time"
"stream.api/internal/database/model"
)
type UserView struct {
ID string
Email string
Username *string
Avatar *string
Role *string
GoogleID *string
StorageUsed int64
PlanID *string
PlanStartedAt *time.Time
PlanExpiresAt *time.Time
PlanTermMonths *int32
PlanPaymentMethod *string
PlanExpiringSoon bool
WalletBalance float64
Language string
Locale string
CreatedAt *time.Time
UpdatedAt time.Time
}
type PreferencesView struct {
EmailNotifications bool
PushNotifications bool
MarketingNotifications bool
TelegramNotifications bool
Language string
Locale string
}
type UsageView struct {
UserID string
TotalVideos int64
TotalStorage int64
}
type NotificationView struct {
Notification model.Notification
}
type ListNotificationsResult struct {
Items []NotificationView
}
type UpdateProfileCommand struct {
UserID string
Username *string
Email *string
Language *string
Locale *string
}
type UpdatePreferencesCommand struct {
UserID string
EmailNotifications *bool
PushNotifications *bool
MarketingNotifications *bool
TelegramNotifications *bool
Language *string
Locale *string
}
type MarkNotificationCommand struct {
UserID string
ID string
}
type UserPatch struct {
Email *string
Username *string
Role *string
PlanID **string
Password *string
}
type AdminUserView struct {
ID string
Email string
Username *string
Avatar *string
Role *string
PlanID *string
PlanName *string
StorageUsed int64
VideoCount int64
WalletBalance float64
CreatedAt *time.Time
UpdatedAt time.Time
}
type ReferralUserSummaryView struct {
ID string
Email string
Username *string
}
type AdminUserReferralInfoView struct {
Referrer *ReferralUserSummaryView
ReferralEligible bool
EffectiveRewardPercent float64
RewardOverridePercent *float64
ShareLink *string
RewardGranted bool
RewardGrantedAt *time.Time
RewardPaymentID *string
RewardAmount *float64
}
type AdminUserDetailView struct {
User AdminUserView
Subscription *model.PlanSubscription
Referral *AdminUserReferralInfoView
}
type ListAdminUsersQuery struct {
Page int32
Limit int32
Search string
Role string
}
type ListAdminUsersResult struct {
Items []AdminUserView
Total int64
Page int32
Limit int32
}
type GetAdminUserQuery struct {
ID string
}
type CreateAdminUserCommand struct {
Email string
Password string
Username *string
Role string
PlanID *string
}
type UpdateAdminUserCommand struct {
ActorUserID string
ID string
Patch UserPatch
}
type UpdateReferralSettingsCommand struct {
ID string
RefUsername *string
ClearReferrer *bool
ReferralEligible *bool
ReferralRewardBps *int32
ClearReferralRewardBps *bool
}
type UpdateUserRoleCommand struct {
ActorUserID string
ID string
Role string
}
type DeleteAdminUserCommand struct {
ActorUserID string
ID string
}

View File

@@ -0,0 +1,127 @@
package videos
import (
"context"
"strings"
appv1 "stream.api/internal/gen/proto/app/v1"
)
type Handler struct {
appv1.UnimplementedVideosServiceServer
module *Module
}
var _ appv1.VideosServiceServer = (*Handler)(nil)
func NewHandler(module *Module) *Handler { return &Handler{module: module} }
func (h *Handler) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.GetUploadURL(ctx, GetUploadURLCommand{UserID: result.UserID, Filename: req.GetFilename()})
if err != nil {
return nil, err
}
return presentGetUploadURLResponse(payload), nil
}
func (h *Handler) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.CreateVideo(ctx, CreateVideoCommand{UserID: result.UserID, Title: req.GetTitle(), Description: req.GetDescription(), URL: req.GetUrl(), Size: req.GetSize(), Duration: req.GetDuration(), Format: req.GetFormat()})
if err != nil {
return nil, err
}
return presentCreateVideoResponse(*payload), nil
}
func (h *Handler) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.ListVideos(ctx, ListVideosQuery{UserID: result.UserID, Page: req.GetPage(), Limit: req.GetLimit(), Search: req.GetSearch(), StatusFilter: req.GetStatus()})
if err != nil {
return nil, err
}
return presentListVideosResponse(payload), nil
}
func (h *Handler) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.GetVideo(ctx, GetVideoQuery{UserID: result.UserID, ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, err
}
return presentGetVideoResponse(*payload), nil
}
func (h *Handler) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := h.module.UpdateVideo(ctx, UpdateVideoCommand{UserID: result.UserID, ID: strings.TrimSpace(req.GetId()), Title: req.GetTitle(), Description: req.Description, URL: req.GetUrl(), Size: req.GetSize(), Duration: req.GetDuration(), Format: req.Format, Status: req.Status})
if err != nil {
return nil, err
}
return presentUpdateVideoResponse(*payload), nil
}
func (h *Handler) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) {
result, err := h.module.runtime.Authenticate(ctx)
if err != nil {
return nil, err
}
if err := h.module.DeleteVideo(ctx, DeleteVideoCommand{UserID: result.UserID, ID: strings.TrimSpace(req.GetId())}); err != nil {
return nil, err
}
return &appv1.MessageResponse{Message: "Video deleted successfully"}, nil
}
func (h *Handler) ListAdminVideos(ctx context.Context, req *appv1.ListAdminVideosRequest) (*appv1.ListAdminVideosResponse, error) {
payload, err := h.module.ListAdminVideos(ctx, ListAdminVideosQuery{Page: req.GetPage(), Limit: req.GetLimit(), Search: req.GetSearch(), UserID: req.GetUserId(), StatusFilter: req.GetStatus()})
if err != nil {
return nil, err
}
return presentListAdminVideosResponse(payload), nil
}
func (h *Handler) GetAdminVideo(ctx context.Context, req *appv1.GetAdminVideoRequest) (*appv1.GetAdminVideoResponse, error) {
payload, err := h.module.GetAdminVideo(ctx, GetAdminVideoQuery{ID: strings.TrimSpace(req.GetId())})
if err != nil {
return nil, err
}
return presentGetAdminVideoResponse(*payload), nil
}
func (h *Handler) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdminVideoRequest) (*appv1.CreateAdminVideoResponse, error) {
payload, err := h.module.CreateAdminVideo(ctx, CreateAdminVideoCommand{UserID: req.GetUserId(), Title: req.GetTitle(), Description: req.Description, URL: req.GetUrl(), Size: req.GetSize(), Duration: req.GetDuration(), Format: req.GetFormat(), AdTemplateID: req.AdTemplateId})
if err != nil {
return nil, err
}
return presentCreateAdminVideoResponse(*payload), nil
}
func (h *Handler) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdminVideoRequest) (*appv1.UpdateAdminVideoResponse, error) {
payload, err := h.module.UpdateAdminVideo(ctx, UpdateAdminVideoCommand{ID: strings.TrimSpace(req.GetId()), UserID: req.GetUserId(), Title: req.GetTitle(), Description: req.Description, URL: req.GetUrl(), Size: req.GetSize(), Duration: req.GetDuration(), Format: req.GetFormat(), Status: req.GetStatus(), AdTemplateID: req.AdTemplateId})
if err != nil {
return nil, err
}
return presentUpdateAdminVideoResponse(*payload), nil
}
func (h *Handler) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdminVideoRequest) (*appv1.MessageResponse, error) {
if err := h.module.DeleteAdminVideo(ctx, DeleteAdminVideoCommand{ID: strings.TrimSpace(req.GetId())}); err != nil {
return nil, err
}
return &appv1.MessageResponse{Message: "Video deleted"}, nil
}

View File

@@ -0,0 +1,550 @@
package videos
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/modules/common"
videodomain "stream.api/internal/video"
)
type Module struct {
runtime *common.Runtime
}
func New(runtime *common.Runtime) *Module {
return &Module{runtime: runtime}
}
func (m *Module) GetUploadURL(ctx context.Context, cmd GetUploadURLCommand) (*GetUploadURLResult, error) {
storageProvider := m.runtime.StorageProvider()
if storageProvider == nil {
return nil, status.Error(codes.FailedPrecondition, "Storage provider is not configured")
}
filename := strings.TrimSpace(cmd.Filename)
if filename == "" {
return nil, status.Error(codes.InvalidArgument, "Filename is required")
}
fileID := uuid.New().String()
key := fmt.Sprintf("videos/%s/%s-%s", cmd.UserID, fileID, filename)
uploadURL, err := storageProvider.GeneratePresignedURL(key, 15*time.Minute)
if err != nil {
m.runtime.Logger().Error("Failed to generate upload URL", "error", err)
return nil, status.Error(codes.Internal, "Storage error")
}
return &GetUploadURLResult{UploadURL: uploadURL, Key: key, FileID: fileID}, nil
}
func (m *Module) CreateVideo(ctx context.Context, cmd CreateVideoCommand) (*VideoView, error) {
videoService := m.runtime.VideoService()
if videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
title := strings.TrimSpace(cmd.Title)
if title == "" {
return nil, status.Error(codes.InvalidArgument, "Title is required")
}
videoURL := strings.TrimSpace(cmd.URL)
if videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "URL is required")
}
description := strings.TrimSpace(cmd.Description)
created, err := videoService.CreateVideo(ctx, videodomain.CreateVideoInput{UserID: cmd.UserID, Title: title, Description: &description, URL: videoURL, Size: cmd.Size, Duration: cmd.Duration, Format: strings.TrimSpace(cmd.Format)})
if err != nil {
m.runtime.Logger().Error("Failed to create video", "error", err)
switch {
case errors.Is(err, videodomain.ErrJobServiceUnavailable):
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
default:
return nil, status.Error(codes.Internal, "Failed to create video")
}
}
jobID := created.Job.ID
return &VideoView{Video: created.Video, JobID: &jobID}, nil
}
func (m *Module) ListVideos(ctx context.Context, queryValue ListVideosQuery) (*ListVideosResult, error) {
page := queryValue.Page
if page < 1 {
page = 1
}
limit := queryValue.Limit
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
offset := int((page - 1) * limit)
db := m.runtime.DB().WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", queryValue.UserID)
if search := strings.TrimSpace(queryValue.Search); search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
}
if st := strings.TrimSpace(queryValue.StatusFilter); st != "" && !strings.EqualFold(st, "all") {
db = db.Where("status = ?", common.NormalizeVideoStatusValue(st))
}
var total int64
if err := db.Count(&total).Error; err != nil {
m.runtime.Logger().Error("Failed to count videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&videos).Error; err != nil {
m.runtime.Logger().Error("Failed to list videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
items := make([]VideoView, 0, len(videos))
for i := range videos {
payload, err := m.BuildVideo(ctx, &videos[i])
if err != nil {
m.runtime.Logger().Error("Failed to build video payload", "error", err, "video_id", videos[i].ID)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
items = append(items, payload)
}
return &ListVideosResult{Items: items, Total: total, Page: page, Limit: limit}, nil
}
func (m *Module) GetVideo(ctx context.Context, queryValue GetVideoQuery) (*VideoView, error) {
id := strings.TrimSpace(queryValue.ID)
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
_ = m.runtime.DB().WithContext(ctx).Model(&model.Video{}).Where("id = ? AND user_id = ?", id, queryValue.UserID).UpdateColumn("views", gorm.Expr("views + ?", 1)).Error
var video model.Video
if err := m.runtime.DB().WithContext(ctx).Where("id = ? AND user_id = ?", id, queryValue.UserID).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
m.runtime.Logger().Error("Failed to fetch video", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch video")
}
payload, err := m.BuildVideo(ctx, &video)
if err != nil {
m.runtime.Logger().Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to fetch video")
}
return &payload, nil
}
func (m *Module) UpdateVideo(ctx context.Context, cmd UpdateVideoCommand) (*VideoView, error) {
id := strings.TrimSpace(cmd.ID)
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
updates := map[string]any{}
if title := strings.TrimSpace(cmd.Title); title != "" {
updates["name"] = title
updates["title"] = title
}
if cmd.Description != nil {
desc := strings.TrimSpace(*cmd.Description)
updates["description"] = common.NullableTrimmedString(&desc)
}
if urlValue := strings.TrimSpace(cmd.URL); urlValue != "" {
updates["url"] = urlValue
}
if cmd.Size > 0 {
updates["size"] = cmd.Size
}
if cmd.Duration > 0 {
updates["duration"] = cmd.Duration
}
if cmd.Format != nil {
updates["format"] = strings.TrimSpace(*cmd.Format)
}
if cmd.Status != nil {
updates["status"] = common.NormalizeVideoStatusValue(*cmd.Status)
}
if len(updates) == 0 {
return nil, status.Error(codes.InvalidArgument, "No changes provided")
}
res := m.runtime.DB().WithContext(ctx).Model(&model.Video{}).Where("id = ? AND user_id = ?", id, cmd.UserID).Updates(updates)
if res.Error != nil {
m.runtime.Logger().Error("Failed to update video", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to update video")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := m.runtime.DB().WithContext(ctx).Where("id = ? AND user_id = ?", id, cmd.UserID).First(&video).Error; err != nil {
m.runtime.Logger().Error("Failed to reload video", "error", err)
return nil, status.Error(codes.Internal, "Failed to update video")
}
payload, err := m.BuildVideo(ctx, &video)
if err != nil {
m.runtime.Logger().Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to update video")
}
return &payload, nil
}
func (m *Module) DeleteVideo(ctx context.Context, cmd DeleteVideoCommand) error {
id := strings.TrimSpace(cmd.ID)
if id == "" {
return status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := m.runtime.DB().WithContext(ctx).Where("id = ? AND user_id = ?", id, cmd.UserID).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return status.Error(codes.NotFound, "Video not found")
}
m.runtime.Logger().Error("Failed to load video", "error", err)
return status.Error(codes.Internal, "Failed to delete video")
}
storageProvider := m.runtime.StorageProvider()
if storageProvider != nil && common.ShouldDeleteStoredObject(video.URL) {
if err := storageProvider.Delete(video.URL); err != nil {
if parsedKey := common.ExtractObjectKey(video.URL); parsedKey != "" && parsedKey != video.URL {
if deleteErr := storageProvider.Delete(parsedKey); deleteErr != nil {
m.runtime.Logger().Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID)
return status.Error(codes.Internal, "Failed to delete video")
}
} else {
m.runtime.Logger().Error("Failed to delete video object", "error", err, "video_id", video.ID)
return status.Error(codes.Internal, "Failed to delete video")
}
}
}
if err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND user_id = ?", video.ID, cmd.UserID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", cmd.UserID).UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error
}); err != nil {
m.runtime.Logger().Error("Failed to delete video", "error", err)
return status.Error(codes.Internal, "Failed to delete video")
}
return nil
}
func (m *Module) ListAdminVideos(ctx context.Context, queryValue ListAdminVideosQuery) (*ListAdminVideosResult, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := common.AdminPageLimitOffset(queryValue.Page, queryValue.Limit)
limitInt := int(limit)
db := m.runtime.DB().WithContext(ctx).Model(&model.Video{})
if search := strings.TrimSpace(queryValue.Search); search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ?", like)
}
if userID := strings.TrimSpace(queryValue.UserID); userID != "" {
db = db.Where("user_id = ?", userID)
}
if statusFilter := strings.TrimSpace(queryValue.StatusFilter); statusFilter != "" && !strings.EqualFold(statusFilter, "all") {
db = db.Where("status = ?", common.NormalizeVideoStatusValue(statusFilter))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&videos).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
items := make([]AdminVideoView, 0, len(videos))
for i := range videos {
payload, err := m.BuildAdminVideo(ctx, &videos[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
items = append(items, payload)
}
return &ListAdminVideosResult{Items: items, Total: total, Page: page, Limit: limit}, nil
}
func (m *Module) GetAdminVideo(ctx context.Context, queryValue GetAdminVideoQuery) (*AdminVideoView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(queryValue.ID)
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to get video")
}
payload, err := m.BuildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get video")
}
return &payload, nil
}
func (m *Module) CreateAdminVideo(ctx context.Context, cmd CreateAdminVideoCommand) (*AdminVideoView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
videoService := m.runtime.VideoService()
if videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
userID := strings.TrimSpace(cmd.UserID)
title := strings.TrimSpace(cmd.Title)
videoURL := strings.TrimSpace(cmd.URL)
if userID == "" || title == "" || videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
}
if cmd.Size < 0 {
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
}
created, err := videoService.CreateVideo(ctx, videodomain.CreateVideoInput{UserID: userID, Title: title, Description: cmd.Description, URL: videoURL, Size: cmd.Size, Duration: cmd.Duration, Format: strings.TrimSpace(cmd.Format), AdTemplateID: cmd.AdTemplateID})
if err != nil {
switch {
case errors.Is(err, videodomain.ErrUserNotFound):
return nil, status.Error(codes.InvalidArgument, "User not found")
case errors.Is(err, videodomain.ErrAdTemplateNotFound):
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
case errors.Is(err, videodomain.ErrJobServiceUnavailable):
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
default:
return nil, status.Error(codes.Internal, "Failed to create video")
}
}
payload, err := m.BuildAdminVideo(ctx, created.Video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create video")
}
return &payload, nil
}
func (m *Module) UpdateAdminVideo(ctx context.Context, cmd UpdateAdminVideoCommand) (*AdminVideoView, error) {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(cmd.ID)
userID := strings.TrimSpace(cmd.UserID)
title := strings.TrimSpace(cmd.Title)
videoURL := strings.TrimSpace(cmd.URL)
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
if userID == "" || title == "" || videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
}
if cmd.Size < 0 {
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
}
var video model.Video
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
var user model.User
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
oldSize := video.Size
oldUserID := video.UserID
statusValue := common.NormalizeVideoStatusValue(cmd.Status)
processingStatus := strings.ToUpper(statusValue)
video.UserID = user.ID
video.Name = title
video.Title = title
video.Description = common.NullableTrimmedString(cmd.Description)
video.URL = videoURL
video.Size = cmd.Size
video.Duration = cmd.Duration
video.Format = strings.TrimSpace(cmd.Format)
video.Status = model.StringPtr(statusValue)
video.ProcessingStatus = model.StringPtr(processingStatus)
video.StorageType = model.StringPtr(common.DetectStorageType(videoURL))
err := m.runtime.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
}
}
return m.saveAdminVideoAdConfig(ctx, tx, &video, user.ID, cmd.AdTemplateID)
})
if err != nil {
if strings.Contains(err.Error(), "Ad template not found") {
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
payload, err := m.BuildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update video")
}
return &payload, nil
}
func (m *Module) DeleteAdminVideo(ctx context.Context, cmd DeleteAdminVideoCommand) error {
if _, err := m.runtime.RequireAdmin(ctx); err != nil {
return err
}
id := strings.TrimSpace(cmd.ID)
if id == "" {
return status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := m.runtime.DB().WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return status.Error(codes.NotFound, "Video not found")
}
return status.Error(codes.Internal, "Failed to find video")
}
err := m.runtime.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", video.UserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", video.Size)).Error
})
if err != nil {
return status.Error(codes.Internal, "Failed to delete video")
}
return nil
}
func (m *Module) BuildVideo(ctx context.Context, video *model.Video) (VideoView, error) {
if video == nil {
return VideoView{}, nil
}
jobID, err := m.LoadLatestVideoJobID(ctx, video.ID)
if err != nil {
return VideoView{}, err
}
return VideoView{Video: video, JobID: jobID}, nil
}
func (m *Module) BuildAdminVideo(ctx context.Context, video *model.Video) (AdminVideoView, error) {
if video == nil {
return AdminVideoView{}, nil
}
statusValue := common.StringValue(video.Status)
if statusValue == "" {
statusValue = "ready"
}
jobID, err := m.LoadLatestVideoJobID(ctx, video.ID)
if err != nil {
return AdminVideoView{}, err
}
ownerEmail, err := m.loadAdminUserEmail(ctx, video.UserID)
if err != nil {
return AdminVideoView{}, err
}
adTemplateID, adTemplateName, err := m.loadAdminVideoAdTemplateDetails(ctx, video)
if err != nil {
return AdminVideoView{}, err
}
var createdAt *string
if video.CreatedAt != nil {
formatted := video.CreatedAt.UTC().Format(time.RFC3339)
createdAt = &formatted
}
updated := video.UpdatedAt.UTC().Format(time.RFC3339)
updatedAt := &updated
return AdminVideoView{ID: video.ID, UserID: video.UserID, Title: video.Title, Description: common.NullableTrimmedString(video.Description), URL: video.URL, Status: strings.ToLower(statusValue), Size: video.Size, Duration: video.Duration, Format: video.Format, CreatedAt: createdAt, UpdatedAt: updatedAt, ProcessingStatus: common.NullableTrimmedString(video.ProcessingStatus), JobID: jobID, OwnerEmail: ownerEmail, AdTemplateID: adTemplateID, AdTemplateName: adTemplateName}, nil
}
func (m *Module) LoadLatestVideoJobID(ctx context.Context, videoID string) (*string, error) {
videoID = strings.TrimSpace(videoID)
if videoID == "" {
return nil, nil
}
var job model.Job
if err := m.runtime.DB().WithContext(ctx).Where("config::jsonb ->> 'video_id' = ?", videoID).Order("created_at DESC").First(&job).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return common.StringPointerOrNil(job.ID), nil
}
func (m *Module) saveAdminVideoAdConfig(ctx context.Context, tx *gorm.DB, video *model.Video, userID string, adTemplateID *string) error {
if video == nil || adTemplateID == nil {
return nil
}
trimmed := strings.TrimSpace(*adTemplateID)
if trimmed == "" {
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil {
return err
}
video.AdID = nil
return nil
}
var template model.AdTemplate
if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("Ad template not found")
}
return err
}
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil {
return err
}
video.AdID = &template.ID
return 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 (m *Module) loadAdminVideoAdTemplateDetails(ctx context.Context, video *model.Video) (*string, *string, error) {
if video == nil {
return nil, nil, nil
}
adTemplateID := common.NullableTrimmedString(video.AdID)
if adTemplateID == nil {
return nil, nil, nil
}
adTemplateName, err := m.loadAdminAdTemplateName(ctx, *adTemplateID)
if err != nil {
return nil, nil, err
}
return adTemplateID, adTemplateName, nil
}
func (m *Module) loadAdminAdTemplateName(ctx context.Context, adTemplateID string) (*string, error) {
var template model.AdTemplate
if err := m.runtime.DB().WithContext(ctx).Select("id, name").Where("id = ?", adTemplateID).First(&template).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return common.NullableTrimmedString(&template.Name), nil
}

View File

@@ -0,0 +1,92 @@
package videos
import (
"time"
appv1 "stream.api/internal/gen/proto/app/v1"
"google.golang.org/protobuf/types/known/timestamppb"
"stream.api/internal/modules/common"
)
func presentGetUploadURLResponse(result *GetUploadURLResult) *appv1.GetUploadUrlResponse {
return &appv1.GetUploadUrlResponse{UploadUrl: result.UploadURL, Key: result.Key, FileId: result.FileID}
}
func presentVideo(view VideoView) *appv1.Video {
if view.JobID != nil {
return common.ToProtoVideo(view.Video, *view.JobID)
}
return common.ToProtoVideo(view.Video)
}
func presentCreateVideoResponse(view VideoView) *appv1.CreateVideoResponse {
return &appv1.CreateVideoResponse{Video: presentVideo(view)}
}
func presentListVideosResponse(result *ListVideosResult) *appv1.ListVideosResponse {
items := make([]*appv1.Video, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentVideo(item))
}
return &appv1.ListVideosResponse{Videos: items, Total: result.Total, Page: result.Page, Limit: result.Limit}
}
func presentGetVideoResponse(view VideoView) *appv1.GetVideoResponse {
return &appv1.GetVideoResponse{Video: presentVideo(view)}
}
func presentUpdateVideoResponse(view VideoView) *appv1.UpdateVideoResponse {
return &appv1.UpdateVideoResponse{Video: presentVideo(view)}
}
func presentAdminVideo(view AdminVideoView) *appv1.AdminVideo {
return &appv1.AdminVideo{
Id: view.ID,
UserId: view.UserID,
Title: view.Title,
Description: view.Description,
Url: view.URL,
Status: view.Status,
Size: view.Size,
Duration: view.Duration,
Format: view.Format,
CreatedAt: parseRFC3339ToProto(view.CreatedAt),
UpdatedAt: parseRFC3339ToProto(view.UpdatedAt),
ProcessingStatus: view.ProcessingStatus,
JobId: view.JobID,
OwnerEmail: view.OwnerEmail,
AdTemplateId: view.AdTemplateID,
AdTemplateName: view.AdTemplateName,
}
}
func presentListAdminVideosResponse(result *ListAdminVideosResult) *appv1.ListAdminVideosResponse {
items := make([]*appv1.AdminVideo, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, presentAdminVideo(item))
}
return &appv1.ListAdminVideosResponse{Videos: items, Total: result.Total, Page: result.Page, Limit: result.Limit}
}
func presentGetAdminVideoResponse(view AdminVideoView) *appv1.GetAdminVideoResponse {
return &appv1.GetAdminVideoResponse{Video: presentAdminVideo(view)}
}
func presentCreateAdminVideoResponse(view AdminVideoView) *appv1.CreateAdminVideoResponse {
return &appv1.CreateAdminVideoResponse{Video: presentAdminVideo(view)}
}
func presentUpdateAdminVideoResponse(view AdminVideoView) *appv1.UpdateAdminVideoResponse {
return &appv1.UpdateAdminVideoResponse{Video: presentAdminVideo(view)}
}
func parseRFC3339ToProto(value *string) *timestamppb.Timestamp {
if value == nil || *value == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, *value)
if err != nil {
return nil
}
return timestamppb.New(parsed.UTC())
}

View File

@@ -0,0 +1,132 @@
package videos
import "stream.api/internal/database/model"
type GetUploadURLCommand struct {
UserID string
Filename string
}
type GetUploadURLResult struct {
UploadURL string
Key string
FileID string
}
type CreateVideoCommand struct {
UserID string
Title string
Description string
URL string
Size int64
Duration int32
Format string
}
type VideoView struct {
Video *model.Video
JobID *string
}
type ListVideosQuery struct {
UserID string
Page int32
Limit int32
Search string
StatusFilter string
}
type ListVideosResult struct {
Items []VideoView
Total int64
Page int32
Limit int32
}
type GetVideoQuery struct {
UserID string
ID string
}
type UpdateVideoCommand struct {
UserID string
ID string
Title string
Description *string
URL string
Size int64
Duration int32
Format *string
Status *string
}
type DeleteVideoCommand struct {
UserID string
ID string
}
type AdminVideoView struct {
ID string
UserID string
Title string
Description *string
URL string
Status string
Size int64
Duration int32
Format string
CreatedAt *string
UpdatedAt *string
ProcessingStatus *string
JobID *string
OwnerEmail *string
AdTemplateID *string
AdTemplateName *string
}
type ListAdminVideosQuery struct {
Page int32
Limit int32
Search string
UserID string
StatusFilter string
}
type ListAdminVideosResult struct {
Items []AdminVideoView
Total int64
Page int32
Limit int32
}
type GetAdminVideoQuery struct {
ID string
}
type CreateAdminVideoCommand struct {
UserID string
Title string
Description *string
URL string
Size int64
Duration int32
Format string
AdTemplateID *string
}
type UpdateAdminVideoCommand struct {
ID string
UserID string
Title string
Description *string
URL string
Size int64
Duration int32
Format string
Status string
AdTemplateID *string
}
type DeleteAdminVideoCommand struct {
ID string
}

216
internal/rpc/app/core.go Normal file
View File

@@ -0,0 +1,216 @@
package app
import (
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm"
"stream.api/internal/config"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
adminhandler "stream.api/internal/modules/admin"
adtemplatesmodule "stream.api/internal/modules/adtemplates"
authmodule "stream.api/internal/modules/auth"
"stream.api/internal/modules/common"
dashboardmodule "stream.api/internal/modules/dashboard"
domainsmodule "stream.api/internal/modules/domains"
jobsmodule "stream.api/internal/modules/jobs"
paymentsmodule "stream.api/internal/modules/payments"
playerconfigsmodule "stream.api/internal/modules/playerconfigs"
plansmodule "stream.api/internal/modules/plans"
usersmodule "stream.api/internal/modules/users"
videosmodule "stream.api/internal/modules/videos"
"stream.api/internal/video"
"stream.api/pkg/cache"
"stream.api/pkg/logger"
"stream.api/pkg/storage"
"stream.api/pkg/token"
)
const defaultGoogleUserInfoURL = common.DefaultGoogleUserInfoURL
type Services struct {
AuthServiceServer
AccountServiceServer
PreferencesServiceServer
UsageServiceServer
NotificationsServiceServer
DomainsServiceServer
AdTemplatesServiceServer
PlayerConfigsServiceServer
PlansServiceServer
PaymentsServiceServer
VideosServiceServer
AdminServiceServer
}
type appServices struct {
db *gorm.DB
logger logger.Logger
authenticator *middleware.Authenticator
tokenProvider token.Provider
cache cache.Cache
storageProvider storage.Provider
videoService *video.Service
agentRuntime video.AgentRuntime
googleOauth *oauth2.Config
googleStateTTL time.Duration
googleUserInfoURL string
frontendBaseURL string
runtime *common.Runtime
authModule *authmodule.Module
usersModule *usersmodule.Module
domainsModule *domainsmodule.Module
adTemplatesModule *adtemplatesmodule.Module
playerConfigsModule *playerconfigsmodule.Module
plansModule *plansmodule.Module
paymentsModule *paymentsmodule.Module
videosModule *videosmodule.Module
jobsModule *jobsmodule.Module
dashboardModule *dashboardmodule.Module
authHandler *authmodule.Handler
accountHandler *usersmodule.AccountHandler
preferencesHandler *usersmodule.PreferencesHandler
usageHandler *usersmodule.UsageHandler
notificationsHandler *usersmodule.NotificationsHandler
domainsHandler *domainsmodule.Handler
adTemplatesHandler *adtemplatesmodule.Handler
playerConfigsHandler *playerconfigsmodule.Handler
plansHandler *plansmodule.Handler
paymentsHandler *paymentsmodule.Handler
videosHandler *videosmodule.Handler
adminHandler *adminhandler.Handler
}
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *video.Service, agentRuntime video.AgentRuntime) *Services {
var storageProvider storage.Provider
if cfg != nil {
provider, err := storage.NewS3Provider(cfg)
if err != nil {
l.Error("Failed to initialize S3 provider for gRPC app services", "error", err)
} else {
storageProvider = provider
}
}
googleStateTTL := 10 * time.Minute
googleOauth := &oauth2.Config{}
frontendBaseURL := ""
trustedMarker := ""
if cfg != nil {
if cfg.Google.StateTTLMinute > 0 {
googleStateTTL = time.Duration(cfg.Google.StateTTLMinute) * time.Minute
}
googleOauth = &oauth2.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
RedirectURL: cfg.Google.RedirectURL,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
frontendBaseURL = cfg.Frontend.BaseURL
trustedMarker = cfg.Internal.Marker
}
service := &appServices{
db: db,
logger: l,
authenticator: middleware.NewAuthenticator(db, l, trustedMarker),
tokenProvider: t,
cache: c,
storageProvider: storageProvider,
videoService: videoService,
agentRuntime: agentRuntime,
googleOauth: googleOauth,
googleStateTTL: googleStateTTL,
googleUserInfoURL: defaultGoogleUserInfoURL,
frontendBaseURL: frontendBaseURL,
}
service.initModules()
return &Services{
AuthServiceServer: service.authHandler,
AccountServiceServer: service.accountHandler,
PreferencesServiceServer: service.preferencesHandler,
UsageServiceServer: service.usageHandler,
NotificationsServiceServer: service.notificationsHandler,
DomainsServiceServer: service.domainsHandler,
AdTemplatesServiceServer: service.adTemplatesHandler,
PlayerConfigsServiceServer: service.playerConfigsHandler,
PlansServiceServer: service.plansHandler,
PaymentsServiceServer: service.paymentsHandler,
VideosServiceServer: service.videosHandler,
AdminServiceServer: service.adminHandler,
}
}
type AuthServiceServer interface { appv1.AuthServiceServer }
type AccountServiceServer interface { appv1.AccountServiceServer }
type PreferencesServiceServer interface { appv1.PreferencesServiceServer }
type UsageServiceServer interface { appv1.UsageServiceServer }
type NotificationsServiceServer interface { appv1.NotificationsServiceServer }
type DomainsServiceServer interface { appv1.DomainsServiceServer }
type AdTemplatesServiceServer interface { appv1.AdTemplatesServiceServer }
type PlayerConfigsServiceServer interface { appv1.PlayerConfigsServiceServer }
type PlansServiceServer interface { appv1.PlansServiceServer }
type PaymentsServiceServer interface { appv1.PaymentsServiceServer }
type VideosServiceServer interface { appv1.VideosServiceServer }
type AdminServiceServer interface { appv1.AdminServiceServer }
func (s *appServices) initModules() {
s.runtime = common.NewRuntime(common.RuntimeOptions{
DB: s.db,
Logger: s.logger,
Authenticator: s.authenticator,
TokenProvider: s.tokenProvider,
Cache: s.cache,
GoogleOauth: s.googleOauth,
GoogleStateTTL: s.googleStateTTL,
GoogleUserInfoURL: s.googleUserInfoURL,
FrontendBaseURL: s.frontendBaseURL,
StorageProvider: func() storage.Provider {
return s.storageProvider
},
VideoService: func() *video.Service {
return s.videoService
},
AgentRuntime: func() video.AgentRuntime {
return s.agentRuntime
},
})
s.usersModule = usersmodule.New(s.runtime)
s.authModule = authmodule.New(s.runtime, s.usersModule)
s.domainsModule = domainsmodule.New(s.runtime)
s.adTemplatesModule = adtemplatesmodule.New(s.runtime)
s.playerConfigsModule = playerconfigsmodule.New(s.runtime)
s.plansModule = plansmodule.New(s.runtime)
s.paymentsModule = paymentsmodule.New(s.runtime)
s.videosModule = videosmodule.New(s.runtime)
s.jobsModule = jobsmodule.New(s.runtime)
s.dashboardModule = dashboardmodule.New(s.runtime)
s.authHandler = authmodule.NewHandler(s.authModule)
s.accountHandler = usersmodule.NewAccountHandler(s.usersModule)
s.preferencesHandler = usersmodule.NewPreferencesHandler(s.usersModule)
s.usageHandler = usersmodule.NewUsageHandler(s.usersModule)
s.notificationsHandler = usersmodule.NewNotificationsHandler(s.usersModule)
s.domainsHandler = domainsmodule.NewHandler(s.domainsModule)
s.adTemplatesHandler = adtemplatesmodule.NewHandler(s.adTemplatesModule)
s.playerConfigsHandler = playerconfigsmodule.NewHandler(s.playerConfigsModule)
s.plansHandler = plansmodule.NewHandler(s.plansModule)
s.paymentsHandler = paymentsmodule.NewHandler(s.paymentsModule)
s.videosHandler = videosmodule.NewHandler(s.videosModule)
s.adminHandler = adminhandler.NewHandler(s.dashboardModule, s.usersModule, s.videosModule, s.paymentsModule, s.plansModule, s.adTemplatesModule, s.playerConfigsModule, s.jobsModule)
}
var (
_ = model.Plan{}
)

View File

@@ -1,63 +0,0 @@
package app
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
)
type updatePreferencesInput struct {
EmailNotifications *bool
PushNotifications *bool
MarketingNotifications *bool
TelegramNotifications *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 updatePreferencesInput) (*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.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

@@ -1,87 +0,0 @@
package app
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

@@ -1,187 +0,0 @@
package app
import (
"context"
"errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/wrapperspb"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := buildUserPayload(ctx, s.db, result.User)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return &appv1.GetMeResponse{User: toProtoUser(payload)}, nil
}
func (s *appServices) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) {
_, err := s.authenticator.RequireTrustedMetadata(ctx)
if err != nil {
return nil, err
}
u := query.User
user, err := u.WithContext(ctx).Where(u.ID.Eq(req.Value)).First()
if err != nil {
return nil, status.Error(codes.Unauthenticated, "Unauthorized")
}
payload, err := buildUserPayload(ctx, s.db, user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return toProtoUser(payload), nil
}
func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
updatedUser, err := updateUserProfile(ctx, s.db, s.logger, result.UserID, updateProfileInput{
Username: req.Username,
Email: req.Email,
Language: req.Language,
Locale: req.Locale,
})
if err != nil {
switch {
case errors.Is(err, errEmailRequired), errors.Is(err, errEmailAlreadyRegistered):
return nil, status.Error(codes.InvalidArgument, err.Error())
default:
return nil, status.Error(codes.Internal, "Failed to update profile")
}
}
payload, err := buildUserPayload(ctx, s.db, updatedUser)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload")
}
return &appv1.UpdateMeResponse{User: toProtoUser(payload)}, nil
}
func (s *appServices) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
userID := result.UserID
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to delete user", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete account")
}
return messageResponse("Account deleted successfully"), nil
}
func (s *appServices) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
userID := result.UserID
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{"storage_used": 0}).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to clear user data", "error", err)
return nil, status.Error(codes.Internal, "Failed to clear data")
}
return messageResponse("Data cleared successfully"), nil
}
func (s *appServices) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
pref, err := loadUserPreferences(ctx, s.db, result.UserID)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load preferences")
}
return &appv1.GetPreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
}
func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
pref, err := updateUserPreferences(ctx, s.db, s.logger, result.UserID, updatePreferencesInput{
EmailNotifications: req.EmailNotifications,
PushNotifications: req.PushNotifications,
MarketingNotifications: req.MarketingNotifications,
TelegramNotifications: req.TelegramNotifications,
Language: req.Language,
Locale: req.Locale,
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save preferences")
}
return &appv1.UpdatePreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
}
func (s *appServices) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
payload, err := loadUsage(ctx, s.db, s.logger, result.User)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load usage")
}
return &appv1.GetUsageResponse{
UserId: payload.UserID,
TotalVideos: payload.TotalVideos,
TotalStorage: payload.TotalStorage,
}, nil
}

View File

@@ -1,735 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) ListAdminPayments(ctx context.Context, req *appv1.ListAdminPaymentsRequest) (*appv1.ListAdminPaymentsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
userID := strings.TrimSpace(req.GetUserId())
statusFilter := strings.TrimSpace(req.GetStatus())
db := s.db.WithContext(ctx).Model(&model.Payment{})
if userID != "" {
db = db.Where("user_id = ?", userID)
}
if statusFilter != "" {
db = db.Where("UPPER(status) = ?", strings.ToUpper(statusFilter))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments")
}
var payments []model.Payment
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&payments).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments")
}
items := make([]*appv1.AdminPayment, 0, len(payments))
for _, payment := range payments {
payload, err := s.buildAdminPayment(ctx, &payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments")
}
items = append(items, payload)
}
return &appv1.ListAdminPaymentsResponse{Payments: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetAdminPayment(ctx context.Context, req *appv1.GetAdminPaymentRequest) (*appv1.GetAdminPaymentResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Payment not found")
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Payment not found")
}
return nil, status.Error(codes.Internal, "Failed to get payment")
}
payload, err := s.buildAdminPayment(ctx, &payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get payment")
}
return &appv1.GetAdminPaymentResponse{Payment: payload}, nil
}
func (s *appServices) CreateAdminPayment(ctx context.Context, req *appv1.CreateAdminPaymentRequest) (*appv1.CreateAdminPaymentResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
userID := strings.TrimSpace(req.GetUserId())
planID := strings.TrimSpace(req.GetPlanId())
if userID == "" || planID == "" {
return nil, status.Error(codes.InvalidArgument, "User ID and plan ID are required")
}
if !isAllowedTermMonths(req.GetTermMonths()) {
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
}
paymentMethod := normalizePaymentMethod(req.GetPaymentMethod())
if paymentMethod == "" {
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
}
user, err := s.loadPaymentUserForAdmin(ctx, userID)
if err != nil {
return nil, err
}
planRecord, err := s.loadPaymentPlanForAdmin(ctx, planID)
if err != nil {
return nil, err
}
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
UserID: user.ID,
Plan: planRecord,
TermMonths: req.GetTermMonths(),
PaymentMethod: paymentMethod,
TopupAmount: req.TopupAmount,
})
if err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, status.Error(codes.Internal, "Failed to create payment")
}
payload, err := s.buildAdminPayment(ctx, resultValue.Payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create payment")
}
return &appv1.CreateAdminPaymentResponse{
Payment: payload,
Subscription: toProtoPlanSubscription(resultValue.Subscription),
WalletBalance: resultValue.WalletBalance,
InvoiceId: resultValue.InvoiceID,
}, nil
}
func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateAdminPaymentRequest) (*appv1.UpdateAdminPaymentResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Payment not found")
}
newStatus := strings.ToUpper(strings.TrimSpace(req.GetStatus()))
if newStatus == "" {
newStatus = "SUCCESS"
}
if newStatus != "SUCCESS" && newStatus != "FAILED" && newStatus != "PENDING" {
return nil, status.Error(codes.InvalidArgument, "Invalid payment status")
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Payment not found")
}
return nil, status.Error(codes.Internal, "Failed to update payment")
}
currentStatus := strings.ToUpper(normalizePaymentStatus(payment.Status))
if currentStatus != newStatus {
if (currentStatus == "FAILED" || currentStatus == "PENDING") && newStatus == "SUCCESS" {
return nil, status.Error(codes.InvalidArgument, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead")
}
payment.Status = model.StringPtr(newStatus)
if err := s.db.WithContext(ctx).Save(&payment).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update payment")
}
}
payload, err := s.buildAdminPayment(ctx, &payment)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update payment")
}
return &appv1.UpdateAdminPaymentResponse{Payment: payload}, nil
}
func (s *appServices) ListAdminPlans(ctx context.Context, _ *appv1.ListAdminPlansRequest) (*appv1.ListAdminPlansResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
var plans []model.Plan
if err := s.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list plans")
}
items := make([]*appv1.AdminPlan, 0, len(plans))
for i := range plans {
payload, err := s.buildAdminPlan(ctx, &plans[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list plans")
}
items = append(items, payload)
}
return &appv1.ListAdminPlansResponse{Plans: items}, nil
}
func (s *appServices) CreateAdminPlan(ctx context.Context, req *appv1.CreateAdminPlanRequest) (*appv1.CreateAdminPlanResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if msg := validateAdminPlanInput(req.GetName(), req.GetCycle(), req.GetPrice(), req.GetStorageLimit(), req.GetUploadLimit()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
plan := &model.Plan{
ID: uuid.New().String(),
Name: strings.TrimSpace(req.GetName()),
Description: nullableTrimmedStringPtr(req.Description),
Features: append([]string(nil), req.GetFeatures()...),
Price: req.GetPrice(),
Cycle: strings.TrimSpace(req.GetCycle()),
StorageLimit: req.GetStorageLimit(),
UploadLimit: req.GetUploadLimit(),
DurationLimit: 0,
QualityLimit: "",
IsActive: model.BoolPtr(req.GetIsActive()),
}
if err := s.db.WithContext(ctx).Create(plan).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to create plan")
}
payload, err := s.buildAdminPlan(ctx, plan)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create plan")
}
return &appv1.CreateAdminPlanResponse{Plan: payload}, nil
}
func (s *appServices) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdminPlanRequest) (*appv1.UpdateAdminPlanResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Plan not found")
}
if msg := validateAdminPlanInput(req.GetName(), req.GetCycle(), req.GetPrice(), req.GetStorageLimit(), req.GetUploadLimit()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var plan model.Plan
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found")
}
return nil, status.Error(codes.Internal, "Failed to update plan")
}
plan.Name = strings.TrimSpace(req.GetName())
plan.Description = nullableTrimmedStringPtr(req.Description)
plan.Features = append([]string(nil), req.GetFeatures()...)
plan.Price = req.GetPrice()
plan.Cycle = strings.TrimSpace(req.GetCycle())
plan.StorageLimit = req.GetStorageLimit()
plan.UploadLimit = req.GetUploadLimit()
plan.IsActive = model.BoolPtr(req.GetIsActive())
if err := s.db.WithContext(ctx).Save(&plan).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan")
}
payload, err := s.buildAdminPlan(ctx, &plan)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan")
}
return &appv1.UpdateAdminPlanResponse{Plan: payload}, nil
}
func (s *appServices) DeleteAdminPlan(ctx context.Context, req *appv1.DeleteAdminPlanRequest) (*appv1.DeleteAdminPlanResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Plan not found")
}
var plan model.Plan
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found")
}
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
var paymentCount int64
if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", id).Count(&paymentCount).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
var subscriptionCount int64
if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", id).Count(&subscriptionCount).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
if paymentCount > 0 || subscriptionCount > 0 {
inactive := false
if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", id).Update("is_active", inactive).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to deactivate plan")
}
return &appv1.DeleteAdminPlanResponse{Message: "Plan deactivated", Mode: "deactivated"}, nil
}
if err := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Plan{}).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan")
}
return &appv1.DeleteAdminPlanResponse{Message: "Plan deleted", Mode: "deleted"}, nil
}
func (s *appServices) ListAdminAdTemplates(ctx context.Context, req *appv1.ListAdminAdTemplatesRequest) (*appv1.ListAdminAdTemplatesResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(protoStringValue(req.Search))
userID := strings.TrimSpace(protoStringValue(req.UserId))
db := s.db.WithContext(ctx).Model(&model.AdTemplate{})
if search != "" {
like := "%" + search + "%"
db = db.Where("name ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
var templates []model.AdTemplate
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&templates).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
items := make([]*appv1.AdminAdTemplate, 0, len(templates))
for i := range templates {
payload, err := s.buildAdminAdTemplate(ctx, &templates[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
items = append(items, payload)
}
return &appv1.ListAdminAdTemplatesResponse{
Templates: items,
Total: total,
Page: page,
Limit: limit,
}, nil
}
func (s *appServices) GetAdminAdTemplate(ctx context.Context, req *appv1.GetAdminAdTemplateRequest) (*appv1.GetAdminAdTemplateResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
var item model.AdTemplate
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to load ad template")
}
payload, err := s.buildAdminAdTemplate(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load ad template")
}
return &appv1.GetAdminAdTemplateResponse{Template: payload}, nil
}
func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.CreateAdminAdTemplateRequest) (*appv1.CreateAdminAdTemplateResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
duration := req.Duration
if msg := validateAdminAdTemplateInput(req.GetUserId(), req.GetName(), req.GetVastTagUrl(), req.GetAdFormat(), duration); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item := &model.AdTemplate{
ID: uuid.New().String(),
UserID: user.ID,
Name: strings.TrimSpace(req.GetName()),
Description: nullableTrimmedStringPtr(req.Description),
VastTagURL: strings.TrimSpace(req.GetVastTagUrl()),
AdFormat: model.StringPtr(normalizeAdFormat(req.GetAdFormat())),
Duration: duration,
IsActive: model.BoolPtr(req.GetIsActive()),
IsDefault: req.GetIsDefault(),
}
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
payload, err := s.buildAdminAdTemplate(ctx, item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.CreateAdminAdTemplateResponse{Template: payload}, nil
}
func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.UpdateAdminAdTemplateRequest) (*appv1.UpdateAdminAdTemplateResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
duration := req.Duration
if msg := validateAdminAdTemplateInput(req.GetUserId(), req.GetName(), req.GetVastTagUrl(), req.GetAdFormat(), duration); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
var item model.AdTemplate
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item.UserID = user.ID
item.Name = strings.TrimSpace(req.GetName())
item.Description = nullableTrimmedStringPtr(req.Description)
item.VastTagURL = strings.TrimSpace(req.GetVastTagUrl())
item.AdFormat = model.StringPtr(normalizeAdFormat(req.GetAdFormat()))
item.Duration = duration
item.IsActive = model.BoolPtr(req.GetIsActive())
item.IsDefault = req.GetIsDefault()
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
payload, err := s.buildAdminAdTemplate(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.UpdateAdminAdTemplateResponse{Template: payload}, nil
}
func (s *appServices) DeleteAdminAdTemplate(ctx context.Context, req *appv1.DeleteAdminAdTemplateRequest) (*appv1.MessageResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).Where("ad_id = ?", id).Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ?", id).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to delete ad template")
}
return &appv1.MessageResponse{Message: "Ad template deleted"}, nil
}
func (s *appServices) ListAdminPlayerConfigs(ctx context.Context, req *appv1.ListAdminPlayerConfigsRequest) (*appv1.ListAdminPlayerConfigsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(protoStringValue(req.Search))
userID := strings.TrimSpace(protoStringValue(req.UserId))
db := s.db.WithContext(ctx).Model(&model.PlayerConfig{})
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 player configs")
}
var configs []model.PlayerConfig
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&configs).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
items := make([]*appv1.AdminPlayerConfig, 0, len(configs))
for i := range configs {
payload, err := s.buildAdminPlayerConfig(ctx, &configs[i])
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
items = append(items, payload)
}
return &appv1.ListAdminPlayerConfigsResponse{
Configs: items,
Total: total,
Page: page,
Limit: limit,
}, nil
}
func (s *appServices) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAdminPlayerConfigRequest) (*appv1.GetAdminPlayerConfigResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
var item model.PlayerConfig
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
return nil, status.Error(codes.Internal, "Failed to load player config")
}
payload, err := s.buildAdminPlayerConfig(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load player config")
}
return &appv1.GetAdminPlayerConfigResponse{Config: payload}, nil
}
func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.CreateAdminPlayerConfigRequest) (*appv1.CreateAdminPlayerConfigResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to save player config")
}
item := &model.PlayerConfig{
ID: uuid.New().String(),
UserID: user.ID,
Name: strings.TrimSpace(req.GetName()),
Description: nullableTrimmedStringPtr(req.Description),
Autoplay: req.GetAutoplay(),
Loop: req.GetLoop(),
Muted: req.GetMuted(),
ShowControls: model.BoolPtr(req.GetShowControls()),
Pip: model.BoolPtr(req.GetPip()),
Airplay: model.BoolPtr(req.GetAirplay()),
Chromecast: model.BoolPtr(req.GetChromecast()),
IsActive: model.BoolPtr(req.GetIsActive()),
IsDefault: req.GetIsDefault(),
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
LogoURL: nullableTrimmedStringPtr(req.LogoUrl),
}
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
payload, err := s.buildAdminPlayerConfig(ctx, item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.CreateAdminPlayerConfigResponse{Config: payload}, nil
}
func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.UpdateAdminPlayerConfigRequest) (*appv1.UpdateAdminPlayerConfigResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
return nil, status.Error(codes.InvalidArgument, msg)
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to save player config")
}
var item model.PlayerConfig
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
return nil, status.Error(codes.Internal, "Failed to save player config")
}
item.UserID = user.ID
item.Name = strings.TrimSpace(req.GetName())
item.Description = nullableTrimmedStringPtr(req.Description)
item.Autoplay = req.GetAutoplay()
item.Loop = req.GetLoop()
item.Muted = req.GetMuted()
item.ShowControls = model.BoolPtr(req.GetShowControls())
item.Pip = model.BoolPtr(req.GetPip())
item.Airplay = model.BoolPtr(req.GetAirplay())
item.Chromecast = model.BoolPtr(req.GetChromecast())
item.IsActive = model.BoolPtr(req.GetIsActive())
item.IsDefault = req.GetIsDefault()
if req.EncrytionM3U8 != nil {
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
}
if req.LogoUrl != nil {
item.LogoURL = nullableTrimmedStringPtr(req.LogoUrl)
}
if !boolValue(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
payload, err := s.buildAdminPlayerConfig(ctx, &item)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.UpdateAdminPlayerConfigResponse{Config: payload}, nil
}
func (s *appServices) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.DeleteAdminPlayerConfigRequest) (*appv1.MessageResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
res := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.PlayerConfig{})
if res.Error != nil {
return nil, status.Error(codes.Internal, "Failed to delete player config")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Player config not found")
}
return &appv1.MessageResponse{Message: "Player config deleted"}, nil
}

View File

@@ -9,29 +9,23 @@ import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"stream.api/internal/database/model" "stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1" appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
) )
func TestCreateAdminPayment(t *testing.T) { func TestCreateAdminPayment(t *testing.T) {
t.Run("happy path admin", func(t *testing.T) { t.Run("happy path admin", func(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
defer cleanup() defer cleanup()
client := newAdminClient(conn) client := newAdminClient(conn)
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{ resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
if err != nil { if err != nil {
t.Fatalf("CreateAdminPayment() error = %v", err) t.Fatalf("CreateAdminPayment() error = %v", err)
} }
@@ -41,8 +35,8 @@ func TestCreateAdminPayment(t *testing.T) {
if resp.Payment.UserId != user.ID { if resp.Payment.UserId != user.ID {
t.Fatalf("payment user_id = %q, want %q", resp.Payment.UserId, user.ID) t.Fatalf("payment user_id = %q, want %q", resp.Payment.UserId, user.ID)
} }
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) { if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id)) t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
} }
if resp.Payment.GetWalletAmount() != 30 { if resp.Payment.GetWalletAmount() != 30 {
t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount()) t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount())
@@ -64,12 +58,7 @@ func TestCreateAdminPayment(t *testing.T) {
client := newAdminClient(conn) client := newAdminClient(conn)
var trailer metadata.MD var trailer metadata.MD
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{ _, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument) assertGRPCCode(t, err, codes.InvalidArgument)
if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" { if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" {
t.Fatal("expected x-error-body trailer") t.Fatal("expected x-error-body trailer")

View File

@@ -1,212 +0,0 @@
package app
import (
"context"
"encoding/json"
"errors"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/video"
)
func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJobsRequest) (*appv1.ListAdminJobsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
agentID := strings.TrimSpace(req.GetAgentId())
offset := int(req.GetOffset())
limit := int(req.GetLimit())
pageSize := int(req.GetPageSize())
useCursorPagination := req.Cursor != nil || pageSize > 0
var (
result *video.PaginatedJobs
err error
)
if useCursorPagination {
result, err = s.videoService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize)
} else if agentID != "" {
result, err = s.videoService.ListJobsByAgent(ctx, agentID, offset, limit)
} else {
result, err = s.videoService.ListJobs(ctx, offset, limit)
}
if err != nil {
if errors.Is(err, video.ErrInvalidJobCursor) {
return nil, status.Error(codes.InvalidArgument, "Invalid job cursor")
}
return nil, status.Error(codes.Internal, "Failed to list jobs")
}
jobs := make([]*appv1.AdminJob, 0, len(result.Jobs))
for _, job := range result.Jobs {
jobs = append(jobs, buildAdminJob(job))
}
response := &appv1.ListAdminJobsResponse{
Jobs: jobs,
Total: result.Total,
Offset: int32(result.Offset),
Limit: int32(result.Limit),
HasMore: result.HasMore,
PageSize: int32(result.PageSize),
}
if strings.TrimSpace(result.NextCursor) != "" {
response.NextCursor = &result.NextCursor
}
return response, nil
}
func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobRequest) (*appv1.GetAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
job, err := s.videoService.GetJob(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.Internal, "Failed to load job")
}
return &appv1.GetAdminJobResponse{Job: buildAdminJob(job)}, nil
}
func (s *appServices) GetAdminJobLogs(ctx context.Context, req *appv1.GetAdminJobLogsRequest) (*appv1.GetAdminJobLogsResponse, error) {
response, err := s.GetAdminJob(ctx, &appv1.GetAdminJobRequest{Id: req.GetId()})
if err != nil {
return nil, err
}
return &appv1.GetAdminJobLogsResponse{Logs: response.GetJob().GetLogs()}, nil
}
func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdminJobRequest) (*appv1.CreateAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
command := strings.TrimSpace(req.GetCommand())
if command == "" {
return nil, status.Error(codes.InvalidArgument, "Command is required")
}
image := strings.TrimSpace(req.GetImage())
if image == "" {
image = "alpine"
}
name := strings.TrimSpace(req.GetName())
if name == "" {
name = command
}
payload, err := json.Marshal(map[string]any{
"image": image,
"commands": []string{command},
"environment": req.GetEnv(),
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create job payload")
}
videoID := ""
if req.VideoId != nil {
videoID = strings.TrimSpace(req.GetVideoId())
}
job, err := s.videoService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit())
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create job")
}
return &appv1.CreateAdminJobResponse{Job: buildAdminJob(job)}, nil
}
func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdminJobRequest) (*appv1.CancelAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
if err := s.videoService.CancelJob(ctx, id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &appv1.CancelAdminJobResponse{Status: "cancelled", JobId: id}, nil
}
func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJobRequest) (*appv1.RetryAdminJobResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Job not found")
}
job, err := s.videoService.RetryJob(ctx, id)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found")
}
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &appv1.RetryAdminJobResponse{Job: buildAdminJob(job)}, nil
}
func (s *appServices) ListAdminAgents(ctx context.Context, _ *appv1.ListAdminAgentsRequest) (*appv1.ListAdminAgentsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.agentRuntime == nil {
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
items := s.agentRuntime.ListAgentsWithStats()
agents := make([]*appv1.AdminAgent, 0, len(items))
for _, item := range items {
agents = append(agents, buildAdminAgent(item))
}
return &appv1.ListAdminAgentsResponse{Agents: agents}, nil
}
func (s *appServices) RestartAdminAgent(ctx context.Context, req *appv1.RestartAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.agentRuntime == nil {
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
if !s.agentRuntime.SendCommand(strings.TrimSpace(req.GetId()), "restart") {
return nil, status.Error(codes.Unavailable, "Agent not active or command channel full")
}
return &appv1.AdminAgentCommandResponse{Status: "restart command sent"}, nil
}
func (s *appServices) UpdateAdminAgent(ctx context.Context, req *appv1.UpdateAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.agentRuntime == nil {
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
}
if !s.agentRuntime.SendCommand(strings.TrimSpace(req.GetId()), "update") {
return nil, status.Error(codes.Unavailable, "Agent not active or command channel full")
}
return &appv1.AdminAgentCommandResponse{Status: "update command sent"}, nil
}

View File

@@ -1,637 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/video"
)
func (s *appServices) GetAdminDashboard(ctx context.Context, _ *appv1.GetAdminDashboardRequest) (*appv1.GetAdminDashboardResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
dashboard := &appv1.AdminDashboard{}
db := s.db.WithContext(ctx)
db.Model(&model.User{}).Count(&dashboard.TotalUsers)
db.Model(&model.Video{}).Count(&dashboard.TotalVideos)
db.Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Row().Scan(&dashboard.TotalStorageUsed)
db.Model(&model.Payment{}).Count(&dashboard.TotalPayments)
db.Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Row().Scan(&dashboard.TotalRevenue)
db.Model(&model.PlanSubscription{}).Where("expires_at > ?", time.Now()).Count(&dashboard.ActiveSubscriptions)
db.Model(&model.AdTemplate{}).Count(&dashboard.TotalAdTemplates)
today := time.Now().Truncate(24 * time.Hour)
db.Model(&model.User{}).Where("created_at >= ?", today).Count(&dashboard.NewUsersToday)
db.Model(&model.Video{}).Where("created_at >= ?", today).Count(&dashboard.NewVideosToday)
return &appv1.GetAdminDashboardResponse{Dashboard: dashboard}, nil
}
func (s *appServices) ListAdminUsers(ctx context.Context, req *appv1.ListAdminUsersRequest) (*appv1.ListAdminUsersResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(req.GetSearch())
role := strings.TrimSpace(req.GetRole())
db := s.db.WithContext(ctx).Model(&model.User{})
if search != "" {
like := "%" + search + "%"
db = db.Where("email ILIKE ? OR username ILIKE ?", like, like)
}
if role != "" {
db = db.Where("UPPER(role) = ?", strings.ToUpper(role))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list users")
}
var users []model.User
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&users).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list users")
}
items := make([]*appv1.AdminUser, 0, len(users))
for _, user := range users {
payload, err := s.buildAdminUser(ctx, &user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list users")
}
items = append(items, payload)
}
return &appv1.ListAdminUsersResponse{Users: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserRequest) (*appv1.GetAdminUserResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to get user")
}
var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
subscription = &subscriptionRecord
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.Internal, "Failed to get user")
}
detail, err := s.buildAdminUserDetail(ctx, &user, subscription)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get user")
}
return &appv1.GetAdminUserResponse{User: detail}, nil
}
func (s *appServices) CreateAdminUser(ctx context.Context, req *appv1.CreateAdminUserRequest) (*appv1.CreateAdminUserResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
email := strings.TrimSpace(req.GetEmail())
password := req.GetPassword()
if email == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Email and password are required")
}
role := normalizeAdminRoleValue(req.GetRole())
if !isValidAdminRoleValue(role) {
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
}
planID := nullableTrimmedString(req.PlanId)
if err := s.ensurePlanExists(ctx, planID); err != nil {
return nil, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to hash password")
}
user := &model.User{
ID: uuid.New().String(),
Email: email,
Password: model.StringPtr(string(hashedPassword)),
Username: nullableTrimmedString(req.Username),
Role: model.StringPtr(role),
PlanID: planID,
}
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, status.Error(codes.AlreadyExists, "Email already registered")
}
return nil, status.Error(codes.Internal, "Failed to create user")
}
payload, err := s.buildAdminUser(ctx, user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create user")
}
return &appv1.CreateAdminUserResponse{User: payload}, nil
}
func (s *appServices) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdminUserRequest) (*appv1.UpdateAdminUserResponse, error) {
adminResult, err := s.requireAdmin(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
updates := map[string]interface{}{}
if req.Email != nil {
email := strings.TrimSpace(req.GetEmail())
if email == "" {
return nil, status.Error(codes.InvalidArgument, "Email is required")
}
updates["email"] = email
}
if req.Username != nil {
updates["username"] = nullableTrimmedString(req.Username)
}
if req.Role != nil {
role := normalizeAdminRoleValue(req.GetRole())
if !isValidAdminRoleValue(role) {
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
}
if id == adminResult.UserID && role != "ADMIN" {
return nil, status.Error(codes.InvalidArgument, "Cannot change your own role")
}
updates["role"] = role
}
if req.PlanId != nil {
planID := nullableTrimmedString(req.PlanId)
if err := s.ensurePlanExists(ctx, planID); err != nil {
return nil, err
}
updates["plan_id"] = planID
}
if req.Password != nil {
if strings.TrimSpace(req.GetPassword()) == "" {
return nil, status.Error(codes.InvalidArgument, "Password must not be empty")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.GetPassword()), bcrypt.DefaultCost)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to hash password")
}
updates["password"] = string(hashedPassword)
}
if len(updates) == 0 {
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to update user")
}
payload, err := s.buildAdminUser(ctx, &user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update user")
}
return &appv1.UpdateAdminUserResponse{User: payload}, nil
}
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrDuplicatedKey) {
return nil, status.Error(codes.AlreadyExists, "Email already registered")
}
return nil, status.Error(codes.Internal, "Failed to update user")
}
if result.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "User not found")
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update user")
}
payload, err := s.buildAdminUser(ctx, &user)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update user")
}
return &appv1.UpdateAdminUserResponse{User: payload}, nil
}
func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
if req.ClearReferrer != nil && req.GetClearReferrer() && req.RefUsername != nil && strings.TrimSpace(req.GetRefUsername()) != "" {
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referrer at the same time")
}
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() && req.ReferralRewardBps != nil {
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referral reward override at the same time")
}
if req.ReferralRewardBps != nil {
bps := req.GetReferralRewardBps()
if bps < 0 || bps > 10000 {
return nil, status.Error(codes.InvalidArgument, "Referral reward bps must be between 0 and 10000")
}
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
updates := map[string]any{}
if req.RefUsername != nil || (req.ClearReferrer != nil && req.GetClearReferrer()) {
if referralRewardProcessed(&user) {
return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted")
}
if req.ClearReferrer != nil && req.GetClearReferrer() {
updates["referred_by_user_id"] = nil
} else if req.RefUsername != nil {
referrer, err := s.loadReferralUserByUsernameStrict(ctx, req.GetRefUsername())
if err != nil {
return nil, err
}
if referrer.ID == user.ID {
return nil, status.Error(codes.InvalidArgument, "User cannot refer themselves")
}
updates["referred_by_user_id"] = referrer.ID
}
}
if req.ReferralEligible != nil {
updates["referral_eligible"] = req.GetReferralEligible()
}
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() {
updates["referral_reward_bps"] = nil
} else if req.ReferralRewardBps != nil {
updates["referral_reward_bps"] = req.GetReferralRewardBps()
}
if len(updates) > 0 {
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
if result.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "User not found")
}
}
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
subscription = &subscriptionRecord
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
payload, err := s.buildAdminUserDetail(ctx, &user, subscription)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings")
}
return &appv1.UpdateAdminUserReferralSettingsResponse{User: payload}, nil
}
func (s *appServices) UpdateAdminUserRole(ctx context.Context, req *appv1.UpdateAdminUserRoleRequest) (*appv1.UpdateAdminUserRoleResponse, error) {
adminResult, err := s.requireAdmin(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
if id == adminResult.UserID {
return nil, status.Error(codes.InvalidArgument, "Cannot change your own role")
}
role := normalizeAdminRoleValue(req.GetRole())
if !isValidAdminRoleValue(role) {
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
}
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("role", role)
if result.Error != nil {
return nil, status.Error(codes.Internal, "Failed to update role")
}
if result.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "User not found")
}
return &appv1.UpdateAdminUserRoleResponse{Message: "Role updated", Role: role}, nil
}
func (s *appServices) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdminUserRequest) (*appv1.MessageResponse, error) {
adminResult, err := s.requireAdmin(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "User not found")
}
if id == adminResult.UserID {
return nil, status.Error(codes.InvalidArgument, "Cannot delete your own account")
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to find user")
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
tables := []struct {
model interface{}
where string
}{
{&model.AdTemplate{}, "user_id = ?"},
{&model.Notification{}, "user_id = ?"},
{&model.Domain{}, "user_id = ?"},
{&model.WalletTransaction{}, "user_id = ?"},
{&model.PlanSubscription{}, "user_id = ?"},
{&model.UserPreference{}, "user_id = ?"},
{&model.Video{}, "user_id = ?"},
{&model.Payment{}, "user_id = ?"},
}
for _, item := range tables {
if err := tx.Where(item.where, id).Delete(item.model).Error; err != nil {
return err
}
}
return tx.Where("id = ?", id).Delete(&model.User{}).Error
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete user")
}
return messageResponse("User deleted"), nil
}
func (s *appServices) ListAdminVideos(ctx context.Context, req *appv1.ListAdminVideosRequest) (*appv1.ListAdminVideosResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(req.GetSearch())
userID := strings.TrimSpace(req.GetUserId())
statusFilter := strings.TrimSpace(req.GetStatus())
db := s.db.WithContext(ctx).Model(&model.Video{})
if search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
if statusFilter != "" && !strings.EqualFold(statusFilter, "all") {
db = db.Where("status = ?", normalizeVideoStatusValue(statusFilter))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&videos).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
items := make([]*appv1.AdminVideo, 0, len(videos))
for _, video := range videos {
payload, err := s.buildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
items = append(items, payload)
}
return &appv1.ListAdminVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetAdminVideo(ctx context.Context, req *appv1.GetAdminVideoRequest) (*appv1.GetAdminVideoResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to get video")
}
payload, err := s.buildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to get video")
}
return &appv1.GetAdminVideoResponse{Video: payload}, nil
}
func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdminVideoRequest) (*appv1.CreateAdminVideoResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
userID := strings.TrimSpace(req.GetUserId())
title := strings.TrimSpace(req.GetTitle())
videoURL := strings.TrimSpace(req.GetUrl())
if userID == "" || title == "" || videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
}
if req.GetSize() < 0 {
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
}
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
UserID: userID,
Title: title,
Description: req.Description,
URL: videoURL,
Size: req.GetSize(),
Duration: req.GetDuration(),
Format: strings.TrimSpace(req.GetFormat()),
AdTemplateID: nullableTrimmedString(req.AdTemplateId),
})
if err != nil {
switch {
case errors.Is(err, video.ErrUserNotFound):
return nil, status.Error(codes.InvalidArgument, "User not found")
case errors.Is(err, video.ErrAdTemplateNotFound):
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
case errors.Is(err, video.ErrJobServiceUnavailable):
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
default:
return nil, status.Error(codes.Internal, "Failed to create video")
}
}
payload, err := s.buildAdminVideo(ctx, created.Video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to create video")
}
return &appv1.CreateAdminVideoResponse{Video: payload}, nil
}
func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdminVideoRequest) (*appv1.UpdateAdminVideoResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
userID := strings.TrimSpace(req.GetUserId())
title := strings.TrimSpace(req.GetTitle())
videoURL := strings.TrimSpace(req.GetUrl())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
if userID == "" || title == "" || videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
}
if req.GetSize() < 0 {
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
oldSize := video.Size
oldUserID := video.UserID
statusValue := normalizeVideoStatusValue(req.GetStatus())
processingStatus := strings.ToUpper(statusValue)
video.UserID = user.ID
video.Name = title
video.Title = title
video.Description = nullableTrimmedString(req.Description)
video.URL = videoURL
video.Size = req.GetSize()
video.Duration = req.GetDuration()
video.Format = strings.TrimSpace(req.GetFormat())
video.Status = model.StringPtr(statusValue)
video.ProcessingStatus = model.StringPtr(processingStatus)
video.StorageType = model.StringPtr(detectStorageType(videoURL))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&video).Error; err != nil {
return err
}
if oldUserID == user.ID {
delta := video.Size - oldSize
if delta != 0 {
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used + ?, 0)", delta)).Error; err != nil {
return err
}
}
} else {
if err := tx.Model(&model.User{}).Where("id = ?", oldUserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", oldSize)).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
return err
}
}
return s.saveAdminVideoAdConfig(ctx, tx, &video, user.ID, nullableTrimmedString(req.AdTemplateId))
})
if err != nil {
if strings.Contains(err.Error(), "Ad template not found") {
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
}
return nil, status.Error(codes.Internal, "Failed to update video")
}
payload, err := s.buildAdminVideo(ctx, &video)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update video")
}
return &appv1.UpdateAdminVideoResponse{Video: payload}, nil
}
func (s *appServices) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdminVideoRequest) (*appv1.MessageResponse, error) {
if _, err := s.requireAdmin(ctx); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
return nil, status.Error(codes.Internal, "Failed to find video")
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", video.UserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", video.Size)).Error
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete video")
}
return messageResponse("Video deleted"), nil
}

View File

@@ -1,234 +0,0 @@
package app
import (
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"gorm.io/gorm"
"stream.api/internal/config"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
"stream.api/internal/video"
"stream.api/pkg/cache"
"stream.api/pkg/logger"
"stream.api/pkg/storage"
"stream.api/pkg/token"
)
const adTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
const defaultGoogleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
const (
playerConfigFreePlanLimitMessage = "Free plan supports only 1 player config"
playerConfigFreePlanReconciliationMessage = "Delete extra player configs to continue managing player configs on the free plan"
)
const (
walletTransactionTypeTopup = "topup"
walletTransactionTypeSubscriptionDebit = "subscription_debit"
walletTransactionTypeReferralReward = "referral_reward"
paymentMethodWallet = "wallet"
paymentMethodTopup = "topup"
paymentKindSubscription = "subscription"
paymentKindWalletTopup = "wallet_topup"
defaultReferralRewardBps = int32(500)
)
var allowedTermMonths = map[int32]struct{}{
1: {},
3: {},
6: {},
12: {},
}
type Services struct {
AuthServiceServer
AccountServiceServer
PreferencesServiceServer
UsageServiceServer
NotificationsServiceServer
DomainsServiceServer
AdTemplatesServiceServer
PlayerConfigsServiceServer
PlansServiceServer
PaymentsServiceServer
VideosServiceServer
AdminServiceServer
}
type appServices struct {
appv1.UnimplementedAuthServiceServer
appv1.UnimplementedAccountServiceServer
appv1.UnimplementedPreferencesServiceServer
appv1.UnimplementedUsageServiceServer
appv1.UnimplementedNotificationsServiceServer
appv1.UnimplementedDomainsServiceServer
appv1.UnimplementedAdTemplatesServiceServer
appv1.UnimplementedPlayerConfigsServiceServer
appv1.UnimplementedPlansServiceServer
appv1.UnimplementedPaymentsServiceServer
appv1.UnimplementedVideosServiceServer
appv1.UnimplementedAdminServiceServer
db *gorm.DB
logger logger.Logger
authenticator *middleware.Authenticator
tokenProvider token.Provider
cache cache.Cache
storageProvider storage.Provider
videoService *video.Service
agentRuntime video.AgentRuntime
googleOauth *oauth2.Config
googleStateTTL time.Duration
googleUserInfoURL string
frontendBaseURL string
}
type paymentInvoiceDetails struct {
PlanName string
TermMonths *int32
PaymentMethod string
ExpiresAt *time.Time
WalletAmount float64
TopupAmount float64
}
type paymentExecutionInput struct {
UserID string
Plan *model.Plan
TermMonths int32
PaymentMethod string
TopupAmount *float64
}
type paymentExecutionResult struct {
Payment *model.Payment
Subscription *model.PlanSubscription
WalletBalance float64
InvoiceID string
}
type referralRewardResult struct {
Granted bool
Amount float64
}
type apiErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *video.Service, agentRuntime video.AgentRuntime) *Services {
var storageProvider storage.Provider
if cfg != nil {
provider, err := storage.NewS3Provider(cfg)
if err != nil {
l.Error("Failed to initialize S3 provider for gRPC app services", "error", err)
} else {
storageProvider = provider
}
}
googleStateTTL := 10 * time.Minute
googleOauth := &oauth2.Config{}
if cfg != nil {
if cfg.Google.StateTTLMinute > 0 {
googleStateTTL = time.Duration(cfg.Google.StateTTLMinute) * time.Minute
}
googleOauth = &oauth2.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
RedirectURL: cfg.Google.RedirectURL,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
}
frontendBaseURL := ""
if cfg != nil {
frontendBaseURL = cfg.Frontend.BaseURL
}
service := &appServices{
db: db,
logger: l,
authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker),
tokenProvider: t,
cache: c,
storageProvider: storageProvider,
videoService: videoService,
agentRuntime: agentRuntime,
googleOauth: googleOauth,
googleStateTTL: googleStateTTL,
googleUserInfoURL: defaultGoogleUserInfoURL,
frontendBaseURL: frontendBaseURL,
}
return &Services{
AuthServiceServer: service,
AccountServiceServer: service,
PreferencesServiceServer: service,
UsageServiceServer: service,
NotificationsServiceServer: service,
DomainsServiceServer: service,
AdTemplatesServiceServer: service,
PlayerConfigsServiceServer: service,
PlansServiceServer: service,
PaymentsServiceServer: service,
VideosServiceServer: service,
AdminServiceServer: service,
}
}
type AuthServiceServer interface {
appv1.AuthServiceServer
}
type AccountServiceServer interface {
appv1.AccountServiceServer
}
type PreferencesServiceServer interface {
appv1.PreferencesServiceServer
}
type UsageServiceServer interface {
appv1.UsageServiceServer
}
type NotificationsServiceServer interface {
appv1.NotificationsServiceServer
}
type DomainsServiceServer interface {
appv1.DomainsServiceServer
}
type AdTemplatesServiceServer interface {
appv1.AdTemplatesServiceServer
}
type PlayerConfigsServiceServer interface {
appv1.PlayerConfigsServiceServer
}
type PlansServiceServer interface {
appv1.PlansServiceServer
}
type PaymentsServiceServer interface {
appv1.PaymentsServiceServer
}
type VideosServiceServer interface {
appv1.VideosServiceServer
}
type AdminServiceServer interface {
appv1.AdminServiceServer
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,144 +8,77 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/modules/common"
paymentsmodule "stream.api/internal/modules/payments"
) )
func TestValidatePaymentFunding(t *testing.T) { func TestValidatePaymentFunding(t *testing.T) {
baseInput := paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodWallet}
baseInput := paymentExecutionInput{PaymentMethod: paymentMethodWallet}
tests := []struct { tests := []struct {
name string name string
input paymentExecutionInput input paymentsmodule.ExecutionInput
totalAmount float64 totalAmount float64
walletBalance float64 walletBalance float64
wantTopup float64 wantTopup float64
wantCode codes.Code wantCode codes.Code
wantMessage string wantMessage string
}{ }{
{ {name: "wallet đủ tiền", input: baseInput, totalAmount: 30, walletBalance: 30, wantTopup: 0},
name: "wallet đủ tiền", {name: "wallet thiếu tiền", input: baseInput, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Insufficient wallet balance"},
input: baseInput, {name: "topup thiếu amount", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount is required when payment method is topup"},
totalAmount: 30, {name: "topup amount <= 0", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(0)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than 0"},
walletBalance: 30, {name: "topup amount nhỏ hơn shortfall", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(20)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than or equal to the required shortfall"},
wantTopup: 0, {name: "topup hợp lệ", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(30)}, totalAmount: 50, walletBalance: 20, wantTopup: 30},
},
{
name: "wallet thiếu tiền",
input: baseInput,
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Insufficient wallet balance",
},
{
name: "topup thiếu amount",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount is required when payment method is topup",
},
{
name: "topup amount <= 0",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(0)},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount must be greater than 0",
},
{
name: "topup amount nhỏ hơn shortfall",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(20)},
totalAmount: 50,
walletBalance: 20,
wantCode: codes.InvalidArgument,
wantMessage: "Top-up amount must be greater than or equal to the required shortfall",
},
{
name: "topup hợp lệ",
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(30)},
totalAmount: 50,
walletBalance: 20,
wantTopup: 30,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := validatePaymentFunding(context.Background(), tt.input, tt.totalAmount, tt.walletBalance) got, err := paymentsmodule.ValidatePaymentFunding(tt.input, tt.totalAmount, tt.walletBalance)
if tt.wantCode == codes.OK { if tt.wantCode == codes.OK {
if err != nil { if err != nil {
t.Fatalf("validatePaymentFunding() error = %v", err) t.Fatalf("ValidatePaymentFunding() error = %v", err)
} }
if got != tt.wantTopup { if got != tt.wantTopup {
t.Fatalf("validatePaymentFunding() topup = %v, want %v", got, tt.wantTopup) t.Fatalf("ValidatePaymentFunding() topup = %v, want %v", got, tt.wantTopup)
} }
return return
} }
if err == nil { if err == nil {
t.Fatalf("validatePaymentFunding() error = nil, want %v", tt.wantCode) t.Fatalf("ValidatePaymentFunding() error = nil, want %v", tt.wantCode)
} }
if status.Code(err) != tt.wantCode { if validationErr, ok := err.(*paymentsmodule.PaymentValidationError); !ok || codes.Code(validationErr.GRPCCode) != tt.wantCode {
t.Fatalf("validatePaymentFunding() code = %v, want %v", status.Code(err), tt.wantCode) gotCode := codes.Unknown
if ok {
gotCode = codes.Code(validationErr.GRPCCode)
}
t.Fatalf("ValidatePaymentFunding() code = %v, want %v", gotCode, tt.wantCode)
} }
if got := err.Error(); !strings.Contains(got, tt.wantMessage) { if got := err.Error(); !strings.Contains(got, tt.wantMessage) {
t.Fatalf("validatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage) t.Fatalf("ValidatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage)
} }
}) })
} }
} }
func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) { func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Role: ptrString("USER"), StorageUsed: 0})
ID: uuid.NewString(), plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 10, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, DurationLimit: 0, QualityLimit: "1080p", Features: []string{"priority"}, IsActive: ptrBool(true)})
Email: "payer@example.com", seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD"), Note: ptrString("Initial funds")})
Role: ptrString("USER"),
StorageUsed: 0,
})
plan := seedTestPlan(t, db, model.Plan{
ID: uuid.NewString(),
Name: "Pro",
Price: 10,
Cycle: "monthly",
StorageLimit: 100,
UploadLimit: 10,
DurationLimit: 0,
QualityLimit: "1080p",
Features: []string{"priority"},
IsActive: ptrBool(true),
})
seedWalletTransaction(t, db, model.WalletTransaction{
ID: uuid.NewString(),
UserID: user.ID,
Type: walletTransactionTypeTopup,
Amount: 5,
Currency: ptrString("USD"),
Note: ptrString("Initial funds"),
})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{ result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: user.ID, Plan: &plan, TermMonths: 3, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
UserID: user.ID,
Plan: &plan,
TermMonths: 3,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
if err != nil { if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err) t.Fatalf("ExecutePaymentFlow() error = %v", err)
} }
if result == nil || result.Payment == nil || result.Subscription == nil { if result == nil || result.Payment == nil || result.Subscription == nil {
t.Fatalf("executePaymentFlow() returned incomplete result: %#v", result) t.Fatalf("ExecutePaymentFlow() returned incomplete result: %#v", result)
} }
if result.InvoiceID != buildInvoiceID(result.Payment.ID) { if result.InvoiceID != common.BuildInvoiceID(result.Payment.ID) {
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, buildInvoiceID(result.Payment.ID)) t.Fatalf("invoice id = %q, want %q", result.InvoiceID, common.BuildInvoiceID(result.Payment.ID))
} }
if result.WalletBalance != 0 { if result.WalletBalance != 0 {
t.Fatalf("wallet balance = %v, want 0", result.WalletBalance) t.Fatalf("wallet balance = %v, want 0", result.WalletBalance)
@@ -158,8 +91,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if payment.PlanID == nil || *payment.PlanID != plan.ID { if payment.PlanID == nil || *payment.PlanID != plan.ID {
t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID) t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID)
} }
if normalizePaymentStatus(payment.Status) != "success" { if common.NormalizePaymentStatus(payment.Status) != "success" {
t.Fatalf("payment status = %q, want success", normalizePaymentStatus(payment.Status)) t.Fatalf("payment status = %q, want success", common.NormalizePaymentStatus(payment.Status))
} }
subscription := mustLoadSubscriptionByPayment(t, db, payment.ID) subscription := mustLoadSubscriptionByPayment(t, db, payment.ID)
@@ -172,8 +105,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if subscription.TermMonths != 3 { if subscription.TermMonths != 3 {
t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths) t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths)
} }
if subscription.PaymentMethod != paymentMethodTopup { if subscription.PaymentMethod != common.PaymentMethodTopup {
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, paymentMethodTopup) t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, common.PaymentMethodTopup)
} }
if subscription.WalletAmount != 30 { if subscription.WalletAmount != 30 {
t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount) t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount)
@@ -189,10 +122,10 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if len(walletTransactions) != 2 { if len(walletTransactions) != 2 {
t.Fatalf("wallet transaction count = %d, want 2", len(walletTransactions)) t.Fatalf("wallet transaction count = %d, want 2", len(walletTransactions))
} }
if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != walletTransactionTypeTopup { if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != common.WalletTransactionTypeTopup {
t.Fatalf("first wallet transaction = %#v, want topup amount 25", walletTransactions[0]) t.Fatalf("first wallet transaction = %#v, want topup amount 25", walletTransactions[0])
} }
if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != walletTransactionTypeSubscriptionDebit { if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != common.WalletTransactionTypeSubscriptionDebit {
t.Fatalf("second wallet transaction = %#v, want debit amount -30", walletTransactions[1]) t.Fatalf("second wallet transaction = %#v, want debit amount -30", walletTransactions[1])
} }
@@ -226,8 +159,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if metadataPayload["payment_id"] != payment.ID { if metadataPayload["payment_id"] != payment.ID {
t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID) t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID)
} }
if metadataPayload["payment_method"] != paymentMethodTopup { if metadataPayload["payment_method"] != common.PaymentMethodTopup {
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], paymentMethodTopup) t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], common.PaymentMethodTopup)
} }
if metadataPayload["wallet_amount"] != 30.0 { if metadataPayload["wallet_amount"] != 30.0 {
t.Fatalf("metadata wallet_amount = %v, want 30", metadataPayload["wallet_amount"]) t.Fatalf("metadata wallet_amount = %v, want 30", metadataPayload["wallet_amount"])

View File

@@ -1,279 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
planID := strings.TrimSpace(req.GetPlanId())
if planID == "" {
return nil, status.Error(codes.InvalidArgument, "Plan ID is required")
}
if !isAllowedTermMonths(req.GetTermMonths()) {
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
}
paymentMethod := normalizePaymentMethod(req.GetPaymentMethod())
if paymentMethod == "" {
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
}
planRecord, err := s.loadPaymentPlanForUser(ctx, planID)
if err != nil {
return nil, err
}
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
UserID: result.UserID,
Plan: planRecord,
TermMonths: req.GetTermMonths(),
PaymentMethod: paymentMethod,
TopupAmount: req.TopupAmount,
})
if err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
s.logger.Error("Failed to create payment", "error", err)
return nil, status.Error(codes.Internal, "Failed to create payment")
}
return &appv1.CreatePaymentResponse{
Payment: toProtoPayment(resultValue.Payment),
Subscription: toProtoPlanSubscription(resultValue.Subscription),
WalletBalance: resultValue.WalletBalance,
InvoiceId: resultValue.InvoiceID,
Message: "Payment completed successfully",
}, nil
}
func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
type paymentHistoryRow 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"`
InvoiceID string `gorm:"column:invoice_id"`
Kind string `gorm:"column:kind"`
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"`
}
baseQuery := `
WITH history AS (
SELECT
p.id AS id,
p.amount AS amount,
p.currency AS currency,
p.status AS status,
p.plan_id AS plan_id,
pl.name AS plan_name,
p.id AS invoice_id,
? AS kind,
ps.term_months AS term_months,
ps.payment_method AS payment_method,
ps.expires_at AS expires_at,
p.created_at AS created_at
FROM payment AS p
LEFT JOIN plan AS pl ON pl.id = p.plan_id
LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id
WHERE p.user_id = ?
UNION ALL
SELECT
wt.id AS id,
wt.amount AS amount,
wt.currency AS currency,
'SUCCESS' AS status,
NULL AS plan_id,
NULL AS plan_name,
wt.id AS invoice_id,
? AS kind,
NULL AS term_months,
NULL AS payment_method,
NULL AS expires_at,
wt.created_at AS created_at
FROM wallet_transactions AS wt
WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL
)
`
var total int64
if err := s.db.WithContext(ctx).
Raw(baseQuery+`SELECT COUNT(*) FROM history`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup).
Scan(&total).Error; err != nil {
s.logger.Error("Failed to count payment history", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
}
var rows []paymentHistoryRow
if err := s.db.WithContext(ctx).
Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup, limit, offset).
Scan(&rows).Error; err != nil {
s.logger.Error("Failed to fetch payment history", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
}
items := make([]*appv1.PaymentHistoryItem, 0, len(rows))
for _, row := range rows {
items = append(items, &appv1.PaymentHistoryItem{
Id: row.ID,
Amount: row.Amount,
Currency: normalizeCurrency(row.Currency),
Status: normalizePaymentStatus(row.Status),
PlanId: row.PlanID,
PlanName: row.PlanName,
InvoiceId: buildInvoiceID(row.InvoiceID),
Kind: row.Kind,
TermMonths: row.TermMonths,
PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod),
ExpiresAt: timeToProto(row.ExpiresAt),
CreatedAt: timeToProto(row.CreatedAt),
})
}
hasPrev := page > 1 && total > 0
hasNext := int64(offset)+int64(len(items)) < total
return &appv1.ListPaymentHistoryResponse{
Payments: items,
Total: total,
Page: page,
Limit: limit,
HasPrev: hasPrev,
HasNext: hasNext,
}, nil
}
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
amount := req.GetAmount()
if amount < 1 {
return nil, status.Error(codes.InvalidArgument, "Amount must be at least 1")
}
transaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: result.UserID,
Type: walletTransactionTypeTopup,
Amount: amount,
Currency: model.StringPtr("USD"),
Note: model.StringPtr(fmt.Sprintf("Wallet top-up of %.2f USD", amount)),
}
notification := &model.Notification{
ID: uuid.New().String(),
UserID: result.UserID,
Type: "billing.topup",
Title: "Wallet credited",
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount),
Metadata: model.StringPtr(mustMarshalJSON(map[string]any{
"wallet_transaction_id": transaction.ID,
"invoice_id": buildInvoiceID(transaction.ID),
})),
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
return err
}
if err := tx.Create(transaction).Error; err != nil {
return err
}
if err := tx.Create(notification).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to top up wallet", "error", err)
return nil, status.Error(codes.Internal, "Failed to top up wallet")
}
balance, err := model.GetWalletBalance(ctx, s.db, result.UserID)
if err != nil {
s.logger.Error("Failed to calculate wallet balance", "error", err)
return nil, status.Error(codes.Internal, "Failed to top up wallet")
}
return &appv1.TopupWalletResponse{
WalletTransaction: toProtoWalletTransaction(transaction),
WalletBalance: balance,
InvoiceId: buildInvoiceID(transaction.ID),
}, nil
}
func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Invoice not found")
}
paymentRecord, err := query.Payment.WithContext(ctx).
Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(result.UserID)).
First()
if err == nil {
invoiceText, filename, buildErr := s.buildPaymentInvoice(ctx, paymentRecord)
if buildErr != nil {
s.logger.Error("Failed to build payment invoice", "error", buildErr)
return nil, status.Error(codes.Internal, "Failed to download invoice")
}
return &appv1.DownloadInvoiceResponse{
Filename: filename,
ContentType: "text/plain; charset=utf-8",
Content: invoiceText,
}, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load payment invoice", "error", err)
return nil, status.Error(codes.Internal, "Failed to download invoice")
}
var topup model.WalletTransaction
if err := s.db.WithContext(ctx).
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, result.UserID, walletTransactionTypeTopup).
First(&topup).Error; err == nil {
return &appv1.DownloadInvoiceResponse{
Filename: buildInvoiceFilename(topup.ID),
ContentType: "text/plain; charset=utf-8",
Content: buildTopupInvoice(&topup),
}, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load topup invoice", "error", err)
return nil, status.Error(codes.Internal, "Failed to download invoice")
}
return nil, status.Error(codes.NotFound, "Invoice not found")
}

View File

@@ -13,24 +13,18 @@ import (
"stream.api/internal/database/model" "stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1" appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware" "stream.api/internal/middleware"
"stream.api/internal/modules/common"
) )
func TestCreatePayment(t *testing.T) { func TestCreatePayment(t *testing.T) {
t.Run("plan không tồn tại", func(t *testing.T) { t.Run("plan không tồn tại", func(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
defer cleanup() defer cleanup()
client := newPaymentsClient(conn) client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: uuid.NewString(), TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
PlanId: uuid.NewString(),
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
assertGRPCCode(t, err, codes.NotFound) assertGRPCCode(t, err, codes.NotFound)
}) })
@@ -39,16 +33,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(false)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(false)})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
defer cleanup() defer cleanup()
client := newPaymentsClient(conn) client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
assertGRPCCode(t, err, codes.InvalidArgument) assertGRPCCode(t, err, codes.InvalidArgument)
}) })
@@ -57,16 +45,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
defer cleanup() defer cleanup()
client := newPaymentsClient(conn) client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 2, PaymentMethod: common.PaymentMethodWallet})
PlanId: plan.ID,
TermMonths: 2,
PaymentMethod: paymentMethodWallet,
})
assertGRPCCode(t, err, codes.InvalidArgument) assertGRPCCode(t, err, codes.InvalidArgument)
}) })
@@ -75,16 +57,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
defer cleanup() defer cleanup()
client := newPaymentsClient(conn) client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: "bank_transfer"})
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: "bank_transfer",
})
assertGRPCCode(t, err, codes.InvalidArgument) assertGRPCCode(t, err, codes.InvalidArgument)
}) })
@@ -93,18 +69,12 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 50, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 50, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
defer cleanup() defer cleanup()
client := newPaymentsClient(conn) client := newPaymentsClient(conn)
var trailer metadata.MD var trailer metadata.MD
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument) assertGRPCCode(t, err, codes.InvalidArgument)
body := firstTestMetadataValue(trailer, "x-error-body") body := firstTestMetadataValue(trailer, "x-error-body")
if body == "" { if body == "" {
@@ -120,29 +90,22 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
defer cleanup() defer cleanup()
client := newPaymentsClient(conn) client := newPaymentsClient(conn)
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(15)})
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(15),
})
if err != nil { if err != nil {
t.Fatalf("CreatePayment() error = %v", err) t.Fatalf("CreatePayment() error = %v", err)
} }
if resp.Payment == nil || resp.Subscription == nil { if resp.Payment == nil || resp.Subscription == nil {
t.Fatalf("CreatePayment() response incomplete: %#v", resp) t.Fatalf("CreatePayment() response incomplete: %#v", resp)
} }
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) { if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id)) t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
} }
if resp.Subscription.PaymentMethod != paymentMethodTopup { if resp.Subscription.PaymentMethod != common.PaymentMethodTopup {
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, paymentMethodTopup) t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, common.PaymentMethodTopup)
} }
if resp.Subscription.WalletAmount != 20 { if resp.Subscription.WalletAmount != 20 {
t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount) t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount)
@@ -153,7 +116,6 @@ func TestCreatePayment(t *testing.T) {
if resp.WalletBalance != 0 { if resp.WalletBalance != 0 {
t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance) t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance)
} }
payment := mustLoadPayment(t, db, resp.Payment.Id) payment := mustLoadPayment(t, db, resp.Payment.Id)
if payment.Amount != 20 { if payment.Amount != 20 {
t.Fatalf("payment amount = %v, want 20", payment.Amount) t.Fatalf("payment amount = %v, want 20", payment.Amount)

View File

@@ -15,6 +15,7 @@ import (
"stream.api/internal/database/model" "stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1" appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware" "stream.api/internal/middleware"
"stream.api/internal/modules/common"
) )
func TestPlayerConfigsPolicy(t *testing.T) { func TestPlayerConfigsPolicy(t *testing.T) {
@@ -50,8 +51,8 @@ func TestPlayerConfigsPolicy(t *testing.T) {
_, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Second"}) _, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Second"})
assertGRPCCode(t, err, codes.FailedPrecondition) assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanLimitMessage { if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanLimitMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanLimitMessage) t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanLimitMessage)
} }
}) })
@@ -107,8 +108,8 @@ func TestPlayerConfigsPolicy(t *testing.T) {
IsActive: ptrBool(true), IsActive: ptrBool(true),
}) })
assertGRPCCode(t, err, codes.FailedPrecondition) assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanReconciliationMessage { if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanReconciliationMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanReconciliationMessage) t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanReconciliationMessage)
} }
_, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: second.ID}) _, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: second.ID})
@@ -212,7 +213,7 @@ func TestPlayerConfigsPolicy(t *testing.T) {
t.Fatalf("player config count = %d, want 1", len(items)) t.Fatalf("player config count = %d, want 1", len(items))
} }
for _, message := range messages { for _, message := range messages {
if message != playerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") { if message != common.PlayerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") {
t.Fatalf("unexpected concurrent create error message: %q", message) t.Fatalf("unexpected concurrent create error message: %q", message)
} }
} }

View File

@@ -3,13 +3,14 @@ package app
import ( import (
"context" "context"
"testing" "testing"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"gorm.io/gorm" "gorm.io/gorm"
"stream.api/internal/database/model" "stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1" appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
paymentsmodule "stream.api/internal/modules/payments"
) )
func TestRegisterReferralCapture(t *testing.T) { func TestRegisterReferralCapture(t *testing.T) {
@@ -18,12 +19,7 @@ func TestRegisterReferralCapture(t *testing.T) {
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{ resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "bob", Email: "bob@example.com", Password: "secret123", RefUsername: ptrString("alice")})
Username: "bob",
Email: "bob@example.com",
Password: "secret123",
RefUsername: ptrString("alice"),
})
if err != nil { if err != nil {
t.Fatalf("Register() error = %v", err) t.Fatalf("Register() error = %v", err)
} }
@@ -39,13 +35,7 @@ func TestRegisterReferralCapture(t *testing.T) {
t.Run("register với ref invalid hoặc self-ref vẫn tạo user", func(t *testing.T) { t.Run("register với ref invalid hoặc self-ref vẫn tạo user", func(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "selfie", Email: "selfie@example.com", Password: "secret123", RefUsername: ptrString("selfie")})
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{
Username: "selfie",
Email: "selfie@example.com",
Password: "secret123",
RefUsername: ptrString("selfie"),
})
if err != nil { if err != nil {
t.Fatalf("Register() error = %v", err) t.Fatalf("Register() error = %v", err)
} }
@@ -61,9 +51,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob") referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob")
if err != nil { if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err) t.Fatalf("ResolveSignupReferrerID() error = %v", err)
} }
if referrerID == nil || *referrerID != referrer.ID { if referrerID == nil || *referrerID != referrer.ID {
t.Fatalf("referrerID = %v, want %s", referrerID, referrer.ID) t.Fatalf("referrerID = %v, want %s", referrerID, referrer.ID)
@@ -73,9 +63,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
t.Run("invalid hoặc self-ref bị ignore", func(t *testing.T) { t.Run("invalid hoặc self-ref bị ignore", func(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
referrerID, err := services.resolveSignupReferrerID(context.Background(), "bob", "bob") referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "bob", "bob")
if err != nil { if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err) t.Fatalf("ResolveSignupReferrerID() error = %v", err)
} }
if referrerID != nil { if referrerID != nil {
t.Fatalf("referrerID = %v, want nil", referrerID) t.Fatalf("referrerID = %v, want nil", referrerID)
@@ -87,9 +77,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "a@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "a@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "b@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "b@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob") referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob")
if err != nil { if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err) t.Fatalf("ResolveSignupReferrerID() error = %v", err)
} }
if referrerID != nil { if referrerID != nil {
t.Fatalf("referrerID = %v, want nil", referrerID) t.Fatalf("referrerID = %v, want nil", referrerID)
@@ -110,11 +100,10 @@ func TestReferralRewardFlow(t *testing.T) {
t.Run("first subscription thưởng 5 phần trăm", func(t *testing.T) { t.Run("first subscription thưởng 5 phần trăm", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t) services, db, referrer, referee, plan := setup(t)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet})
if err != nil { if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err) t.Fatalf("ExecutePaymentFlow() error = %v", err)
} }
updatedReferee := mustLoadUser(t, db, referee.ID) updatedReferee := mustLoadUser(t, db, referee.ID)
if updatedReferee.ReferralRewardPaymentID == nil || *updatedReferee.ReferralRewardPaymentID != result.Payment.ID { if updatedReferee.ReferralRewardPaymentID == nil || *updatedReferee.ReferralRewardPaymentID != result.Payment.ID {
@@ -138,12 +127,12 @@ func TestReferralRewardFlow(t *testing.T) {
t.Run("subscription thứ hai không thưởng lại", func(t *testing.T) { t.Run("subscription thứ hai không thưởng lại", func(t *testing.T) {
services, db, referrer, referee, plan := setup(t) services, db, referrer, referee, plan := setup(t)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("first executePaymentFlow() error = %v", err) t.Fatalf("first ExecutePaymentFlow() error = %v", err)
} }
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("second executePaymentFlow() error = %v", err) t.Fatalf("second ExecutePaymentFlow() error = %v", err)
} }
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID) balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil { if err != nil {
@@ -174,9 +163,9 @@ func TestReferralRewardFlow(t *testing.T) {
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_eligible", false).Error; err != nil { if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_eligible", false).Error; err != nil {
t.Fatalf("update referral_eligible: %v", err) t.Fatalf("update referral_eligible: %v", err)
} }
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err) t.Fatalf("ExecutePaymentFlow() error = %v", err)
} }
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID) balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil { if err != nil {
@@ -192,9 +181,9 @@ func TestReferralRewardFlow(t *testing.T) {
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_reward_bps", 750).Error; err != nil { if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_reward_bps", 750).Error; err != nil {
t.Fatalf("update referral_reward_bps: %v", err) t.Fatalf("update referral_reward_bps: %v", err)
} }
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err) t.Fatalf("ExecutePaymentFlow() error = %v", err)
} }
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID) balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
if err != nil { if err != nil {
@@ -213,23 +202,10 @@ func TestUpdateAdminUserReferralSettings(t *testing.T) {
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)}) referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)})
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err) t.Fatalf("ExecutePaymentFlow() error = %v", err)
} }
_, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{Id: referee.ID, RefUsername: ptrString("alice")})
_, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{
Id: referee.ID,
RefUsername: ptrString("alice"),
})
assertGRPCCode(t, err, codes.InvalidArgument) assertGRPCCode(t, err, codes.InvalidArgument)
} }
func containsAny(value string, parts ...string) bool {
for _, part := range parts {
if part != "" && strings.Contains(value, part) {
return true
}
}
return false
}

View File

@@ -1,624 +0,0 @@
package app
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
)
func (s *appServices) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var rows []model.Notification
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
s.logger.Error("Failed to list notifications", "error", err)
return nil, status.Error(codes.Internal, "Failed to load notifications")
}
items := make([]*appv1.Notification, 0, len(rows))
for _, row := range rows {
items = append(items, toProtoNotification(row))
}
return &appv1.ListNotificationsResponse{Notifications: items}, nil
}
func (s *appServices) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Notification not found")
}
res := s.db.WithContext(ctx).
Model(&model.Notification{}).
Where("id = ? AND user_id = ?", id, result.UserID).
Update("is_read", true)
if res.Error != nil {
s.logger.Error("Failed to update notification", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to update notification")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Notification not found")
}
return messageResponse("Notification updated"), nil
}
func (s *appServices) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).
Model(&model.Notification{}).
Where("user_id = ? AND is_read = ?", result.UserID, false).
Update("is_read", true).Error; err != nil {
s.logger.Error("Failed to mark all notifications as read", "error", err)
return nil, status.Error(codes.Internal, "Failed to update notifications")
}
return messageResponse("All notifications marked as read"), nil
}
func (s *appServices) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Notification not found")
}
res := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", id, result.UserID).
Delete(&model.Notification{})
if res.Error != nil {
s.logger.Error("Failed to delete notification", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to delete notification")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Notification not found")
}
return messageResponse("Notification deleted"), nil
}
func (s *appServices) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Where("user_id = ?", result.UserID).Delete(&model.Notification{}).Error; err != nil {
s.logger.Error("Failed to clear notifications", "error", err)
return nil, status.Error(codes.Internal, "Failed to clear notifications")
}
return messageResponse("All notifications deleted"), nil
}
func (s *appServices) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var rows []model.Domain
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
s.logger.Error("Failed to list domains", "error", err)
return nil, status.Error(codes.Internal, "Failed to load domains")
}
items := make([]*appv1.Domain, 0, len(rows))
for _, row := range rows {
item := row
items = append(items, toProtoDomain(&item))
}
return &appv1.ListDomainsResponse{Domains: items}, nil
}
func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
name := normalizeDomain(req.GetName())
if name == "" || !strings.Contains(name, ".") || strings.ContainsAny(name, "/ ") {
return nil, status.Error(codes.InvalidArgument, "Invalid domain")
}
var count int64
if err := s.db.WithContext(ctx).
Model(&model.Domain{}).
Where("user_id = ? AND name = ?", result.UserID, name).
Count(&count).Error; err != nil {
s.logger.Error("Failed to validate domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain")
}
if count > 0 {
return nil, status.Error(codes.InvalidArgument, "Domain already exists")
}
item := &model.Domain{
ID: uuid.New().String(),
UserID: result.UserID,
Name: name,
}
if err := s.db.WithContext(ctx).Create(item).Error; err != nil {
s.logger.Error("Failed to create domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain")
}
return &appv1.CreateDomainResponse{Domain: toProtoDomain(item)}, nil
}
func (s *appServices) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Domain not found")
}
res := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", id, result.UserID).
Delete(&model.Domain{})
if res.Error != nil {
s.logger.Error("Failed to delete domain", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to delete domain")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Domain not found")
}
return messageResponse("Domain deleted"), nil
}
func (s *appServices) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var items []model.AdTemplate
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
s.logger.Error("Failed to list ad templates", "error", err)
return nil, status.Error(codes.Internal, "Failed to load ad templates")
}
payload := make([]*appv1.AdTemplate, 0, len(items))
for _, item := range items {
copyItem := item
payload = append(payload, toProtoAdTemplate(&copyItem))
}
return &appv1.ListAdTemplatesResponse{Templates: payload}, nil
}
func (s *appServices) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := ensurePaidPlan(result.User); err != nil {
return nil, err
}
name := strings.TrimSpace(req.GetName())
vastURL := strings.TrimSpace(req.GetVastTagUrl())
if name == "" || vastURL == "" {
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
}
format := normalizeAdFormat(req.GetAdFormat())
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
}
item := &model.AdTemplate{
ID: uuid.New().String(),
UserID: result.UserID,
Name: name,
Description: nullableTrimmedString(req.Description),
VastTagURL: vastURL,
AdFormat: model.StringPtr(format),
Duration: int32PtrToInt64Ptr(req.Duration),
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
IsDefault: req.IsDefault != nil && *req.IsDefault,
}
if !adTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, result.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
s.logger.Error("Failed to create ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.CreateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil
}
func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := ensurePaidPlan(result.User); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
name := strings.TrimSpace(req.GetName())
vastURL := strings.TrimSpace(req.GetVastTagUrl())
if name == "" || vastURL == "" {
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
}
format := normalizeAdFormat(req.GetAdFormat())
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
}
var item model.AdTemplate
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
s.logger.Error("Failed to load ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
item.Name = name
item.Description = nullableTrimmedString(req.Description)
item.VastTagURL = vastURL
item.AdFormat = model.StringPtr(format)
item.Duration = int32PtrToInt64Ptr(req.Duration)
if req.IsActive != nil {
item.IsActive = model.BoolPtr(*req.IsActive)
}
if req.IsDefault != nil {
item.IsDefault = *req.IsDefault
}
if !adTemplateIsActive(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, result.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
s.logger.Error("Failed to update ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template")
}
return &appv1.UpdateAdTemplateResponse{Template: toProtoAdTemplate(&item)}, nil
}
func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if err := ensurePaidPlan(result.User); err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).
Where("user_id = ? AND ad_id = ?", result.UserID, id).
Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found")
}
s.logger.Error("Failed to delete ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete ad template")
}
return messageResponse("Ad template deleted"), nil
}
func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) {
if _, err := s.authenticate(ctx); err != nil {
return nil, err
}
var plans []model.Plan
if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil {
s.logger.Error("Failed to fetch plans", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch plans")
}
items := make([]*appv1.Plan, 0, len(plans))
for _, plan := range plans {
copyPlan := plan
items = append(items, toProtoPlan(&copyPlan))
}
return &appv1.ListPlansResponse{Plans: items}, nil
}
func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
var items []model.PlayerConfig
if err := s.db.WithContext(ctx).
Where("user_id = ?", result.UserID).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
s.logger.Error("Failed to list player configs", "error", err)
return nil, status.Error(codes.Internal, "Failed to load player configs")
}
payload := make([]*appv1.PlayerConfig, 0, len(items))
for _, item := range items {
copyItem := item
payload = append(payload, toProtoPlayerConfig(&copyItem))
}
return &appv1.ListPlayerConfigsResponse{Configs: payload}, nil
}
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(req.GetName())
if name == "" {
return nil, status.Error(codes.InvalidArgument, "Name is required")
}
item := &model.PlayerConfig{
ID: uuid.New().String(),
UserID: result.UserID,
Name: name,
Description: nullableTrimmedString(req.Description),
Autoplay: req.GetAutoplay(),
Loop: req.GetLoop(),
Muted: req.GetMuted(),
ShowControls: model.BoolPtr(req.GetShowControls()),
Pip: model.BoolPtr(req.GetPip()),
Airplay: model.BoolPtr(req.GetAirplay()),
Chromecast: model.BoolPtr(req.GetChromecast()),
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
IsDefault: req.IsDefault != nil && *req.IsDefault,
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
LogoURL: nullableTrimmedString(req.LogoUrl),
}
if !playerConfigIsActive(item.IsActive) {
item.IsDefault = false
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := playerConfigActionAllowed(lockedUser, configCount, "create"); err != nil {
return err
}
if item.IsDefault {
if err := unsetDefaultPlayerConfigs(tx, result.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
if status.Code(err) != codes.Unknown {
return nil, err
}
s.logger.Error("Failed to create player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.CreatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil
}
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
name := strings.TrimSpace(req.GetName())
if name == "" {
return nil, status.Error(codes.InvalidArgument, "Name is required")
}
var item model.PlayerConfig
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
return err
}
action := "update"
wasActive := playerConfigIsActive(item.IsActive)
if req.IsActive != nil && *req.IsActive != wasActive {
action = "toggle-active"
}
if req.IsDefault != nil && *req.IsDefault {
action = "set-default"
}
if err := playerConfigActionAllowed(lockedUser, configCount, action); err != nil {
return err
}
item.Name = name
item.Description = nullableTrimmedString(req.Description)
item.Autoplay = req.GetAutoplay()
item.Loop = req.GetLoop()
item.Muted = req.GetMuted()
item.ShowControls = model.BoolPtr(req.GetShowControls())
item.Pip = model.BoolPtr(req.GetPip())
item.Airplay = model.BoolPtr(req.GetAirplay())
item.Chromecast = model.BoolPtr(req.GetChromecast())
if req.EncrytionM3U8 != nil {
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
}
if req.LogoUrl != nil {
item.LogoURL = nullableTrimmedString(req.LogoUrl)
}
if req.IsActive != nil {
item.IsActive = model.BoolPtr(*req.IsActive)
}
if req.IsDefault != nil {
item.IsDefault = *req.IsDefault
}
if !playerConfigIsActive(item.IsActive) {
item.IsDefault = false
}
if item.IsDefault {
if err := unsetDefaultPlayerConfigs(tx, result.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if status.Code(err) != codes.Unknown {
return nil, err
}
s.logger.Error("Failed to update player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to save player config")
}
return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(&item)}, nil
}
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := playerConfigActionAllowed(lockedUser, configCount, "delete"); err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.PlayerConfig{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found")
}
if status.Code(err) != codes.Unknown {
return nil, err
}
s.logger.Error("Failed to delete player config", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete player config")
}
return messageResponse("Player config deleted"), nil
}

View File

@@ -1,277 +0,0 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/video"
)
func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if s.storageProvider == nil {
return nil, status.Error(codes.FailedPrecondition, "Storage provider is not configured")
}
filename := strings.TrimSpace(req.GetFilename())
if filename == "" {
return nil, status.Error(codes.InvalidArgument, "Filename is required")
}
fileID := uuid.New().String()
key := fmt.Sprintf("videos/%s/%s-%s", result.UserID, fileID, filename)
uploadURL, err := s.storageProvider.GeneratePresignedURL(key, 15*time.Minute)
if err != nil {
s.logger.Error("Failed to generate upload URL", "error", err)
return nil, status.Error(codes.Internal, "Storage error")
}
return &appv1.GetUploadUrlResponse{UploadUrl: uploadURL, Key: key, FileId: fileID}, nil
}
func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
if s.videoService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
}
title := strings.TrimSpace(req.GetTitle())
if title == "" {
return nil, status.Error(codes.InvalidArgument, "Title is required")
}
videoURL := strings.TrimSpace(req.GetUrl())
if videoURL == "" {
return nil, status.Error(codes.InvalidArgument, "URL is required")
}
description := strings.TrimSpace(req.GetDescription())
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
UserID: result.UserID,
Title: title,
Description: &description,
URL: videoURL,
Size: req.GetSize(),
Duration: req.GetDuration(),
Format: strings.TrimSpace(req.GetFormat()),
})
if err != nil {
s.logger.Error("Failed to create video", "error", err)
switch {
case errors.Is(err, video.ErrJobServiceUnavailable):
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
default:
return nil, status.Error(codes.Internal, "Failed to create video")
}
}
return &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil
}
func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
page := req.GetPage()
if page < 1 {
page = 1
}
limit := req.GetLimit()
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
offset := int((page - 1) * limit)
db := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", result.UserID)
if search := strings.TrimSpace(req.GetSearch()); search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
}
if st := strings.TrimSpace(req.GetStatus()); st != "" && !strings.EqualFold(st, "all") {
db = db.Where("status = ?", normalizeVideoStatusValue(st))
}
var total int64
if err := db.Count(&total).Error; err != nil {
s.logger.Error("Failed to count videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&videos).Error; err != nil {
s.logger.Error("Failed to list videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
items := make([]*appv1.Video, 0, len(videos))
for i := range videos {
payload, err := s.buildVideo(ctx, &videos[i])
if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", videos[i].ID)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
items = append(items, payload)
}
return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
_ = s.db.WithContext(ctx).Model(&model.Video{}).
Where("id = ? AND user_id = ?", id, result.UserID).
UpdateColumn("views", gorm.Expr("views + ?", 1)).Error
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
s.logger.Error("Failed to fetch video", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch video")
}
payload, err := s.buildVideo(ctx, &video)
if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to fetch video")
}
return &appv1.GetVideoResponse{Video: payload}, nil
}
func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
updates := map[string]any{}
if title := strings.TrimSpace(req.GetTitle()); title != "" {
updates["name"] = title
updates["title"] = title
}
if req.Description != nil {
desc := strings.TrimSpace(req.GetDescription())
updates["description"] = nullableTrimmedString(&desc)
}
if urlValue := strings.TrimSpace(req.GetUrl()); urlValue != "" {
updates["url"] = urlValue
}
if req.Size > 0 {
updates["size"] = req.GetSize()
}
if req.Duration > 0 {
updates["duration"] = req.GetDuration()
}
if req.Format != nil {
updates["format"] = strings.TrimSpace(req.GetFormat())
}
if req.Status != nil {
updates["status"] = normalizeVideoStatusValue(req.GetStatus())
}
if len(updates) == 0 {
return nil, status.Error(codes.InvalidArgument, "No changes provided")
}
res := s.db.WithContext(ctx).
Model(&model.Video{}).
Where("id = ? AND user_id = ?", id, result.UserID).
Updates(updates)
if res.Error != nil {
s.logger.Error("Failed to update video", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to update video")
}
if res.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
s.logger.Error("Failed to reload video", "error", err)
return nil, status.Error(codes.Internal, "Failed to update video")
}
payload, err := s.buildVideo(ctx, &video)
if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to update video")
}
return &appv1.UpdateVideoResponse{Video: payload}, nil
}
func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx)
if err != nil {
return nil, err
}
id := strings.TrimSpace(req.GetId())
if id == "" {
return nil, status.Error(codes.NotFound, "Video not found")
}
var video model.Video
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found")
}
s.logger.Error("Failed to load video", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
if s.storageProvider != nil && shouldDeleteStoredObject(video.URL) {
if err := s.storageProvider.Delete(video.URL); err != nil {
if parsedKey := extractObjectKey(video.URL); parsedKey != "" && parsedKey != video.URL {
if deleteErr := s.storageProvider.Delete(parsedKey); deleteErr != nil {
s.logger.Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
} else {
s.logger.Error("Failed to delete video object", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
}
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("id = ?", result.UserID).
UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error
}); err != nil {
s.logger.Error("Failed to delete video", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete video")
}
return messageResponse("Video deleted successfully"), nil
}

View File

@@ -0,0 +1,33 @@
package app
import (
"context"
appv1 "stream.api/internal/gen/proto/app/v1"
paymentsmodule "stream.api/internal/modules/payments"
playerconfigsmodule "stream.api/internal/modules/playerconfigs"
)
func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
return s.authModule.Register(ctx, req)
}
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
return paymentsmodule.NewHandler(s.paymentsModule).TopupWallet(ctx, req)
}
func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
return s.usersModule.UpdateAdminUserReferralSettings(ctx, req)
}
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
return playerconfigsmodule.NewHandler(s.playerConfigsModule).CreatePlayerConfig(ctx, req)
}
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
return playerconfigsmodule.NewHandler(s.playerConfigsModule).UpdatePlayerConfig(ctx, req)
}
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
return playerconfigsmodule.NewHandler(s.playerConfigsModule).DeletePlayerConfig(ctx, req)
}

View File

@@ -244,7 +244,7 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
db = newTestDB(t) db = newTestDB(t)
} }
return &appServices{ services := &appServices{
db: db, db: db,
logger: testLogger{}, logger: testLogger{},
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker), authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
@@ -252,6 +252,8 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
tokenProvider: fakeTokenProvider{}, tokenProvider: fakeTokenProvider{},
googleUserInfoURL: defaultGoogleUserInfoURL, googleUserInfoURL: defaultGoogleUserInfoURL,
} }
services.initModules()
return services
} }
func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) { func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) {
@@ -260,18 +262,18 @@ func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, f
lis := bufconn.Listen(testBufDialerListenerSize) lis := bufconn.Listen(testBufDialerListenerSize)
server := grpc.NewServer() server := grpc.NewServer()
Register(server, &Services{ Register(server, &Services{
AuthServiceServer: services, AuthServiceServer: services.authHandler,
AccountServiceServer: services, AccountServiceServer: services.accountHandler,
PreferencesServiceServer: services, PreferencesServiceServer: services.preferencesHandler,
UsageServiceServer: services, UsageServiceServer: services.usageHandler,
NotificationsServiceServer: services, NotificationsServiceServer: services.notificationsHandler,
DomainsServiceServer: services, DomainsServiceServer: services.domainsHandler,
AdTemplatesServiceServer: services, AdTemplatesServiceServer: services.adTemplatesHandler,
PlayerConfigsServiceServer: services, PlayerConfigsServiceServer: services.playerConfigsHandler,
PlansServiceServer: services, PlansServiceServer: services.plansHandler,
PaymentsServiceServer: services, PaymentsServiceServer: services.paymentsHandler,
VideosServiceServer: services, VideosServiceServer: services.videosHandler,
AdminServiceServer: services, AdminServiceServer: services.adminHandler,
}) })
go func() { go func() {

View File

@@ -1,29 +0,0 @@
package app
import (
"context"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/pkg/logger"
)
type usagePayload struct {
UserID string `json:"user_id"`
TotalVideos int64 `json:"total_videos"`
TotalStorage int64 `json:"total_storage"`
}
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

@@ -1,76 +0,0 @@
//go:build ignore
// +build ignore
package runtime
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func (m *Module) MetricsHandler() gin.HandlerFunc {
return gin.WrapH(promhttp.Handler())
}
// HandleLive godoc
// @Summary Liveness health check
// @Description Returns liveness status for the API and render module
// @Tags health
// @Produce json
// @Success 200 {object} map[string]string
// @Failure 503 {object} map[string]string
// @Router /health/live [get]
func (m *Module) HandleLive(c *gin.Context) {
status, code := m.healthService.SimpleHealthCheck(c.Request.Context())
c.JSON(code, gin.H{"status": status})
}
// HandleReady godoc
// @Summary Readiness health check
// @Description Returns readiness status including render gRPC availability flag
// @Tags health
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 503 {object} map[string]interface{}
// @Router /health/ready [get]
func (m *Module) HandleReady(c *gin.Context) {
status, code := m.healthService.SimpleHealthCheck(c.Request.Context())
c.JSON(code, gin.H{"status": status, "grpc_enabled": m.grpcServer != nil})
}
// HandleDetailed godoc
// @Summary Detailed health check
// @Description Returns detailed health state for database, redis, and render dependencies
// @Tags health
// @Produce json
// @Success 200 {object} services.HealthReport
// @Router /health/detailed [get]
func (m *Module) HandleDetailed(c *gin.Context) {
c.JSON(http.StatusOK, m.healthService.CheckHealth(c.Request.Context()))
}
var (
httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "stream_api_http_requests_total", Help: "Total HTTP requests."}, []string{"method", "path", "status"})
httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "stream_api_http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path"})
)
func init() {
prometheus.MustRegister(httpRequests, httpDuration)
}
func MetricsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
path := c.FullPath()
if path == "" {
path = c.Request.URL.Path
}
httpRequests.WithLabelValues(c.Request.Method, path, http.StatusText(c.Writer.Status())).Inc()
httpDuration.WithLabelValues(c.Request.Method, path).Observe(time.Since(start).Seconds())
}
}

View File

@@ -0,0 +1,186 @@
// Copyright 2021 Woodpecker Authors
// Copyright 2011 Drone.IO Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
option go_package = "go.woodpecker-ci.org/woodpecker/v3/rpc/proto";
package proto;
// !IMPORTANT!
// Increased Version in version.go by 1 if you change something here!
// !IMPORTANT!
// Woodpecker Server Service
service Woodpecker {
rpc Version (Empty) returns (VersionResponse) {}
rpc Next (NextRequest) returns (NextResponse) {}
rpc Init (InitRequest) returns (Empty) {}
rpc Wait (WaitRequest) returns (WaitResponse) {}
rpc Done (DoneRequest) returns (Empty) {}
rpc Extend (ExtendRequest) returns (Empty) {}
rpc Update (UpdateRequest) returns (Empty) {}
rpc Log (LogRequest) returns (Empty) {}
rpc RegisterAgent (RegisterAgentRequest) returns (RegisterAgentResponse) {}
rpc UnregisterAgent (Empty) returns (Empty) {}
rpc ReportHealth (ReportHealthRequest) returns (Empty) {}
// New Streaming RPCs
rpc StreamJobs (StreamOptions) returns (stream Workflow) {}
rpc SubmitStatus (stream StatusUpdate) returns (Empty) {}
}
//
// Basic Types
//
message StepState {
string step_uuid = 1;
int64 started = 2;
int64 finished = 3;
bool exited = 4;
int32 exit_code = 5;
string error = 6;
bool canceled = 7;
}
message WorkflowState {
int64 started = 1;
int64 finished = 2;
string error = 3;
bool canceled = 4;
}
message LogEntry {
string step_uuid = 1;
int64 time = 2;
int32 line = 3;
int32 type = 4; // 0 = stdout, 1 = stderr, 2 = exit-code, 3 = metadata, 4 = progress
bytes data = 5;
}
message Filter {
map<string, string> labels = 1;
}
message Workflow {
string id = 1;
int64 timeout = 2;
bytes payload = 3;
bool cancel = 4;
}
//
// Request types
//
message NextRequest {
Filter filter = 1;
}
message InitRequest {
string id = 1;
WorkflowState state = 2;
}
message WaitRequest {
string id = 1;
}
message DoneRequest {
string id = 1;
WorkflowState state = 2;
}
message ExtendRequest {
string id = 1;
}
message UpdateRequest {
string id = 1;
StepState state = 2;
}
message LogRequest {
repeated LogEntry logEntries = 1;
}
message Empty {
}
message ReportHealthRequest {
string status = 1;
}
message AgentInfo {
string platform = 1;
int32 capacity = 2;
string backend = 3;
string version = 4;
map<string, string> customLabels = 5;
}
message RegisterAgentRequest {
AgentInfo info = 1;
}
//
// Response types
//
message VersionResponse {
int32 grpc_version = 1;
string server_version = 2;
}
message NextResponse {
Workflow workflow = 1;
}
message RegisterAgentResponse {
string agent_id = 1;
}
message WaitResponse {
bool canceled = 1;
};
// Woodpecker auth service is a simple service to authenticate agents and acquire a token
service WoodpeckerAuth {
rpc Auth (AuthRequest) returns (AuthResponse) {}
}
message AuthRequest {
string agent_token = 1;
string agent_id = 2;
string hostname = 3;
}
message AuthResponse {
string status = 1;
string agent_id = 2;
string access_token = 3;
}
message StreamOptions {
Filter filter = 1;
}
message StatusUpdate {
string step_uuid = 1;
int64 time = 2;
int32 type = 3; // 0=stdout, 1=stderr, 2=exit-code, 3=metadata, 4=progress, 5=system-resource
bytes data = 4;
}