draft
This commit is contained in:
213
internal/modules/admin/handler.go
Normal file
213
internal/modules/admin/handler.go
Normal 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)
|
||||
}
|
||||
151
internal/modules/adtemplates/handler.go
Normal file
151
internal/modules/adtemplates/handler.go
Normal 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
|
||||
}
|
||||
364
internal/modules/adtemplates/module.go
Normal file
364
internal/modules/adtemplates/module.go
Normal 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 ""
|
||||
}
|
||||
85
internal/modules/adtemplates/presenter.go
Normal file
85
internal/modules/adtemplates/presenter.go
Normal 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
|
||||
}
|
||||
103
internal/modules/adtemplates/types.go
Normal file
103
internal/modules/adtemplates/types.go
Normal 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
|
||||
}
|
||||
50
internal/modules/auth/handler.go
Normal file
50
internal/modules/auth/handler.go
Normal 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)
|
||||
}
|
||||
276
internal/modules/auth/module.go
Normal file
276
internal/modules/auth/module.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/oauth2"
|
||||
"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"
|
||||
"stream.api/internal/modules/common"
|
||||
usersmodule "stream.api/internal/modules/users"
|
||||
)
|
||||
|
||||
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 {
|
||||
return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
|
||||
}
|
||||
if user.Password == nil || strings.TrimSpace(*user.Password) == "" {
|
||||
return nil, status.Error(codes.Unauthenticated, "Please login with Google")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
|
||||
}
|
||||
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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: common.ToProtoUser(payload)}, nil
|
||||
}
|
||||
|
||||
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()
|
||||
refUsername := strings.TrimSpace(req.GetRefUsername())
|
||||
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 {
|
||||
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 := m.users.ResolveSignupReferrerID(ctx, refUsername, username)
|
||||
if err != nil {
|
||||
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)}
|
||||
if err := u.WithContext(ctx).Create(newUser); err != nil {
|
||||
m.runtime.Logger().Error("Failed to create user", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to register")
|
||||
}
|
||||
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: common.ToProtoUser(payload)}, nil
|
||||
}
|
||||
|
||||
func (m *Module) Logout(context.Context, *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
|
||||
return common.MessageResponse("Logged out"), nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
currentPassword := req.GetCurrentPassword()
|
||||
newPassword := req.GetNewPassword()
|
||||
if currentPassword == "" || newPassword == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "Current password and new password are required")
|
||||
}
|
||||
if currentPassword == newPassword {
|
||||
return nil, status.Error(codes.InvalidArgument, "New password must be different")
|
||||
}
|
||||
if result.User.Password == nil || strings.TrimSpace(*result.User.Password) == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "This account does not have a local password")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*result.User.Password), []byte(currentPassword)); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "Current password is incorrect")
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
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 {
|
||||
m.runtime.Logger().Error("Failed to change password", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to change password")
|
||||
}
|
||||
return common.MessageResponse("Password changed successfully"), nil
|
||||
}
|
||||
|
||||
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 common.MessageResponse("If email exists, a reset link has been sent"), nil
|
||||
}
|
||||
tokenID := uuid.New().String()
|
||||
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")
|
||||
}
|
||||
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 (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 := 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 {
|
||||
m.runtime.Logger().Error("Failed to update password", "error", err)
|
||||
return nil, status.Error(codes.Internal, "Failed to update password")
|
||||
}
|
||||
_ = m.runtime.Cache().Del(ctx, "reset_pw:"+resetToken)
|
||||
return common.MessageResponse("Password reset successfully"), 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
|
||||
}
|
||||
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 := common.GenerateOAuthState()
|
||||
if err != nil {
|
||||
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 := 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 := googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, 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
|
||||
}
|
||||
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 := googleOauth.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
m.runtime.Logger().Error("Failed to exchange Google OAuth token", "error", err)
|
||||
return nil, status.Error(codes.Unauthenticated, "exchange_failed")
|
||||
}
|
||||
client := googleOauth.Client(ctx, tokenResp)
|
||||
resp, err := client.Get(m.runtime.GoogleUserInfoURL())
|
||||
if err != nil {
|
||||
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 {
|
||||
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, Email, Name, Picture string }
|
||||
if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil {
|
||||
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) {
|
||||
m.runtime.Logger().Error("Failed to load Google user", "error", err)
|
||||
return nil, status.Error(codes.Internal, "load_user_failed")
|
||||
}
|
||||
referrerID, resolveErr := m.users.ResolveSignupReferrerID(ctx, refUsername, googleUser.Name)
|
||||
if resolveErr != nil {
|
||||
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: 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 {
|
||||
m.runtime.Logger().Error("Failed to create Google user", "error", err)
|
||||
return nil, status.Error(codes.Internal, "create_user_failed")
|
||||
}
|
||||
} else {
|
||||
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 := 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 {
|
||||
m.runtime.Logger().Error("Failed to reload Google user", "error", err)
|
||||
return nil, status.Error(codes.Internal, "reload_user_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
|
||||
return nil, status.Error(codes.Internal, "session_failed")
|
||||
}
|
||||
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: common.ToProtoUser(payload)}, nil
|
||||
}
|
||||
19
internal/modules/auth/types.go
Normal file
19
internal/modules/auth/types.go
Normal 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
|
||||
}
|
||||
753
internal/modules/common/helpers.go
Normal file
753
internal/modules/common/helpers.go
Normal 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),
|
||||
}
|
||||
}
|
||||
187
internal/modules/common/runtime.go
Normal file
187
internal/modules/common/runtime.go
Normal 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()
|
||||
}
|
||||
120
internal/modules/common/user_payload.go
Normal file
120
internal/modules/common/user_payload.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"stream.api/internal/database/model"
|
||||
)
|
||||
|
||||
type UserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
GoogleID *string `json:"google_id,omitempty"`
|
||||
StorageUsed int64 `json:"storage_used"`
|
||||
PlanID *string `json:"plan_id,omitempty"`
|
||||
PlanStartedAt *time.Time `json:"plan_started_at,omitempty"`
|
||||
PlanExpiresAt *time.Time `json:"plan_expires_at,omitempty"`
|
||||
PlanTermMonths *int32 `json:"plan_term_months,omitempty"`
|
||||
PlanPaymentMethod *string `json:"plan_payment_method,omitempty"`
|
||||
PlanExpiringSoon bool `json:"plan_expiring_soon"`
|
||||
WalletBalance float64 `json:"wallet_balance"`
|
||||
Language string `json:"language"`
|
||||
Locale string `json:"locale"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func BuildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*UserPayload, error) {
|
||||
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
walletBalance, err := model.GetWalletBalance(ctx, db, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
language := strings.TrimSpace(model.StringValue(pref.Language))
|
||||
if language == "" {
|
||||
language = "en"
|
||||
}
|
||||
locale := strings.TrimSpace(model.StringValue(pref.Locale))
|
||||
if locale == "" {
|
||||
locale = language
|
||||
}
|
||||
|
||||
effectivePlanID := user.PlanID
|
||||
var planStartedAt *time.Time
|
||||
var planExpiresAt *time.Time
|
||||
var planTermMonths *int32
|
||||
var planPaymentMethod *string
|
||||
planExpiringSoon := false
|
||||
now := time.Now().UTC()
|
||||
|
||||
subscription, err := model.GetLatestPlanSubscription(ctx, db, user.ID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
startedAt := subscription.StartedAt.UTC()
|
||||
expiresAt := subscription.ExpiresAt.UTC()
|
||||
termMonths := subscription.TermMonths
|
||||
paymentMethod := normalizePlanPaymentMethod(subscription.PaymentMethod)
|
||||
|
||||
planStartedAt = &startedAt
|
||||
planExpiresAt = &expiresAt
|
||||
planTermMonths = &termMonths
|
||||
planPaymentMethod = &paymentMethod
|
||||
|
||||
if expiresAt.After(now) {
|
||||
effectivePlanID = &subscription.PlanID
|
||||
planExpiringSoon = isPlanExpiringSoon(expiresAt, now)
|
||||
} else {
|
||||
effectivePlanID = nil
|
||||
}
|
||||
}
|
||||
|
||||
return &UserPayload{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
Avatar: user.Avatar,
|
||||
Role: user.Role,
|
||||
GoogleID: user.GoogleID,
|
||||
StorageUsed: user.StorageUsed,
|
||||
PlanID: effectivePlanID,
|
||||
PlanStartedAt: planStartedAt,
|
||||
PlanExpiresAt: planExpiresAt,
|
||||
PlanTermMonths: planTermMonths,
|
||||
PlanPaymentMethod: planPaymentMethod,
|
||||
PlanExpiringSoon: planExpiringSoon,
|
||||
WalletBalance: walletBalance,
|
||||
Language: language,
|
||||
Locale: locale,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizePlanPaymentMethod(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "topup":
|
||||
return "topup"
|
||||
default:
|
||||
return "wallet"
|
||||
}
|
||||
}
|
||||
|
||||
func isPlanExpiringSoon(expiresAt time.Time, now time.Time) bool {
|
||||
hoursUntilExpiry := expiresAt.Sub(now).Hours()
|
||||
const thresholdHours = 7 * 24
|
||||
return hoursUntilExpiry > 0 && hoursUntilExpiry <= thresholdHours
|
||||
}
|
||||
37
internal/modules/dashboard/module.go
Normal file
37
internal/modules/dashboard/module.go
Normal 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
|
||||
}
|
||||
55
internal/modules/domains/handler.go
Normal file
55
internal/modules/domains/handler.go
Normal 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}
|
||||
}
|
||||
69
internal/modules/domains/module.go
Normal file
69
internal/modules/domains/module.go
Normal 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
|
||||
}
|
||||
18
internal/modules/domains/presenter.go
Normal file
18
internal/modules/domains/presenter.go
Normal 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)}
|
||||
}
|
||||
25
internal/modules/domains/types.go
Normal file
25
internal/modules/domains/types.go
Normal 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
|
||||
}
|
||||
87
internal/modules/jobs/handler.go
Normal file
87
internal/modules/jobs/handler.go
Normal 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
|
||||
}
|
||||
184
internal/modules/jobs/module.go
Normal file
184
internal/modules/jobs/module.go
Normal 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)
|
||||
}
|
||||
54
internal/modules/jobs/presenter.go
Normal file
54
internal/modules/jobs/presenter.go
Normal 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}
|
||||
}
|
||||
56
internal/modules/jobs/types.go
Normal file
56
internal/modules/jobs/types.go
Normal 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
|
||||
}
|
||||
33
internal/modules/payments/errors.go
Normal file
33
internal/modules/payments/errors.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
144
internal/modules/payments/handler.go
Normal file
144
internal/modules/payments/handler.go
Normal 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)
|
||||
}
|
||||
656
internal/modules/payments/module.go
Normal file
656
internal/modules/payments/module.go
Normal 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: ¤cy, 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
|
||||
}
|
||||
122
internal/modules/payments/presenter.go
Normal file
122
internal/modules/payments/presenter.go
Normal 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())
|
||||
}
|
||||
137
internal/modules/payments/types.go
Normal file
137
internal/modules/payments/types.go
Normal 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
|
||||
}
|
||||
56
internal/modules/plans/handler.go
Normal file
56
internal/modules/plans/handler.go
Normal 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
|
||||
}
|
||||
191
internal/modules/plans/module.go
Normal file
191
internal/modules/plans/module.go
Normal 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 ""
|
||||
}
|
||||
62
internal/modules/plans/presenter.go
Normal file
62
internal/modules/plans/presenter.go
Normal 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}
|
||||
}
|
||||
64
internal/modules/plans/types.go
Normal file
64
internal/modules/plans/types.go
Normal 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
|
||||
}
|
||||
103
internal/modules/playerconfigs/handler.go
Normal file
103
internal/modules/playerconfigs/handler.go
Normal 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
|
||||
}
|
||||
394
internal/modules/playerconfigs/module.go
Normal file
394
internal/modules/playerconfigs/module.go
Normal 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 ""
|
||||
}
|
||||
83
internal/modules/playerconfigs/presenter.go
Normal file
83
internal/modules/playerconfigs/presenter.go
Normal 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())
|
||||
}
|
||||
133
internal/modules/playerconfigs/types.go
Normal file
133
internal/modules/playerconfigs/types.go
Normal 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
|
||||
}
|
||||
139
internal/modules/users/handler.go
Normal file
139
internal/modules/users/handler.go
Normal 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} }
|
||||
457
internal/modules/users/module.go
Normal file
457
internal/modules/users/module.go
Normal 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
|
||||
}
|
||||
93
internal/modules/users/presenter.go
Normal file
93
internal/modules/users/presenter.go
Normal 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),
|
||||
}
|
||||
}
|
||||
173
internal/modules/users/types.go
Normal file
173
internal/modules/users/types.go
Normal 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
|
||||
}
|
||||
127
internal/modules/videos/handler.go
Normal file
127
internal/modules/videos/handler.go
Normal 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
|
||||
}
|
||||
550
internal/modules/videos/module.go
Normal file
550
internal/modules/videos/module.go
Normal 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
|
||||
}
|
||||
92
internal/modules/videos/presenter.go
Normal file
92
internal/modules/videos/presenter.go
Normal 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())
|
||||
}
|
||||
132
internal/modules/videos/types.go
Normal file
132
internal/modules/videos/types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user