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 (
"context"
@@ -6,7 +6,6 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
@@ -17,15 +16,25 @@ import (
"stream.api/internal/database/model"
"stream.api/internal/database/query"
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())
password := req.GetPassword()
if email == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Email and password are required")
}
u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
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 {
return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
}
if err := s.issueSessionCookies(ctx, user); err != nil {
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
return nil, err
}
payload, err := buildUserPayload(ctx, s.db, user)
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
if err != nil {
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())
username := strings.TrimSpace(req.GetUsername())
password := req.GetPassword()
@@ -56,55 +64,44 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest)
if email == "" || username == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Username, email and password are required")
}
u := query.User
count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count()
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")
}
if count > 0 {
return nil, status.Error(codes.InvalidArgument, "Email already registered")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to register")
}
referrerID, err := s.resolveSignupReferrerID(ctx, refUsername, username)
referrerID, err := m.users.ResolveSignupReferrerID(ctx, refUsername, username)
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")
}
role := "USER"
passwordHash := string(hashedPassword)
newUser := &model.User{
ID: uuid.New().String(),
Email: email,
Password: &passwordHash,
Username: &username,
Role: &role,
ReferredByUserID: referrerID,
ReferralEligible: model.BoolPtr(true),
}
newUser := &model.User{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 {
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")
}
payload, err := buildUserPayload(ctx, s.db, newUser)
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), newUser)
if err != nil {
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 {
return nil, err
}
@@ -126,191 +123,154 @@ func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePassw
if err != nil {
return nil, status.Error(codes.Internal, "Failed to change password")
}
if _, err := query.User.WithContext(ctx).
Where(query.User.ID.Eq(result.UserID)).
Update(query.User.Password, string(newHash)); err != nil {
s.logger.Error("Failed to change password", "error", err)
if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(result.UserID)).Update(query.User.Password, string(newHash)); err != nil {
m.runtime.Logger().Error("Failed to change password", "error", err)
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())
if email == "" {
return nil, status.Error(codes.InvalidArgument, "Email is required")
}
u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
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()
if err := s.cache.Set(ctx, "reset_pw:"+tokenID, user.ID, 15*time.Minute); err != nil {
s.logger.Error("Failed to set reset token", "error", err)
if err := m.runtime.Cache().Set(ctx, "reset_pw:"+tokenID, user.ID, 15*60*1000000000); err != nil {
m.runtime.Logger().Error("Failed to set reset token", "error", err)
return nil, status.Error(codes.Internal, "Try again later")
}
s.logger.Info("Generated password reset token", "email", email, "token", tokenID)
return messageResponse("If email exists, a reset link has been sent"), nil
m.runtime.Logger().Info("Generated password reset token", "email", email, "token", tokenID)
return common.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())
newPassword := req.GetNewPassword()
if resetToken == "" || newPassword == "" {
return nil, status.Error(codes.InvalidArgument, "Token and new password are required")
}
userID, err := s.cache.Get(ctx, "reset_pw:"+resetToken)
userID, err := m.runtime.Cache().Get(ctx, "reset_pw:"+resetToken)
if err != nil || strings.TrimSpace(userID) == "" {
return nil, status.Error(codes.InvalidArgument, "Invalid or expired token")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
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 {
s.logger.Error("Failed to update password", "error", err)
if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(userID)).Update(query.User.Password, string(hashedPassword)); err != nil {
m.runtime.Logger().Error("Failed to update password", "error", err)
return nil, status.Error(codes.Internal, "Failed to update password")
}
_ = s.cache.Del(ctx, "reset_pw:"+resetToken)
return messageResponse("Password reset successfully"), nil
_ = m.runtime.Cache().Del(ctx, "reset_pw:"+resetToken)
return common.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
}
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")
}
state, err := generateOAuthState()
state, err := common.GenerateOAuthState()
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")
}
if err := s.cache.Set(ctx, googleOAuthStateCacheKey(state), "1", s.googleStateTTL); err != nil {
s.logger.Error("Failed to persist Google OAuth state", "error", err)
if err := m.runtime.Cache().Set(ctx, common.GoogleOAuthStateCacheKey(state), "1", m.runtime.GoogleStateTTL()); err != nil {
m.runtime.Logger().Error("Failed to persist Google OAuth state", "error", err)
return nil, status.Error(codes.Internal, "Failed to start Google login")
}
loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
loginURL := googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
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
}
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")
}
code := strings.TrimSpace(req.GetCode())
if code == "" {
return nil, status.Error(codes.InvalidArgument, "Code is required")
}
tokenResp, err := s.googleOauth.Exchange(ctx, code)
tokenResp, err := googleOauth.Exchange(ctx, code)
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")
}
client := s.googleOauth.Client(ctx, tokenResp)
resp, err := client.Get(s.googleUserInfoURL)
client := googleOauth.Client(ctx, tokenResp)
resp, err := client.Get(m.runtime.GoogleUserInfoURL())
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")
}
defer resp.Body.Close()
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")
}
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
var googleUser struct { ID, Email, Name, Picture string }
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")
}
email := strings.TrimSpace(strings.ToLower(googleUser.Email))
refUsername := strings.TrimSpace(req.GetRefUsername())
if email == "" {
return nil, status.Error(codes.InvalidArgument, "missing_email")
}
u := query.User
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil {
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")
}
referrerID, resolveErr := s.resolveSignupReferrerID(ctx, refUsername, googleUser.Name)
referrerID, resolveErr := m.users.ResolveSignupReferrerID(ctx, refUsername, googleUser.Name)
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")
}
role := "USER"
user = &model.User{
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),
}
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)}
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")
}
} else {
updates := map[string]interface{}{}
if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" {
updates["google_id"] = googleUser.ID
}
if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" {
updates["avatar"] = googleUser.Picture
}
if user.Username == nil || strings.TrimSpace(*user.Username) == "" {
updates["username"] = googleUser.Name
}
updates := map[string]any{}
if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" { updates["google_id"] = googleUser.ID }
if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" { updates["avatar"] = googleUser.Picture }
if user.Username == nil || strings.TrimSpace(*user.Username) == "" { updates["username"] = googleUser.Name }
if len(updates) > 0 {
if err := s.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)
if err := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
m.runtime.Logger().Error("Failed to update Google user", "error", err)
return nil, status.Error(codes.Internal, "update_user_failed")
}
user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First()
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")
}
}
}
if err := s.issueSessionCookies(ctx, user); err != nil {
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
return nil, status.Error(codes.Internal, "session_failed")
}
payload, err := buildUserPayload(ctx, s.db, user)
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
if err != nil {
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 (
"context"
@@ -10,7 +10,7 @@ import (
"stream.api/internal/database/model"
)
type userPayload struct {
type UserPayload struct {
ID string `json:"id"`
Email string `json:"email"`
Username *string `json:"username,omitempty"`
@@ -31,7 +31,7 @@ type userPayload struct {
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)
if err != nil {
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,
Email: user.Email,
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"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
)
func TestCreateAdminPayment(t *testing.T) {
t.Run("happy path admin", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
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")})
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)
defer cleanup()
client := newAdminClient(conn)
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
if err != nil {
t.Fatalf("CreateAdminPayment() error = %v", err)
}
@@ -41,8 +35,8 @@ func TestCreateAdminPayment(t *testing.T) {
if 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) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
}
if resp.Payment.GetWalletAmount() != 30 {
t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount())
@@ -64,12 +58,7 @@ func TestCreateAdminPayment(t *testing.T) {
client := newAdminClient(conn)
var trailer metadata.MD
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
UserId: user.ID,
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument)
if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" {
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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"stream.api/internal/database/model"
"stream.api/internal/modules/common"
paymentsmodule "stream.api/internal/modules/payments"
)
func TestValidatePaymentFunding(t *testing.T) {
baseInput := paymentExecutionInput{PaymentMethod: paymentMethodWallet}
baseInput := paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodWallet}
tests := []struct {
name string
input paymentExecutionInput
input paymentsmodule.ExecutionInput
totalAmount float64
walletBalance float64
wantTopup float64
wantCode codes.Code
wantMessage string
}{
{
name: "wallet đủ tiền",
input: baseInput,
totalAmount: 30,
walletBalance: 30,
wantTopup: 0,
},
{
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,
},
{name: "wallet đủ tiền", input: baseInput, totalAmount: 30, walletBalance: 30, wantTopup: 0},
{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: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount is required when payment method is topup"},
{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"},
{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"},
{name: "topup hợp lệ", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(30)}, totalAmount: 50, walletBalance: 20, wantTopup: 30},
}
for _, tt := range tests {
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 err != nil {
t.Fatalf("validatePaymentFunding() error = %v", err)
t.Fatalf("ValidatePaymentFunding() error = %v", err)
}
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
}
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 {
t.Fatalf("validatePaymentFunding() code = %v, want %v", status.Code(err), tt.wantCode)
if validationErr, ok := err.(*paymentsmodule.PaymentValidationError); !ok || codes.Code(validationErr.GRPCCode) != 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) {
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) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{
ID: uuid.NewString(),
Email: "payer@example.com",
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"),
})
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", 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: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD"), Note: ptrString("Initial funds")})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{
UserID: user.ID,
Plan: &plan,
TermMonths: 3,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(25),
})
result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: user.ID, Plan: &plan, TermMonths: 3, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
t.Fatalf("ExecutePaymentFlow() error = %v", err)
}
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) {
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, buildInvoiceID(result.Payment.ID))
if result.InvoiceID != common.BuildInvoiceID(result.Payment.ID) {
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, common.BuildInvoiceID(result.Payment.ID))
}
if result.WalletBalance != 0 {
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 {
t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID)
}
if normalizePaymentStatus(payment.Status) != "success" {
t.Fatalf("payment status = %q, want success", normalizePaymentStatus(payment.Status))
if common.NormalizePaymentStatus(payment.Status) != "success" {
t.Fatalf("payment status = %q, want success", common.NormalizePaymentStatus(payment.Status))
}
subscription := mustLoadSubscriptionByPayment(t, db, payment.ID)
@@ -172,8 +105,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if subscription.TermMonths != 3 {
t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths)
}
if subscription.PaymentMethod != paymentMethodTopup {
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, paymentMethodTopup)
if subscription.PaymentMethod != common.PaymentMethodTopup {
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, common.PaymentMethodTopup)
}
if subscription.WalletAmount != 30 {
t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount)
@@ -189,10 +122,10 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if len(walletTransactions) != 2 {
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])
}
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])
}
@@ -226,8 +159,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
if metadataPayload["payment_id"] != payment.ID {
t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID)
}
if metadataPayload["payment_method"] != paymentMethodTopup {
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], paymentMethodTopup)
if metadataPayload["payment_method"] != common.PaymentMethodTopup {
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], common.PaymentMethodTopup)
}
if metadataPayload["wallet_amount"] != 30.0 {
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"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
"stream.api/internal/modules/common"
)
func TestCreatePayment(t *testing.T) {
t.Run("plan không tồn tại", func(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: uuid.NewString(),
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: uuid.NewString(), TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
assertGRPCCode(t, err, codes.NotFound)
})
@@ -39,16 +33,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
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)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
assertGRPCCode(t, err, codes.InvalidArgument)
})
@@ -57,16 +45,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
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)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 2,
PaymentMethod: paymentMethodWallet,
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 2, PaymentMethod: common.PaymentMethodWallet})
assertGRPCCode(t, err, codes.InvalidArgument)
})
@@ -75,16 +57,10 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
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)})
conn, cleanup := newTestGRPCServer(t, services)
defer cleanup()
client := newPaymentsClient(conn)
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: "bank_transfer",
})
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: "bank_transfer"})
assertGRPCCode(t, err, codes.InvalidArgument)
})
@@ -93,18 +69,12 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
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)})
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)
defer cleanup()
client := newPaymentsClient(conn)
var trailer metadata.MD
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodWallet,
}, grpc.Trailer(&trailer))
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
assertGRPCCode(t, err, codes.InvalidArgument)
body := firstTestMetadataValue(trailer, "x-error-body")
if body == "" {
@@ -120,29 +90,22 @@ func TestCreatePayment(t *testing.T) {
services := newTestAppServices(t, db)
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)})
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)
defer cleanup()
client := newPaymentsClient(conn)
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
PlanId: plan.ID,
TermMonths: 1,
PaymentMethod: paymentMethodTopup,
TopupAmount: ptrFloat64(15),
})
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(15)})
if err != nil {
t.Fatalf("CreatePayment() error = %v", err)
}
if resp.Payment == nil || resp.Subscription == nil {
t.Fatalf("CreatePayment() response incomplete: %#v", resp)
}
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
}
if resp.Subscription.PaymentMethod != paymentMethodTopup {
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, paymentMethodTopup)
if resp.Subscription.PaymentMethod != common.PaymentMethodTopup {
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, common.PaymentMethodTopup)
}
if resp.Subscription.WalletAmount != 20 {
t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount)
@@ -153,7 +116,6 @@ func TestCreatePayment(t *testing.T) {
if resp.WalletBalance != 0 {
t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance)
}
payment := mustLoadPayment(t, db, resp.Payment.Id)
if payment.Amount != 20 {
t.Fatalf("payment amount = %v, want 20", payment.Amount)

View File

@@ -15,6 +15,7 @@ import (
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/middleware"
"stream.api/internal/modules/common"
)
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"})
assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanLimitMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanLimitMessage)
if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanLimitMessage {
t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanLimitMessage)
}
})
@@ -107,8 +108,8 @@ func TestPlayerConfigsPolicy(t *testing.T) {
IsActive: ptrBool(true),
})
assertGRPCCode(t, err, codes.FailedPrecondition)
if got := status.Convert(err).Message(); got != playerConfigFreePlanReconciliationMessage {
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanReconciliationMessage)
if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanReconciliationMessage {
t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanReconciliationMessage)
}
_, 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))
}
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)
}
}

View File

@@ -3,13 +3,14 @@ package app
import (
"context"
"testing"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"gorm.io/gorm"
"stream.api/internal/database/model"
appv1 "stream.api/internal/gen/proto/app/v1"
"stream.api/internal/modules/common"
paymentsmodule "stream.api/internal/modules/payments"
)
func TestRegisterReferralCapture(t *testing.T) {
@@ -18,12 +19,7 @@ func TestRegisterReferralCapture(t *testing.T) {
services := newTestAppServices(t, db)
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{
Username: "bob",
Email: "bob@example.com",
Password: "secret123",
RefUsername: ptrString("alice"),
})
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "bob", Email: "bob@example.com", Password: "secret123", RefUsername: ptrString("alice")})
if err != nil {
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) {
db := newTestDB(t)
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 {
t.Fatalf("Register() error = %v", err)
}
@@ -61,9 +51,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
db := newTestDB(t)
services := newTestAppServices(t, db)
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 {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
}
if referrerID == nil || *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) {
db := newTestDB(t)
services := newTestAppServices(t, db)
referrerID, err := services.resolveSignupReferrerID(context.Background(), "bob", "bob")
referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "bob", "bob")
if err != nil {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
}
if referrerID != nil {
t.Fatalf("referrerID = %v, want nil", referrerID)
@@ -87,9 +77,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
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: "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 {
t.Fatalf("resolveSignupReferrerID() error = %v", err)
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
}
if referrerID != nil {
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) {
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")})
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet})
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})
if err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
t.Fatalf("ExecutePaymentFlow() error = %v", err)
}
updatedReferee := mustLoadUser(t, db, referee.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) {
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")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("first executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")})
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)
}
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("second executePaymentFlow() error = %v", err)
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)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
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 {
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")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
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)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
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 {
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")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
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)
}
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
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")})
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)})
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
t.Fatalf("executePaymentFlow() error = %v", err)
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
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)
}
_, 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)
}
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)
}
return &appServices{
services := &appServices{
db: db,
logger: testLogger{},
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
@@ -252,6 +252,8 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
tokenProvider: fakeTokenProvider{},
googleUserInfoURL: defaultGoogleUserInfoURL,
}
services.initModules()
return services
}
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)
server := grpc.NewServer()
Register(server, &Services{
AuthServiceServer: services,
AccountServiceServer: services,
PreferencesServiceServer: services,
UsageServiceServer: services,
NotificationsServiceServer: services,
DomainsServiceServer: services,
AdTemplatesServiceServer: services,
PlayerConfigsServiceServer: services,
PlansServiceServer: services,
PaymentsServiceServer: services,
VideosServiceServer: services,
AdminServiceServer: services,
AuthServiceServer: services.authHandler,
AccountServiceServer: services.accountHandler,
PreferencesServiceServer: services.preferencesHandler,
UsageServiceServer: services.usageHandler,
NotificationsServiceServer: services.notificationsHandler,
DomainsServiceServer: services.domainsHandler,
AdTemplatesServiceServer: services.adTemplatesHandler,
PlayerConfigsServiceServer: services.playerConfigsHandler,
PlansServiceServer: services.plansHandler,
PaymentsServiceServer: services.paymentsHandler,
VideosServiceServer: services.videosHandler,
AdminServiceServer: services.adminHandler,
})
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;
}