diff --git a/bin/api b/bin/api index 362acea..d837f06 100755 Binary files a/bin/api and b/bin/api differ diff --git a/internal/modules/admin/handler.go b/internal/modules/admin/handler.go new file mode 100644 index 0000000..4a7b574 --- /dev/null +++ b/internal/modules/admin/handler.go @@ -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) +} diff --git a/internal/modules/adtemplates/handler.go b/internal/modules/adtemplates/handler.go new file mode 100644 index 0000000..b3a837b --- /dev/null +++ b/internal/modules/adtemplates/handler.go @@ -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 +} diff --git a/internal/modules/adtemplates/module.go b/internal/modules/adtemplates/module.go new file mode 100644 index 0000000..729dd3b --- /dev/null +++ b/internal/modules/adtemplates/module.go @@ -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 "" +} diff --git a/internal/modules/adtemplates/presenter.go b/internal/modules/adtemplates/presenter.go new file mode 100644 index 0000000..9f219a1 --- /dev/null +++ b/internal/modules/adtemplates/presenter.go @@ -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 +} diff --git a/internal/modules/adtemplates/types.go b/internal/modules/adtemplates/types.go new file mode 100644 index 0000000..fa3896b --- /dev/null +++ b/internal/modules/adtemplates/types.go @@ -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 +} diff --git a/internal/modules/auth/handler.go b/internal/modules/auth/handler.go new file mode 100644 index 0000000..731881d --- /dev/null +++ b/internal/modules/auth/handler.go @@ -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) +} diff --git a/internal/rpc/app/service_auth.go b/internal/modules/auth/module.go similarity index 52% rename from internal/rpc/app/service_auth.go rename to internal/modules/auth/module.go index 95442f3..0347316 100644 --- a/internal/rpc/app/service_auth.go +++ b/internal/modules/auth/module.go @@ -1,4 +1,4 @@ -package app +package auth import ( "context" @@ -6,7 +6,6 @@ import ( "errors" "net/http" "strings" - "time" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" @@ -17,15 +16,25 @@ import ( "stream.api/internal/database/model" "stream.api/internal/database/query" appv1 "stream.api/internal/gen/proto/app/v1" + "stream.api/internal/modules/common" + usersmodule "stream.api/internal/modules/users" ) -func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) { +type Module struct { + runtime *common.Runtime + users *usersmodule.Module +} + +func New(runtime *common.Runtime, users *usersmodule.Module) *Module { + return &Module{runtime: runtime, users: users} +} + +func (m *Module) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) { email := strings.TrimSpace(req.GetEmail()) password := req.GetPassword() if email == "" || password == "" { return nil, status.Error(codes.InvalidArgument, "Email and password are required") } - u := query.User user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() if err != nil { @@ -37,18 +46,17 @@ func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(password)); err != nil { return nil, status.Error(codes.Unauthenticated, "Invalid credentials") } - - if err := s.issueSessionCookies(ctx, user); err != nil { + if err := m.runtime.IssueSessionCookies(ctx, user); err != nil { return nil, err } - - payload, err := buildUserPayload(ctx, s.db, user) + payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } - return &appv1.LoginResponse{User: toProtoUser(payload)}, nil + return &appv1.LoginResponse{User: common.ToProtoUser(payload)}, nil } -func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) { + +func (m *Module) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) { email := strings.TrimSpace(req.GetEmail()) username := strings.TrimSpace(req.GetUsername()) password := req.GetPassword() @@ -56,55 +64,44 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) if email == "" || username == "" || password == "" { return nil, status.Error(codes.InvalidArgument, "Username, email and password are required") } - u := query.User count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count() if err != nil { - s.logger.Error("Failed to check existing user", "error", err) + m.runtime.Logger().Error("Failed to check existing user", "error", err) return nil, status.Error(codes.Internal, "Failed to register") } if count > 0 { return nil, status.Error(codes.InvalidArgument, "Email already registered") } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, status.Error(codes.Internal, "Failed to register") } - - referrerID, err := s.resolveSignupReferrerID(ctx, refUsername, username) + referrerID, err := m.users.ResolveSignupReferrerID(ctx, refUsername, username) if err != nil { - s.logger.Error("Failed to resolve signup referrer", "error", err) + m.runtime.Logger().Error("Failed to resolve signup referrer", "error", err) return nil, status.Error(codes.Internal, "Failed to register") } - role := "USER" passwordHash := string(hashedPassword) - newUser := &model.User{ - ID: uuid.New().String(), - Email: email, - Password: &passwordHash, - Username: &username, - Role: &role, - ReferredByUserID: referrerID, - ReferralEligible: model.BoolPtr(true), - } + newUser := &model.User{ID: uuid.New().String(), Email: email, Password: &passwordHash, Username: &username, Role: &role, ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true)} if err := u.WithContext(ctx).Create(newUser); err != nil { - s.logger.Error("Failed to create user", "error", err) + m.runtime.Logger().Error("Failed to create user", "error", err) return nil, status.Error(codes.Internal, "Failed to register") } - - payload, err := buildUserPayload(ctx, s.db, newUser) + payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), newUser) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } - return &appv1.RegisterResponse{User: toProtoUser(payload)}, nil + return &appv1.RegisterResponse{User: common.ToProtoUser(payload)}, nil } -func (s *appServices) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) { - return messageResponse("Logged out"), nil + +func (m *Module) Logout(context.Context, *appv1.LogoutRequest) (*appv1.MessageResponse, error) { + return common.MessageResponse("Logged out"), nil } -func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) { - result, err := s.authenticate(ctx) + +func (m *Module) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) { + result, err := m.runtime.Authenticate(ctx) if err != nil { return nil, err } @@ -126,191 +123,154 @@ func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePassw if err != nil { return nil, status.Error(codes.Internal, "Failed to change password") } - if _, err := query.User.WithContext(ctx). - Where(query.User.ID.Eq(result.UserID)). - Update(query.User.Password, string(newHash)); err != nil { - s.logger.Error("Failed to change password", "error", err) + if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(result.UserID)).Update(query.User.Password, string(newHash)); err != nil { + m.runtime.Logger().Error("Failed to change password", "error", err) return nil, status.Error(codes.Internal, "Failed to change password") } - return messageResponse("Password changed successfully"), nil + return common.MessageResponse("Password changed successfully"), nil } -func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) { + +func (m *Module) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) { email := strings.TrimSpace(req.GetEmail()) if email == "" { return nil, status.Error(codes.InvalidArgument, "Email is required") } - u := query.User user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() if err != nil { - return messageResponse("If email exists, a reset link has been sent"), nil + return common.MessageResponse("If email exists, a reset link has been sent"), nil } - tokenID := uuid.New().String() - if err := s.cache.Set(ctx, "reset_pw:"+tokenID, user.ID, 15*time.Minute); err != nil { - s.logger.Error("Failed to set reset token", "error", err) + if err := m.runtime.Cache().Set(ctx, "reset_pw:"+tokenID, user.ID, 15*60*1000000000); err != nil { + m.runtime.Logger().Error("Failed to set reset token", "error", err) return nil, status.Error(codes.Internal, "Try again later") } - - s.logger.Info("Generated password reset token", "email", email, "token", tokenID) - return messageResponse("If email exists, a reset link has been sent"), nil + m.runtime.Logger().Info("Generated password reset token", "email", email, "token", tokenID) + return common.MessageResponse("If email exists, a reset link has been sent"), nil } -func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) { + +func (m *Module) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) { resetToken := strings.TrimSpace(req.GetToken()) newPassword := req.GetNewPassword() if resetToken == "" || newPassword == "" { return nil, status.Error(codes.InvalidArgument, "Token and new password are required") } - - userID, err := s.cache.Get(ctx, "reset_pw:"+resetToken) + userID, err := m.runtime.Cache().Get(ctx, "reset_pw:"+resetToken) if err != nil || strings.TrimSpace(userID) == "" { return nil, status.Error(codes.InvalidArgument, "Invalid or expired token") } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return nil, status.Error(codes.Internal, "Internal error") } - - if _, err := query.User.WithContext(ctx). - Where(query.User.ID.Eq(userID)). - Update(query.User.Password, string(hashedPassword)); err != nil { - s.logger.Error("Failed to update password", "error", err) + if _, err := query.User.WithContext(ctx).Where(query.User.ID.Eq(userID)).Update(query.User.Password, string(hashedPassword)); err != nil { + m.runtime.Logger().Error("Failed to update password", "error", err) return nil, status.Error(codes.Internal, "Failed to update password") } - - _ = s.cache.Del(ctx, "reset_pw:"+resetToken) - return messageResponse("Password reset successfully"), nil + _ = m.runtime.Cache().Del(ctx, "reset_pw:"+resetToken) + return common.MessageResponse("Password reset successfully"), nil } -func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) { - if err := s.authenticator.RequireInternalCall(ctx); err != nil { + +func (m *Module) GetGoogleLoginURL(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) { + if err := m.runtime.Authenticator().RequireInternalCall(ctx); err != nil { return nil, err } - if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" { + googleOauth := m.runtime.GoogleOauth() + if googleOauth == nil || strings.TrimSpace(googleOauth.ClientID) == "" || strings.TrimSpace(googleOauth.RedirectURL) == "" { return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured") } - - state, err := generateOAuthState() + state, err := common.GenerateOAuthState() if err != nil { - s.logger.Error("Failed to generate Google OAuth state", "error", err) + m.runtime.Logger().Error("Failed to generate Google OAuth state", "error", err) return nil, status.Error(codes.Internal, "Failed to start Google login") } - - if err := s.cache.Set(ctx, googleOAuthStateCacheKey(state), "1", s.googleStateTTL); err != nil { - s.logger.Error("Failed to persist Google OAuth state", "error", err) + if err := m.runtime.Cache().Set(ctx, common.GoogleOAuthStateCacheKey(state), "1", m.runtime.GoogleStateTTL()); err != nil { + m.runtime.Logger().Error("Failed to persist Google OAuth state", "error", err) return nil, status.Error(codes.Internal, "Failed to start Google login") } - - loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline) + loginURL := googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline) return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil } -func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) { - if err := s.authenticator.RequireInternalCall(ctx); err != nil { + +func (m *Module) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) { + if err := m.runtime.Authenticator().RequireInternalCall(ctx); err != nil { return nil, err } - if s.googleOauth == nil || strings.TrimSpace(s.googleOauth.ClientID) == "" || strings.TrimSpace(s.googleOauth.RedirectURL) == "" { + googleOauth := m.runtime.GoogleOauth() + if googleOauth == nil || strings.TrimSpace(googleOauth.ClientID) == "" || strings.TrimSpace(googleOauth.RedirectURL) == "" { return nil, status.Error(codes.FailedPrecondition, "Google OAuth is not configured") } - code := strings.TrimSpace(req.GetCode()) if code == "" { return nil, status.Error(codes.InvalidArgument, "Code is required") } - - tokenResp, err := s.googleOauth.Exchange(ctx, code) + tokenResp, err := googleOauth.Exchange(ctx, code) if err != nil { - s.logger.Error("Failed to exchange Google OAuth token", "error", err) + m.runtime.Logger().Error("Failed to exchange Google OAuth token", "error", err) return nil, status.Error(codes.Unauthenticated, "exchange_failed") } - - client := s.googleOauth.Client(ctx, tokenResp) - resp, err := client.Get(s.googleUserInfoURL) + client := googleOauth.Client(ctx, tokenResp) + resp, err := client.Get(m.runtime.GoogleUserInfoURL()) if err != nil { - s.logger.Error("Failed to fetch Google user info", "error", err) + m.runtime.Logger().Error("Failed to fetch Google user info", "error", err) return nil, status.Error(codes.Unauthenticated, "userinfo_failed") } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - s.logger.Error("Google user info returned non-200", "status", resp.StatusCode) + m.runtime.Logger().Error("Google user info returned non-200", "status", resp.StatusCode) return nil, status.Error(codes.Unauthenticated, "userinfo_failed") } - - var googleUser struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Picture string `json:"picture"` - } + var googleUser struct { ID, Email, Name, Picture string } if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil { - s.logger.Error("Failed to decode Google user info", "error", err) + m.runtime.Logger().Error("Failed to decode Google user info", "error", err) return nil, status.Error(codes.Internal, "userinfo_parse_failed") } - email := strings.TrimSpace(strings.ToLower(googleUser.Email)) refUsername := strings.TrimSpace(req.GetRefUsername()) if email == "" { return nil, status.Error(codes.InvalidArgument, "missing_email") } - u := query.User user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { - s.logger.Error("Failed to load Google user", "error", err) + m.runtime.Logger().Error("Failed to load Google user", "error", err) return nil, status.Error(codes.Internal, "load_user_failed") } - referrerID, resolveErr := s.resolveSignupReferrerID(ctx, refUsername, googleUser.Name) + referrerID, resolveErr := m.users.ResolveSignupReferrerID(ctx, refUsername, googleUser.Name) if resolveErr != nil { - s.logger.Error("Failed to resolve Google signup referrer", "error", resolveErr) + m.runtime.Logger().Error("Failed to resolve Google signup referrer", "error", resolveErr) return nil, status.Error(codes.Internal, "create_user_failed") } role := "USER" - user = &model.User{ - ID: uuid.New().String(), - Email: email, - Username: stringPointerOrNil(googleUser.Name), - GoogleID: stringPointerOrNil(googleUser.ID), - Avatar: stringPointerOrNil(googleUser.Picture), - Role: &role, - ReferredByUserID: referrerID, - ReferralEligible: model.BoolPtr(true), - } + user = &model.User{ID: uuid.New().String(), Email: email, Username: common.StringPointerOrNil(googleUser.Name), GoogleID: common.StringPointerOrNil(googleUser.ID), Avatar: common.StringPointerOrNil(googleUser.Picture), Role: &role, ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true)} if err := u.WithContext(ctx).Create(user); err != nil { - s.logger.Error("Failed to create Google user", "error", err) + m.runtime.Logger().Error("Failed to create Google user", "error", err) return nil, status.Error(codes.Internal, "create_user_failed") } } else { - updates := map[string]interface{}{} - if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" { - updates["google_id"] = googleUser.ID - } - if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" { - updates["avatar"] = googleUser.Picture - } - if user.Username == nil || strings.TrimSpace(*user.Username) == "" { - updates["username"] = googleUser.Name - } + updates := map[string]any{} + if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" { updates["google_id"] = googleUser.ID } + if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" { updates["avatar"] = googleUser.Picture } + if user.Username == nil || strings.TrimSpace(*user.Username) == "" { updates["username"] = googleUser.Name } if len(updates) > 0 { - if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { - s.logger.Error("Failed to update Google user", "error", err) + if err := m.runtime.DB().WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { + m.runtime.Logger().Error("Failed to update Google user", "error", err) return nil, status.Error(codes.Internal, "update_user_failed") } user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First() if err != nil { - s.logger.Error("Failed to reload Google user", "error", err) + m.runtime.Logger().Error("Failed to reload Google user", "error", err) return nil, status.Error(codes.Internal, "reload_user_failed") } } } - - if err := s.issueSessionCookies(ctx, user); err != nil { + if err := m.runtime.IssueSessionCookies(ctx, user); err != nil { return nil, status.Error(codes.Internal, "session_failed") } - - payload, err := buildUserPayload(ctx, s.db, user) + payload, err := common.BuildUserPayload(ctx, m.runtime.DB(), user) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } - return &appv1.CompleteGoogleLoginResponse{User: toProtoUser(payload)}, nil + return &appv1.CompleteGoogleLoginResponse{User: common.ToProtoUser(payload)}, nil } diff --git a/internal/modules/auth/types.go b/internal/modules/auth/types.go new file mode 100644 index 0000000..f8185e7 --- /dev/null +++ b/internal/modules/auth/types.go @@ -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 +} diff --git a/internal/modules/common/helpers.go b/internal/modules/common/helpers.go new file mode 100644 index 0000000..0ad1363 --- /dev/null +++ b/internal/modules/common/helpers.go @@ -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), + } +} diff --git a/internal/modules/common/runtime.go b/internal/modules/common/runtime.go new file mode 100644 index 0000000..7945b15 --- /dev/null +++ b/internal/modules/common/runtime.go @@ -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() +} diff --git a/internal/rpc/app/user_payload.go b/internal/modules/common/user_payload.go similarity index 95% rename from internal/rpc/app/user_payload.go rename to internal/modules/common/user_payload.go index e10c24d..3936f4e 100644 --- a/internal/rpc/app/user_payload.go +++ b/internal/modules/common/user_payload.go @@ -1,4 +1,4 @@ -package app +package common import ( "context" @@ -10,7 +10,7 @@ import ( "stream.api/internal/database/model" ) -type userPayload struct { +type UserPayload struct { ID string `json:"id"` Email string `json:"email"` Username *string `json:"username,omitempty"` @@ -31,7 +31,7 @@ type userPayload struct { UpdatedAt time.Time `json:"updated_at"` } -func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*userPayload, error) { +func BuildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*UserPayload, error) { pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID) if err != nil { return nil, err @@ -82,7 +82,7 @@ func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*user } } - return &userPayload{ + return &UserPayload{ ID: user.ID, Email: user.Email, Username: user.Username, diff --git a/internal/modules/dashboard/module.go b/internal/modules/dashboard/module.go new file mode 100644 index 0000000..ec8b7c3 --- /dev/null +++ b/internal/modules/dashboard/module.go @@ -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 +} diff --git a/internal/modules/domains/handler.go b/internal/modules/domains/handler.go new file mode 100644 index 0000000..fcf2648 --- /dev/null +++ b/internal/modules/domains/handler.go @@ -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} +} diff --git a/internal/modules/domains/module.go b/internal/modules/domains/module.go new file mode 100644 index 0000000..29925a7 --- /dev/null +++ b/internal/modules/domains/module.go @@ -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 +} diff --git a/internal/modules/domains/presenter.go b/internal/modules/domains/presenter.go new file mode 100644 index 0000000..092bcc4 --- /dev/null +++ b/internal/modules/domains/presenter.go @@ -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)} +} diff --git a/internal/modules/domains/types.go b/internal/modules/domains/types.go new file mode 100644 index 0000000..4053813 --- /dev/null +++ b/internal/modules/domains/types.go @@ -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 +} diff --git a/internal/modules/jobs/handler.go b/internal/modules/jobs/handler.go new file mode 100644 index 0000000..9203954 --- /dev/null +++ b/internal/modules/jobs/handler.go @@ -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 +} diff --git a/internal/modules/jobs/module.go b/internal/modules/jobs/module.go new file mode 100644 index 0000000..a89c902 --- /dev/null +++ b/internal/modules/jobs/module.go @@ -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) +} diff --git a/internal/modules/jobs/presenter.go b/internal/modules/jobs/presenter.go new file mode 100644 index 0000000..bef529c --- /dev/null +++ b/internal/modules/jobs/presenter.go @@ -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} +} diff --git a/internal/modules/jobs/types.go b/internal/modules/jobs/types.go new file mode 100644 index 0000000..0f88288 --- /dev/null +++ b/internal/modules/jobs/types.go @@ -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 +} diff --git a/internal/modules/payments/errors.go b/internal/modules/payments/errors.go new file mode 100644 index 0000000..e4a9b18 --- /dev/null +++ b/internal/modules/payments/errors.go @@ -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, + } +} + diff --git a/internal/modules/payments/handler.go b/internal/modules/payments/handler.go new file mode 100644 index 0000000..fce3da7 --- /dev/null +++ b/internal/modules/payments/handler.go @@ -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) +} diff --git a/internal/modules/payments/module.go b/internal/modules/payments/module.go new file mode 100644 index 0000000..f002ed4 --- /dev/null +++ b/internal/modules/payments/module.go @@ -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 +} diff --git a/internal/modules/payments/presenter.go b/internal/modules/payments/presenter.go new file mode 100644 index 0000000..8931cc0 --- /dev/null +++ b/internal/modules/payments/presenter.go @@ -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()) +} diff --git a/internal/modules/payments/types.go b/internal/modules/payments/types.go new file mode 100644 index 0000000..7aafe0b --- /dev/null +++ b/internal/modules/payments/types.go @@ -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 +} diff --git a/internal/modules/plans/handler.go b/internal/modules/plans/handler.go new file mode 100644 index 0000000..151b151 --- /dev/null +++ b/internal/modules/plans/handler.go @@ -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 +} diff --git a/internal/modules/plans/module.go b/internal/modules/plans/module.go new file mode 100644 index 0000000..c9c5c64 --- /dev/null +++ b/internal/modules/plans/module.go @@ -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 "" +} diff --git a/internal/modules/plans/presenter.go b/internal/modules/plans/presenter.go new file mode 100644 index 0000000..56d08f6 --- /dev/null +++ b/internal/modules/plans/presenter.go @@ -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} +} diff --git a/internal/modules/plans/types.go b/internal/modules/plans/types.go new file mode 100644 index 0000000..83f3d84 --- /dev/null +++ b/internal/modules/plans/types.go @@ -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 +} diff --git a/internal/modules/playerconfigs/handler.go b/internal/modules/playerconfigs/handler.go new file mode 100644 index 0000000..bd486b0 --- /dev/null +++ b/internal/modules/playerconfigs/handler.go @@ -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 +} diff --git a/internal/modules/playerconfigs/module.go b/internal/modules/playerconfigs/module.go new file mode 100644 index 0000000..cec4563 --- /dev/null +++ b/internal/modules/playerconfigs/module.go @@ -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 "" +} diff --git a/internal/modules/playerconfigs/presenter.go b/internal/modules/playerconfigs/presenter.go new file mode 100644 index 0000000..06cdff4 --- /dev/null +++ b/internal/modules/playerconfigs/presenter.go @@ -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()) +} diff --git a/internal/modules/playerconfigs/types.go b/internal/modules/playerconfigs/types.go new file mode 100644 index 0000000..37cd7c9 --- /dev/null +++ b/internal/modules/playerconfigs/types.go @@ -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 +} diff --git a/internal/modules/users/handler.go b/internal/modules/users/handler.go new file mode 100644 index 0000000..f86a294 --- /dev/null +++ b/internal/modules/users/handler.go @@ -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} } diff --git a/internal/modules/users/module.go b/internal/modules/users/module.go new file mode 100644 index 0000000..813e0b3 --- /dev/null +++ b/internal/modules/users/module.go @@ -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 +} diff --git a/internal/modules/users/presenter.go b/internal/modules/users/presenter.go new file mode 100644 index 0000000..407d252 --- /dev/null +++ b/internal/modules/users/presenter.go @@ -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), + } +} diff --git a/internal/modules/users/types.go b/internal/modules/users/types.go new file mode 100644 index 0000000..c6b20d8 --- /dev/null +++ b/internal/modules/users/types.go @@ -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 +} diff --git a/internal/modules/videos/handler.go b/internal/modules/videos/handler.go new file mode 100644 index 0000000..9d34402 --- /dev/null +++ b/internal/modules/videos/handler.go @@ -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 +} diff --git a/internal/modules/videos/module.go b/internal/modules/videos/module.go new file mode 100644 index 0000000..aa6d80a --- /dev/null +++ b/internal/modules/videos/module.go @@ -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 +} diff --git a/internal/modules/videos/presenter.go b/internal/modules/videos/presenter.go new file mode 100644 index 0000000..677ca3b --- /dev/null +++ b/internal/modules/videos/presenter.go @@ -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()) +} diff --git a/internal/modules/videos/types.go b/internal/modules/videos/types.go new file mode 100644 index 0000000..a00df70 --- /dev/null +++ b/internal/modules/videos/types.go @@ -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 +} diff --git a/internal/rpc/app/core.go b/internal/rpc/app/core.go new file mode 100644 index 0000000..087a631 --- /dev/null +++ b/internal/rpc/app/core.go @@ -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{} +) diff --git a/internal/rpc/app/preferences_helpers.go b/internal/rpc/app/preferences_helpers.go deleted file mode 100644 index 8bd835f..0000000 --- a/internal/rpc/app/preferences_helpers.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/profile_helpers.go b/internal/rpc/app/profile_helpers.go deleted file mode 100644 index 0f79341..0000000 --- a/internal/rpc/app/profile_helpers.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/service_account.go b/internal/rpc/app/service_account.go deleted file mode 100644 index 42465ce..0000000 --- a/internal/rpc/app/service_account.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/service_admin_finance_catalog.go b/internal/rpc/app/service_admin_finance_catalog.go deleted file mode 100644 index 7af2bf6..0000000 --- a/internal/rpc/app/service_admin_finance_catalog.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/service_admin_finance_catalog_test.go b/internal/rpc/app/service_admin_finance_catalog_test.go index 9110cee..5e3ed1c 100644 --- a/internal/rpc/app/service_admin_finance_catalog_test.go +++ b/internal/rpc/app/service_admin_finance_catalog_test.go @@ -9,29 +9,23 @@ import ( "google.golang.org/grpc/metadata" "stream.api/internal/database/model" appv1 "stream.api/internal/gen/proto/app/v1" + "stream.api/internal/modules/common" ) func TestCreateAdminPayment(t *testing.T) { - t.Run("happy path admin", func(t *testing.T) { db := newTestDB(t) services := newTestAppServices(t, db) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Team", Price: 30, Cycle: "monthly", StorageLimit: 200, UploadLimit: 20, QualityLimit: "1440p", IsActive: ptrBool(true)}) - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")}) + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")}) conn, cleanup := newTestGRPCServer(t, services) defer cleanup() client := newAdminClient(conn) - resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{ - UserId: user.ID, - PlanId: plan.ID, - TermMonths: 1, - PaymentMethod: paymentMethodTopup, - TopupAmount: ptrFloat64(25), - }) + resp, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)}) if err != nil { t.Fatalf("CreateAdminPayment() error = %v", err) } @@ -41,8 +35,8 @@ func TestCreateAdminPayment(t *testing.T) { if resp.Payment.UserId != user.ID { t.Fatalf("payment user_id = %q, want %q", resp.Payment.UserId, user.ID) } - if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) { - t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id)) + if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) { + t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id)) } if resp.Payment.GetWalletAmount() != 30 { t.Fatalf("payment wallet_amount = %v, want 30", resp.Payment.GetWalletAmount()) @@ -64,12 +58,7 @@ func TestCreateAdminPayment(t *testing.T) { client := newAdminClient(conn) var trailer metadata.MD - _, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{ - UserId: user.ID, - PlanId: plan.ID, - TermMonths: 1, - PaymentMethod: paymentMethodWallet, - }, grpc.Trailer(&trailer)) + _, err := client.CreateAdminPayment(testActorOutgoingContext(admin.ID, "ADMIN"), &appv1.CreateAdminPaymentRequest{UserId: user.ID, PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer)) assertGRPCCode(t, err, codes.InvalidArgument) if body := firstTestMetadataValue(trailer, "x-error-body"); body == "" { t.Fatal("expected x-error-body trailer") diff --git a/internal/rpc/app/service_admin_jobs_agents.go b/internal/rpc/app/service_admin_jobs_agents.go deleted file mode 100644 index c8f2a0a..0000000 --- a/internal/rpc/app/service_admin_jobs_agents.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/service_admin_users_videos.go b/internal/rpc/app/service_admin_users_videos.go deleted file mode 100644 index 9df3fc2..0000000 --- a/internal/rpc/app/service_admin_users_videos.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/service_core.go b/internal/rpc/app/service_core.go deleted file mode 100644 index d98ee81..0000000 --- a/internal/rpc/app/service_core.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/service_helpers.go b/internal/rpc/app/service_helpers.go deleted file mode 100644 index 90667f8..0000000 --- a/internal/rpc/app/service_helpers.go +++ /dev/null @@ -1,1760 +0,0 @@ -package app - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/google/uuid" - "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" - "stream.api/internal/middleware" - "stream.api/internal/video/runtime/domain" - "stream.api/internal/video/runtime/services" -) - -func (s *appServices) requireAdmin(ctx context.Context) (*middleware.AuthResult, error) { - result, err := s.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 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 *domain.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 *services.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 (s *appServices) 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 := s.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 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 sameTrimmedStringFold(left *string, right string) bool { - if left == nil { - return false - } - return strings.EqualFold(strings.TrimSpace(*left), strings.TrimSpace(right)) -} - -func (s *appServices) buildReferralShareLink(username *string) *string { - trimmed := strings.TrimSpace(stringValue(username)) - if trimmed == "" { - return nil - } - path := "/ref/" + url.PathEscape(trimmed) - base := strings.TrimRight(strings.TrimSpace(s.frontendBaseURL), "/") - if base == "" { - return &path - } - link := base + path - return &link -} - -func (s *appServices) loadReferralUsersByUsername(ctx context.Context, username string) ([]model.User, error) { - trimmed := strings.TrimSpace(username) - if trimmed == "" { - return nil, nil - } - var users []model.User - if err := s.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 (s *appServices) resolveReferralUserByUsername(ctx context.Context, username string) (*model.User, error) { - users, err := s.loadReferralUsersByUsername(ctx, username) - if err != nil { - return nil, err - } - if len(users) != 1 { - return nil, nil - } - return &users[0], nil -} - -func (s *appServices) 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 := s.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 (s *appServices) 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 := s.resolveReferralUserByUsername(ctx, trimmedRefUsername) - if err != nil { - return nil, err - } - if referrer == nil { - return nil, nil - } - return &referrer.ID, nil -} -func (s *appServices) 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 (s *appServices) buildAdminUser(ctx context.Context, user *model.User) (*appv1.AdminUser, error) { - if user == nil { - return nil, nil - } - - payload := &appv1.AdminUser{ - Id: user.ID, - Email: user.Email, - Username: nullableTrimmedString(user.Username), - Avatar: nullableTrimmedString(user.Avatar), - Role: nullableTrimmedString(user.Role), - PlanId: nullableTrimmedString(user.PlanID), - StorageUsed: user.StorageUsed, - CreatedAt: timeToProto(user.CreatedAt), - UpdatedAt: timestamppb.New(user.UpdatedAt.UTC()), - WalletBalance: 0, - } - - videoCount, err := s.loadAdminUserVideoCount(ctx, user.ID) - if err != nil { - return nil, err - } - payload.VideoCount = videoCount - - walletBalance, err := model.GetWalletBalance(ctx, s.db, user.ID) - if err != nil { - return nil, err - } - payload.WalletBalance = walletBalance - - planName, err := s.loadAdminPlanName(ctx, user.PlanID) - if err != nil { - return nil, err - } - payload.PlanName = planName - - return payload, nil -} - -func (s *appServices) buildAdminUserDetail(ctx context.Context, user *model.User, subscription *model.PlanSubscription) (*appv1.AdminUserDetail, error) { - payload, err := s.buildAdminUser(ctx, user) - if err != nil { - return nil, err - } - referral, err := s.buildAdminUserReferralInfo(ctx, user) - if err != nil { - return nil, err - } - return &appv1.AdminUserDetail{ - User: payload, - Subscription: toProtoPlanSubscription(subscription), - Referral: referral, - }, nil -} - -func (s *appServices) buildAdminUserReferralInfo(ctx context.Context, user *model.User) (*appv1.AdminUserReferralInfo, error) { - if user == nil { - return nil, nil - } - - var referrer *appv1.ReferralUserSummary - if user.ReferredByUserID != nil && strings.TrimSpace(*user.ReferredByUserID) != "" { - loadedReferrer, err := s.loadReferralUserSummary(ctx, strings.TrimSpace(*user.ReferredByUserID)) - if err != nil { - return nil, err - } - referrer = loadedReferrer - } - - bps := effectiveReferralRewardBps(user.ReferralRewardBps) - referral := &appv1.AdminUserReferralInfo{ - Referrer: referrer, - ReferralEligible: referralUserEligible(user), - EffectiveRewardPercent: referralRewardBpsToPercent(bps), - RewardOverridePercent: func() *float64 { - if user.ReferralRewardBps == nil { - return nil - } - value := referralRewardBpsToPercent(*user.ReferralRewardBps) - return &value - }(), - ShareLink: s.buildReferralShareLink(user.Username), - RewardGranted: referralRewardProcessed(user), - RewardGrantedAt: timeToProto(user.ReferralRewardGrantedAt), - RewardPaymentId: nullableTrimmedString(user.ReferralRewardPaymentID), - RewardAmount: user.ReferralRewardAmount, - } - return referral, nil -} -func (s *appServices) buildAdminVideo(ctx context.Context, video *model.Video) (*appv1.AdminVideo, error) { - if video == nil { - return nil, nil - } - - statusValue := stringValue(video.Status) - if statusValue == "" { - statusValue = "ready" - } - jobID, err := s.loadLatestVideoJobID(ctx, video.ID) - if err != nil { - return nil, err - } - - payload := &appv1.AdminVideo{ - Id: video.ID, - UserId: video.UserID, - Title: video.Title, - Description: nullableTrimmedString(video.Description), - Url: video.URL, - Status: strings.ToLower(statusValue), - Size: video.Size, - Duration: video.Duration, - Format: video.Format, - CreatedAt: timeToProto(video.CreatedAt), - UpdatedAt: timestamppb.New(video.UpdatedAt.UTC()), - ProcessingStatus: nullableTrimmedString(video.ProcessingStatus), - JobId: jobID, - } - - ownerEmail, err := s.loadAdminUserEmail(ctx, video.UserID) - if err != nil { - return nil, err - } - payload.OwnerEmail = ownerEmail - - adTemplateID, adTemplateName, err := s.loadAdminVideoAdTemplateDetails(ctx, video) - if err != nil { - return nil, err - } - payload.AdTemplateId = adTemplateID - payload.AdTemplateName = adTemplateName - - return payload, nil -} -func (s *appServices) buildAdminPayment(ctx context.Context, payment *model.Payment) (*appv1.AdminPayment, error) { - if payment == nil { - return nil, nil - } - - payload := &appv1.AdminPayment{ - Id: payment.ID, - UserId: payment.UserID, - PlanId: nullableTrimmedString(payment.PlanID), - Amount: payment.Amount, - Currency: normalizeCurrency(payment.Currency), - Status: normalizePaymentStatus(payment.Status), - Provider: strings.ToUpper(stringValue(payment.Provider)), - TransactionId: nullableTrimmedString(payment.TransactionID), - InvoiceId: payment.ID, - CreatedAt: timeToProto(payment.CreatedAt), - UpdatedAt: timestamppb.New(payment.UpdatedAt.UTC()), - } - - userEmail, err := s.loadAdminUserEmail(ctx, payment.UserID) - if err != nil { - return nil, err - } - payload.UserEmail = userEmail - - planName, err := s.loadAdminPlanName(ctx, payment.PlanID) - if err != nil { - return nil, err - } - payload.PlanName = planName - - termMonths, paymentMethod, expiresAt, walletAmount, topupAmount, err := s.loadAdminPaymentSubscriptionDetails(ctx, payment.ID) - if err != nil { - return nil, err - } - payload.TermMonths = termMonths - payload.PaymentMethod = paymentMethod - payload.ExpiresAt = expiresAt - payload.WalletAmount = walletAmount - payload.TopupAmount = topupAmount - - return payload, nil -} -func (s *appServices) loadAdminUserVideoCount(ctx context.Context, userID string) (int64, error) { - var videoCount int64 - if err := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", userID).Count(&videoCount).Error; err != nil { - return 0, err - } - return videoCount, nil -} - -func (s *appServices) loadAdminUserEmail(ctx context.Context, userID string) (*string, error) { - var user model.User - if err := s.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 nullableTrimmedString(&user.Email), nil -} - -func (s *appServices) loadReferralUserSummary(ctx context.Context, userID string) (*appv1.ReferralUserSummary, error) { - if strings.TrimSpace(userID) == "" { - return nil, nil - } - var user model.User - if err := s.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 &appv1.ReferralUserSummary{ - Id: user.ID, - Email: user.Email, - Username: nullableTrimmedString(user.Username), - }, nil -} - -func (s *appServices) loadAdminPlanName(ctx context.Context, planID *string) (*string, error) { - if planID == nil || strings.TrimSpace(*planID) == "" { - return nil, nil - } - var plan model.Plan - if err := s.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 nullableTrimmedString(&plan.Name), nil -} - -func (s *appServices) loadAdminVideoAdTemplateDetails(ctx context.Context, video *model.Video) (*string, *string, error) { - if video == nil { - return nil, nil, nil - } - adTemplateID := nullableTrimmedString(video.AdID) - if adTemplateID == nil { - return nil, nil, nil - } - adTemplateName, err := s.loadAdminAdTemplateName(ctx, *adTemplateID) - if err != nil { - return nil, nil, err - } - return adTemplateID, adTemplateName, nil -} - -func (s *appServices) loadAdminAdTemplateName(ctx context.Context, adTemplateID string) (*string, error) { - var template model.AdTemplate - if err := s.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 nullableTrimmedString(&template.Name), nil -} - -func (s *appServices) loadLatestVideoJobID(ctx context.Context, videoID string) (*string, error) { - videoID = strings.TrimSpace(videoID) - if videoID == "" { - return nil, nil - } - - var job model.Job - if err := s.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 stringPointerOrNil(job.ID), nil -} - -func (s *appServices) loadAdminPaymentSubscriptionDetails(ctx context.Context, paymentID string) (*int32, *string, *string, *float64, *float64, error) { - var subscription model.PlanSubscription - if err := s.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 := nullableTrimmedString(&subscription.PaymentMethod) - expiresAt := subscription.ExpiresAt.UTC().Format(time.RFC3339) - walletAmount := subscription.WalletAmount - topupAmount := subscription.TopupAmount - return &termMonths, paymentMethod, nullableTrimmedString(&expiresAt), &walletAmount, &topupAmount, nil -} - -func (s *appServices) loadAdminPlanUsageCounts(ctx context.Context, planID string) (int64, int64, int64, error) { - var userCount int64 - if err := s.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 := s.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 := s.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 "" -} -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 := normalizeAdFormat(adFormat) - if format == "mid-roll" && (duration == nil || *duration <= 0) { - return "Duration is required for mid-roll templates" - } - return "" -} -func validateAdminPlayerConfigInput(userID, name string) string { - if strings.TrimSpace(userID) == "" { - return "User ID is required" - } - if strings.TrimSpace(name) == "" { - return "Name is required" - } - return "" -} -func (s *appServices) unsetAdminDefaultTemplates(ctx context.Context, tx *gorm.DB, userID, excludeID string) error { - query := tx.WithContext(ctx).Model(&model.AdTemplate{}).Where("user_id = ?", userID) - if excludeID != "" { - query = query.Where("id <> ?", excludeID) - } - return query.Update("is_default", false).Error -} -func (s *appServices) unsetAdminDefaultPlayerConfigs(ctx context.Context, tx *gorm.DB, userID, excludeID string) error { - query := tx.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", userID) - if excludeID != "" { - query = query.Where("id <> ?", excludeID) - } - return query.Update("is_default", false).Error -} -func (s *appServices) buildAdminPlan(ctx context.Context, plan *model.Plan) (*appv1.AdminPlan, error) { - if plan == nil { - return nil, nil - } - - userCount, paymentCount, subscriptionCount, err := s.loadAdminPlanUsageCounts(ctx, plan.ID) - if err != nil { - return nil, err - } - - payload := &appv1.AdminPlan{ - Id: plan.ID, - Name: plan.Name, - Description: nullableTrimmedString(plan.Description), - Features: append([]string(nil), plan.Features...), - Price: plan.Price, - Cycle: plan.Cycle, - StorageLimit: plan.StorageLimit, - UploadLimit: plan.UploadLimit, - DurationLimit: plan.DurationLimit, - QualityLimit: plan.QualityLimit, - IsActive: boolValue(plan.IsActive), - UserCount: userCount, - PaymentCount: paymentCount, - SubscriptionCount: subscriptionCount, - } - return payload, nil -} -func (s *appServices) buildAdminAdTemplate(ctx context.Context, item *model.AdTemplate) (*appv1.AdminAdTemplate, error) { - if item == nil { - return nil, nil - } - - payload := &appv1.AdminAdTemplate{ - Id: item.ID, - UserId: item.UserID, - Name: item.Name, - Description: nullableTrimmedString(item.Description), - VastTagUrl: item.VastTagURL, - AdFormat: stringValue(item.AdFormat), - Duration: item.Duration, - IsActive: boolValue(item.IsActive), - IsDefault: item.IsDefault, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timeToProto(item.UpdatedAt), - } - - ownerEmail, err := s.loadAdminUserEmail(ctx, item.UserID) - if err != nil { - return nil, err - } - payload.OwnerEmail = ownerEmail - - return payload, nil -} -func (s *appServices) buildAdminPlayerConfig(ctx context.Context, item *model.PlayerConfig) (*appv1.AdminPlayerConfig, error) { - if item == nil { - return nil, nil - } - - payload := &appv1.AdminPlayerConfig{ - Id: item.ID, - UserId: item.UserID, - Name: item.Name, - Description: nullableTrimmedString(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), - } - - ownerEmail, err := s.loadAdminUserEmail(ctx, item.UserID) - if err != nil { - return nil, err - } - payload.OwnerEmail = ownerEmail - - return payload, nil -} -func (s *appServices) authenticate(ctx context.Context) (*middleware.AuthResult, error) { - return s.authenticator.Authenticate(ctx) -} -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 (s *appServices) loadPaymentPlanForUser(ctx context.Context, planID string) (*model.Plan, error) { - var planRecord model.Plan - if err := s.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") - } - s.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 (s *appServices) loadPaymentPlanForAdmin(ctx context.Context, planID string) (*model.Plan, error) { - var planRecord model.Plan - if err := s.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 (s *appServices) loadPaymentUserForAdmin(ctx context.Context, userID string) (*model.User, error) { - 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 create payment") - } - return &user, nil -} - -func (s *appServices) executePaymentFlow(ctx context.Context, input paymentExecutionInput) (*paymentExecutionResult, 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 := normalizeCurrency(nil) - transactionID := 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 := buildInvoiceID(paymentRecord.ID) - - result := &paymentExecutionResult{ - Payment: paymentRecord, - InvoiceID: invoiceID, - } - - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if _, err := 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(ctx, 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 - } - notification := buildSubscriptionNotification(input.UserID, paymentRecord.ID, invoiceID, input.Plan, subscription) - if err := tx.Create(notification).Error; err != nil { - return err - } - if _, err := s.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(ctx context.Context, input paymentExecutionInput, totalAmount, currentWalletBalance float64) (float64, error) { - shortfall := maxFloat(totalAmount-currentWalletBalance, 0) - if input.PaymentMethod == paymentMethodWallet && shortfall > 0 { - return 0, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "Insufficient wallet balance", map[string]any{ - "payment_method": input.PaymentMethod, - "wallet_balance": currentWalletBalance, - "total_amount": totalAmount, - "shortfall": shortfall, - }) - } - if input.PaymentMethod != paymentMethodTopup { - return 0, nil - } - if input.TopupAmount == nil { - return 0, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "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 := maxFloat(*input.TopupAmount, 0) - if topupAmount <= 0 { - return 0, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "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, statusErrorWithBody(ctx, codes.InvalidArgument, http.StatusBadRequest, "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 paymentExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, currency string) error { - if input.PaymentMethod == paymentMethodTopup { - topupTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: input.UserID, - Type: 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: int32Ptr(input.TermMonths), - } - if err := tx.Create(topupTransaction).Error; err != nil { - return err - } - } - debitTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: input.UserID, - Type: 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: int32Ptr(input.TermMonths), - } - return tx.Create(debitTransaction).Error -} - -func buildPaymentSubscription(input paymentExecutionInput, 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 (s *appServices) issueSessionCookies(ctx context.Context, user *model.User) error { - if user == nil { - return status.Error(codes.Unauthenticated, "Unauthorized") - } - tokenPair, err := s.tokenProvider.GenerateTokenPair(user.ID, user.Email, safeRole(user.Role)) - if err != nil { - s.logger.Error("Token generation failed", "error", err) - return status.Error(codes.Internal, "Error generating tokens") - } - - if err := s.cache.Set(ctx, "refresh_uuid:"+tokenPair.RefreshUUID, user.ID, time.Until(time.Unix(tokenPair.RtExpires, 0))); err != nil { - s.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 { - s.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() -} -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) - } - return nil - case "delete": - return nil - case "update", "set-default", "toggle-active": - if configCount > 1 { - return status.Error(codes.FailedPrecondition, playerConfigFreePlanReconciliationMessage) - } - return nil - default: - return nil - } -} -func safeRole(role *string) string { - if role == nil || strings.TrimSpace(*role) == "" { - return "USER" - } - return *role -} -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 stringPointerOrNil(value string) *string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return nil - } - return &trimmed -} -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 (s *appServices) buildVideo(ctx context.Context, video *model.Video) (*appv1.Video, error) { - if video == nil { - return nil, nil - } - jobID, err := s.loadLatestVideoJobID(ctx, video.ID) - if err != nil { - return nil, err - } - if jobID != nil { - return toProtoVideo(video, *jobID), nil - } - return toProtoVideo(video), nil -} - -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 protoUserFromPayload(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 toProtoUser(user *userPayload) *appv1.User { - return protoUserFromPayload(user) -} -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), - } -} -func timeToProto(value *time.Time) *timestamppb.Timestamp { - if value == nil { - return nil - } - return timestamppb.New(value.UTC()) -} -func boolValue(value *bool) bool { - return value != nil && *value -} -func stringValue(value *string) string { - if value == nil { - return "" - } - return *value -} -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 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 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(status *string) string { - value := strings.ToLower(strings.TrimSpace(stringValue(status))) - 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 (s *appServices) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) { - details, err := s.loadPaymentInvoiceDetails(ctx, paymentRecord) - if err != nil { - return "", "", err - } - - createdAt := formatOptionalTimestamp(paymentRecord.CreatedAt) - lines := []string{ - "Stream API Invoice", - fmt.Sprintf("Invoice ID: %s", 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, normalizeCurrency(paymentRecord.Currency)), - fmt.Sprintf("Status: %s", strings.ToUpper(normalizePaymentStatus(paymentRecord.Status))), - fmt.Sprintf("Provider: %s", strings.ToUpper(stringValue(paymentRecord.Provider))), - fmt.Sprintf("Payment Method: %s", strings.ToUpper(details.PaymentMethod)), - fmt.Sprintf("Transaction ID: %s", 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, normalizeCurrency(paymentRecord.Currency))) - } - if details.TopupAmount > 0 { - lines = append(lines, fmt.Sprintf("Top-up Added: %.2f %s", details.TopupAmount, normalizeCurrency(paymentRecord.Currency))) - } - lines = append(lines, fmt.Sprintf("Created At: %s", createdAt)) - - return strings.Join(lines, "\n"), buildInvoiceFilename(paymentRecord.ID), nil -} -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 (s *appServices) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) { - details := &paymentInvoiceDetails{ - PlanName: "Unknown plan", - PaymentMethod: paymentMethodWallet, - } - - if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" { - var planRecord model.Plan - if err := s.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 := s.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 - } - - details.TermMonths = &subscription.TermMonths - details.PaymentMethod = normalizePaymentMethod(subscription.PaymentMethod) - if details.PaymentMethod == "" { - details.PaymentMethod = paymentMethodWallet - } - details.ExpiresAt = &subscription.ExpiresAt - details.WalletAmount = subscription.WalletAmount - details.TopupAmount = subscription.TopupAmount - - return details, nil -} -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(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(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(mustMarshalJSON(map[string]any{ - "payment_id": paymentRecord.ID, - "referee_id": referee.ID, - "amount": rewardAmount, - })), - } -} - -func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB, input paymentExecutionInput, paymentRecord *model.Payment, subscription *model.PlanSubscription) (*referralRewardResult, error) { - if paymentRecord == nil || subscription == nil || input.Plan == nil { - return &referralRewardResult{}, nil - } - if subscription.PaymentMethod != paymentMethodWallet && subscription.PaymentMethod != paymentMethodTopup { - return &referralRewardResult{}, nil - } - - referee, err := lockUserForUpdate(ctx, tx, input.UserID) - if err != nil { - return nil, err - } - if referee.ReferredByUserID == nil || strings.TrimSpace(*referee.ReferredByUserID) == "" { - return &referralRewardResult{}, nil - } - if 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 := 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 || !referralUserEligible(referrer) { - return &referralRewardResult{}, nil - } - - bps := 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 := normalizeCurrency(paymentRecord.Currency) - rewardTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: referrer.ID, - Type: 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 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 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) -} diff --git a/internal/rpc/app/service_helpers_payment_flow_test.go b/internal/rpc/app/service_helpers_payment_flow_test.go index 74d1569..0c60e7f 100644 --- a/internal/rpc/app/service_helpers_payment_flow_test.go +++ b/internal/rpc/app/service_helpers_payment_flow_test.go @@ -8,144 +8,77 @@ import ( "github.com/google/uuid" "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "stream.api/internal/database/model" + "stream.api/internal/modules/common" + paymentsmodule "stream.api/internal/modules/payments" ) func TestValidatePaymentFunding(t *testing.T) { - - baseInput := paymentExecutionInput{PaymentMethod: paymentMethodWallet} + baseInput := paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodWallet} tests := []struct { name string - input paymentExecutionInput + input paymentsmodule.ExecutionInput totalAmount float64 walletBalance float64 wantTopup float64 wantCode codes.Code wantMessage string }{ - { - name: "wallet đủ tiền", - input: baseInput, - totalAmount: 30, - walletBalance: 30, - wantTopup: 0, - }, - { - name: "wallet thiếu tiền", - input: baseInput, - totalAmount: 50, - walletBalance: 20, - wantCode: codes.InvalidArgument, - wantMessage: "Insufficient wallet balance", - }, - { - name: "topup thiếu amount", - input: paymentExecutionInput{PaymentMethod: paymentMethodTopup}, - totalAmount: 50, - walletBalance: 20, - wantCode: codes.InvalidArgument, - wantMessage: "Top-up amount is required when payment method is topup", - }, - { - name: "topup amount <= 0", - input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(0)}, - totalAmount: 50, - walletBalance: 20, - wantCode: codes.InvalidArgument, - wantMessage: "Top-up amount must be greater than 0", - }, - { - name: "topup amount nhỏ hơn shortfall", - input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(20)}, - totalAmount: 50, - walletBalance: 20, - wantCode: codes.InvalidArgument, - wantMessage: "Top-up amount must be greater than or equal to the required shortfall", - }, - { - name: "topup hợp lệ", - input: paymentExecutionInput{PaymentMethod: paymentMethodTopup, TopupAmount: ptrFloat64(30)}, - totalAmount: 50, - walletBalance: 20, - wantTopup: 30, - }, + {name: "wallet đủ tiền", input: baseInput, totalAmount: 30, walletBalance: 30, wantTopup: 0}, + {name: "wallet thiếu tiền", input: baseInput, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Insufficient wallet balance"}, + {name: "topup thiếu amount", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount is required when payment method is topup"}, + {name: "topup amount <= 0", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(0)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than 0"}, + {name: "topup amount nhỏ hơn shortfall", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(20)}, totalAmount: 50, walletBalance: 20, wantCode: codes.InvalidArgument, wantMessage: "Top-up amount must be greater than or equal to the required shortfall"}, + {name: "topup hợp lệ", input: paymentsmodule.ExecutionInput{PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(30)}, totalAmount: 50, walletBalance: 20, wantTopup: 30}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := validatePaymentFunding(context.Background(), tt.input, tt.totalAmount, tt.walletBalance) + got, err := paymentsmodule.ValidatePaymentFunding(tt.input, tt.totalAmount, tt.walletBalance) if tt.wantCode == codes.OK { if err != nil { - t.Fatalf("validatePaymentFunding() error = %v", err) + t.Fatalf("ValidatePaymentFunding() error = %v", err) } if got != tt.wantTopup { - t.Fatalf("validatePaymentFunding() topup = %v, want %v", got, tt.wantTopup) + t.Fatalf("ValidatePaymentFunding() topup = %v, want %v", got, tt.wantTopup) } return } - if err == nil { - t.Fatalf("validatePaymentFunding() error = nil, want %v", tt.wantCode) + t.Fatalf("ValidatePaymentFunding() error = nil, want %v", tt.wantCode) } - if status.Code(err) != tt.wantCode { - t.Fatalf("validatePaymentFunding() code = %v, want %v", status.Code(err), tt.wantCode) + if validationErr, ok := err.(*paymentsmodule.PaymentValidationError); !ok || codes.Code(validationErr.GRPCCode) != tt.wantCode { + gotCode := codes.Unknown + if ok { + gotCode = codes.Code(validationErr.GRPCCode) + } + t.Fatalf("ValidatePaymentFunding() code = %v, want %v", gotCode, tt.wantCode) } if got := err.Error(); !strings.Contains(got, tt.wantMessage) { - t.Fatalf("validatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage) + t.Fatalf("ValidatePaymentFunding() message = %q, want contains %q", got, tt.wantMessage) } }) } } func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) { - db := newTestDB(t) services := newTestAppServices(t, db) - user := seedTestUser(t, db, model.User{ - ID: uuid.NewString(), - Email: "payer@example.com", - Role: ptrString("USER"), - StorageUsed: 0, - }) - plan := seedTestPlan(t, db, model.Plan{ - ID: uuid.NewString(), - Name: "Pro", - Price: 10, - Cycle: "monthly", - StorageLimit: 100, - UploadLimit: 10, - DurationLimit: 0, - QualityLimit: "1080p", - Features: []string{"priority"}, - IsActive: ptrBool(true), - }) - seedWalletTransaction(t, db, model.WalletTransaction{ - ID: uuid.NewString(), - UserID: user.ID, - Type: walletTransactionTypeTopup, - Amount: 5, - Currency: ptrString("USD"), - Note: ptrString("Initial funds"), - }) + user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Role: ptrString("USER"), StorageUsed: 0}) + plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 10, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, DurationLimit: 0, QualityLimit: "1080p", Features: []string{"priority"}, IsActive: ptrBool(true)}) + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD"), Note: ptrString("Initial funds")}) - result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{ - UserID: user.ID, - Plan: &plan, - TermMonths: 3, - PaymentMethod: paymentMethodTopup, - TopupAmount: ptrFloat64(25), - }) + result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: user.ID, Plan: &plan, TermMonths: 3, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(25)}) if err != nil { - t.Fatalf("executePaymentFlow() error = %v", err) + t.Fatalf("ExecutePaymentFlow() error = %v", err) } if result == nil || result.Payment == nil || result.Subscription == nil { - t.Fatalf("executePaymentFlow() returned incomplete result: %#v", result) + t.Fatalf("ExecutePaymentFlow() returned incomplete result: %#v", result) } - if result.InvoiceID != buildInvoiceID(result.Payment.ID) { - t.Fatalf("invoice id = %q, want %q", result.InvoiceID, buildInvoiceID(result.Payment.ID)) + if result.InvoiceID != common.BuildInvoiceID(result.Payment.ID) { + t.Fatalf("invoice id = %q, want %q", result.InvoiceID, common.BuildInvoiceID(result.Payment.ID)) } if result.WalletBalance != 0 { t.Fatalf("wallet balance = %v, want 0", result.WalletBalance) @@ -158,8 +91,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) { if payment.PlanID == nil || *payment.PlanID != plan.ID { t.Fatalf("payment plan_id = %v, want %s", payment.PlanID, plan.ID) } - if normalizePaymentStatus(payment.Status) != "success" { - t.Fatalf("payment status = %q, want success", normalizePaymentStatus(payment.Status)) + if common.NormalizePaymentStatus(payment.Status) != "success" { + t.Fatalf("payment status = %q, want success", common.NormalizePaymentStatus(payment.Status)) } subscription := mustLoadSubscriptionByPayment(t, db, payment.ID) @@ -172,8 +105,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) { if subscription.TermMonths != 3 { t.Fatalf("subscription term_months = %d, want 3", subscription.TermMonths) } - if subscription.PaymentMethod != paymentMethodTopup { - t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, paymentMethodTopup) + if subscription.PaymentMethod != common.PaymentMethodTopup { + t.Fatalf("subscription payment_method = %q, want %q", subscription.PaymentMethod, common.PaymentMethodTopup) } if subscription.WalletAmount != 30 { t.Fatalf("subscription wallet_amount = %v, want 30", subscription.WalletAmount) @@ -189,10 +122,10 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) { if len(walletTransactions) != 2 { t.Fatalf("wallet transaction count = %d, want 2", len(walletTransactions)) } - if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != walletTransactionTypeTopup { + if walletTransactions[0].Amount != 25 || walletTransactions[0].Type != common.WalletTransactionTypeTopup { t.Fatalf("first wallet transaction = %#v, want topup amount 25", walletTransactions[0]) } - if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != walletTransactionTypeSubscriptionDebit { + if walletTransactions[1].Amount != -30 || walletTransactions[1].Type != common.WalletTransactionTypeSubscriptionDebit { t.Fatalf("second wallet transaction = %#v, want debit amount -30", walletTransactions[1]) } @@ -226,8 +159,8 @@ func TestExecutePaymentFlow_CreatesExpectedRecords(t *testing.T) { if metadataPayload["payment_id"] != payment.ID { t.Fatalf("metadata payment_id = %v, want %q", metadataPayload["payment_id"], payment.ID) } - if metadataPayload["payment_method"] != paymentMethodTopup { - t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], paymentMethodTopup) + if metadataPayload["payment_method"] != common.PaymentMethodTopup { + t.Fatalf("metadata payment_method = %v, want %q", metadataPayload["payment_method"], common.PaymentMethodTopup) } if metadataPayload["wallet_amount"] != 30.0 { t.Fatalf("metadata wallet_amount = %v, want 30", metadataPayload["wallet_amount"]) diff --git a/internal/rpc/app/service_payments.go b/internal/rpc/app/service_payments.go deleted file mode 100644 index 290fd4a..0000000 --- a/internal/rpc/app/service_payments.go +++ /dev/null @@ -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") -} diff --git a/internal/rpc/app/service_payments_test.go b/internal/rpc/app/service_payments_test.go index 106a5bb..ed73b8f 100644 --- a/internal/rpc/app/service_payments_test.go +++ b/internal/rpc/app/service_payments_test.go @@ -13,24 +13,18 @@ import ( "stream.api/internal/database/model" appv1 "stream.api/internal/gen/proto/app/v1" "stream.api/internal/middleware" + "stream.api/internal/modules/common" ) func TestCreatePayment(t *testing.T) { - t.Run("plan không tồn tại", func(t *testing.T) { db := newTestDB(t) services := newTestAppServices(t, db) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) - conn, cleanup := newTestGRPCServer(t, services) defer cleanup() - client := newPaymentsClient(conn) - _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ - PlanId: uuid.NewString(), - TermMonths: 1, - PaymentMethod: paymentMethodWallet, - }) + _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: uuid.NewString(), TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}) assertGRPCCode(t, err, codes.NotFound) }) @@ -39,16 +33,10 @@ func TestCreatePayment(t *testing.T) { services := newTestAppServices(t, db) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(false)}) - conn, cleanup := newTestGRPCServer(t, services) defer cleanup() - client := newPaymentsClient(conn) - _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ - PlanId: plan.ID, - TermMonths: 1, - PaymentMethod: paymentMethodWallet, - }) + _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}) assertGRPCCode(t, err, codes.InvalidArgument) }) @@ -57,16 +45,10 @@ func TestCreatePayment(t *testing.T) { services := newTestAppServices(t, db) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)}) - conn, cleanup := newTestGRPCServer(t, services) defer cleanup() - client := newPaymentsClient(conn) - _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ - PlanId: plan.ID, - TermMonths: 2, - PaymentMethod: paymentMethodWallet, - }) + _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 2, PaymentMethod: common.PaymentMethodWallet}) assertGRPCCode(t, err, codes.InvalidArgument) }) @@ -75,16 +57,10 @@ func TestCreatePayment(t *testing.T) { services := newTestAppServices(t, db) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Starter", Price: 10, Cycle: "monthly", StorageLimit: 10, UploadLimit: 1, QualityLimit: "720p", IsActive: ptrBool(true)}) - conn, cleanup := newTestGRPCServer(t, services) defer cleanup() - client := newPaymentsClient(conn) - _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ - PlanId: plan.ID, - TermMonths: 1, - PaymentMethod: "bank_transfer", - }) + _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: "bank_transfer"}) assertGRPCCode(t, err, codes.InvalidArgument) }) @@ -93,18 +69,12 @@ func TestCreatePayment(t *testing.T) { services := newTestAppServices(t, db) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 50, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)}) - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")}) - + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 10, Currency: ptrString("USD")}) conn, cleanup := newTestGRPCServer(t, services) defer cleanup() - client := newPaymentsClient(conn) var trailer metadata.MD - _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ - PlanId: plan.ID, - TermMonths: 1, - PaymentMethod: paymentMethodWallet, - }, grpc.Trailer(&trailer)) + _, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}, grpc.Trailer(&trailer)) assertGRPCCode(t, err, codes.InvalidArgument) body := firstTestMetadataValue(trailer, "x-error-body") if body == "" { @@ -120,29 +90,22 @@ func TestCreatePayment(t *testing.T) { services := newTestAppServices(t, db) user := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "user@example.com", Role: ptrString("USER")}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)}) - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: walletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")}) - + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: user.ID, Type: common.WalletTransactionTypeTopup, Amount: 5, Currency: ptrString("USD")}) conn, cleanup := newTestGRPCServer(t, services) defer cleanup() - client := newPaymentsClient(conn) - resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{ - PlanId: plan.ID, - TermMonths: 1, - PaymentMethod: paymentMethodTopup, - TopupAmount: ptrFloat64(15), - }) + resp, err := client.CreatePayment(testActorOutgoingContext(user.ID, "USER"), &appv1.CreatePaymentRequest{PlanId: plan.ID, TermMonths: 1, PaymentMethod: common.PaymentMethodTopup, TopupAmount: ptrFloat64(15)}) if err != nil { t.Fatalf("CreatePayment() error = %v", err) } if resp.Payment == nil || resp.Subscription == nil { t.Fatalf("CreatePayment() response incomplete: %#v", resp) } - if resp.InvoiceId != buildInvoiceID(resp.Payment.Id) { - t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, buildInvoiceID(resp.Payment.Id)) + if resp.InvoiceId != common.BuildInvoiceID(resp.Payment.Id) { + t.Fatalf("invoice id = %q, want %q", resp.InvoiceId, common.BuildInvoiceID(resp.Payment.Id)) } - if resp.Subscription.PaymentMethod != paymentMethodTopup { - t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, paymentMethodTopup) + if resp.Subscription.PaymentMethod != common.PaymentMethodTopup { + t.Fatalf("subscription payment method = %q, want %q", resp.Subscription.PaymentMethod, common.PaymentMethodTopup) } if resp.Subscription.WalletAmount != 20 { t.Fatalf("subscription wallet amount = %v, want 20", resp.Subscription.WalletAmount) @@ -153,7 +116,6 @@ func TestCreatePayment(t *testing.T) { if resp.WalletBalance != 0 { t.Fatalf("wallet balance = %v, want 0", resp.WalletBalance) } - payment := mustLoadPayment(t, db, resp.Payment.Id) if payment.Amount != 20 { t.Fatalf("payment amount = %v, want 20", payment.Amount) diff --git a/internal/rpc/app/service_player_configs_test.go b/internal/rpc/app/service_player_configs_test.go index c2be16b..1b1c650 100644 --- a/internal/rpc/app/service_player_configs_test.go +++ b/internal/rpc/app/service_player_configs_test.go @@ -15,6 +15,7 @@ import ( "stream.api/internal/database/model" appv1 "stream.api/internal/gen/proto/app/v1" "stream.api/internal/middleware" + "stream.api/internal/modules/common" ) func TestPlayerConfigsPolicy(t *testing.T) { @@ -50,8 +51,8 @@ func TestPlayerConfigsPolicy(t *testing.T) { _, err := services.CreatePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.CreatePlayerConfigRequest{Name: "Second"}) assertGRPCCode(t, err, codes.FailedPrecondition) - if got := status.Convert(err).Message(); got != playerConfigFreePlanLimitMessage { - t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanLimitMessage) + if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanLimitMessage { + t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanLimitMessage) } }) @@ -107,8 +108,8 @@ func TestPlayerConfigsPolicy(t *testing.T) { IsActive: ptrBool(true), }) assertGRPCCode(t, err, codes.FailedPrecondition) - if got := status.Convert(err).Message(); got != playerConfigFreePlanReconciliationMessage { - t.Fatalf("grpc message = %q, want %q", got, playerConfigFreePlanReconciliationMessage) + if got := status.Convert(err).Message(); got != common.PlayerConfigFreePlanReconciliationMessage { + t.Fatalf("grpc message = %q, want %q", got, common.PlayerConfigFreePlanReconciliationMessage) } _, err = services.DeletePlayerConfig(testActorIncomingContext(user.ID, "USER"), &appv1.DeletePlayerConfigRequest{Id: second.ID}) @@ -212,7 +213,7 @@ func TestPlayerConfigsPolicy(t *testing.T) { t.Fatalf("player config count = %d, want 1", len(items)) } for _, message := range messages { - if message != playerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") { + if message != common.PlayerConfigFreePlanLimitMessage && !strings.Contains(strings.ToLower(message), "locked") { t.Fatalf("unexpected concurrent create error message: %q", message) } } diff --git a/internal/rpc/app/service_referrals_test.go b/internal/rpc/app/service_referrals_test.go index 7b3dad0..ab46860 100644 --- a/internal/rpc/app/service_referrals_test.go +++ b/internal/rpc/app/service_referrals_test.go @@ -3,13 +3,14 @@ package app import ( "context" "testing" - "strings" "github.com/google/uuid" "google.golang.org/grpc/codes" "gorm.io/gorm" "stream.api/internal/database/model" appv1 "stream.api/internal/gen/proto/app/v1" + "stream.api/internal/modules/common" + paymentsmodule "stream.api/internal/modules/payments" ) func TestRegisterReferralCapture(t *testing.T) { @@ -18,12 +19,7 @@ func TestRegisterReferralCapture(t *testing.T) { services := newTestAppServices(t, db) referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) - resp, err := services.Register(context.Background(), &appv1.RegisterRequest{ - Username: "bob", - Email: "bob@example.com", - Password: "secret123", - RefUsername: ptrString("alice"), - }) + resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "bob", Email: "bob@example.com", Password: "secret123", RefUsername: ptrString("alice")}) if err != nil { t.Fatalf("Register() error = %v", err) } @@ -39,13 +35,7 @@ func TestRegisterReferralCapture(t *testing.T) { t.Run("register với ref invalid hoặc self-ref vẫn tạo user", func(t *testing.T) { db := newTestDB(t) services := newTestAppServices(t, db) - - resp, err := services.Register(context.Background(), &appv1.RegisterRequest{ - Username: "selfie", - Email: "selfie@example.com", - Password: "secret123", - RefUsername: ptrString("selfie"), - }) + resp, err := services.Register(context.Background(), &appv1.RegisterRequest{Username: "selfie", Email: "selfie@example.com", Password: "secret123", RefUsername: ptrString("selfie")}) if err != nil { t.Fatalf("Register() error = %v", err) } @@ -61,9 +51,9 @@ func TestResolveSignupReferrerID(t *testing.T) { db := newTestDB(t) services := newTestAppServices(t, db) referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) - referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob") + referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob") if err != nil { - t.Fatalf("resolveSignupReferrerID() error = %v", err) + t.Fatalf("ResolveSignupReferrerID() error = %v", err) } if referrerID == nil || *referrerID != referrer.ID { t.Fatalf("referrerID = %v, want %s", referrerID, referrer.ID) @@ -73,9 +63,9 @@ func TestResolveSignupReferrerID(t *testing.T) { t.Run("invalid hoặc self-ref bị ignore", func(t *testing.T) { db := newTestDB(t) services := newTestAppServices(t, db) - referrerID, err := services.resolveSignupReferrerID(context.Background(), "bob", "bob") + referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "bob", "bob") if err != nil { - t.Fatalf("resolveSignupReferrerID() error = %v", err) + t.Fatalf("ResolveSignupReferrerID() error = %v", err) } if referrerID != nil { t.Fatalf("referrerID = %v, want nil", referrerID) @@ -87,9 +77,9 @@ func TestResolveSignupReferrerID(t *testing.T) { services := newTestAppServices(t, db) seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "a@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "b@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) - referrerID, err := services.resolveSignupReferrerID(context.Background(), "alice", "bob") + referrerID, err := services.usersModule.ResolveSignupReferrerID(context.Background(), "alice", "bob") if err != nil { - t.Fatalf("resolveSignupReferrerID() error = %v", err) + t.Fatalf("ResolveSignupReferrerID() error = %v", err) } if referrerID != nil { t.Fatalf("referrerID = %v, want nil", referrerID) @@ -110,11 +100,10 @@ func TestReferralRewardFlow(t *testing.T) { t.Run("first subscription thưởng 5 phần trăm", func(t *testing.T) { services, db, referrer, referee, plan := setup(t) - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) - - result, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}) + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) + result, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}) if err != nil { - t.Fatalf("executePaymentFlow() error = %v", err) + t.Fatalf("ExecutePaymentFlow() error = %v", err) } updatedReferee := mustLoadUser(t, db, referee.ID) if updatedReferee.ReferralRewardPaymentID == nil || *updatedReferee.ReferralRewardPaymentID != result.Payment.ID { @@ -138,12 +127,12 @@ func TestReferralRewardFlow(t *testing.T) { t.Run("subscription thứ hai không thưởng lại", func(t *testing.T) { services, db, referrer, referee, plan := setup(t) - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")}) - if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { - t.Fatalf("first executePaymentFlow() error = %v", err) + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 40, Currency: ptrString("USD")}) + if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil { + t.Fatalf("first ExecutePaymentFlow() error = %v", err) } - if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { - t.Fatalf("second executePaymentFlow() error = %v", err) + if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil { + t.Fatalf("second ExecutePaymentFlow() error = %v", err) } balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID) if err != nil { @@ -174,9 +163,9 @@ func TestReferralRewardFlow(t *testing.T) { if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_eligible", false).Error; err != nil { t.Fatalf("update referral_eligible: %v", err) } - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) - if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { - t.Fatalf("executePaymentFlow() error = %v", err) + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) + if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil { + t.Fatalf("ExecutePaymentFlow() error = %v", err) } balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID) if err != nil { @@ -192,9 +181,9 @@ func TestReferralRewardFlow(t *testing.T) { if err := db.Model(&model.User{}).Where("id = ?", referrer.ID).Update("referral_reward_bps", 750).Error; err != nil { t.Fatalf("update referral_reward_bps: %v", err) } - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) - if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { - t.Fatalf("executePaymentFlow() error = %v", err) + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) + if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil { + t.Fatalf("ExecutePaymentFlow() error = %v", err) } balance, err := model.GetWalletBalance(context.Background(), db, referrer.ID) if err != nil { @@ -213,23 +202,10 @@ func TestUpdateAdminUserReferralSettings(t *testing.T) { referrer := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "ref@example.com", Username: ptrString("alice"), Role: ptrString("USER")}) referee := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "payer@example.com", Username: ptrString("bob"), Role: ptrString("USER"), ReferredByUserID: &referrer.ID, ReferralEligible: ptrBool(true)}) plan := seedTestPlan(t, db, model.Plan{ID: uuid.NewString(), Name: "Pro", Price: 20, Cycle: "monthly", StorageLimit: 100, UploadLimit: 10, QualityLimit: "1080p", IsActive: ptrBool(true)}) - seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: walletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) - if _, err := services.executePaymentFlow(context.Background(), paymentExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: paymentMethodWallet}); err != nil { - t.Fatalf("executePaymentFlow() error = %v", err) + seedWalletTransaction(t, db, model.WalletTransaction{ID: uuid.NewString(), UserID: referee.ID, Type: common.WalletTransactionTypeTopup, Amount: 20, Currency: ptrString("USD")}) + if _, err := services.paymentsModule.ExecutePaymentFlow(context.Background(), paymentsmodule.ExecutionInput{UserID: referee.ID, Plan: &plan, TermMonths: 1, PaymentMethod: common.PaymentMethodWallet}); err != nil { + t.Fatalf("ExecutePaymentFlow() error = %v", err) } - - _, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{ - Id: referee.ID, - RefUsername: ptrString("alice"), - }) + _, err := services.UpdateAdminUserReferralSettings(testActorIncomingContext(admin.ID, "ADMIN"), &appv1.UpdateAdminUserReferralSettingsRequest{Id: referee.ID, RefUsername: ptrString("alice")}) assertGRPCCode(t, err, codes.InvalidArgument) } - -func containsAny(value string, parts ...string) bool { - for _, part := range parts { - if part != "" && strings.Contains(value, part) { - return true - } - } - return false -} diff --git a/internal/rpc/app/service_user_features.go b/internal/rpc/app/service_user_features.go deleted file mode 100644 index 64baaf1..0000000 --- a/internal/rpc/app/service_user_features.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/service_videos.go b/internal/rpc/app/service_videos.go deleted file mode 100644 index d9e1ac7..0000000 --- a/internal/rpc/app/service_videos.go +++ /dev/null @@ -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 -} diff --git a/internal/rpc/app/test_wrappers_test.go b/internal/rpc/app/test_wrappers_test.go new file mode 100644 index 0000000..72fa410 --- /dev/null +++ b/internal/rpc/app/test_wrappers_test.go @@ -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) +} diff --git a/internal/rpc/app/testdb_setup_test.go b/internal/rpc/app/testdb_setup_test.go index ee4fd3f..d1f24ff 100644 --- a/internal/rpc/app/testdb_setup_test.go +++ b/internal/rpc/app/testdb_setup_test.go @@ -244,7 +244,7 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices { db = newTestDB(t) } - return &appServices{ + services := &appServices{ db: db, logger: testLogger{}, authenticator: middleware.NewAuthenticator(db, testLogger{}, testTrustedMarker), @@ -252,6 +252,8 @@ func newTestAppServices(t *testing.T, db *gorm.DB) *appServices { tokenProvider: fakeTokenProvider{}, googleUserInfoURL: defaultGoogleUserInfoURL, } + services.initModules() + return services } func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, func()) { @@ -260,18 +262,18 @@ func newTestGRPCServer(t *testing.T, services *appServices) (*grpc.ClientConn, f lis := bufconn.Listen(testBufDialerListenerSize) server := grpc.NewServer() Register(server, &Services{ - AuthServiceServer: services, - AccountServiceServer: services, - PreferencesServiceServer: services, - UsageServiceServer: services, - NotificationsServiceServer: services, - DomainsServiceServer: services, - AdTemplatesServiceServer: services, - PlayerConfigsServiceServer: services, - PlansServiceServer: services, - PaymentsServiceServer: services, - VideosServiceServer: services, - AdminServiceServer: services, + AuthServiceServer: services.authHandler, + AccountServiceServer: services.accountHandler, + PreferencesServiceServer: services.preferencesHandler, + UsageServiceServer: services.usageHandler, + NotificationsServiceServer: services.notificationsHandler, + DomainsServiceServer: services.domainsHandler, + AdTemplatesServiceServer: services.adTemplatesHandler, + PlayerConfigsServiceServer: services.playerConfigsHandler, + PlansServiceServer: services.plansHandler, + PaymentsServiceServer: services.paymentsHandler, + VideosServiceServer: services.videosHandler, + AdminServiceServer: services.adminHandler, }) go func() { diff --git a/internal/rpc/app/usage_helpers.go b/internal/rpc/app/usage_helpers.go deleted file mode 100644 index d06fdad..0000000 --- a/internal/rpc/app/usage_helpers.go +++ /dev/null @@ -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 -} diff --git a/internal/video/runtime/http.go b/internal/video/runtime/http.go deleted file mode 100644 index 38bbed6..0000000 --- a/internal/video/runtime/http.go +++ /dev/null @@ -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()) - } -} diff --git a/proto/app/v1/woodpecker.proto b/proto/app/v1/woodpecker.proto new file mode 100644 index 0000000..16e2c82 --- /dev/null +++ b/proto/app/v1/woodpecker.proto @@ -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 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 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; +}