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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -17,15 +16,25 @@ import (
|
|||||||
"stream.api/internal/database/model"
|
"stream.api/internal/database/model"
|
||||||
"stream.api/internal/database/query"
|
"stream.api/internal/database/query"
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||||
|
"stream.api/internal/modules/common"
|
||||||
|
usersmodule "stream.api/internal/modules/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) {
|
type Module struct {
|
||||||
|
runtime *common.Runtime
|
||||||
|
users *usersmodule.Module
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(runtime *common.Runtime, users *usersmodule.Module) *Module {
|
||||||
|
return &Module{runtime: runtime, users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) {
|
||||||
email := strings.TrimSpace(req.GetEmail())
|
email := strings.TrimSpace(req.GetEmail())
|
||||||
password := req.GetPassword()
|
password := req.GetPassword()
|
||||||
if email == "" || password == "" {
|
if email == "" || password == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "Email and password are required")
|
return nil, status.Error(codes.InvalidArgument, "Email and password are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
u := query.User
|
u := query.User
|
||||||
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
|
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,18 +46,17 @@ func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv
|
|||||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil {
|
||||||
return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
|
return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
|
||||||
}
|
}
|
||||||
|
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
|
||||||
if err := s.issueSessionCookies(ctx, user); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
|
||||||
payload, err := buildUserPayload(ctx, s.db, user)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||||
}
|
}
|
||||||
return &appv1.LoginResponse{User: toProtoUser(payload)}, nil
|
return &appv1.LoginResponse{User: common.ToProtoUser(payload)}, nil
|
||||||
}
|
}
|
||||||
func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
|
|
||||||
|
func (m *Module) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
|
||||||
email := strings.TrimSpace(req.GetEmail())
|
email := strings.TrimSpace(req.GetEmail())
|
||||||
username := strings.TrimSpace(req.GetUsername())
|
username := strings.TrimSpace(req.GetUsername())
|
||||||
password := req.GetPassword()
|
password := req.GetPassword()
|
||||||
@@ -56,55 +64,44 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest)
|
|||||||
if email == "" || username == "" || password == "" {
|
if email == "" || username == "" || password == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "Username, email and password are required")
|
return nil, status.Error(codes.InvalidArgument, "Username, email and password are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
u := query.User
|
u := query.User
|
||||||
count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count()
|
count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to check existing user", "error", err)
|
m.runtime.Logger().Error("Failed to check existing user", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "Failed to register")
|
return nil, status.Error(codes.Internal, "Failed to register")
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil, status.Error(codes.InvalidArgument, "Email already registered")
|
return nil, status.Error(codes.InvalidArgument, "Email already registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "Failed to register")
|
return nil, status.Error(codes.Internal, "Failed to register")
|
||||||
}
|
}
|
||||||
|
referrerID, err := m.users.ResolveSignupReferrerID(ctx, refUsername, username)
|
||||||
referrerID, err := s.resolveSignupReferrerID(ctx, refUsername, username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to resolve signup referrer", "error", err)
|
m.runtime.Logger().Error("Failed to resolve signup referrer", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "Failed to register")
|
return nil, status.Error(codes.Internal, "Failed to register")
|
||||||
}
|
}
|
||||||
|
|
||||||
role := "USER"
|
role := "USER"
|
||||||
passwordHash := string(hashedPassword)
|
passwordHash := string(hashedPassword)
|
||||||
newUser := &model.User{
|
newUser := &model.User{ID: uuid.New().String(), Email: email, Password: &passwordHash, Username: &username, Role: &role, ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true)}
|
||||||
ID: uuid.New().String(),
|
|
||||||
Email: email,
|
|
||||||
Password: &passwordHash,
|
|
||||||
Username: &username,
|
|
||||||
Role: &role,
|
|
||||||
ReferredByUserID: referrerID,
|
|
||||||
ReferralEligible: model.BoolPtr(true),
|
|
||||||
}
|
|
||||||
if err := u.WithContext(ctx).Create(newUser); err != nil {
|
if err := u.WithContext(ctx).Create(newUser); err != nil {
|
||||||
s.logger.Error("Failed to create user", "error", err)
|
m.runtime.Logger().Error("Failed to create user", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "Failed to register")
|
return nil, status.Error(codes.Internal, "Failed to register")
|
||||||
}
|
}
|
||||||
|
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), newUser)
|
||||||
payload, err := buildUserPayload(ctx, s.db, newUser)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||||
}
|
}
|
||||||
return &appv1.RegisterResponse{User: toProtoUser(payload)}, nil
|
return &appv1.RegisterResponse{User: common.ToProtoUser(payload)}, nil
|
||||||
}
|
}
|
||||||
func (s *appServices) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
|
|
||||||
return messageResponse("Logged out"), nil
|
func (m *Module) Logout(context.Context, *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
|
||||||
|
return common.MessageResponse("Logged out"), nil
|
||||||
}
|
}
|
||||||
func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
func (m *Module) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) {
|
||||||
|
result, err := m.runtime.Authenticate(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -126,191 +123,154 @@ func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePassw
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "Failed to change password")
|
return nil, status.Error(codes.Internal, "Failed to change password")
|
||||||
}
|
}
|
||||||
if _, err := query.User.WithContext(ctx).
|
if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(result.UserID)).Update(query.User.Password, string(newHash)); err != nil {
|
||||||
Where(query.User.ID.Eq(result.UserID)).
|
m.runtime.Logger().Error("Failed to change password", "error", err)
|
||||||
Update(query.User.Password, string(newHash)); err != nil {
|
|
||||||
s.logger.Error("Failed to change password", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to change password")
|
return nil, status.Error(codes.Internal, "Failed to change password")
|
||||||
}
|
}
|
||||||
return messageResponse("Password changed successfully"), nil
|
return common.MessageResponse("Password changed successfully"), nil
|
||||||
}
|
}
|
||||||
func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) {
|
|
||||||
|
func (m *Module) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) {
|
||||||
email := strings.TrimSpace(req.GetEmail())
|
email := strings.TrimSpace(req.GetEmail())
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "Email is required")
|
return nil, status.Error(codes.InvalidArgument, "Email is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
u := query.User
|
u := query.User
|
||||||
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
|
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return messageResponse("If email exists, a reset link has been sent"), nil
|
return common.MessageResponse("If email exists, a reset link has been sent"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenID := uuid.New().String()
|
tokenID := uuid.New().String()
|
||||||
if err := s.cache.Set(ctx, "reset_pw:"+tokenID, user.ID, 15*time.Minute); err != nil {
|
if err := m.runtime.Cache().Set(ctx, "reset_pw:"+tokenID, user.ID, 15*60*1000000000); err != nil {
|
||||||
s.logger.Error("Failed to set reset token", "error", err)
|
m.runtime.Logger().Error("Failed to set reset token", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "Try again later")
|
return nil, status.Error(codes.Internal, "Try again later")
|
||||||
}
|
}
|
||||||
|
m.runtime.Logger().Info("Generated password reset token", "email", email, "token", tokenID)
|
||||||
s.logger.Info("Generated password reset token", "email", email, "token", tokenID)
|
return common.MessageResponse("If email exists, a reset link has been sent"), nil
|
||||||
return messageResponse("If email exists, a reset link has been sent"), nil
|
|
||||||
}
|
}
|
||||||
func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) {
|
|
||||||
|
func (m *Module) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) {
|
||||||
resetToken := strings.TrimSpace(req.GetToken())
|
resetToken := strings.TrimSpace(req.GetToken())
|
||||||
newPassword := req.GetNewPassword()
|
newPassword := req.GetNewPassword()
|
||||||
if resetToken == "" || newPassword == "" {
|
if resetToken == "" || newPassword == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "Token and new password are required")
|
return nil, status.Error(codes.InvalidArgument, "Token and new password are required")
|
||||||
}
|
}
|
||||||
|
userID, err := m.runtime.Cache().Get(ctx, "reset_pw:"+resetToken)
|
||||||
userID, err := s.cache.Get(ctx, "reset_pw:"+resetToken)
|
|
||||||
if err != nil || strings.TrimSpace(userID) == "" {
|
if err != nil || strings.TrimSpace(userID) == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "Invalid or expired token")
|
return nil, status.Error(codes.InvalidArgument, "Invalid or expired token")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "Internal error")
|
return nil, status.Error(codes.Internal, "Internal error")
|
||||||
}
|
}
|
||||||
|
if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(userID)).Update(query.User.Password, string(hashedPassword)); err != nil {
|
||||||
if _, err := query.User.WithContext(ctx).
|
m.runtime.Logger().Error("Failed to update password", "error", err)
|
||||||
Where(query.User.ID.Eq(userID)).
|
|
||||||
Update(query.User.Password, string(hashedPassword)); err != nil {
|
|
||||||
s.logger.Error("Failed to update password", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update password")
|
return nil, status.Error(codes.Internal, "Failed to update password")
|
||||||
}
|
}
|
||||||
|
_ = m.runtime.Cache().Del(ctx, "reset_pw:"+resetToken)
|
||||||
_ = s.cache.Del(ctx, "reset_pw:"+resetToken)
|
return common.MessageResponse("Password reset successfully"), nil
|
||||||
return messageResponse("Password reset successfully"), nil
|
|
||||||
}
|
}
|
||||||
func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) {
|
|
||||||
if err := s.authenticator.RequireInternalCall(ctx); err != nil {
|
func (m *Module) GetGoogleLoginURL(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) {
|
||||||
|
if err := m.runtime.Authenticator().RequireInternalCall(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" {
|
googleOauth := m.runtime.GoogleOauth()
|
||||||
|
if googleOauth == nil || strings.TrimSpace(googleOauth.ClientID) == "" || strings.TrimSpace(googleOauth.RedirectURL) == "" {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
|
||||||
}
|
}
|
||||||
|
state, err := common.GenerateOAuthState()
|
||||||
state, err := generateOAuthState()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to generate Google OAuth state", "error", err)
|
m.runtime.Logger().Error("Failed to generate Google OAuth state", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "Failed to start Google login")
|
return nil, status.Error(codes.Internal, "Failed to start Google login")
|
||||||
}
|
}
|
||||||
|
if err := m.runtime.Cache().Set(ctx, common.GoogleOAuthStateCacheKey(state), "1", m.runtime.GoogleStateTTL()); err != nil {
|
||||||
if err := s.cache.Set(ctx, googleOAuthStateCacheKey(state), "1", s.googleStateTTL); err != nil {
|
m.runtime.Logger().Error("Failed to persist Google OAuth state", "error", err)
|
||||||
s.logger.Error("Failed to persist Google OAuth state", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to start Google login")
|
return nil, status.Error(codes.Internal, "Failed to start Google login")
|
||||||
}
|
}
|
||||||
|
loginURL := googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||||
loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
|
||||||
return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil
|
return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil
|
||||||
}
|
}
|
||||||
func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) {
|
|
||||||
if err := s.authenticator.RequireInternalCall(ctx); err != nil {
|
func (m *Module) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) {
|
||||||
|
if err := m.runtime.Authenticator().RequireInternalCall(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" {
|
googleOauth := m.runtime.GoogleOauth()
|
||||||
|
if googleOauth == nil || strings.TrimSpace(googleOauth.ClientID) == "" || strings.TrimSpace(googleOauth.RedirectURL) == "" {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
code := strings.TrimSpace(req.GetCode())
|
code := strings.TrimSpace(req.GetCode())
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "Code is required")
|
return nil, status.Error(codes.InvalidArgument, "Code is required")
|
||||||
}
|
}
|
||||||
|
tokenResp, err := googleOauth.Exchange(ctx, code)
|
||||||
tokenResp, err := s.googleOauth.Exchange(ctx, code)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to exchange Google OAuth token", "error", err)
|
m.runtime.Logger().Error("Failed to exchange Google OAuth token", "error", err)
|
||||||
return nil, status.Error(codes.Unauthenticated, "exchange_failed")
|
return nil, status.Error(codes.Unauthenticated, "exchange_failed")
|
||||||
}
|
}
|
||||||
|
client := googleOauth.Client(ctx, tokenResp)
|
||||||
client := s.googleOauth.Client(ctx, tokenResp)
|
resp, err := client.Get(m.runtime.GoogleUserInfoURL())
|
||||||
resp, err := client.Get(s.googleUserInfoURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to fetch Google user info", "error", err)
|
m.runtime.Logger().Error("Failed to fetch Google user info", "error", err)
|
||||||
return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
|
return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
s.logger.Error("Google user info returned non-200", "status", resp.StatusCode)
|
m.runtime.Logger().Error("Google user info returned non-200", "status", resp.StatusCode)
|
||||||
return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
|
return nil, status.Error(codes.Unauthenticated, "userinfo_failed")
|
||||||
}
|
}
|
||||||
|
var googleUser struct { ID, Email, Name, Picture string }
|
||||||
var googleUser struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Picture string `json:"picture"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil {
|
||||||
s.logger.Error("Failed to decode Google user info", "error", err)
|
m.runtime.Logger().Error("Failed to decode Google user info", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "userinfo_parse_failed")
|
return nil, status.Error(codes.Internal, "userinfo_parse_failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
email := strings.TrimSpace(strings.ToLower(googleUser.Email))
|
email := strings.TrimSpace(strings.ToLower(googleUser.Email))
|
||||||
refUsername := strings.TrimSpace(req.GetRefUsername())
|
refUsername := strings.TrimSpace(req.GetRefUsername())
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing_email")
|
return nil, status.Error(codes.InvalidArgument, "missing_email")
|
||||||
}
|
}
|
||||||
|
|
||||||
u := query.User
|
u := query.User
|
||||||
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
|
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
s.logger.Error("Failed to load Google user", "error", err)
|
m.runtime.Logger().Error("Failed to load Google user", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "load_user_failed")
|
return nil, status.Error(codes.Internal, "load_user_failed")
|
||||||
}
|
}
|
||||||
referrerID, resolveErr := s.resolveSignupReferrerID(ctx, refUsername, googleUser.Name)
|
referrerID, resolveErr := m.users.ResolveSignupReferrerID(ctx, refUsername, googleUser.Name)
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
s.logger.Error("Failed to resolve Google signup referrer", "error", resolveErr)
|
m.runtime.Logger().Error("Failed to resolve Google signup referrer", "error", resolveErr)
|
||||||
return nil, status.Error(codes.Internal, "create_user_failed")
|
return nil, status.Error(codes.Internal, "create_user_failed")
|
||||||
}
|
}
|
||||||
role := "USER"
|
role := "USER"
|
||||||
user = &model.User{
|
user = &model.User{ID: uuid.New().String(), Email: email, Username: common.StringPointerOrNil(googleUser.Name), GoogleID: common.StringPointerOrNil(googleUser.ID), Avatar: common.StringPointerOrNil(googleUser.Picture), Role: &role, ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true)}
|
||||||
ID: uuid.New().String(),
|
|
||||||
Email: email,
|
|
||||||
Username: stringPointerOrNil(googleUser.Name),
|
|
||||||
GoogleID: stringPointerOrNil(googleUser.ID),
|
|
||||||
Avatar: stringPointerOrNil(googleUser.Picture),
|
|
||||||
Role: &role,
|
|
||||||
ReferredByUserID: referrerID,
|
|
||||||
ReferralEligible: model.BoolPtr(true),
|
|
||||||
}
|
|
||||||
if err := u.WithContext(ctx).Create(user); err != nil {
|
if err := u.WithContext(ctx).Create(user); err != nil {
|
||||||
s.logger.Error("Failed to create Google user", "error", err)
|
m.runtime.Logger().Error("Failed to create Google user", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "create_user_failed")
|
return nil, status.Error(codes.Internal, "create_user_failed")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]any{}
|
||||||
if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" {
|
if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" { updates["google_id"] = googleUser.ID }
|
||||||
updates["google_id"] = googleUser.ID
|
if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" { updates["avatar"] = googleUser.Picture }
|
||||||
}
|
if user.Username == nil || strings.TrimSpace(*user.Username) == "" { updates["username"] = googleUser.Name }
|
||||||
if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" {
|
|
||||||
updates["avatar"] = googleUser.Picture
|
|
||||||
}
|
|
||||||
if user.Username == nil || strings.TrimSpace(*user.Username) == "" {
|
|
||||||
updates["username"] = googleUser.Name
|
|
||||||
}
|
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
if err := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
||||||
s.logger.Error("Failed to update Google user", "error", err)
|
m.runtime.Logger().Error("Failed to update Google user", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "update_user_failed")
|
return nil, status.Error(codes.Internal, "update_user_failed")
|
||||||
}
|
}
|
||||||
user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First()
|
user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to reload Google user", "error", err)
|
m.runtime.Logger().Error("Failed to reload Google user", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "reload_user_failed")
|
return nil, status.Error(codes.Internal, "reload_user_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := m.runtime.IssueSessionCookies(ctx, user); err != nil {
|
||||||
if err := s.issueSessionCookies(ctx, user); err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "session_failed")
|
return nil, status.Error(codes.Internal, "session_failed")
|
||||||
}
|
}
|
||||||
|
payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user)
|
||||||
payload, err := buildUserPayload(ctx, s.db, user)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
||||||
}
|
}
|
||||||
return &appv1.CompleteGoogleLoginResponse{User: toProtoUser(payload)}, nil
|
return &appv1.CompleteGoogleLoginResponse{User: common.ToProtoUser(payload)}, nil
|
||||||
}
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"stream.api/internal/database/model"
|
"stream.api/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type userPayload struct {
|
type UserPayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Username *string `json:"username,omitempty"`
|
Username *string `json:"username,omitempty"`
|
||||||
@@ -31,7 +31,7 @@ type userPayload struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*userPayload, error) {
|
func BuildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*UserPayload, error) {
|
||||||
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID)
|
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -82,7 +82,7 @@ func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*user
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &userPayload{
|
return &UserPayload{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
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
|
||||||
|
}
|
||||||
216
internal/rpc/app/core.go
Normal file
216
internal/rpc/app/core.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"stream.api/internal/config"
|
||||||
|
"stream.api/internal/database/model"
|
||||||
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||||
|
"stream.api/internal/middleware"
|
||||||
|
adminhandler "stream.api/internal/modules/admin"
|
||||||
|
adtemplatesmodule "stream.api/internal/modules/adtemplates"
|
||||||
|
authmodule "stream.api/internal/modules/auth"
|
||||||
|
"stream.api/internal/modules/common"
|
||||||
|
dashboardmodule "stream.api/internal/modules/dashboard"
|
||||||
|
domainsmodule "stream.api/internal/modules/domains"
|
||||||
|
jobsmodule "stream.api/internal/modules/jobs"
|
||||||
|
paymentsmodule "stream.api/internal/modules/payments"
|
||||||
|
playerconfigsmodule "stream.api/internal/modules/playerconfigs"
|
||||||
|
plansmodule "stream.api/internal/modules/plans"
|
||||||
|
usersmodule "stream.api/internal/modules/users"
|
||||||
|
videosmodule "stream.api/internal/modules/videos"
|
||||||
|
"stream.api/internal/video"
|
||||||
|
"stream.api/pkg/cache"
|
||||||
|
"stream.api/pkg/logger"
|
||||||
|
"stream.api/pkg/storage"
|
||||||
|
"stream.api/pkg/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultGoogleUserInfoURL = common.DefaultGoogleUserInfoURL
|
||||||
|
|
||||||
|
type Services struct {
|
||||||
|
AuthServiceServer
|
||||||
|
AccountServiceServer
|
||||||
|
PreferencesServiceServer
|
||||||
|
UsageServiceServer
|
||||||
|
NotificationsServiceServer
|
||||||
|
DomainsServiceServer
|
||||||
|
AdTemplatesServiceServer
|
||||||
|
PlayerConfigsServiceServer
|
||||||
|
PlansServiceServer
|
||||||
|
PaymentsServiceServer
|
||||||
|
VideosServiceServer
|
||||||
|
AdminServiceServer
|
||||||
|
}
|
||||||
|
|
||||||
|
type appServices struct {
|
||||||
|
db *gorm.DB
|
||||||
|
logger logger.Logger
|
||||||
|
authenticator *middleware.Authenticator
|
||||||
|
tokenProvider token.Provider
|
||||||
|
cache cache.Cache
|
||||||
|
storageProvider storage.Provider
|
||||||
|
videoService *video.Service
|
||||||
|
agentRuntime video.AgentRuntime
|
||||||
|
googleOauth *oauth2.Config
|
||||||
|
googleStateTTL time.Duration
|
||||||
|
googleUserInfoURL string
|
||||||
|
frontendBaseURL string
|
||||||
|
|
||||||
|
runtime *common.Runtime
|
||||||
|
authModule *authmodule.Module
|
||||||
|
usersModule *usersmodule.Module
|
||||||
|
domainsModule *domainsmodule.Module
|
||||||
|
adTemplatesModule *adtemplatesmodule.Module
|
||||||
|
playerConfigsModule *playerconfigsmodule.Module
|
||||||
|
plansModule *plansmodule.Module
|
||||||
|
paymentsModule *paymentsmodule.Module
|
||||||
|
videosModule *videosmodule.Module
|
||||||
|
jobsModule *jobsmodule.Module
|
||||||
|
dashboardModule *dashboardmodule.Module
|
||||||
|
authHandler *authmodule.Handler
|
||||||
|
accountHandler *usersmodule.AccountHandler
|
||||||
|
preferencesHandler *usersmodule.PreferencesHandler
|
||||||
|
usageHandler *usersmodule.UsageHandler
|
||||||
|
notificationsHandler *usersmodule.NotificationsHandler
|
||||||
|
domainsHandler *domainsmodule.Handler
|
||||||
|
adTemplatesHandler *adtemplatesmodule.Handler
|
||||||
|
playerConfigsHandler *playerconfigsmodule.Handler
|
||||||
|
plansHandler *plansmodule.Handler
|
||||||
|
paymentsHandler *paymentsmodule.Handler
|
||||||
|
videosHandler *videosmodule.Handler
|
||||||
|
adminHandler *adminhandler.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *video.Service, agentRuntime video.AgentRuntime) *Services {
|
||||||
|
var storageProvider storage.Provider
|
||||||
|
if cfg != nil {
|
||||||
|
provider, err := storage.NewS3Provider(cfg)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Failed to initialize S3 provider for gRPC app services", "error", err)
|
||||||
|
} else {
|
||||||
|
storageProvider = provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
googleStateTTL := 10 * time.Minute
|
||||||
|
googleOauth := &oauth2.Config{}
|
||||||
|
frontendBaseURL := ""
|
||||||
|
trustedMarker := ""
|
||||||
|
if cfg != nil {
|
||||||
|
if cfg.Google.StateTTLMinute > 0 {
|
||||||
|
googleStateTTL = time.Duration(cfg.Google.StateTTLMinute) * time.Minute
|
||||||
|
}
|
||||||
|
googleOauth = &oauth2.Config{
|
||||||
|
ClientID: cfg.Google.ClientID,
|
||||||
|
ClientSecret: cfg.Google.ClientSecret,
|
||||||
|
RedirectURL: cfg.Google.RedirectURL,
|
||||||
|
Scopes: []string{
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
frontendBaseURL = cfg.Frontend.BaseURL
|
||||||
|
trustedMarker = cfg.Internal.Marker
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &appServices{
|
||||||
|
db: db,
|
||||||
|
logger: l,
|
||||||
|
authenticator: middleware.NewAuthenticator(db, l, trustedMarker),
|
||||||
|
tokenProvider: t,
|
||||||
|
cache: c,
|
||||||
|
storageProvider: storageProvider,
|
||||||
|
videoService: videoService,
|
||||||
|
agentRuntime: agentRuntime,
|
||||||
|
googleOauth: googleOauth,
|
||||||
|
googleStateTTL: googleStateTTL,
|
||||||
|
googleUserInfoURL: defaultGoogleUserInfoURL,
|
||||||
|
frontendBaseURL: frontendBaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
service.initModules()
|
||||||
|
|
||||||
|
return &Services{
|
||||||
|
AuthServiceServer: service.authHandler,
|
||||||
|
AccountServiceServer: service.accountHandler,
|
||||||
|
PreferencesServiceServer: service.preferencesHandler,
|
||||||
|
UsageServiceServer: service.usageHandler,
|
||||||
|
NotificationsServiceServer: service.notificationsHandler,
|
||||||
|
DomainsServiceServer: service.domainsHandler,
|
||||||
|
AdTemplatesServiceServer: service.adTemplatesHandler,
|
||||||
|
PlayerConfigsServiceServer: service.playerConfigsHandler,
|
||||||
|
PlansServiceServer: service.plansHandler,
|
||||||
|
PaymentsServiceServer: service.paymentsHandler,
|
||||||
|
VideosServiceServer: service.videosHandler,
|
||||||
|
AdminServiceServer: service.adminHandler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthServiceServer interface { appv1.AuthServiceServer }
|
||||||
|
type AccountServiceServer interface { appv1.AccountServiceServer }
|
||||||
|
type PreferencesServiceServer interface { appv1.PreferencesServiceServer }
|
||||||
|
type UsageServiceServer interface { appv1.UsageServiceServer }
|
||||||
|
type NotificationsServiceServer interface { appv1.NotificationsServiceServer }
|
||||||
|
type DomainsServiceServer interface { appv1.DomainsServiceServer }
|
||||||
|
type AdTemplatesServiceServer interface { appv1.AdTemplatesServiceServer }
|
||||||
|
type PlayerConfigsServiceServer interface { appv1.PlayerConfigsServiceServer }
|
||||||
|
type PlansServiceServer interface { appv1.PlansServiceServer }
|
||||||
|
type PaymentsServiceServer interface { appv1.PaymentsServiceServer }
|
||||||
|
type VideosServiceServer interface { appv1.VideosServiceServer }
|
||||||
|
type AdminServiceServer interface { appv1.AdminServiceServer }
|
||||||
|
|
||||||
|
func (s *appServices) initModules() {
|
||||||
|
s.runtime = common.NewRuntime(common.RuntimeOptions{
|
||||||
|
DB: s.db,
|
||||||
|
Logger: s.logger,
|
||||||
|
Authenticator: s.authenticator,
|
||||||
|
TokenProvider: s.tokenProvider,
|
||||||
|
Cache: s.cache,
|
||||||
|
GoogleOauth: s.googleOauth,
|
||||||
|
GoogleStateTTL: s.googleStateTTL,
|
||||||
|
GoogleUserInfoURL: s.googleUserInfoURL,
|
||||||
|
FrontendBaseURL: s.frontendBaseURL,
|
||||||
|
StorageProvider: func() storage.Provider {
|
||||||
|
return s.storageProvider
|
||||||
|
},
|
||||||
|
VideoService: func() *video.Service {
|
||||||
|
return s.videoService
|
||||||
|
},
|
||||||
|
AgentRuntime: func() video.AgentRuntime {
|
||||||
|
return s.agentRuntime
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
s.usersModule = usersmodule.New(s.runtime)
|
||||||
|
s.authModule = authmodule.New(s.runtime, s.usersModule)
|
||||||
|
s.domainsModule = domainsmodule.New(s.runtime)
|
||||||
|
s.adTemplatesModule = adtemplatesmodule.New(s.runtime)
|
||||||
|
s.playerConfigsModule = playerconfigsmodule.New(s.runtime)
|
||||||
|
s.plansModule = plansmodule.New(s.runtime)
|
||||||
|
s.paymentsModule = paymentsmodule.New(s.runtime)
|
||||||
|
s.videosModule = videosmodule.New(s.runtime)
|
||||||
|
s.jobsModule = jobsmodule.New(s.runtime)
|
||||||
|
s.dashboardModule = dashboardmodule.New(s.runtime)
|
||||||
|
|
||||||
|
s.authHandler = authmodule.NewHandler(s.authModule)
|
||||||
|
s.accountHandler = usersmodule.NewAccountHandler(s.usersModule)
|
||||||
|
s.preferencesHandler = usersmodule.NewPreferencesHandler(s.usersModule)
|
||||||
|
s.usageHandler = usersmodule.NewUsageHandler(s.usersModule)
|
||||||
|
s.notificationsHandler = usersmodule.NewNotificationsHandler(s.usersModule)
|
||||||
|
s.domainsHandler = domainsmodule.NewHandler(s.domainsModule)
|
||||||
|
s.adTemplatesHandler = adtemplatesmodule.NewHandler(s.adTemplatesModule)
|
||||||
|
s.playerConfigsHandler = playerconfigsmodule.NewHandler(s.playerConfigsModule)
|
||||||
|
s.plansHandler = plansmodule.NewHandler(s.plansModule)
|
||||||
|
s.paymentsHandler = paymentsmodule.NewHandler(s.paymentsModule)
|
||||||
|
s.videosHandler = videosmodule.NewHandler(s.videosModule)
|
||||||
|
s.adminHandler = adminhandler.NewHandler(s.dashboardModule, s.usersModule, s.videosModule, s.paymentsModule, s.plansModule, s.adTemplatesModule, s.playerConfigsModule, s.jobsModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = model.Plan{}
|
||||||
|
)
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
"stream.api/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type updatePreferencesInput struct {
|
|
||||||
EmailNotifications *bool
|
|
||||||
PushNotifications *bool
|
|
||||||
MarketingNotifications *bool
|
|
||||||
TelegramNotifications *bool
|
|
||||||
Language *string
|
|
||||||
Locale *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadUserPreferences(ctx context.Context, db *gorm.DB, userID string) (*model.UserPreference, error) {
|
|
||||||
return model.FindOrCreateUserPreference(ctx, db, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updatePreferencesInput) (*model.UserPreference, error) {
|
|
||||||
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
|
|
||||||
if err != nil {
|
|
||||||
l.Error("Failed to load preferences", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.EmailNotifications != nil {
|
|
||||||
pref.EmailNotifications = model.BoolPtr(*req.EmailNotifications)
|
|
||||||
}
|
|
||||||
if req.PushNotifications != nil {
|
|
||||||
pref.PushNotifications = model.BoolPtr(*req.PushNotifications)
|
|
||||||
}
|
|
||||||
if req.MarketingNotifications != nil {
|
|
||||||
pref.MarketingNotifications = *req.MarketingNotifications
|
|
||||||
}
|
|
||||||
if req.TelegramNotifications != nil {
|
|
||||||
pref.TelegramNotifications = *req.TelegramNotifications
|
|
||||||
}
|
|
||||||
if req.Language != nil {
|
|
||||||
pref.Language = model.StringPtr(strings.TrimSpace(*req.Language))
|
|
||||||
}
|
|
||||||
if req.Locale != nil {
|
|
||||||
pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model.StringValue(pref.Language)) == "" {
|
|
||||||
pref.Language = model.StringPtr("en")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" {
|
|
||||||
pref.Locale = model.StringPtr(model.StringValue(pref.Language))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.WithContext(ctx).Save(pref).Error; err != nil {
|
|
||||||
l.Error("Failed to save preferences", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return pref, nil
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
"stream.api/internal/database/query"
|
|
||||||
"stream.api/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errEmailRequired = errors.New("Email is required")
|
|
||||||
errEmailAlreadyRegistered = errors.New("Email already registered")
|
|
||||||
)
|
|
||||||
|
|
||||||
type updateProfileInput struct {
|
|
||||||
Username *string
|
|
||||||
Email *string
|
|
||||||
Language *string
|
|
||||||
Locale *string
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updateProfileInput) (*model.User, error) {
|
|
||||||
updates := map[string]any{}
|
|
||||||
if req.Username != nil {
|
|
||||||
username := strings.TrimSpace(*req.Username)
|
|
||||||
updates["username"] = username
|
|
||||||
}
|
|
||||||
if req.Email != nil {
|
|
||||||
email := strings.TrimSpace(*req.Email)
|
|
||||||
if email == "" {
|
|
||||||
return nil, errEmailRequired
|
|
||||||
}
|
|
||||||
updates["email"] = email
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(updates) > 0 {
|
|
||||||
if err := db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
||||||
return nil, errEmailAlreadyRegistered
|
|
||||||
}
|
|
||||||
l.Error("Failed to update user", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pref, err := model.FindOrCreateUserPreference(ctx, db, userID)
|
|
||||||
if err != nil {
|
|
||||||
l.Error("Failed to load user preference", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
prefChanged := false
|
|
||||||
if req.Language != nil {
|
|
||||||
pref.Language = model.StringPtr(strings.TrimSpace(*req.Language))
|
|
||||||
prefChanged = true
|
|
||||||
}
|
|
||||||
if req.Locale != nil {
|
|
||||||
pref.Locale = model.StringPtr(strings.TrimSpace(*req.Locale))
|
|
||||||
prefChanged = true
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model.StringValue(pref.Language)) == "" {
|
|
||||||
pref.Language = model.StringPtr("en")
|
|
||||||
prefChanged = true
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(model.StringValue(pref.Locale)) == "" {
|
|
||||||
pref.Locale = model.StringPtr(model.StringValue(pref.Language))
|
|
||||||
prefChanged = true
|
|
||||||
}
|
|
||||||
if prefChanged {
|
|
||||||
if err := db.WithContext(ctx).Save(pref).Error; err != nil {
|
|
||||||
l.Error("Failed to save user preference", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
u := query.User
|
|
||||||
user, err := u.WithContext(ctx).Where(u.ID.Eq(userID)).First()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/wrapperspb"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
"stream.api/internal/database/query"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appServices) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload, err := buildUserPayload(ctx, s.db, result.User)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
|
||||||
}
|
|
||||||
return &appv1.GetMeResponse{User: toProtoUser(payload)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) {
|
|
||||||
_, err := s.authenticator.RequireTrustedMetadata(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
u := query.User
|
|
||||||
user, err := u.WithContext(ctx).Where(u.ID.Eq(req.Value)).First()
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Unauthenticated, "Unauthorized")
|
|
||||||
}
|
|
||||||
payload, err := buildUserPayload(ctx, s.db, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
|
||||||
}
|
|
||||||
return toProtoUser(payload), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedUser, err := updateUserProfile(ctx, s.db, s.logger, result.UserID, updateProfileInput{
|
|
||||||
Username: req.Username,
|
|
||||||
Email: req.Email,
|
|
||||||
Language: req.Language,
|
|
||||||
Locale: req.Locale,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, errEmailRequired), errors.Is(err, errEmailAlreadyRegistered):
|
|
||||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
||||||
default:
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update profile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := buildUserPayload(ctx, s.db, updatedUser)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to build user payload")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateMeResponse{User: toProtoUser(payload)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := result.UserID
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
s.logger.Error("Failed to delete user", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete account")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Account deleted successfully"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := result.UserID
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{"storage_used": 0}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
s.logger.Error("Failed to clear user data", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to clear data")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Data cleared successfully"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pref, err := loadUserPreferences(ctx, s.db, result.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load preferences")
|
|
||||||
}
|
|
||||||
return &appv1.GetPreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pref, err := updateUserPreferences(ctx, s.db, s.logger, result.UserID, updatePreferencesInput{
|
|
||||||
EmailNotifications: req.EmailNotifications,
|
|
||||||
PushNotifications: req.PushNotifications,
|
|
||||||
MarketingNotifications: req.MarketingNotifications,
|
|
||||||
TelegramNotifications: req.TelegramNotifications,
|
|
||||||
Language: req.Language,
|
|
||||||
Locale: req.Locale,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save preferences")
|
|
||||||
}
|
|
||||||
return &appv1.UpdatePreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload, err := loadUsage(ctx, s.db, s.logger, result.User)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load usage")
|
|
||||||
}
|
|
||||||
return &appv1.GetUsageResponse{
|
|
||||||
UserId: payload.UserID,
|
|
||||||
TotalVideos: payload.TotalVideos,
|
|
||||||
TotalStorage: payload.TotalStorage,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,735 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appServices) ListAdminPayments(ctx context.Context, req *appv1.ListAdminPaymentsRequest) (*appv1.ListAdminPaymentsResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
|
||||||
limitInt := int(limit)
|
|
||||||
userID := strings.TrimSpace(req.GetUserId())
|
|
||||||
statusFilter := strings.TrimSpace(req.GetStatus())
|
|
||||||
|
|
||||||
db := s.db.WithContext(ctx).Model(&model.Payment{})
|
|
||||||
if userID != "" {
|
|
||||||
db = db.Where("user_id = ?", userID)
|
|
||||||
}
|
|
||||||
if statusFilter != "" {
|
|
||||||
db = db.Where("UPPER(status) = ?", strings.ToUpper(statusFilter))
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list payments")
|
|
||||||
}
|
|
||||||
|
|
||||||
var payments []model.Payment
|
|
||||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&payments).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list payments")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.AdminPayment, 0, len(payments))
|
|
||||||
for _, payment := range payments {
|
|
||||||
payload, err := s.buildAdminPayment(ctx, &payment)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list payments")
|
|
||||||
}
|
|
||||||
items = append(items, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListAdminPaymentsResponse{Payments: items, Total: total, Page: page, Limit: limit}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetAdminPayment(ctx context.Context, req *appv1.GetAdminPaymentRequest) (*appv1.GetAdminPaymentResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Payment not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var payment model.Payment
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Payment not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to get payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPayment(ctx, &payment)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to get payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.GetAdminPaymentResponse{Payment: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateAdminPayment(ctx context.Context, req *appv1.CreateAdminPaymentRequest) (*appv1.CreateAdminPaymentResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := strings.TrimSpace(req.GetUserId())
|
|
||||||
planID := strings.TrimSpace(req.GetPlanId())
|
|
||||||
if userID == "" || planID == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User ID and plan ID are required")
|
|
||||||
}
|
|
||||||
if !isAllowedTermMonths(req.GetTermMonths()) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentMethod := normalizePaymentMethod(req.GetPaymentMethod())
|
|
||||||
if paymentMethod == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.loadPaymentUserForAdmin(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
planRecord, err := s.loadPaymentPlanForAdmin(ctx, planID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
|
|
||||||
UserID: user.ID,
|
|
||||||
Plan: planRecord,
|
|
||||||
TermMonths: req.GetTermMonths(),
|
|
||||||
PaymentMethod: paymentMethod,
|
|
||||||
TopupAmount: req.TopupAmount,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if _, ok := status.FromError(err); ok {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPayment(ctx, resultValue.Payment)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
|
||||||
}
|
|
||||||
return &appv1.CreateAdminPaymentResponse{
|
|
||||||
Payment: payload,
|
|
||||||
Subscription: toProtoPlanSubscription(resultValue.Subscription),
|
|
||||||
WalletBalance: resultValue.WalletBalance,
|
|
||||||
InvoiceId: resultValue.InvoiceID,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateAdminPaymentRequest) (*appv1.UpdateAdminPaymentResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Payment not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
newStatus := strings.ToUpper(strings.TrimSpace(req.GetStatus()))
|
|
||||||
if newStatus == "" {
|
|
||||||
newStatus = "SUCCESS"
|
|
||||||
}
|
|
||||||
if newStatus != "SUCCESS" && newStatus != "FAILED" && newStatus != "PENDING" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Invalid payment status")
|
|
||||||
}
|
|
||||||
|
|
||||||
var payment model.Payment
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Payment not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentStatus := strings.ToUpper(normalizePaymentStatus(payment.Status))
|
|
||||||
if currentStatus != newStatus {
|
|
||||||
if (currentStatus == "FAILED" || currentStatus == "PENDING") && newStatus == "SUCCESS" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead")
|
|
||||||
}
|
|
||||||
payment.Status = model.StringPtr(newStatus)
|
|
||||||
if err := s.db.WithContext(ctx).Save(&payment).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update payment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPayment(ctx, &payment)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update payment")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminPaymentResponse{Payment: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListAdminPlans(ctx context.Context, _ *appv1.ListAdminPlansRequest) (*appv1.ListAdminPlansResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var plans []model.Plan
|
|
||||||
if err := s.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list plans")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.AdminPlan, 0, len(plans))
|
|
||||||
for i := range plans {
|
|
||||||
payload, err := s.buildAdminPlan(ctx, &plans[i])
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list plans")
|
|
||||||
}
|
|
||||||
items = append(items, payload)
|
|
||||||
}
|
|
||||||
return &appv1.ListAdminPlansResponse{Plans: items}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateAdminPlan(ctx context.Context, req *appv1.CreateAdminPlanRequest) (*appv1.CreateAdminPlanResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg := validateAdminPlanInput(req.GetName(), req.GetCycle(), req.GetPrice(), req.GetStorageLimit(), req.GetUploadLimit()); msg != "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
plan := &model.Plan{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
Name: strings.TrimSpace(req.GetName()),
|
|
||||||
Description: nullableTrimmedStringPtr(req.Description),
|
|
||||||
Features: append([]string(nil), req.GetFeatures()...),
|
|
||||||
Price: req.GetPrice(),
|
|
||||||
Cycle: strings.TrimSpace(req.GetCycle()),
|
|
||||||
StorageLimit: req.GetStorageLimit(),
|
|
||||||
UploadLimit: req.GetUploadLimit(),
|
|
||||||
DurationLimit: 0,
|
|
||||||
QualityLimit: "",
|
|
||||||
IsActive: model.BoolPtr(req.GetIsActive()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Create(plan).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create plan")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPlan(ctx, plan)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create plan")
|
|
||||||
}
|
|
||||||
return &appv1.CreateAdminPlanResponse{Plan: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdminPlanRequest) (*appv1.UpdateAdminPlanResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Plan not found")
|
|
||||||
}
|
|
||||||
if msg := validateAdminPlanInput(req.GetName(), req.GetCycle(), req.GetPrice(), req.GetStorageLimit(), req.GetUploadLimit()); msg != "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
var plan model.Plan
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Plan not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update plan")
|
|
||||||
}
|
|
||||||
|
|
||||||
plan.Name = strings.TrimSpace(req.GetName())
|
|
||||||
plan.Description = nullableTrimmedStringPtr(req.Description)
|
|
||||||
plan.Features = append([]string(nil), req.GetFeatures()...)
|
|
||||||
plan.Price = req.GetPrice()
|
|
||||||
plan.Cycle = strings.TrimSpace(req.GetCycle())
|
|
||||||
plan.StorageLimit = req.GetStorageLimit()
|
|
||||||
plan.UploadLimit = req.GetUploadLimit()
|
|
||||||
plan.IsActive = model.BoolPtr(req.GetIsActive())
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Save(&plan).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update plan")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPlan(ctx, &plan)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update plan")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminPlanResponse{Plan: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteAdminPlan(ctx context.Context, req *appv1.DeleteAdminPlanRequest) (*appv1.DeleteAdminPlanResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Plan not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var plan model.Plan
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Plan not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete plan")
|
|
||||||
}
|
|
||||||
|
|
||||||
var paymentCount int64
|
|
||||||
if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", id).Count(&paymentCount).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete plan")
|
|
||||||
}
|
|
||||||
var subscriptionCount int64
|
|
||||||
if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", id).Count(&subscriptionCount).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete plan")
|
|
||||||
}
|
|
||||||
|
|
||||||
if paymentCount > 0 || subscriptionCount > 0 {
|
|
||||||
inactive := false
|
|
||||||
if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", id).Update("is_active", inactive).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to deactivate plan")
|
|
||||||
}
|
|
||||||
return &appv1.DeleteAdminPlanResponse{Message: "Plan deactivated", Mode: "deactivated"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Plan{}).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete plan")
|
|
||||||
}
|
|
||||||
return &appv1.DeleteAdminPlanResponse{Message: "Plan deleted", Mode: "deleted"}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListAdminAdTemplates(ctx context.Context, req *appv1.ListAdminAdTemplatesRequest) (*appv1.ListAdminAdTemplatesResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
|
||||||
limitInt := int(limit)
|
|
||||||
search := strings.TrimSpace(protoStringValue(req.Search))
|
|
||||||
userID := strings.TrimSpace(protoStringValue(req.UserId))
|
|
||||||
|
|
||||||
db := s.db.WithContext(ctx).Model(&model.AdTemplate{})
|
|
||||||
if search != "" {
|
|
||||||
like := "%" + search + "%"
|
|
||||||
db = db.Where("name ILIKE ?", like)
|
|
||||||
}
|
|
||||||
if userID != "" {
|
|
||||||
db = db.Where("user_id = ?", userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list ad templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
var templates []model.AdTemplate
|
|
||||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&templates).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list ad templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.AdminAdTemplate, 0, len(templates))
|
|
||||||
for i := range templates {
|
|
||||||
payload, err := s.buildAdminAdTemplate(ctx, &templates[i])
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list ad templates")
|
|
||||||
}
|
|
||||||
items = append(items, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListAdminAdTemplatesResponse{
|
|
||||||
Templates: items,
|
|
||||||
Total: total,
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetAdminAdTemplate(ctx context.Context, req *appv1.GetAdminAdTemplateRequest) (*appv1.GetAdminAdTemplateResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var item model.AdTemplate
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminAdTemplate(ctx, &item)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load ad template")
|
|
||||||
}
|
|
||||||
return &appv1.GetAdminAdTemplateResponse{Template: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.CreateAdminAdTemplateRequest) (*appv1.CreateAdminAdTemplateResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := req.Duration
|
|
||||||
if msg := validateAdminAdTemplateInput(req.GetUserId(), req.GetName(), req.GetVastTagUrl(), req.GetAdFormat(), duration); msg != "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
item := &model.AdTemplate{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: user.ID,
|
|
||||||
Name: strings.TrimSpace(req.GetName()),
|
|
||||||
Description: nullableTrimmedStringPtr(req.Description),
|
|
||||||
VastTagURL: strings.TrimSpace(req.GetVastTagUrl()),
|
|
||||||
AdFormat: model.StringPtr(normalizeAdFormat(req.GetAdFormat())),
|
|
||||||
Duration: duration,
|
|
||||||
IsActive: model.BoolPtr(req.GetIsActive()),
|
|
||||||
IsDefault: req.GetIsDefault(),
|
|
||||||
}
|
|
||||||
if !boolValue(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, ""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Create(item).Error
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminAdTemplate(ctx, item)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
return &appv1.CreateAdminAdTemplateResponse{Template: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.UpdateAdminAdTemplateRequest) (*appv1.UpdateAdminAdTemplateResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
duration := req.Duration
|
|
||||||
if msg := validateAdminAdTemplateInput(req.GetUserId(), req.GetName(), req.GetVastTagUrl(), req.GetAdFormat(), duration); msg != "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
var item model.AdTemplate
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
item.UserID = user.ID
|
|
||||||
item.Name = strings.TrimSpace(req.GetName())
|
|
||||||
item.Description = nullableTrimmedStringPtr(req.Description)
|
|
||||||
item.VastTagURL = strings.TrimSpace(req.GetVastTagUrl())
|
|
||||||
item.AdFormat = model.StringPtr(normalizeAdFormat(req.GetAdFormat()))
|
|
||||||
item.Duration = duration
|
|
||||||
item.IsActive = model.BoolPtr(req.GetIsActive())
|
|
||||||
item.IsDefault = req.GetIsDefault()
|
|
||||||
if !boolValue(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, item.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Save(&item).Error
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminAdTemplate(ctx, &item)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminAdTemplateResponse{Template: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteAdminAdTemplate(ctx context.Context, req *appv1.DeleteAdminAdTemplateRequest) (*appv1.MessageResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&model.Video{}).Where("ad_id = ?", id).Update("ad_id", nil).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
res := tx.Where("id = ?", id).Delete(&model.AdTemplate{})
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete ad template")
|
|
||||||
}
|
|
||||||
return &appv1.MessageResponse{Message: "Ad template deleted"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) ListAdminPlayerConfigs(ctx context.Context, req *appv1.ListAdminPlayerConfigsRequest) (*appv1.ListAdminPlayerConfigsResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
|
||||||
limitInt := int(limit)
|
|
||||||
search := strings.TrimSpace(protoStringValue(req.Search))
|
|
||||||
userID := strings.TrimSpace(protoStringValue(req.UserId))
|
|
||||||
|
|
||||||
db := s.db.WithContext(ctx).Model(&model.PlayerConfig{})
|
|
||||||
if search != "" {
|
|
||||||
like := "%" + search + "%"
|
|
||||||
db = db.Where("name ILIKE ?", like)
|
|
||||||
}
|
|
||||||
if userID != "" {
|
|
||||||
db = db.Where("user_id = ?", userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list player configs")
|
|
||||||
}
|
|
||||||
|
|
||||||
var configs []model.PlayerConfig
|
|
||||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&configs).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list player configs")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.AdminPlayerConfig, 0, len(configs))
|
|
||||||
for i := range configs {
|
|
||||||
payload, err := s.buildAdminPlayerConfig(ctx, &configs[i])
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list player configs")
|
|
||||||
}
|
|
||||||
items = append(items, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListAdminPlayerConfigsResponse{
|
|
||||||
Configs: items,
|
|
||||||
Total: total,
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAdminPlayerConfigRequest) (*appv1.GetAdminPlayerConfigResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var item model.PlayerConfig
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPlayerConfig(ctx, &item)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load player config")
|
|
||||||
}
|
|
||||||
return &appv1.GetAdminPlayerConfigResponse{Config: payload}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.CreateAdminPlayerConfigRequest) (*appv1.CreateAdminPlayerConfigResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
item := &model.PlayerConfig{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: user.ID,
|
|
||||||
Name: strings.TrimSpace(req.GetName()),
|
|
||||||
Description: nullableTrimmedStringPtr(req.Description),
|
|
||||||
Autoplay: req.GetAutoplay(),
|
|
||||||
Loop: req.GetLoop(),
|
|
||||||
Muted: req.GetMuted(),
|
|
||||||
ShowControls: model.BoolPtr(req.GetShowControls()),
|
|
||||||
Pip: model.BoolPtr(req.GetPip()),
|
|
||||||
Airplay: model.BoolPtr(req.GetAirplay()),
|
|
||||||
Chromecast: model.BoolPtr(req.GetChromecast()),
|
|
||||||
IsActive: model.BoolPtr(req.GetIsActive()),
|
|
||||||
IsDefault: req.GetIsDefault(),
|
|
||||||
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
|
|
||||||
LogoURL: nullableTrimmedStringPtr(req.LogoUrl),
|
|
||||||
}
|
|
||||||
if !boolValue(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, ""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Create(item).Error
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPlayerConfig(ctx, item)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
return &appv1.CreateAdminPlayerConfigResponse{Config: payload}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.UpdateAdminPlayerConfigRequest) (*appv1.UpdateAdminPlayerConfigResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg := validateAdminPlayerConfigInput(req.GetUserId(), req.GetName()); msg != "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
var item model.PlayerConfig
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
item.UserID = user.ID
|
|
||||||
item.Name = strings.TrimSpace(req.GetName())
|
|
||||||
item.Description = nullableTrimmedStringPtr(req.Description)
|
|
||||||
item.Autoplay = req.GetAutoplay()
|
|
||||||
item.Loop = req.GetLoop()
|
|
||||||
item.Muted = req.GetMuted()
|
|
||||||
item.ShowControls = model.BoolPtr(req.GetShowControls())
|
|
||||||
item.Pip = model.BoolPtr(req.GetPip())
|
|
||||||
item.Airplay = model.BoolPtr(req.GetAirplay())
|
|
||||||
item.Chromecast = model.BoolPtr(req.GetChromecast())
|
|
||||||
item.IsActive = model.BoolPtr(req.GetIsActive())
|
|
||||||
item.IsDefault = req.GetIsDefault()
|
|
||||||
if req.EncrytionM3U8 != nil {
|
|
||||||
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
|
|
||||||
}
|
|
||||||
if req.LogoUrl != nil {
|
|
||||||
item.LogoURL = nullableTrimmedStringPtr(req.LogoUrl)
|
|
||||||
}
|
|
||||||
if !boolValue(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, item.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Save(&item).Error
|
|
||||||
}); err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminPlayerConfig(ctx, &item)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminPlayerConfigResponse{Config: payload}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.DeleteAdminPlayerConfigRequest) (*appv1.MessageResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
res := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.PlayerConfig{})
|
|
||||||
if res.Error != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete player config")
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.MessageResponse{Message: "Player config deleted"}, nil
|
|
||||||
}
|
|
||||||
@@ -9,29 +9,23 @@ import (
|
|||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"stream.api/internal/database/model"
|
"stream.api/internal/database/model"
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||||
|
"stream.api/internal/modules/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateAdminPayment(t *testing.T) {
|
func TestCreateAdminPayment(t *testing.T) {
|
||||||
|
|
||||||
t.Run("happy path admin", func(t *testing.T) {
|
t.Run("happy path admin", func(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
|
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
|
||||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)})
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)})
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
|
||||||
|
|
||||||
conn, cleanup := newTestGRPCServer(t, services)
|
conn, cleanup := newTestGRPCServer(t, services)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
client := newAdminClient(conn)
|
client := newAdminClient(conn)
|
||||||
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
|
resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
|
||||||
UserId: user.ID,
|
|
||||||
PlanId: plan.ID,
|
|
||||||
TermMonths: 1,
|
|
||||||
PaymentMethod: paymentMethodTopup,
|
|
||||||
TopupAmount: ptrFloat64(25),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAdminPayment() error = %v", err)
|
t.Fatalf("CreateAdminPayment() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,8 +35,8 @@ func TestCreateAdminPayment(t *testing.T) {
|
|||||||
if resp.Payment.UserId != user.ID {
|
if resp.Payment.UserId != user.ID {
|
||||||
t.Fatalf("payment user_id = %q, want %q", resp.Payment.UserId, user.ID)
|
t.Fatalf("payment user_id = %q, want %q", resp.Payment.UserId, user.ID)
|
||||||
}
|
}
|
||||||
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) {
|
if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
|
||||||
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
|
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
|
||||||
}
|
}
|
||||||
if resp.Payment.GetWalletAmount() != 30 {
|
if resp.Payment.GetWalletAmount() != 30 {
|
||||||
t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount())
|
t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount())
|
||||||
@@ -64,12 +58,7 @@ func TestCreateAdminPayment(t *testing.T) {
|
|||||||
|
|
||||||
client := newAdminClient(conn)
|
client := newAdminClient(conn)
|
||||||
var trailer metadata.MD
|
var trailer metadata.MD
|
||||||
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{
|
_, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
|
||||||
UserId: user.ID,
|
|
||||||
PlanId: plan.ID,
|
|
||||||
TermMonths: 1,
|
|
||||||
PaymentMethod: paymentMethodWallet,
|
|
||||||
}, grpc.Trailer(&trailer))
|
|
||||||
assertGRPCCode(t, err, codes.InvalidArgument)
|
assertGRPCCode(t, err, codes.InvalidArgument)
|
||||||
if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" {
|
if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" {
|
||||||
t.Fatal("expected x-error-body trailer")
|
t.Fatal("expected x-error-body trailer")
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
"stream.api/internal/video"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJobsRequest) (*appv1.ListAdminJobsResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.videoService == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
agentID := strings.TrimSpace(req.GetAgentId())
|
|
||||||
offset := int(req.GetOffset())
|
|
||||||
limit := int(req.GetLimit())
|
|
||||||
pageSize := int(req.GetPageSize())
|
|
||||||
useCursorPagination := req.Cursor != nil || pageSize > 0
|
|
||||||
|
|
||||||
var (
|
|
||||||
result *video.PaginatedJobs
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if useCursorPagination {
|
|
||||||
result, err = s.videoService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize)
|
|
||||||
} else if agentID != "" {
|
|
||||||
result, err = s.videoService.ListJobsByAgent(ctx, agentID, offset, limit)
|
|
||||||
} else {
|
|
||||||
result, err = s.videoService.ListJobs(ctx, offset, limit)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, video.ErrInvalidJobCursor) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Invalid job cursor")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list jobs")
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := make([]*appv1.AdminJob, 0, len(result.Jobs))
|
|
||||||
for _, job := range result.Jobs {
|
|
||||||
jobs = append(jobs, buildAdminJob(job))
|
|
||||||
}
|
|
||||||
|
|
||||||
response := &appv1.ListAdminJobsResponse{
|
|
||||||
Jobs: jobs,
|
|
||||||
Total: result.Total,
|
|
||||||
Offset: int32(result.Offset),
|
|
||||||
Limit: int32(result.Limit),
|
|
||||||
HasMore: result.HasMore,
|
|
||||||
PageSize: int32(result.PageSize),
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(result.NextCursor) != "" {
|
|
||||||
response.NextCursor = &result.NextCursor
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobRequest) (*appv1.GetAdminJobResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.videoService == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Job not found")
|
|
||||||
}
|
|
||||||
job, err := s.videoService.GetJob(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Job not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load job")
|
|
||||||
}
|
|
||||||
return &appv1.GetAdminJobResponse{Job: buildAdminJob(job)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetAdminJobLogs(ctx context.Context, req *appv1.GetAdminJobLogsRequest) (*appv1.GetAdminJobLogsResponse, error) {
|
|
||||||
response, err := s.GetAdminJob(ctx, &appv1.GetAdminJobRequest{Id: req.GetId()})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &appv1.GetAdminJobLogsResponse{Logs: response.GetJob().GetLogs()}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdminJobRequest) (*appv1.CreateAdminJobResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.videoService == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
command := strings.TrimSpace(req.GetCommand())
|
|
||||||
if command == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Command is required")
|
|
||||||
}
|
|
||||||
image := strings.TrimSpace(req.GetImage())
|
|
||||||
if image == "" {
|
|
||||||
image = "alpine"
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(req.GetName())
|
|
||||||
if name == "" {
|
|
||||||
name = command
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(map[string]any{
|
|
||||||
"image": image,
|
|
||||||
"commands": []string{command},
|
|
||||||
"environment": req.GetEnv(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create job payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
videoID := ""
|
|
||||||
if req.VideoId != nil {
|
|
||||||
videoID = strings.TrimSpace(req.GetVideoId())
|
|
||||||
}
|
|
||||||
job, err := s.videoService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit())
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create job")
|
|
||||||
}
|
|
||||||
return &appv1.CreateAdminJobResponse{Job: buildAdminJob(job)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdminJobRequest) (*appv1.CancelAdminJobResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.videoService == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Job not found")
|
|
||||||
}
|
|
||||||
if err := s.videoService.CancelJob(ctx, id); err != nil {
|
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
|
||||||
return nil, status.Error(codes.NotFound, "Job not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
|
||||||
}
|
|
||||||
return &appv1.CancelAdminJobResponse{Status: "cancelled", JobId: id}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJobRequest) (*appv1.RetryAdminJobResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.videoService == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Job not found")
|
|
||||||
}
|
|
||||||
job, err := s.videoService.RetryJob(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
|
||||||
return nil, status.Error(codes.NotFound, "Job not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
|
||||||
}
|
|
||||||
return &appv1.RetryAdminJobResponse{Job: buildAdminJob(job)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListAdminAgents(ctx context.Context, _ *appv1.ListAdminAgentsRequest) (*appv1.ListAdminAgentsResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.agentRuntime == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := s.agentRuntime.ListAgentsWithStats()
|
|
||||||
agents := make([]*appv1.AdminAgent, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
agents = append(agents, buildAdminAgent(item))
|
|
||||||
}
|
|
||||||
return &appv1.ListAdminAgentsResponse{Agents: agents}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) RestartAdminAgent(ctx context.Context, req *appv1.RestartAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.agentRuntime == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
|
|
||||||
}
|
|
||||||
if !s.agentRuntime.SendCommand(strings.TrimSpace(req.GetId()), "restart") {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Agent not active or command channel full")
|
|
||||||
}
|
|
||||||
return &appv1.AdminAgentCommandResponse{Status: "restart command sent"}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminAgent(ctx context.Context, req *appv1.UpdateAdminAgentRequest) (*appv1.AdminAgentCommandResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.agentRuntime == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Agent runtime is unavailable")
|
|
||||||
}
|
|
||||||
if !s.agentRuntime.SendCommand(strings.TrimSpace(req.GetId()), "update") {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Agent not active or command channel full")
|
|
||||||
}
|
|
||||||
return &appv1.AdminAgentCommandResponse{Status: "update command sent"}, nil
|
|
||||||
}
|
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
"stream.api/internal/video"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appServices) GetAdminDashboard(ctx context.Context, _ *appv1.GetAdminDashboardRequest) (*appv1.GetAdminDashboardResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboard := &appv1.AdminDashboard{}
|
|
||||||
db := s.db.WithContext(ctx)
|
|
||||||
|
|
||||||
db.Model(&model.User{}).Count(&dashboard.TotalUsers)
|
|
||||||
db.Model(&model.Video{}).Count(&dashboard.TotalVideos)
|
|
||||||
db.Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Row().Scan(&dashboard.TotalStorageUsed)
|
|
||||||
db.Model(&model.Payment{}).Count(&dashboard.TotalPayments)
|
|
||||||
db.Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Row().Scan(&dashboard.TotalRevenue)
|
|
||||||
db.Model(&model.PlanSubscription{}).Where("expires_at > ?", time.Now()).Count(&dashboard.ActiveSubscriptions)
|
|
||||||
db.Model(&model.AdTemplate{}).Count(&dashboard.TotalAdTemplates)
|
|
||||||
|
|
||||||
today := time.Now().Truncate(24 * time.Hour)
|
|
||||||
db.Model(&model.User{}).Where("created_at >= ?", today).Count(&dashboard.NewUsersToday)
|
|
||||||
db.Model(&model.Video{}).Where("created_at >= ?", today).Count(&dashboard.NewVideosToday)
|
|
||||||
|
|
||||||
return &appv1.GetAdminDashboardResponse{Dashboard: dashboard}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListAdminUsers(ctx context.Context, req *appv1.ListAdminUsersRequest) (*appv1.ListAdminUsersResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
|
||||||
limitInt := int(limit)
|
|
||||||
search := strings.TrimSpace(req.GetSearch())
|
|
||||||
role := strings.TrimSpace(req.GetRole())
|
|
||||||
|
|
||||||
db := s.db.WithContext(ctx).Model(&model.User{})
|
|
||||||
if search != "" {
|
|
||||||
like := "%" + search + "%"
|
|
||||||
db = db.Where("email ILIKE ? OR username ILIKE ?", like, like)
|
|
||||||
}
|
|
||||||
if role != "" {
|
|
||||||
db = db.Where("UPPER(role) = ?", strings.ToUpper(role))
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list users")
|
|
||||||
}
|
|
||||||
|
|
||||||
var users []model.User
|
|
||||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&users).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list users")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.AdminUser, 0, len(users))
|
|
||||||
for _, user := range users {
|
|
||||||
payload, err := s.buildAdminUser(ctx, &user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list users")
|
|
||||||
}
|
|
||||||
items = append(items, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListAdminUsersResponse{Users: items, Total: total, Page: page, Limit: limit}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserRequest) (*appv1.GetAdminUserResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
|
||||||
}
|
|
||||||
|
|
||||||
var subscription *model.PlanSubscription
|
|
||||||
var subscriptionRecord model.PlanSubscription
|
|
||||||
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
|
|
||||||
subscription = &subscriptionRecord
|
|
||||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
|
||||||
}
|
|
||||||
|
|
||||||
detail, err := s.buildAdminUserDetail(ctx, &user, subscription)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to get user")
|
|
||||||
}
|
|
||||||
return &appv1.GetAdminUserResponse{User: detail}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateAdminUser(ctx context.Context, req *appv1.CreateAdminUserRequest) (*appv1.CreateAdminUserResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
email := strings.TrimSpace(req.GetEmail())
|
|
||||||
password := req.GetPassword()
|
|
||||||
if email == "" || password == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Email and password are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
role := normalizeAdminRoleValue(req.GetRole())
|
|
||||||
if !isValidAdminRoleValue(role) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
|
|
||||||
}
|
|
||||||
|
|
||||||
planID := nullableTrimmedString(req.PlanId)
|
|
||||||
if err := s.ensurePlanExists(ctx, planID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to hash password")
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &model.User{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
Email: email,
|
|
||||||
Password: model.StringPtr(string(hashedPassword)),
|
|
||||||
Username: nullableTrimmedString(req.Username),
|
|
||||||
Role: model.StringPtr(role),
|
|
||||||
PlanID: planID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
||||||
return nil, status.Error(codes.AlreadyExists, "Email already registered")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create user")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create user")
|
|
||||||
}
|
|
||||||
return &appv1.CreateAdminUserResponse{User: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdminUserRequest) (*appv1.UpdateAdminUserResponse, error) {
|
|
||||||
adminResult, err := s.requireAdmin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
updates := map[string]interface{}{}
|
|
||||||
if req.Email != nil {
|
|
||||||
email := strings.TrimSpace(req.GetEmail())
|
|
||||||
if email == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Email is required")
|
|
||||||
}
|
|
||||||
updates["email"] = email
|
|
||||||
}
|
|
||||||
if req.Username != nil {
|
|
||||||
updates["username"] = nullableTrimmedString(req.Username)
|
|
||||||
}
|
|
||||||
if req.Role != nil {
|
|
||||||
role := normalizeAdminRoleValue(req.GetRole())
|
|
||||||
if !isValidAdminRoleValue(role) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
|
|
||||||
}
|
|
||||||
if id == adminResult.UserID && role != "ADMIN" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Cannot change your own role")
|
|
||||||
}
|
|
||||||
updates["role"] = role
|
|
||||||
}
|
|
||||||
if req.PlanId != nil {
|
|
||||||
planID := nullableTrimmedString(req.PlanId)
|
|
||||||
if err := s.ensurePlanExists(ctx, planID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
updates["plan_id"] = planID
|
|
||||||
}
|
|
||||||
if req.Password != nil {
|
|
||||||
if strings.TrimSpace(req.GetPassword()) == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Password must not be empty")
|
|
||||||
}
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.GetPassword()), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to hash password")
|
|
||||||
}
|
|
||||||
updates["password"] = string(hashedPassword)
|
|
||||||
}
|
|
||||||
if len(updates) == 0 {
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update user")
|
|
||||||
}
|
|
||||||
payload, err := s.buildAdminUser(ctx, &user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update user")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminUserResponse{User: payload}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
|
|
||||||
if result.Error != nil {
|
|
||||||
if errors.Is(result.Error, gorm.ErrDuplicatedKey) {
|
|
||||||
return nil, status.Error(codes.AlreadyExists, "Email already registered")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update user")
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update user")
|
|
||||||
}
|
|
||||||
payload, err := s.buildAdminUser(ctx, &user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update user")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminUserResponse{User: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
if req.ClearReferrer != nil && req.GetClearReferrer() && req.RefUsername != nil && strings.TrimSpace(req.GetRefUsername()) != "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referrer at the same time")
|
|
||||||
}
|
|
||||||
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() && req.ReferralRewardBps != nil {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Cannot set and clear referral reward override at the same time")
|
|
||||||
}
|
|
||||||
if req.ReferralRewardBps != nil {
|
|
||||||
bps := req.GetReferralRewardBps()
|
|
||||||
if bps < 0 || bps > 10000 {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Referral reward bps must be between 0 and 10000")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
updates := map[string]any{}
|
|
||||||
if req.RefUsername != nil || (req.ClearReferrer != nil && req.GetClearReferrer()) {
|
|
||||||
if referralRewardProcessed(&user) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted")
|
|
||||||
}
|
|
||||||
if req.ClearReferrer != nil && req.GetClearReferrer() {
|
|
||||||
updates["referred_by_user_id"] = nil
|
|
||||||
} else if req.RefUsername != nil {
|
|
||||||
referrer, err := s.loadReferralUserByUsernameStrict(ctx, req.GetRefUsername())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if referrer.ID == user.ID {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User cannot refer themselves")
|
|
||||||
}
|
|
||||||
updates["referred_by_user_id"] = referrer.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.ReferralEligible != nil {
|
|
||||||
updates["referral_eligible"] = req.GetReferralEligible()
|
|
||||||
}
|
|
||||||
if req.ClearReferralRewardBps != nil && req.GetClearReferralRewardBps() {
|
|
||||||
updates["referral_reward_bps"] = nil
|
|
||||||
} else if req.ReferralRewardBps != nil {
|
|
||||||
updates["referral_reward_bps"] = req.GetReferralRewardBps()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(updates) > 0 {
|
|
||||||
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates)
|
|
||||||
if result.Error != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
|
||||||
}
|
|
||||||
var subscription *model.PlanSubscription
|
|
||||||
var subscriptionRecord model.PlanSubscription
|
|
||||||
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil {
|
|
||||||
subscription = &subscriptionRecord
|
|
||||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
|
||||||
}
|
|
||||||
payload, err := s.buildAdminUserDetail(ctx, &user, subscription)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update referral settings")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminUserReferralSettingsResponse{User: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminUserRole(ctx context.Context, req *appv1.UpdateAdminUserRoleRequest) (*appv1.UpdateAdminUserRoleResponse, error) {
|
|
||||||
adminResult, err := s.requireAdmin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
if id == adminResult.UserID {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Cannot change your own role")
|
|
||||||
}
|
|
||||||
|
|
||||||
role := normalizeAdminRoleValue(req.GetRole())
|
|
||||||
if !isValidAdminRoleValue(role) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
|
|
||||||
}
|
|
||||||
|
|
||||||
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("role", role)
|
|
||||||
if result.Error != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update role")
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.UpdateAdminUserRoleResponse{Message: "Role updated", Role: role}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdminUserRequest) (*appv1.MessageResponse, error) {
|
|
||||||
adminResult, err := s.requireAdmin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
if id == adminResult.UserID {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Cannot delete your own account")
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to find user")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
tables := []struct {
|
|
||||||
model interface{}
|
|
||||||
where string
|
|
||||||
}{
|
|
||||||
{&model.AdTemplate{}, "user_id = ?"},
|
|
||||||
{&model.Notification{}, "user_id = ?"},
|
|
||||||
{&model.Domain{}, "user_id = ?"},
|
|
||||||
{&model.WalletTransaction{}, "user_id = ?"},
|
|
||||||
{&model.PlanSubscription{}, "user_id = ?"},
|
|
||||||
{&model.UserPreference{}, "user_id = ?"},
|
|
||||||
{&model.Video{}, "user_id = ?"},
|
|
||||||
{&model.Payment{}, "user_id = ?"},
|
|
||||||
}
|
|
||||||
for _, item := range tables {
|
|
||||||
if err := tx.Where(item.where, id).Delete(item.model).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Where("id = ?", id).Delete(&model.User{}).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete user")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("User deleted"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListAdminVideos(ctx context.Context, req *appv1.ListAdminVideosRequest) (*appv1.ListAdminVideosResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
|
||||||
limitInt := int(limit)
|
|
||||||
search := strings.TrimSpace(req.GetSearch())
|
|
||||||
userID := strings.TrimSpace(req.GetUserId())
|
|
||||||
statusFilter := strings.TrimSpace(req.GetStatus())
|
|
||||||
|
|
||||||
db := s.db.WithContext(ctx).Model(&model.Video{})
|
|
||||||
if search != "" {
|
|
||||||
like := "%" + search + "%"
|
|
||||||
db = db.Where("title ILIKE ?", like)
|
|
||||||
}
|
|
||||||
if userID != "" {
|
|
||||||
db = db.Where("user_id = ?", userID)
|
|
||||||
}
|
|
||||||
if statusFilter != "" && !strings.EqualFold(statusFilter, "all") {
|
|
||||||
db = db.Where("status = ?", normalizeVideoStatusValue(statusFilter))
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list videos")
|
|
||||||
}
|
|
||||||
|
|
||||||
var videos []model.Video
|
|
||||||
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&videos).Error; err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list videos")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.AdminVideo, 0, len(videos))
|
|
||||||
for _, video := range videos {
|
|
||||||
payload, err := s.buildAdminVideo(ctx, &video)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to list videos")
|
|
||||||
}
|
|
||||||
items = append(items, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListAdminVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetAdminVideo(ctx context.Context, req *appv1.GetAdminVideoRequest) (*appv1.GetAdminVideoResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var video model.Video
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to get video")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminVideo(ctx, &video)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to get video")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.GetAdminVideoResponse{Video: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdminVideoRequest) (*appv1.CreateAdminVideoResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.videoService == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := strings.TrimSpace(req.GetUserId())
|
|
||||||
title := strings.TrimSpace(req.GetTitle())
|
|
||||||
videoURL := strings.TrimSpace(req.GetUrl())
|
|
||||||
if userID == "" || title == "" || videoURL == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
|
|
||||||
}
|
|
||||||
if req.GetSize() < 0 {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
|
|
||||||
UserID: userID,
|
|
||||||
Title: title,
|
|
||||||
Description: req.Description,
|
|
||||||
URL: videoURL,
|
|
||||||
Size: req.GetSize(),
|
|
||||||
Duration: req.GetDuration(),
|
|
||||||
Format: strings.TrimSpace(req.GetFormat()),
|
|
||||||
AdTemplateID: nullableTrimmedString(req.AdTemplateId),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, video.ErrUserNotFound):
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
|
||||||
case errors.Is(err, video.ErrAdTemplateNotFound):
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
|
|
||||||
case errors.Is(err, video.ErrJobServiceUnavailable):
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
default:
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminVideo(ctx, created.Video)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
|
||||||
}
|
|
||||||
return &appv1.CreateAdminVideoResponse{Video: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdminVideoRequest) (*appv1.UpdateAdminVideoResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
userID := strings.TrimSpace(req.GetUserId())
|
|
||||||
title := strings.TrimSpace(req.GetTitle())
|
|
||||||
videoURL := strings.TrimSpace(req.GetUrl())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
if userID == "" || title == "" || videoURL == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User ID, title, and URL are required")
|
|
||||||
}
|
|
||||||
if req.GetSize() < 0 {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
var video model.Video
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "User not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
||||||
}
|
|
||||||
|
|
||||||
oldSize := video.Size
|
|
||||||
oldUserID := video.UserID
|
|
||||||
statusValue := normalizeVideoStatusValue(req.GetStatus())
|
|
||||||
processingStatus := strings.ToUpper(statusValue)
|
|
||||||
video.UserID = user.ID
|
|
||||||
video.Name = title
|
|
||||||
video.Title = title
|
|
||||||
video.Description = nullableTrimmedString(req.Description)
|
|
||||||
video.URL = videoURL
|
|
||||||
video.Size = req.GetSize()
|
|
||||||
video.Duration = req.GetDuration()
|
|
||||||
video.Format = strings.TrimSpace(req.GetFormat())
|
|
||||||
video.Status = model.StringPtr(statusValue)
|
|
||||||
video.ProcessingStatus = model.StringPtr(processingStatus)
|
|
||||||
video.StorageType = model.StringPtr(detectStorageType(videoURL))
|
|
||||||
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Save(&video).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if oldUserID == user.ID {
|
|
||||||
delta := video.Size - oldSize
|
|
||||||
if delta != 0 {
|
|
||||||
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used + ?, 0)", delta)).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.Model(&model.User{}).Where("id = ?", oldUserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", oldSize)).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.saveAdminVideoAdConfig(ctx, tx, &video, user.ID, nullableTrimmedString(req.AdTemplateId))
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "Ad template not found") {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Ad template not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildAdminVideo(ctx, &video)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateAdminVideoResponse{Video: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdminVideoRequest) (*appv1.MessageResponse, error) {
|
|
||||||
if _, err := s.requireAdmin(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var video model.Video
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to find video")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&model.User{}).Where("id = ?", video.UserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", video.Size)).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Video deleted"), nil
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/google"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/config"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
"stream.api/internal/middleware"
|
|
||||||
"stream.api/internal/video"
|
|
||||||
"stream.api/pkg/cache"
|
|
||||||
"stream.api/pkg/logger"
|
|
||||||
"stream.api/pkg/storage"
|
|
||||||
"stream.api/pkg/token"
|
|
||||||
)
|
|
||||||
|
|
||||||
const adTemplateUpgradeRequiredMessage = "Upgrade required to manage Ads & VAST"
|
|
||||||
const defaultGoogleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
|
||||||
|
|
||||||
const (
|
|
||||||
playerConfigFreePlanLimitMessage = "Free plan supports only 1 player config"
|
|
||||||
playerConfigFreePlanReconciliationMessage = "Delete extra player configs to continue managing player configs on the free plan"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
walletTransactionTypeTopup = "topup"
|
|
||||||
walletTransactionTypeSubscriptionDebit = "subscription_debit"
|
|
||||||
walletTransactionTypeReferralReward = "referral_reward"
|
|
||||||
paymentMethodWallet = "wallet"
|
|
||||||
paymentMethodTopup = "topup"
|
|
||||||
paymentKindSubscription = "subscription"
|
|
||||||
paymentKindWalletTopup = "wallet_topup"
|
|
||||||
defaultReferralRewardBps = int32(500)
|
|
||||||
)
|
|
||||||
|
|
||||||
var allowedTermMonths = map[int32]struct{}{
|
|
||||||
1: {},
|
|
||||||
3: {},
|
|
||||||
6: {},
|
|
||||||
12: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
type Services struct {
|
|
||||||
AuthServiceServer
|
|
||||||
AccountServiceServer
|
|
||||||
PreferencesServiceServer
|
|
||||||
UsageServiceServer
|
|
||||||
NotificationsServiceServer
|
|
||||||
DomainsServiceServer
|
|
||||||
AdTemplatesServiceServer
|
|
||||||
PlayerConfigsServiceServer
|
|
||||||
PlansServiceServer
|
|
||||||
PaymentsServiceServer
|
|
||||||
VideosServiceServer
|
|
||||||
AdminServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type appServices struct {
|
|
||||||
appv1.UnimplementedAuthServiceServer
|
|
||||||
appv1.UnimplementedAccountServiceServer
|
|
||||||
appv1.UnimplementedPreferencesServiceServer
|
|
||||||
appv1.UnimplementedUsageServiceServer
|
|
||||||
appv1.UnimplementedNotificationsServiceServer
|
|
||||||
appv1.UnimplementedDomainsServiceServer
|
|
||||||
appv1.UnimplementedAdTemplatesServiceServer
|
|
||||||
appv1.UnimplementedPlayerConfigsServiceServer
|
|
||||||
appv1.UnimplementedPlansServiceServer
|
|
||||||
appv1.UnimplementedPaymentsServiceServer
|
|
||||||
appv1.UnimplementedVideosServiceServer
|
|
||||||
appv1.UnimplementedAdminServiceServer
|
|
||||||
|
|
||||||
db *gorm.DB
|
|
||||||
logger logger.Logger
|
|
||||||
authenticator *middleware.Authenticator
|
|
||||||
tokenProvider token.Provider
|
|
||||||
cache cache.Cache
|
|
||||||
storageProvider storage.Provider
|
|
||||||
videoService *video.Service
|
|
||||||
agentRuntime video.AgentRuntime
|
|
||||||
googleOauth *oauth2.Config
|
|
||||||
googleStateTTL time.Duration
|
|
||||||
googleUserInfoURL string
|
|
||||||
frontendBaseURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
type paymentInvoiceDetails struct {
|
|
||||||
PlanName string
|
|
||||||
TermMonths *int32
|
|
||||||
PaymentMethod string
|
|
||||||
ExpiresAt *time.Time
|
|
||||||
WalletAmount float64
|
|
||||||
TopupAmount float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type paymentExecutionInput struct {
|
|
||||||
UserID string
|
|
||||||
Plan *model.Plan
|
|
||||||
TermMonths int32
|
|
||||||
PaymentMethod string
|
|
||||||
TopupAmount *float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type paymentExecutionResult struct {
|
|
||||||
Payment *model.Payment
|
|
||||||
Subscription *model.PlanSubscription
|
|
||||||
WalletBalance float64
|
|
||||||
InvoiceID string
|
|
||||||
}
|
|
||||||
|
|
||||||
type referralRewardResult struct {
|
|
||||||
Granted bool
|
|
||||||
Amount float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiErrorBody struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data any `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServices(c cache.Cache, t token.Provider, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *video.Service, agentRuntime video.AgentRuntime) *Services {
|
|
||||||
var storageProvider storage.Provider
|
|
||||||
if cfg != nil {
|
|
||||||
provider, err := storage.NewS3Provider(cfg)
|
|
||||||
if err != nil {
|
|
||||||
l.Error("Failed to initialize S3 provider for gRPC app services", "error", err)
|
|
||||||
} else {
|
|
||||||
storageProvider = provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
googleStateTTL := 10 * time.Minute
|
|
||||||
googleOauth := &oauth2.Config{}
|
|
||||||
if cfg != nil {
|
|
||||||
if cfg.Google.StateTTLMinute > 0 {
|
|
||||||
googleStateTTL = time.Duration(cfg.Google.StateTTLMinute) * time.Minute
|
|
||||||
}
|
|
||||||
googleOauth = &oauth2.Config{
|
|
||||||
ClientID: cfg.Google.ClientID,
|
|
||||||
ClientSecret: cfg.Google.ClientSecret,
|
|
||||||
RedirectURL: cfg.Google.RedirectURL,
|
|
||||||
Scopes: []string{
|
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
|
||||||
},
|
|
||||||
Endpoint: google.Endpoint,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
frontendBaseURL := ""
|
|
||||||
if cfg != nil {
|
|
||||||
frontendBaseURL = cfg.Frontend.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
service := &appServices{
|
|
||||||
db: db,
|
|
||||||
logger: l,
|
|
||||||
authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker),
|
|
||||||
tokenProvider: t,
|
|
||||||
cache: c,
|
|
||||||
storageProvider: storageProvider,
|
|
||||||
videoService: videoService,
|
|
||||||
agentRuntime: agentRuntime,
|
|
||||||
googleOauth: googleOauth,
|
|
||||||
googleStateTTL: googleStateTTL,
|
|
||||||
googleUserInfoURL: defaultGoogleUserInfoURL,
|
|
||||||
frontendBaseURL: frontendBaseURL,
|
|
||||||
}
|
|
||||||
return &Services{
|
|
||||||
AuthServiceServer: service,
|
|
||||||
AccountServiceServer: service,
|
|
||||||
PreferencesServiceServer: service,
|
|
||||||
UsageServiceServer: service,
|
|
||||||
NotificationsServiceServer: service,
|
|
||||||
DomainsServiceServer: service,
|
|
||||||
AdTemplatesServiceServer: service,
|
|
||||||
PlayerConfigsServiceServer: service,
|
|
||||||
PlansServiceServer: service,
|
|
||||||
PaymentsServiceServer: service,
|
|
||||||
VideosServiceServer: service,
|
|
||||||
AdminServiceServer: service,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthServiceServer interface {
|
|
||||||
appv1.AuthServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountServiceServer interface {
|
|
||||||
appv1.AccountServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreferencesServiceServer interface {
|
|
||||||
appv1.PreferencesServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageServiceServer interface {
|
|
||||||
appv1.UsageServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type NotificationsServiceServer interface {
|
|
||||||
appv1.NotificationsServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type DomainsServiceServer interface {
|
|
||||||
appv1.DomainsServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdTemplatesServiceServer interface {
|
|
||||||
appv1.AdTemplatesServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlayerConfigsServiceServer interface {
|
|
||||||
appv1.PlayerConfigsServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlansServiceServer interface {
|
|
||||||
appv1.PlansServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentsServiceServer interface {
|
|
||||||
appv1.PaymentsServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type VideosServiceServer interface {
|
|
||||||
appv1.VideosServiceServer
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdminServiceServer interface {
|
|
||||||
appv1.AdminServiceServer
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,144 +8,77 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"stream.api/internal/database/model"
|
"stream.api/internal/database/model"
|
||||||
|
"stream.api/internal/modules/common"
|
||||||
|
paymentsmodule "stream.api/internal/modules/payments"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidatePaymentFunding(t *testing.T) {
|
func TestValidatePaymentFunding(t *testing.T) {
|
||||||
|
baseInput := paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodWallet}
|
||||||
baseInput := paymentExecutionInput{PaymentMethod: paymentMethodWallet}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input paymentExecutionInput
|
input paymentsmodule.ExecutionInput
|
||||||
totalAmount float64
|
totalAmount float64
|
||||||
walletBalance float64
|
walletBalance float64
|
||||||
wantTopup float64
|
wantTopup float64
|
||||||
wantCode codes.Code
|
wantCode codes.Code
|
||||||
wantMessage string
|
wantMessage string
|
||||||
}{
|
}{
|
||||||
{
|
{name: "wallet đủ tiền", input: baseInput, totalAmount: 30, walletBalance: 30, wantTopup: 0},
|
||||||
name: "wallet đủ tiền",
|
{name: "wallet thiếu tiền", input: baseInput, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Insufficient wallet balance"},
|
||||||
input: baseInput,
|
{name: "topup thiếu amount", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount is required when payment method is topup"},
|
||||||
totalAmount: 30,
|
{name: "topup amount <= 0", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(0)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than 0"},
|
||||||
walletBalance: 30,
|
{name: "topup amount nhỏ hơn shortfall", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(20)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than or equal to the required shortfall"},
|
||||||
wantTopup: 0,
|
{name: "topup hợp lệ", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(30)}, totalAmount: 50, walletBalance: 20, wantTopup: 30},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wallet thiếu tiền",
|
|
||||||
input: baseInput,
|
|
||||||
totalAmount: 50,
|
|
||||||
walletBalance: 20,
|
|
||||||
wantCode: codes.InvalidArgument,
|
|
||||||
wantMessage: "Insufficient wallet balance",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "topup thiếu amount",
|
|
||||||
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup},
|
|
||||||
totalAmount: 50,
|
|
||||||
walletBalance: 20,
|
|
||||||
wantCode: codes.InvalidArgument,
|
|
||||||
wantMessage: "Top-up amount is required when payment method is topup",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "topup amount <= 0",
|
|
||||||
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(0)},
|
|
||||||
totalAmount: 50,
|
|
||||||
walletBalance: 20,
|
|
||||||
wantCode: codes.InvalidArgument,
|
|
||||||
wantMessage: "Top-up amount must be greater than 0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "topup amount nhỏ hơn shortfall",
|
|
||||||
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(20)},
|
|
||||||
totalAmount: 50,
|
|
||||||
walletBalance: 20,
|
|
||||||
wantCode: codes.InvalidArgument,
|
|
||||||
wantMessage: "Top-up amount must be greater than or equal to the required shortfall",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "topup hợp lệ",
|
|
||||||
input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(30)},
|
|
||||||
totalAmount: 50,
|
|
||||||
walletBalance: 20,
|
|
||||||
wantTopup: 30,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := validatePaymentFunding(context.Background(), tt.input, tt.totalAmount, tt.walletBalance)
|
got, err := paymentsmodule.ValidatePaymentFunding(tt.input, tt.totalAmount, tt.walletBalance)
|
||||||
if tt.wantCode == codes.OK {
|
if tt.wantCode == codes.OK {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("validatePaymentFunding() error = %v", err)
|
t.Fatalf("ValidatePaymentFunding() error = %v", err)
|
||||||
}
|
}
|
||||||
if got != tt.wantTopup {
|
if got != tt.wantTopup {
|
||||||
t.Fatalf("validatePaymentFunding() topup = %v, want %v", got, tt.wantTopup)
|
t.Fatalf("ValidatePaymentFunding() topup = %v, want %v", got, tt.wantTopup)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("validatePaymentFunding() error = nil, want %v", tt.wantCode)
|
t.Fatalf("ValidatePaymentFunding() error = nil, want %v", tt.wantCode)
|
||||||
}
|
}
|
||||||
if status.Code(err) != tt.wantCode {
|
if validationErr, ok := err.(*paymentsmodule.PaymentValidationError); !ok || codes.Code(validationErr.GRPCCode) != tt.wantCode {
|
||||||
t.Fatalf("validatePaymentFunding() code = %v, want %v", status.Code(err), tt.wantCode)
|
gotCode := codes.Unknown
|
||||||
|
if ok {
|
||||||
|
gotCode = codes.Code(validationErr.GRPCCode)
|
||||||
|
}
|
||||||
|
t.Fatalf("ValidatePaymentFunding() code = %v, want %v", gotCode, tt.wantCode)
|
||||||
}
|
}
|
||||||
if got := err.Error(); !strings.Contains(got, tt.wantMessage) {
|
if got := err.Error(); !strings.Contains(got, tt.wantMessage) {
|
||||||
t.Fatalf("validatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage)
|
t.Fatalf("ValidatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
|
func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
|
||||||
|
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
|
|
||||||
user := seedTestUser(t, db, model.User{
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Role: ptrString("USER"), StorageUsed: 0})
|
||||||
ID: uuid.NewString(),
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 10, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, DurationLimit: 0, QualityLimit: "1080p", Features: []string{"priority"}, IsActive: ptrBool(true)})
|
||||||
Email: "payer@example.com",
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD"), Note: ptrString("Initial funds")})
|
||||||
Role: ptrString("USER"),
|
|
||||||
StorageUsed: 0,
|
|
||||||
})
|
|
||||||
plan := seedTestPlan(t, db, model.Plan{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
Name: "Pro",
|
|
||||||
Price: 10,
|
|
||||||
Cycle: "monthly",
|
|
||||||
StorageLimit: 100,
|
|
||||||
UploadLimit: 10,
|
|
||||||
DurationLimit: 0,
|
|
||||||
QualityLimit: "1080p",
|
|
||||||
Features: []string{"priority"},
|
|
||||||
IsActive: ptrBool(true),
|
|
||||||
})
|
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
UserID: user.ID,
|
|
||||||
Type: walletTransactionTypeTopup,
|
|
||||||
Amount: 5,
|
|
||||||
Currency: ptrString("USD"),
|
|
||||||
Note: ptrString("Initial funds"),
|
|
||||||
})
|
|
||||||
|
|
||||||
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{
|
result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: user.ID, Plan: &plan, TermMonths: 3, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)})
|
||||||
UserID: user.ID,
|
|
||||||
Plan: &plan,
|
|
||||||
TermMonths: 3,
|
|
||||||
PaymentMethod: paymentMethodTopup,
|
|
||||||
TopupAmount: ptrFloat64(25),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executePaymentFlow() error = %v", err)
|
t.Fatalf("ExecutePaymentFlow() error = %v", err)
|
||||||
}
|
}
|
||||||
if result == nil || result.Payment == nil || result.Subscription == nil {
|
if result == nil || result.Payment == nil || result.Subscription == nil {
|
||||||
t.Fatalf("executePaymentFlow() returned incomplete result: %#v", result)
|
t.Fatalf("ExecutePaymentFlow() returned incomplete result: %#v", result)
|
||||||
}
|
}
|
||||||
if result.InvoiceID != buildInvoiceID(result.Payment.ID) {
|
if result.InvoiceID != common.BuildInvoiceID(result.Payment.ID) {
|
||||||
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, buildInvoiceID(result.Payment.ID))
|
t.Fatalf("invoice id = %q, want %q", result.InvoiceID, common.BuildInvoiceID(result.Payment.ID))
|
||||||
}
|
}
|
||||||
if result.WalletBalance != 0 {
|
if result.WalletBalance != 0 {
|
||||||
t.Fatalf("wallet balance = %v, want 0", result.WalletBalance)
|
t.Fatalf("wallet balance = %v, want 0", result.WalletBalance)
|
||||||
@@ -158,8 +91,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
|
|||||||
if payment.PlanID == nil || *payment.PlanID != plan.ID {
|
if payment.PlanID == nil || *payment.PlanID != plan.ID {
|
||||||
t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID)
|
t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID)
|
||||||
}
|
}
|
||||||
if normalizePaymentStatus(payment.Status) != "success" {
|
if common.NormalizePaymentStatus(payment.Status) != "success" {
|
||||||
t.Fatalf("payment status = %q, want success", normalizePaymentStatus(payment.Status))
|
t.Fatalf("payment status = %q, want success", common.NormalizePaymentStatus(payment.Status))
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription := mustLoadSubscriptionByPayment(t, db, payment.ID)
|
subscription := mustLoadSubscriptionByPayment(t, db, payment.ID)
|
||||||
@@ -172,8 +105,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
|
|||||||
if subscription.TermMonths != 3 {
|
if subscription.TermMonths != 3 {
|
||||||
t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths)
|
t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths)
|
||||||
}
|
}
|
||||||
if subscription.PaymentMethod != paymentMethodTopup {
|
if subscription.PaymentMethod != common.PaymentMethodTopup {
|
||||||
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, paymentMethodTopup)
|
t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, common.PaymentMethodTopup)
|
||||||
}
|
}
|
||||||
if subscription.WalletAmount != 30 {
|
if subscription.WalletAmount != 30 {
|
||||||
t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount)
|
t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount)
|
||||||
@@ -189,10 +122,10 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
|
|||||||
if len(walletTransactions) != 2 {
|
if len(walletTransactions) != 2 {
|
||||||
t.Fatalf("wallet transaction count = %d, want 2", len(walletTransactions))
|
t.Fatalf("wallet transaction count = %d, want 2", len(walletTransactions))
|
||||||
}
|
}
|
||||||
if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != walletTransactionTypeTopup {
|
if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != common.WalletTransactionTypeTopup {
|
||||||
t.Fatalf("first wallet transaction = %#v, want topup amount 25", walletTransactions[0])
|
t.Fatalf("first wallet transaction = %#v, want topup amount 25", walletTransactions[0])
|
||||||
}
|
}
|
||||||
if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != walletTransactionTypeSubscriptionDebit {
|
if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != common.WalletTransactionTypeSubscriptionDebit {
|
||||||
t.Fatalf("second wallet transaction = %#v, want debit amount -30", walletTransactions[1])
|
t.Fatalf("second wallet transaction = %#v, want debit amount -30", walletTransactions[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,8 +159,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) {
|
|||||||
if metadataPayload["payment_id"] != payment.ID {
|
if metadataPayload["payment_id"] != payment.ID {
|
||||||
t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID)
|
t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID)
|
||||||
}
|
}
|
||||||
if metadataPayload["payment_method"] != paymentMethodTopup {
|
if metadataPayload["payment_method"] != common.PaymentMethodTopup {
|
||||||
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], paymentMethodTopup)
|
t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], common.PaymentMethodTopup)
|
||||||
}
|
}
|
||||||
if metadataPayload["wallet_amount"] != 30.0 {
|
if metadataPayload["wallet_amount"] != 30.0 {
|
||||||
t.Fatalf("metadata wallet_amount = %v, want 30", metadataPayload["wallet_amount"])
|
t.Fatalf("metadata wallet_amount = %v, want 30", metadataPayload["wallet_amount"])
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
"stream.api/internal/database/query"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
planID := strings.TrimSpace(req.GetPlanId())
|
|
||||||
if planID == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Plan ID is required")
|
|
||||||
}
|
|
||||||
if !isAllowedTermMonths(req.GetTermMonths()) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Term months must be one of 1, 3, 6, or 12")
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentMethod := normalizePaymentMethod(req.GetPaymentMethod())
|
|
||||||
if paymentMethod == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Payment method must be wallet or topup")
|
|
||||||
}
|
|
||||||
|
|
||||||
planRecord, err := s.loadPaymentPlanForUser(ctx, planID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resultValue, err := s.executePaymentFlow(ctx, paymentExecutionInput{
|
|
||||||
UserID: result.UserID,
|
|
||||||
Plan: planRecord,
|
|
||||||
TermMonths: req.GetTermMonths(),
|
|
||||||
PaymentMethod: paymentMethod,
|
|
||||||
TopupAmount: req.TopupAmount,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if _, ok := status.FromError(err); ok {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to create payment", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.CreatePaymentResponse{
|
|
||||||
Payment: toProtoPayment(resultValue.Payment),
|
|
||||||
Subscription: toProtoPlanSubscription(resultValue.Subscription),
|
|
||||||
WalletBalance: resultValue.WalletBalance,
|
|
||||||
InvoiceId: resultValue.InvoiceID,
|
|
||||||
Message: "Payment completed successfully",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
|
|
||||||
|
|
||||||
type paymentHistoryRow struct {
|
|
||||||
ID string `gorm:"column:id"`
|
|
||||||
Amount float64 `gorm:"column:amount"`
|
|
||||||
Currency *string `gorm:"column:currency"`
|
|
||||||
Status *string `gorm:"column:status"`
|
|
||||||
PlanID *string `gorm:"column:plan_id"`
|
|
||||||
PlanName *string `gorm:"column:plan_name"`
|
|
||||||
InvoiceID string `gorm:"column:invoice_id"`
|
|
||||||
Kind string `gorm:"column:kind"`
|
|
||||||
TermMonths *int32 `gorm:"column:term_months"`
|
|
||||||
PaymentMethod *string `gorm:"column:payment_method"`
|
|
||||||
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
|
||||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
baseQuery := `
|
|
||||||
WITH history AS (
|
|
||||||
SELECT
|
|
||||||
p.id AS id,
|
|
||||||
p.amount AS amount,
|
|
||||||
p.currency AS currency,
|
|
||||||
p.status AS status,
|
|
||||||
p.plan_id AS plan_id,
|
|
||||||
pl.name AS plan_name,
|
|
||||||
p.id AS invoice_id,
|
|
||||||
? AS kind,
|
|
||||||
ps.term_months AS term_months,
|
|
||||||
ps.payment_method AS payment_method,
|
|
||||||
ps.expires_at AS expires_at,
|
|
||||||
p.created_at AS created_at
|
|
||||||
FROM payment AS p
|
|
||||||
LEFT JOIN plan AS pl ON pl.id = p.plan_id
|
|
||||||
LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id
|
|
||||||
WHERE p.user_id = ?
|
|
||||||
UNION ALL
|
|
||||||
SELECT
|
|
||||||
wt.id AS id,
|
|
||||||
wt.amount AS amount,
|
|
||||||
wt.currency AS currency,
|
|
||||||
'SUCCESS' AS status,
|
|
||||||
NULL AS plan_id,
|
|
||||||
NULL AS plan_name,
|
|
||||||
wt.id AS invoice_id,
|
|
||||||
? AS kind,
|
|
||||||
NULL AS term_months,
|
|
||||||
NULL AS payment_method,
|
|
||||||
NULL AS expires_at,
|
|
||||||
wt.created_at AS created_at
|
|
||||||
FROM wallet_transactions AS wt
|
|
||||||
WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL
|
|
||||||
)
|
|
||||||
`
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Raw(baseQuery+`SELECT COUNT(*) FROM history`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup).
|
|
||||||
Scan(&total).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to count payment history", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []paymentHistoryRow
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup, limit, offset).
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to fetch payment history", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.PaymentHistoryItem, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, &appv1.PaymentHistoryItem{
|
|
||||||
Id: row.ID,
|
|
||||||
Amount: row.Amount,
|
|
||||||
Currency: normalizeCurrency(row.Currency),
|
|
||||||
Status: normalizePaymentStatus(row.Status),
|
|
||||||
PlanId: row.PlanID,
|
|
||||||
PlanName: row.PlanName,
|
|
||||||
InvoiceId: buildInvoiceID(row.InvoiceID),
|
|
||||||
Kind: row.Kind,
|
|
||||||
TermMonths: row.TermMonths,
|
|
||||||
PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod),
|
|
||||||
ExpiresAt: timeToProto(row.ExpiresAt),
|
|
||||||
CreatedAt: timeToProto(row.CreatedAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
hasPrev := page > 1 && total > 0
|
|
||||||
hasNext := int64(offset)+int64(len(items)) < total
|
|
||||||
return &appv1.ListPaymentHistoryResponse{
|
|
||||||
Payments: items,
|
|
||||||
Total: total,
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
HasPrev: hasPrev,
|
|
||||||
HasNext: hasNext,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
amount := req.GetAmount()
|
|
||||||
if amount < 1 {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Amount must be at least 1")
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction := &model.WalletTransaction{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: result.UserID,
|
|
||||||
Type: walletTransactionTypeTopup,
|
|
||||||
Amount: amount,
|
|
||||||
Currency: model.StringPtr("USD"),
|
|
||||||
Note: model.StringPtr(fmt.Sprintf("Wallet top-up of %.2f USD", amount)),
|
|
||||||
}
|
|
||||||
|
|
||||||
notification := &model.Notification{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: result.UserID,
|
|
||||||
Type: "billing.topup",
|
|
||||||
Title: "Wallet credited",
|
|
||||||
Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount),
|
|
||||||
Metadata: model.StringPtr(mustMarshalJSON(map[string]any{
|
|
||||||
"wallet_transaction_id": transaction.ID,
|
|
||||||
"invoice_id": buildInvoiceID(transaction.ID),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Create(transaction).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Create(notification).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
s.logger.Error("Failed to top up wallet", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to top up wallet")
|
|
||||||
}
|
|
||||||
|
|
||||||
balance, err := model.GetWalletBalance(ctx, s.db, result.UserID)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to calculate wallet balance", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to top up wallet")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.TopupWalletResponse{
|
|
||||||
WalletTransaction: toProtoWalletTransaction(transaction),
|
|
||||||
WalletBalance: balance,
|
|
||||||
InvoiceId: buildInvoiceID(transaction.ID),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Invoice not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
paymentRecord, err := query.Payment.WithContext(ctx).
|
|
||||||
Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(result.UserID)).
|
|
||||||
First()
|
|
||||||
if err == nil {
|
|
||||||
invoiceText, filename, buildErr := s.buildPaymentInvoice(ctx, paymentRecord)
|
|
||||||
if buildErr != nil {
|
|
||||||
s.logger.Error("Failed to build payment invoice", "error", buildErr)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to download invoice")
|
|
||||||
}
|
|
||||||
return &appv1.DownloadInvoiceResponse{
|
|
||||||
Filename: filename,
|
|
||||||
ContentType: "text/plain; charset=utf-8",
|
|
||||||
Content: invoiceText,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
s.logger.Error("Failed to load payment invoice", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to download invoice")
|
|
||||||
}
|
|
||||||
|
|
||||||
var topup model.WalletTransaction
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, result.UserID, walletTransactionTypeTopup).
|
|
||||||
First(&topup).Error; err == nil {
|
|
||||||
return &appv1.DownloadInvoiceResponse{
|
|
||||||
Filename: buildInvoiceFilename(topup.ID),
|
|
||||||
ContentType: "text/plain; charset=utf-8",
|
|
||||||
Content: buildTopupInvoice(&topup),
|
|
||||||
}, nil
|
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
s.logger.Error("Failed to load topup invoice", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to download invoice")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, status.Error(codes.NotFound, "Invoice not found")
|
|
||||||
}
|
|
||||||
@@ -13,24 +13,18 @@ import (
|
|||||||
"stream.api/internal/database/model"
|
"stream.api/internal/database/model"
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||||
"stream.api/internal/middleware"
|
"stream.api/internal/middleware"
|
||||||
|
"stream.api/internal/modules/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreatePayment(t *testing.T) {
|
func TestCreatePayment(t *testing.T) {
|
||||||
|
|
||||||
t.Run("plan không tồn tại", func(t *testing.T) {
|
t.Run("plan không tồn tại", func(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||||
|
|
||||||
conn, cleanup := newTestGRPCServer(t, services)
|
conn, cleanup := newTestGRPCServer(t, services)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
client := newPaymentsClient(conn)
|
client := newPaymentsClient(conn)
|
||||||
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
|
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: uuid.NewString(), TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
|
||||||
PlanId: uuid.NewString(),
|
|
||||||
TermMonths: 1,
|
|
||||||
PaymentMethod: paymentMethodWallet,
|
|
||||||
})
|
|
||||||
assertGRPCCode(t, err, codes.NotFound)
|
assertGRPCCode(t, err, codes.NotFound)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,16 +33,10 @@ func TestCreatePayment(t *testing.T) {
|
|||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(false)})
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(false)})
|
||||||
|
|
||||||
conn, cleanup := newTestGRPCServer(t, services)
|
conn, cleanup := newTestGRPCServer(t, services)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
client := newPaymentsClient(conn)
|
client := newPaymentsClient(conn)
|
||||||
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
|
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
|
||||||
PlanId: plan.ID,
|
|
||||||
TermMonths: 1,
|
|
||||||
PaymentMethod: paymentMethodWallet,
|
|
||||||
})
|
|
||||||
assertGRPCCode(t, err, codes.InvalidArgument)
|
assertGRPCCode(t, err, codes.InvalidArgument)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -57,16 +45,10 @@ func TestCreatePayment(t *testing.T) {
|
|||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
|
||||||
|
|
||||||
conn, cleanup := newTestGRPCServer(t, services)
|
conn, cleanup := newTestGRPCServer(t, services)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
client := newPaymentsClient(conn)
|
client := newPaymentsClient(conn)
|
||||||
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
|
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 2, PaymentMethod: common.PaymentMethodWallet})
|
||||||
PlanId: plan.ID,
|
|
||||||
TermMonths: 2,
|
|
||||||
PaymentMethod: paymentMethodWallet,
|
|
||||||
})
|
|
||||||
assertGRPCCode(t, err, codes.InvalidArgument)
|
assertGRPCCode(t, err, codes.InvalidArgument)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,16 +57,10 @@ func TestCreatePayment(t *testing.T) {
|
|||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)})
|
||||||
|
|
||||||
conn, cleanup := newTestGRPCServer(t, services)
|
conn, cleanup := newTestGRPCServer(t, services)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
client := newPaymentsClient(conn)
|
client := newPaymentsClient(conn)
|
||||||
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
|
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: "bank_transfer"})
|
||||||
PlanId: plan.ID,
|
|
||||||
TermMonths: 1,
|
|
||||||
PaymentMethod: "bank_transfer",
|
|
||||||
})
|
|
||||||
assertGRPCCode(t, err, codes.InvalidArgument)
|
assertGRPCCode(t, err, codes.InvalidArgument)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,18 +69,12 @@ func TestCreatePayment(t *testing.T) {
|
|||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 50, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 50, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")})
|
||||||
|
|
||||||
conn, cleanup := newTestGRPCServer(t, services)
|
conn, cleanup := newTestGRPCServer(t, services)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
client := newPaymentsClient(conn)
|
client := newPaymentsClient(conn)
|
||||||
var trailer metadata.MD
|
var trailer metadata.MD
|
||||||
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
|
_, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer))
|
||||||
PlanId: plan.ID,
|
|
||||||
TermMonths: 1,
|
|
||||||
PaymentMethod: paymentMethodWallet,
|
|
||||||
}, grpc.Trailer(&trailer))
|
|
||||||
assertGRPCCode(t, err, codes.InvalidArgument)
|
assertGRPCCode(t, err, codes.InvalidArgument)
|
||||||
body := firstTestMetadataValue(trailer, "x-error-body")
|
body := firstTestMetadataValue(trailer, "x-error-body")
|
||||||
if body == "" {
|
if body == "" {
|
||||||
@@ -120,29 +90,22 @@ func TestCreatePayment(t *testing.T) {
|
|||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")})
|
||||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")})
|
||||||
|
|
||||||
conn, cleanup := newTestGRPCServer(t, services)
|
conn, cleanup := newTestGRPCServer(t, services)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
client := newPaymentsClient(conn)
|
client := newPaymentsClient(conn)
|
||||||
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{
|
resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(15)})
|
||||||
PlanId: plan.ID,
|
|
||||||
TermMonths: 1,
|
|
||||||
PaymentMethod: paymentMethodTopup,
|
|
||||||
TopupAmount: ptrFloat64(15),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreatePayment() error = %v", err)
|
t.Fatalf("CreatePayment() error = %v", err)
|
||||||
}
|
}
|
||||||
if resp.Payment == nil || resp.Subscription == nil {
|
if resp.Payment == nil || resp.Subscription == nil {
|
||||||
t.Fatalf("CreatePayment() response incomplete: %#v", resp)
|
t.Fatalf("CreatePayment() response incomplete: %#v", resp)
|
||||||
}
|
}
|
||||||
if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) {
|
if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) {
|
||||||
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id))
|
t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id))
|
||||||
}
|
}
|
||||||
if resp.Subscription.PaymentMethod != paymentMethodTopup {
|
if resp.Subscription.PaymentMethod != common.PaymentMethodTopup {
|
||||||
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, paymentMethodTopup)
|
t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, common.PaymentMethodTopup)
|
||||||
}
|
}
|
||||||
if resp.Subscription.WalletAmount != 20 {
|
if resp.Subscription.WalletAmount != 20 {
|
||||||
t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount)
|
t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount)
|
||||||
@@ -153,7 +116,6 @@ func TestCreatePayment(t *testing.T) {
|
|||||||
if resp.WalletBalance != 0 {
|
if resp.WalletBalance != 0 {
|
||||||
t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance)
|
t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
payment := mustLoadPayment(t, db, resp.Payment.Id)
|
payment := mustLoadPayment(t, db, resp.Payment.Id)
|
||||||
if payment.Amount != 20 {
|
if payment.Amount != 20 {
|
||||||
t.Fatalf("payment amount = %v, want 20", payment.Amount)
|
t.Fatalf("payment amount = %v, want 20", payment.Amount)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"stream.api/internal/database/model"
|
"stream.api/internal/database/model"
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||||
"stream.api/internal/middleware"
|
"stream.api/internal/middleware"
|
||||||
|
"stream.api/internal/modules/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlayerConfigsPolicy(t *testing.T) {
|
func TestPlayerConfigsPolicy(t *testing.T) {
|
||||||
@@ -50,8 +51,8 @@ func TestPlayerConfigsPolicy(t *testing.T) {
|
|||||||
|
|
||||||
_, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Second"})
|
_, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Second"})
|
||||||
assertGRPCCode(t, err, codes.FailedPrecondition)
|
assertGRPCCode(t, err, codes.FailedPrecondition)
|
||||||
if got := status.Convert(err).Message(); got != playerConfigFreePlanLimitMessage {
|
if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanLimitMessage {
|
||||||
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanLimitMessage)
|
t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanLimitMessage)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -107,8 +108,8 @@ func TestPlayerConfigsPolicy(t *testing.T) {
|
|||||||
IsActive: ptrBool(true),
|
IsActive: ptrBool(true),
|
||||||
})
|
})
|
||||||
assertGRPCCode(t, err, codes.FailedPrecondition)
|
assertGRPCCode(t, err, codes.FailedPrecondition)
|
||||||
if got := status.Convert(err).Message(); got != playerConfigFreePlanReconciliationMessage {
|
if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanReconciliationMessage {
|
||||||
t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanReconciliationMessage)
|
t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanReconciliationMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: second.ID})
|
_, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: second.ID})
|
||||||
@@ -212,7 +213,7 @@ func TestPlayerConfigsPolicy(t *testing.T) {
|
|||||||
t.Fatalf("player config count = %d, want 1", len(items))
|
t.Fatalf("player config count = %d, want 1", len(items))
|
||||||
}
|
}
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
if message != playerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") {
|
if message != common.PlayerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") {
|
||||||
t.Fatalf("unexpected concurrent create error message: %q", message)
|
t.Fatalf("unexpected concurrent create error message: %q", message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"stream.api/internal/database/model"
|
"stream.api/internal/database/model"
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||||
|
"stream.api/internal/modules/common"
|
||||||
|
paymentsmodule "stream.api/internal/modules/payments"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegisterReferralCapture(t *testing.T) {
|
func TestRegisterReferralCapture(t *testing.T) {
|
||||||
@@ -18,12 +19,7 @@ func TestRegisterReferralCapture(t *testing.T) {
|
|||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
||||||
|
|
||||||
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{
|
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "bob", Email: "bob@example.com", Password: "secret123", RefUsername: ptrString("alice")})
|
||||||
Username: "bob",
|
|
||||||
Email: "bob@example.com",
|
|
||||||
Password: "secret123",
|
|
||||||
RefUsername: ptrString("alice"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Register() error = %v", err)
|
t.Fatalf("Register() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -39,13 +35,7 @@ func TestRegisterReferralCapture(t *testing.T) {
|
|||||||
t.Run("register với ref invalid hoặc self-ref vẫn tạo user", func(t *testing.T) {
|
t.Run("register với ref invalid hoặc self-ref vẫn tạo user", func(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
|
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "selfie", Email: "selfie@example.com", Password: "secret123", RefUsername: ptrString("selfie")})
|
||||||
resp, err := services.Register(context.Background(), &appv1.RegisterRequest{
|
|
||||||
Username: "selfie",
|
|
||||||
Email: "selfie@example.com",
|
|
||||||
Password: "secret123",
|
|
||||||
RefUsername: ptrString("selfie"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Register() error = %v", err)
|
t.Fatalf("Register() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,9 +51,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
|
|||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
||||||
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob")
|
referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveSignupReferrerID() error = %v", err)
|
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
|
||||||
}
|
}
|
||||||
if referrerID == nil || *referrerID != referrer.ID {
|
if referrerID == nil || *referrerID != referrer.ID {
|
||||||
t.Fatalf("referrerID = %v, want %s", referrerID, referrer.ID)
|
t.Fatalf("referrerID = %v, want %s", referrerID, referrer.ID)
|
||||||
@@ -73,9 +63,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
|
|||||||
t.Run("invalid hoặc self-ref bị ignore", func(t *testing.T) {
|
t.Run("invalid hoặc self-ref bị ignore", func(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
referrerID, err := services.resolveSignupReferrerID(context.Background(), "bob", "bob")
|
referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "bob", "bob")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveSignupReferrerID() error = %v", err)
|
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
|
||||||
}
|
}
|
||||||
if referrerID != nil {
|
if referrerID != nil {
|
||||||
t.Fatalf("referrerID = %v, want nil", referrerID)
|
t.Fatalf("referrerID = %v, want nil", referrerID)
|
||||||
@@ -87,9 +77,9 @@ func TestResolveSignupReferrerID(t *testing.T) {
|
|||||||
services := newTestAppServices(t, db)
|
services := newTestAppServices(t, db)
|
||||||
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "a@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "a@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
||||||
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "b@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "b@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
||||||
referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob")
|
referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveSignupReferrerID() error = %v", err)
|
t.Fatalf("ResolveSignupReferrerID() error = %v", err)
|
||||||
}
|
}
|
||||||
if referrerID != nil {
|
if referrerID != nil {
|
||||||
t.Fatalf("referrerID = %v, want nil", referrerID)
|
t.Fatalf("referrerID = %v, want nil", referrerID)
|
||||||
@@ -110,11 +100,10 @@ func TestReferralRewardFlow(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("first subscription thưởng 5 phần trăm", func(t *testing.T) {
|
t.Run("first subscription thưởng 5 phần trăm", func(t *testing.T) {
|
||||||
services, db, referrer, referee, plan := setup(t)
|
services, db, referrer, referee, plan := setup(t)
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
||||||
|
result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet})
|
||||||
result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executePaymentFlow() error = %v", err)
|
t.Fatalf("ExecutePaymentFlow() error = %v", err)
|
||||||
}
|
}
|
||||||
updatedReferee := mustLoadUser(t, db, referee.ID)
|
updatedReferee := mustLoadUser(t, db, referee.ID)
|
||||||
if updatedReferee.ReferralRewardPaymentID == nil || *updatedReferee.ReferralRewardPaymentID != result.Payment.ID {
|
if updatedReferee.ReferralRewardPaymentID == nil || *updatedReferee.ReferralRewardPaymentID != result.Payment.ID {
|
||||||
@@ -138,12 +127,12 @@ func TestReferralRewardFlow(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("subscription thứ hai không thưởng lại", func(t *testing.T) {
|
t.Run("subscription thứ hai không thưởng lại", func(t *testing.T) {
|
||||||
services, db, referrer, referee, plan := setup(t)
|
services, db, referrer, referee, plan := setup(t)
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")})
|
||||||
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
|
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
|
||||||
t.Fatalf("first executePaymentFlow() error = %v", err)
|
t.Fatalf("first ExecutePaymentFlow() error = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
|
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
|
||||||
t.Fatalf("second executePaymentFlow() error = %v", err)
|
t.Fatalf("second ExecutePaymentFlow() error = %v", err)
|
||||||
}
|
}
|
||||||
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
|
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,9 +163,9 @@ func TestReferralRewardFlow(t *testing.T) {
|
|||||||
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_eligible", false).Error; err != nil {
|
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_eligible", false).Error; err != nil {
|
||||||
t.Fatalf("update referral_eligible: %v", err)
|
t.Fatalf("update referral_eligible: %v", err)
|
||||||
}
|
}
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
||||||
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
|
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
|
||||||
t.Fatalf("executePaymentFlow() error = %v", err)
|
t.Fatalf("ExecutePaymentFlow() error = %v", err)
|
||||||
}
|
}
|
||||||
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
|
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,9 +181,9 @@ func TestReferralRewardFlow(t *testing.T) {
|
|||||||
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_reward_bps", 750).Error; err != nil {
|
if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_reward_bps", 750).Error; err != nil {
|
||||||
t.Fatalf("update referral_reward_bps: %v", err)
|
t.Fatalf("update referral_reward_bps: %v", err)
|
||||||
}
|
}
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
||||||
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
|
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
|
||||||
t.Fatalf("executePaymentFlow() error = %v", err)
|
t.Fatalf("ExecutePaymentFlow() error = %v", err)
|
||||||
}
|
}
|
||||||
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
|
balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -213,23 +202,10 @@ func TestUpdateAdminUserReferralSettings(t *testing.T) {
|
|||||||
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")})
|
||||||
referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)})
|
referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)})
|
||||||
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
|
plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)})
|
||||||
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")})
|
||||||
if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil {
|
if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil {
|
||||||
t.Fatalf("executePaymentFlow() error = %v", err)
|
t.Fatalf("ExecutePaymentFlow() error = %v", err)
|
||||||
}
|
}
|
||||||
|
_, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{Id: referee.ID, RefUsername: ptrString("alice")})
|
||||||
_, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{
|
|
||||||
Id: referee.ID,
|
|
||||||
RefUsername: ptrString("alice"),
|
|
||||||
})
|
|
||||||
assertGRPCCode(t, err, codes.InvalidArgument)
|
assertGRPCCode(t, err, codes.InvalidArgument)
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsAny(value string, parts ...string) bool {
|
|
||||||
for _, part := range parts {
|
|
||||||
if part != "" && strings.Contains(value, part) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,624 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appServices) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []model.Notification
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("user_id = ?", result.UserID).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&rows).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to list notifications", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load notifications")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.Notification, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, toProtoNotification(row))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListNotificationsResponse{Notifications: items}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Notification not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
res := s.db.WithContext(ctx).
|
|
||||||
Model(&model.Notification{}).
|
|
||||||
Where("id = ? AND user_id = ?", id, result.UserID).
|
|
||||||
Update("is_read", true)
|
|
||||||
if res.Error != nil {
|
|
||||||
s.logger.Error("Failed to update notification", "error", res.Error)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update notification")
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "Notification not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Notification updated"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Model(&model.Notification{}).
|
|
||||||
Where("user_id = ? AND is_read = ?", result.UserID, false).
|
|
||||||
Update("is_read", true).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to mark all notifications as read", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update notifications")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("All notifications marked as read"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Notification not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
res := s.db.WithContext(ctx).
|
|
||||||
Where("id = ? AND user_id = ?", id, result.UserID).
|
|
||||||
Delete(&model.Notification{})
|
|
||||||
if res.Error != nil {
|
|
||||||
s.logger.Error("Failed to delete notification", "error", res.Error)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete notification")
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "Notification not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Notification deleted"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Where("user_id = ?", result.UserID).Delete(&model.Notification{}).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to clear notifications", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to clear notifications")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("All notifications deleted"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []model.Domain
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("user_id = ?", result.UserID).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&rows).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to list domains", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load domains")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.Domain, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
item := row
|
|
||||||
items = append(items, toProtoDomain(&item))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListDomainsResponse{Domains: items}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := normalizeDomain(req.GetName())
|
|
||||||
if name == "" || !strings.Contains(name, ".") || strings.ContainsAny(name, "/ ") {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Invalid domain")
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Model(&model.Domain{}).
|
|
||||||
Where("user_id = ? AND name = ?", result.UserID, name).
|
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to validate domain", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create domain")
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Domain already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
item := &model.Domain{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: result.UserID,
|
|
||||||
Name: name,
|
|
||||||
}
|
|
||||||
if err := s.db.WithContext(ctx).Create(item).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to create domain", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create domain")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.CreateDomainResponse{Domain: toProtoDomain(item)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Domain not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
res := s.db.WithContext(ctx).
|
|
||||||
Where("id = ? AND user_id = ?", id, result.UserID).
|
|
||||||
Delete(&model.Domain{})
|
|
||||||
if res.Error != nil {
|
|
||||||
s.logger.Error("Failed to delete domain", "error", res.Error)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete domain")
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "Domain not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Domain deleted"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var items []model.AdTemplate
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("user_id = ?", result.UserID).
|
|
||||||
Order("is_default DESC").
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to list ad templates", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load ad templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := make([]*appv1.AdTemplate, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
copyItem := item
|
|
||||||
payload = append(payload, toProtoAdTemplate(©Item))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListAdTemplatesResponse{Templates: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := ensurePaidPlan(result.User); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(req.GetName())
|
|
||||||
vastURL := strings.TrimSpace(req.GetVastTagUrl())
|
|
||||||
if name == "" || vastURL == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
format := normalizeAdFormat(req.GetAdFormat())
|
|
||||||
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
item := &model.AdTemplate{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: result.UserID,
|
|
||||||
Name: name,
|
|
||||||
Description: nullableTrimmedString(req.Description),
|
|
||||||
VastTagURL: vastURL,
|
|
||||||
AdFormat: model.StringPtr(format),
|
|
||||||
Duration: int32PtrToInt64Ptr(req.Duration),
|
|
||||||
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
|
|
||||||
IsDefault: req.IsDefault != nil && *req.IsDefault,
|
|
||||||
}
|
|
||||||
if !adTemplateIsActive(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := unsetDefaultTemplates(tx, result.UserID, ""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Create(item).Error
|
|
||||||
}); err != nil {
|
|
||||||
s.logger.Error("Failed to create ad template", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.CreateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := ensurePaidPlan(result.User); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(req.GetName())
|
|
||||||
vastURL := strings.TrimSpace(req.GetVastTagUrl())
|
|
||||||
if name == "" || vastURL == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Name and VAST URL are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
format := normalizeAdFormat(req.GetAdFormat())
|
|
||||||
if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
var item model.AdTemplate
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to load ad template", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Name = name
|
|
||||||
item.Description = nullableTrimmedString(req.Description)
|
|
||||||
item.VastTagURL = vastURL
|
|
||||||
item.AdFormat = model.StringPtr(format)
|
|
||||||
item.Duration = int32PtrToInt64Ptr(req.Duration)
|
|
||||||
if req.IsActive != nil {
|
|
||||||
item.IsActive = model.BoolPtr(*req.IsActive)
|
|
||||||
}
|
|
||||||
if req.IsDefault != nil {
|
|
||||||
item.IsDefault = *req.IsDefault
|
|
||||||
}
|
|
||||||
if !adTemplateIsActive(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := unsetDefaultTemplates(tx, result.UserID, item.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Save(&item).Error
|
|
||||||
}); err != nil {
|
|
||||||
s.logger.Error("Failed to update ad template", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.UpdateAdTemplateResponse{Template: toProtoAdTemplate(&item)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := ensurePaidPlan(result.User); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&model.Video{}).
|
|
||||||
Where("user_id = ? AND ad_id = ?", result.UserID, id).
|
|
||||||
Update("ad_id", nil).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.AdTemplate{})
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Ad template not found")
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to delete ad template", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete ad template")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Ad template deleted"), nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) {
|
|
||||||
if _, err := s.authenticate(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var plans []model.Plan
|
|
||||||
if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to fetch plans", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch plans")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.Plan, 0, len(plans))
|
|
||||||
for _, plan := range plans {
|
|
||||||
copyPlan := plan
|
|
||||||
items = append(items, toProtoPlan(©Plan))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListPlansResponse{Plans: items}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var items []model.PlayerConfig
|
|
||||||
if err := s.db.WithContext(ctx).
|
|
||||||
Where("user_id = ?", result.UserID).
|
|
||||||
Order("is_default DESC").
|
|
||||||
Order("created_at DESC").
|
|
||||||
Find(&items).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to list player configs", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to load player configs")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := make([]*appv1.PlayerConfig, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
copyItem := item
|
|
||||||
payload = append(payload, toProtoPlayerConfig(©Item))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListPlayerConfigsResponse{Configs: payload}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(req.GetName())
|
|
||||||
if name == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
item := &model.PlayerConfig{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: result.UserID,
|
|
||||||
Name: name,
|
|
||||||
Description: nullableTrimmedString(req.Description),
|
|
||||||
Autoplay: req.GetAutoplay(),
|
|
||||||
Loop: req.GetLoop(),
|
|
||||||
Muted: req.GetMuted(),
|
|
||||||
ShowControls: model.BoolPtr(req.GetShowControls()),
|
|
||||||
Pip: model.BoolPtr(req.GetPip()),
|
|
||||||
Airplay: model.BoolPtr(req.GetAirplay()),
|
|
||||||
Chromecast: model.BoolPtr(req.GetChromecast()),
|
|
||||||
IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive),
|
|
||||||
IsDefault: req.IsDefault != nil && *req.IsDefault,
|
|
||||||
EncrytionM3u8: model.BoolPtr(req.EncrytionM3U8 == nil || *req.EncrytionM3U8),
|
|
||||||
LogoURL: nullableTrimmedString(req.LogoUrl),
|
|
||||||
}
|
|
||||||
if !playerConfigIsActive(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var configCount int64
|
|
||||||
if err := tx.WithContext(ctx).
|
|
||||||
Model(&model.PlayerConfig{}).
|
|
||||||
Where("user_id = ?", result.UserID).
|
|
||||||
Count(&configCount).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := playerConfigActionAllowed(lockedUser, configCount, "create"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := unsetDefaultPlayerConfigs(tx, result.UserID, ""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Create(item).Error
|
|
||||||
}); err != nil {
|
|
||||||
if status.Code(err) != codes.Unknown {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to create player config", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.CreatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
name := strings.TrimSpace(req.GetName())
|
|
||||||
if name == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
var item model.PlayerConfig
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var configCount int64
|
|
||||||
if err := tx.WithContext(ctx).
|
|
||||||
Model(&model.PlayerConfig{}).
|
|
||||||
Where("user_id = ?", result.UserID).
|
|
||||||
Count(&configCount).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
action := "update"
|
|
||||||
wasActive := playerConfigIsActive(item.IsActive)
|
|
||||||
if req.IsActive != nil && *req.IsActive != wasActive {
|
|
||||||
action = "toggle-active"
|
|
||||||
}
|
|
||||||
if req.IsDefault != nil && *req.IsDefault {
|
|
||||||
action = "set-default"
|
|
||||||
}
|
|
||||||
if err := playerConfigActionAllowed(lockedUser, configCount, action); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Name = name
|
|
||||||
item.Description = nullableTrimmedString(req.Description)
|
|
||||||
item.Autoplay = req.GetAutoplay()
|
|
||||||
item.Loop = req.GetLoop()
|
|
||||||
item.Muted = req.GetMuted()
|
|
||||||
item.ShowControls = model.BoolPtr(req.GetShowControls())
|
|
||||||
item.Pip = model.BoolPtr(req.GetPip())
|
|
||||||
item.Airplay = model.BoolPtr(req.GetAirplay())
|
|
||||||
item.Chromecast = model.BoolPtr(req.GetChromecast())
|
|
||||||
if req.EncrytionM3U8 != nil {
|
|
||||||
item.EncrytionM3u8 = model.BoolPtr(*req.EncrytionM3U8)
|
|
||||||
}
|
|
||||||
if req.LogoUrl != nil {
|
|
||||||
item.LogoURL = nullableTrimmedString(req.LogoUrl)
|
|
||||||
}
|
|
||||||
if req.IsActive != nil {
|
|
||||||
item.IsActive = model.BoolPtr(*req.IsActive)
|
|
||||||
}
|
|
||||||
if req.IsDefault != nil {
|
|
||||||
item.IsDefault = *req.IsDefault
|
|
||||||
}
|
|
||||||
if !playerConfigIsActive(item.IsActive) {
|
|
||||||
item.IsDefault = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.IsDefault {
|
|
||||||
if err := unsetDefaultPlayerConfigs(tx, result.UserID, item.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Save(&item).Error
|
|
||||||
}); err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
if status.Code(err) != codes.Unknown {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to update player config", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to save player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(&item)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var configCount int64
|
|
||||||
if err := tx.WithContext(ctx).
|
|
||||||
Model(&model.PlayerConfig{}).
|
|
||||||
Where("user_id = ?", result.UserID).
|
|
||||||
Count(&configCount).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := playerConfigActionAllowed(lockedUser, configCount, "delete"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.PlayerConfig{})
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Player config not found")
|
|
||||||
}
|
|
||||||
if status.Code(err) != codes.Unknown {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to delete player config", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete player config")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Player config deleted"), nil
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
appv1 "stream.api/internal/gen/proto/app/v1"
|
|
||||||
"stream.api/internal/video"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.storageProvider == nil {
|
|
||||||
return nil, status.Error(codes.FailedPrecondition, "Storage provider is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := strings.TrimSpace(req.GetFilename())
|
|
||||||
if filename == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Filename is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileID := uuid.New().String()
|
|
||||||
key := fmt.Sprintf("videos/%s/%s-%s", result.UserID, fileID, filename)
|
|
||||||
uploadURL, err := s.storageProvider.GeneratePresignedURL(key, 15*time.Minute)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to generate upload URL", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Storage error")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.GetUploadUrlResponse{UploadUrl: uploadURL, Key: key, FileId: fileID}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.videoService == nil {
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
title := strings.TrimSpace(req.GetTitle())
|
|
||||||
if title == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "Title is required")
|
|
||||||
}
|
|
||||||
videoURL := strings.TrimSpace(req.GetUrl())
|
|
||||||
if videoURL == "" {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "URL is required")
|
|
||||||
}
|
|
||||||
description := strings.TrimSpace(req.GetDescription())
|
|
||||||
|
|
||||||
created, err := s.videoService.CreateVideo(ctx, video.CreateVideoInput{
|
|
||||||
UserID: result.UserID,
|
|
||||||
Title: title,
|
|
||||||
Description: &description,
|
|
||||||
URL: videoURL,
|
|
||||||
Size: req.GetSize(),
|
|
||||||
Duration: req.GetDuration(),
|
|
||||||
Format: strings.TrimSpace(req.GetFormat()),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to create video", "error", err)
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, video.ErrJobServiceUnavailable):
|
|
||||||
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
||||||
default:
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to create video")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
page := req.GetPage()
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
limit := req.GetLimit()
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
if limit > 100 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
offset := int((page - 1) * limit)
|
|
||||||
|
|
||||||
db := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", result.UserID)
|
|
||||||
if search := strings.TrimSpace(req.GetSearch()); search != "" {
|
|
||||||
like := "%" + search + "%"
|
|
||||||
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
|
|
||||||
}
|
|
||||||
if st := strings.TrimSpace(req.GetStatus()); st != "" && !strings.EqualFold(st, "all") {
|
|
||||||
db = db.Where("status = ?", normalizeVideoStatusValue(st))
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to count videos", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch videos")
|
|
||||||
}
|
|
||||||
|
|
||||||
var videos []model.Video
|
|
||||||
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&videos).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to list videos", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch videos")
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]*appv1.Video, 0, len(videos))
|
|
||||||
for i := range videos {
|
|
||||||
payload, err := s.buildVideo(ctx, &videos[i])
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to build video payload", "error", err, "video_id", videos[i].ID)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch videos")
|
|
||||||
}
|
|
||||||
items = append(items, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = s.db.WithContext(ctx).Model(&model.Video{}).
|
|
||||||
Where("id = ? AND user_id = ?", id, result.UserID).
|
|
||||||
UpdateColumn("views", gorm.Expr("views + ?", 1)).Error
|
|
||||||
|
|
||||||
var video model.Video
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to fetch video", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch video")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildVideo(ctx, &video)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to fetch video")
|
|
||||||
}
|
|
||||||
return &appv1.GetVideoResponse{Video: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
updates := map[string]any{}
|
|
||||||
if title := strings.TrimSpace(req.GetTitle()); title != "" {
|
|
||||||
updates["name"] = title
|
|
||||||
updates["title"] = title
|
|
||||||
}
|
|
||||||
if req.Description != nil {
|
|
||||||
desc := strings.TrimSpace(req.GetDescription())
|
|
||||||
updates["description"] = nullableTrimmedString(&desc)
|
|
||||||
}
|
|
||||||
if urlValue := strings.TrimSpace(req.GetUrl()); urlValue != "" {
|
|
||||||
updates["url"] = urlValue
|
|
||||||
}
|
|
||||||
if req.Size > 0 {
|
|
||||||
updates["size"] = req.GetSize()
|
|
||||||
}
|
|
||||||
if req.Duration > 0 {
|
|
||||||
updates["duration"] = req.GetDuration()
|
|
||||||
}
|
|
||||||
if req.Format != nil {
|
|
||||||
updates["format"] = strings.TrimSpace(req.GetFormat())
|
|
||||||
}
|
|
||||||
if req.Status != nil {
|
|
||||||
updates["status"] = normalizeVideoStatusValue(req.GetStatus())
|
|
||||||
}
|
|
||||||
if len(updates) == 0 {
|
|
||||||
return nil, status.Error(codes.InvalidArgument, "No changes provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
res := s.db.WithContext(ctx).
|
|
||||||
Model(&model.Video{}).
|
|
||||||
Where("id = ? AND user_id = ?", id, result.UserID).
|
|
||||||
Updates(updates)
|
|
||||||
if res.Error != nil {
|
|
||||||
s.logger.Error("Failed to update video", "error", res.Error)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var video model.Video
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
|
|
||||||
s.logger.Error("Failed to reload video", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := s.buildVideo(ctx, &video)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
||||||
}
|
|
||||||
return &appv1.UpdateVideoResponse{Video: payload}, nil
|
|
||||||
}
|
|
||||||
func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) {
|
|
||||||
result, err := s.authenticate(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id := strings.TrimSpace(req.GetId())
|
|
||||||
if id == "" {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var video model.Video
|
|
||||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, status.Error(codes.NotFound, "Video not found")
|
|
||||||
}
|
|
||||||
s.logger.Error("Failed to load video", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.storageProvider != nil && shouldDeleteStoredObject(video.URL) {
|
|
||||||
if err := s.storageProvider.Delete(video.URL); err != nil {
|
|
||||||
if parsedKey := extractObjectKey(video.URL); parsedKey != "" && parsedKey != video.URL {
|
|
||||||
if deleteErr := s.storageProvider.Delete(parsedKey); deleteErr != nil {
|
|
||||||
s.logger.Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
s.logger.Error("Failed to delete video object", "error", err, "video_id", video.ID)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.Video{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(&model.User{}).
|
|
||||||
Where("id = ?", result.UserID).
|
|
||||||
UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error
|
|
||||||
}); err != nil {
|
|
||||||
s.logger.Error("Failed to delete video", "error", err)
|
|
||||||
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageResponse("Video deleted successfully"), nil
|
|
||||||
}
|
|
||||||
33
internal/rpc/app/test_wrappers_test.go
Normal file
33
internal/rpc/app/test_wrappers_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
appv1 "stream.api/internal/gen/proto/app/v1"
|
||||||
|
paymentsmodule "stream.api/internal/modules/payments"
|
||||||
|
playerconfigsmodule "stream.api/internal/modules/playerconfigs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
|
||||||
|
return s.authModule.Register(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
|
||||||
|
return paymentsmodule.NewHandler(s.paymentsModule).TopupWallet(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *appv1.UpdateAdminUserReferralSettingsRequest) (*appv1.UpdateAdminUserReferralSettingsResponse, error) {
|
||||||
|
return s.usersModule.UpdateAdminUserReferralSettings(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
|
||||||
|
return playerconfigsmodule.NewHandler(s.playerConfigsModule).CreatePlayerConfig(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
|
||||||
|
return playerconfigsmodule.NewHandler(s.playerConfigsModule).UpdatePlayerConfig(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
|
||||||
|
return playerconfigsmodule.NewHandler(s.playerConfigsModule).DeletePlayerConfig(ctx, req)
|
||||||
|
}
|
||||||
@@ -244,7 +244,7 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
|
|||||||
db = newTestDB(t)
|
db = newTestDB(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &appServices{
|
services := &appServices{
|
||||||
db: db,
|
db: db,
|
||||||
logger: testLogger{},
|
logger: testLogger{},
|
||||||
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
|
authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker),
|
||||||
@@ -252,6 +252,8 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices {
|
|||||||
tokenProvider: fakeTokenProvider{},
|
tokenProvider: fakeTokenProvider{},
|
||||||
googleUserInfoURL: defaultGoogleUserInfoURL,
|
googleUserInfoURL: defaultGoogleUserInfoURL,
|
||||||
}
|
}
|
||||||
|
services.initModules()
|
||||||
|
return services
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) {
|
func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) {
|
||||||
@@ -260,18 +262,18 @@ func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, f
|
|||||||
lis := bufconn.Listen(testBufDialerListenerSize)
|
lis := bufconn.Listen(testBufDialerListenerSize)
|
||||||
server := grpc.NewServer()
|
server := grpc.NewServer()
|
||||||
Register(server, &Services{
|
Register(server, &Services{
|
||||||
AuthServiceServer: services,
|
AuthServiceServer: services.authHandler,
|
||||||
AccountServiceServer: services,
|
AccountServiceServer: services.accountHandler,
|
||||||
PreferencesServiceServer: services,
|
PreferencesServiceServer: services.preferencesHandler,
|
||||||
UsageServiceServer: services,
|
UsageServiceServer: services.usageHandler,
|
||||||
NotificationsServiceServer: services,
|
NotificationsServiceServer: services.notificationsHandler,
|
||||||
DomainsServiceServer: services,
|
DomainsServiceServer: services.domainsHandler,
|
||||||
AdTemplatesServiceServer: services,
|
AdTemplatesServiceServer: services.adTemplatesHandler,
|
||||||
PlayerConfigsServiceServer: services,
|
PlayerConfigsServiceServer: services.playerConfigsHandler,
|
||||||
PlansServiceServer: services,
|
PlansServiceServer: services.plansHandler,
|
||||||
PaymentsServiceServer: services,
|
PaymentsServiceServer: services.paymentsHandler,
|
||||||
VideosServiceServer: services,
|
VideosServiceServer: services.videosHandler,
|
||||||
AdminServiceServer: services,
|
AdminServiceServer: services.adminHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"stream.api/internal/database/model"
|
|
||||||
"stream.api/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type usagePayload struct {
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
TotalVideos int64 `json:"total_videos"`
|
|
||||||
TotalStorage int64 `json:"total_storage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadUsage(ctx context.Context, db *gorm.DB, l logger.Logger, user *model.User) (*usagePayload, error) {
|
|
||||||
var totalVideos int64
|
|
||||||
if err := db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil {
|
|
||||||
l.Error("Failed to count user videos", "error", err, "user_id", user.ID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &usagePayload{
|
|
||||||
UserID: user.ID,
|
|
||||||
TotalVideos: totalVideos,
|
|
||||||
TotalStorage: user.StorageUsed,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
//go:build ignore
|
|
||||||
// +build ignore
|
|
||||||
|
|
||||||
package runtime
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *Module) MetricsHandler() gin.HandlerFunc {
|
|
||||||
return gin.WrapH(promhttp.Handler())
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLive godoc
|
|
||||||
// @Summary Liveness health check
|
|
||||||
// @Description Returns liveness status for the API and render module
|
|
||||||
// @Tags health
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} map[string]string
|
|
||||||
// @Failure 503 {object} map[string]string
|
|
||||||
// @Router /health/live [get]
|
|
||||||
func (m *Module) HandleLive(c *gin.Context) {
|
|
||||||
status, code := m.healthService.SimpleHealthCheck(c.Request.Context())
|
|
||||||
c.JSON(code, gin.H{"status": status})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleReady godoc
|
|
||||||
// @Summary Readiness health check
|
|
||||||
// @Description Returns readiness status including render gRPC availability flag
|
|
||||||
// @Tags health
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} map[string]interface{}
|
|
||||||
// @Failure 503 {object} map[string]interface{}
|
|
||||||
// @Router /health/ready [get]
|
|
||||||
func (m *Module) HandleReady(c *gin.Context) {
|
|
||||||
status, code := m.healthService.SimpleHealthCheck(c.Request.Context())
|
|
||||||
c.JSON(code, gin.H{"status": status, "grpc_enabled": m.grpcServer != nil})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDetailed godoc
|
|
||||||
// @Summary Detailed health check
|
|
||||||
// @Description Returns detailed health state for database, redis, and render dependencies
|
|
||||||
// @Tags health
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} services.HealthReport
|
|
||||||
// @Router /health/detailed [get]
|
|
||||||
func (m *Module) HandleDetailed(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, m.healthService.CheckHealth(c.Request.Context()))
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "stream_api_http_requests_total", Help: "Total HTTP requests."}, []string{"method", "path", "status"})
|
|
||||||
httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "stream_api_http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path"})
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
prometheus.MustRegister(httpRequests, httpDuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MetricsMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
start := time.Now()
|
|
||||||
c.Next()
|
|
||||||
path := c.FullPath()
|
|
||||||
if path == "" {
|
|
||||||
path = c.Request.URL.Path
|
|
||||||
}
|
|
||||||
httpRequests.WithLabelValues(c.Request.Method, path, http.StatusText(c.Writer.Status())).Inc()
|
|
||||||
httpDuration.WithLabelValues(c.Request.Method, path).Observe(time.Since(start).Seconds())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
186
proto/app/v1/woodpecker.proto
Normal file
186
proto/app/v1/woodpecker.proto
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// Copyright 2021 Woodpecker Authors
|
||||||
|
// Copyright 2011 Drone.IO Inc.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option go_package = "go.woodpecker-ci.org/woodpecker/v3/rpc/proto";
|
||||||
|
package proto;
|
||||||
|
|
||||||
|
// !IMPORTANT!
|
||||||
|
// Increased Version in version.go by 1 if you change something here!
|
||||||
|
// !IMPORTANT!
|
||||||
|
|
||||||
|
// Woodpecker Server Service
|
||||||
|
service Woodpecker {
|
||||||
|
rpc Version (Empty) returns (VersionResponse) {}
|
||||||
|
rpc Next (NextRequest) returns (NextResponse) {}
|
||||||
|
rpc Init (InitRequest) returns (Empty) {}
|
||||||
|
rpc Wait (WaitRequest) returns (WaitResponse) {}
|
||||||
|
rpc Done (DoneRequest) returns (Empty) {}
|
||||||
|
rpc Extend (ExtendRequest) returns (Empty) {}
|
||||||
|
rpc Update (UpdateRequest) returns (Empty) {}
|
||||||
|
rpc Log (LogRequest) returns (Empty) {}
|
||||||
|
rpc RegisterAgent (RegisterAgentRequest) returns (RegisterAgentResponse) {}
|
||||||
|
rpc UnregisterAgent (Empty) returns (Empty) {}
|
||||||
|
rpc ReportHealth (ReportHealthRequest) returns (Empty) {}
|
||||||
|
|
||||||
|
// New Streaming RPCs
|
||||||
|
rpc StreamJobs (StreamOptions) returns (stream Workflow) {}
|
||||||
|
rpc SubmitStatus (stream StatusUpdate) returns (Empty) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Basic Types
|
||||||
|
//
|
||||||
|
|
||||||
|
message StepState {
|
||||||
|
string step_uuid = 1;
|
||||||
|
int64 started = 2;
|
||||||
|
int64 finished = 3;
|
||||||
|
bool exited = 4;
|
||||||
|
int32 exit_code = 5;
|
||||||
|
string error = 6;
|
||||||
|
bool canceled = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WorkflowState {
|
||||||
|
int64 started = 1;
|
||||||
|
int64 finished = 2;
|
||||||
|
string error = 3;
|
||||||
|
bool canceled = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogEntry {
|
||||||
|
string step_uuid = 1;
|
||||||
|
int64 time = 2;
|
||||||
|
int32 line = 3;
|
||||||
|
int32 type = 4; // 0 = stdout, 1 = stderr, 2 = exit-code, 3 = metadata, 4 = progress
|
||||||
|
bytes data = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Filter {
|
||||||
|
map<string, string> labels = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Workflow {
|
||||||
|
string id = 1;
|
||||||
|
int64 timeout = 2;
|
||||||
|
bytes payload = 3;
|
||||||
|
bool cancel = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Request types
|
||||||
|
//
|
||||||
|
|
||||||
|
message NextRequest {
|
||||||
|
Filter filter = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InitRequest {
|
||||||
|
string id = 1;
|
||||||
|
WorkflowState state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WaitRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DoneRequest {
|
||||||
|
string id = 1;
|
||||||
|
WorkflowState state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExtendRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateRequest {
|
||||||
|
string id = 1;
|
||||||
|
StepState state = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogRequest {
|
||||||
|
repeated LogEntry logEntries = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Empty {
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReportHealthRequest {
|
||||||
|
string status = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AgentInfo {
|
||||||
|
string platform = 1;
|
||||||
|
int32 capacity = 2;
|
||||||
|
string backend = 3;
|
||||||
|
string version = 4;
|
||||||
|
map<string, string> customLabels = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RegisterAgentRequest {
|
||||||
|
AgentInfo info = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Response types
|
||||||
|
//
|
||||||
|
|
||||||
|
message VersionResponse {
|
||||||
|
int32 grpc_version = 1;
|
||||||
|
string server_version = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NextResponse {
|
||||||
|
Workflow workflow = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RegisterAgentResponse {
|
||||||
|
string agent_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WaitResponse {
|
||||||
|
bool canceled = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Woodpecker auth service is a simple service to authenticate agents and acquire a token
|
||||||
|
|
||||||
|
service WoodpeckerAuth {
|
||||||
|
rpc Auth (AuthRequest) returns (AuthResponse) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message AuthRequest {
|
||||||
|
string agent_token = 1;
|
||||||
|
string agent_id = 2;
|
||||||
|
string hostname = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AuthResponse {
|
||||||
|
string status = 1;
|
||||||
|
string agent_id = 2;
|
||||||
|
string access_token = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StreamOptions {
|
||||||
|
Filter filter = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StatusUpdate {
|
||||||
|
string step_uuid = 1;
|
||||||
|
int64 time = 2;
|
||||||
|
int32 type = 3; // 0=stdout, 1=stderr, 2=exit-code, 3=metadata, 4=progress, 5=system-resource
|
||||||
|
bytes data = 4;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user