diff --git a/server/internal/tenant/transport/kol_admin_handler.go b/server/internal/tenant/transport/kol_admin_handler.go new file mode 100644 index 0000000..d600821 --- /dev/null +++ b/server/internal/tenant/transport/kol_admin_handler.go @@ -0,0 +1,116 @@ +package transport + +import ( + "time" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" +) + +var errTenantAdminRequired = response.ErrForbidden(40371, "tenant_admin_required", "tenant admin role is required") + +type KolAdminHandler struct { + svc *app.KolSubscriptionAdminService +} + +type approveKolSubscriptionRequest struct { + EndAt *time.Time `json:"end_at"` +} + +func NewKolAdminHandler(a *bootstrap.App) *KolAdminHandler { + return &KolAdminHandler{ + svc: app.NewKolSubscriptionAdminService( + a.DB, + a.AuditLogs, + a.KolMarketplace, + a.KolSubscriptions, + a.KolPrompts, + ), + } +} + +func (h *KolAdminHandler) ApproveSubscription(c *gin.Context) { + id, ok := parseInt64Param(c, "id", "subscription id") + if !ok { + return + } + + actor, ok := requireTenantAdmin(c) + if !ok { + return + } + + var req approveKolSubscriptionRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + } + + data, err := h.svc.Approve(c.Request.Context(), id, req.EndAt, actor.UserID) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolAdminHandler) ManualBind(c *gin.Context) { + actor, ok := requireTenantAdmin(c) + if !ok { + return + } + + var req app.ManualBindKolSubscriptionInput + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + + data, err := h.svc.ManualBind(c.Request.Context(), req, actor.UserID) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolAdminHandler) RevokeSubscription(c *gin.Context) { + id, ok := parseInt64Param(c, "id", "subscription id") + if !ok { + return + } + + actor, ok := requireTenantAdmin(c) + if !ok { + return + } + + if err := h.svc.Revoke(c.Request.Context(), id, actor.UserID); err != nil { + response.Error(c, err) + return + } + + response.Success(c, gin.H{"ok": true}) +} + +// V1 compromise: these admin endpoints live on tenant-api until a dedicated +// platform-api/module exists. The temporary gate is tenant_role=tenant_admin. +func requireTenantAdmin(c *gin.Context) (auth.Actor, bool) { + actor, ok := actorFromRequest(c) + if !ok { + return auth.Actor{}, false + } + if actor.Role != "tenant_admin" { + response.Error(c, errTenantAdminRequired) + return auth.Actor{}, false + } + return actor, true +} diff --git a/server/internal/tenant/transport/kol_manage_handler.go b/server/internal/tenant/transport/kol_manage_handler.go index 9b056c2..583054b 100644 --- a/server/internal/tenant/transport/kol_manage_handler.go +++ b/server/internal/tenant/transport/kol_manage_handler.go @@ -24,7 +24,7 @@ func NewKolManageHandler(a *bootstrap.App) *KolManageHandler { return &KolManageHandler{ profileSvc: profileSvc, pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts), - promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolPromptAsset), + promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset), assistSvc: app.NewKolAssistService(profileSvc, a.KolAssists, a.LLM, a.RabbitMQ), } } diff --git a/server/internal/tenant/transport/kol_marketplace_handler.go b/server/internal/tenant/transport/kol_marketplace_handler.go new file mode 100644 index 0000000..0c0be8b --- /dev/null +++ b/server/internal/tenant/transport/kol_marketplace_handler.go @@ -0,0 +1,131 @@ +package transport + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" +) + +type KolMarketplaceHandler struct { + svc *app.KolMarketplaceService +} + +func NewKolMarketplaceHandler(a *bootstrap.App) *KolMarketplaceHandler { + return &KolMarketplaceHandler{ + svc: app.NewKolMarketplaceService(a.KolMarketplace, a.KolSubscriptions), + } +} + +func (h *KolMarketplaceHandler) ListPackages(c *gin.Context) { + actor, ok := actorFromRequest(c) + if !ok { + return + } + + filter := app.MarketFilter{ + Industry: stringPtrOrNil(c.Query("industry")), + Keyword: stringPtrOrNil(c.Query("keyword")), + Offset: parseIntQuery(c.Query("offset"), 0), + Limit: parseIntQuery(c.Query("limit"), 20), + } + + data, err := h.svc.ListPackages(c.Request.Context(), actor, filter) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolMarketplaceHandler) GetPackage(c *gin.Context) { + id, ok := parseInt64Param(c, "id", "package id") + if !ok { + return + } + + actor, ok := actorFromRequest(c) + if !ok { + return + } + + data, err := h.svc.GetPackage(c.Request.Context(), actor, id) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolMarketplaceHandler) Subscribe(c *gin.Context) { + id, ok := parseInt64Param(c, "id", "package id") + if !ok { + return + } + + actor, ok := actorFromRequest(c) + if !ok { + return + } + + data, err := h.svc.Subscribe(c.Request.Context(), actor, id) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolMarketplaceHandler) ListSubscriptions(c *gin.Context) { + actor, ok := actorFromRequest(c) + if !ok { + return + } + + data, err := h.svc.ListMySubscriptions(c.Request.Context(), actor) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func (h *KolMarketplaceHandler) ListSubscriptionPrompts(c *gin.Context) { + actor, ok := actorFromRequest(c) + if !ok { + return + } + + data, err := h.svc.ListMySubscriptionPrompts(c.Request.Context(), actor) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, data) +} + +func parseIntQuery(value string, fallback int) int { + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func stringPtrOrNil(value string) *string { + if value == "" { + return nil + } + return &value +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index b59d10b..3d320cb 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -74,6 +74,24 @@ func RegisterRoutes(app *bootstrap.App) { kolManage.POST("/assist", kolManageHandler.AssistSubmit) kolManage.GET("/assist/:id", kolManageHandler.AssistGet) + kolMarketplace := protected.Group("/tenant/kol/marketplace") + kolMarketplaceHandler := NewKolMarketplaceHandler(app) + kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages) + kolMarketplace.GET("/packages/:id", kolMarketplaceHandler.GetPackage) + kolMarketplace.POST("/packages/:id/subscribe", kolMarketplaceHandler.Subscribe) + + kolSubscriptions := protected.Group("/tenant/kol") + kolSubscriptions.GET("/subscriptions", kolMarketplaceHandler.ListSubscriptions) + kolSubscriptions.GET("/subscription-prompts", kolMarketplaceHandler.ListSubscriptionPrompts) + + // V1 compromise: these admin operations live on tenant-api and are gated by + // tenant_role=tenant_admin until the platform-api/module is built. + kolAdmin := protected.Group("/tenant/kol/admin") + kolAdminHandler := NewKolAdminHandler(app) + kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription) + kolAdmin.POST("/subscriptions", kolAdminHandler.ManualBind) + kolAdmin.PUT("/subscriptions/:id/revoke", kolAdminHandler.RevokeSubscription) + articles := protected.Group("/tenant/articles") artHandler := NewArticleHandler(app) articles.GET("", artHandler.List)