Files
descrybe/apps/api/internal/httpapi/plan_features_handlers.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

210 lines
6.7 KiB
Go

package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// GET /api/billing/capabilities — effective plan ∩ global features for the active company.
func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "company required")
return
}
caps, err := s.Billing.CapabilitiesForCompany(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load capabilities")
return
}
etag := billing.CapabilitiesResponseETag(caps)
// Private: company-scoped. Short max-age + ETag mirrors OpenAPI conditional GET pattern.
w.Header().Set("Cache-Control", "private, max-age=30, must-revalidate")
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
JSON(w, http.StatusOK, caps)
}
// GET /api/admin/plans/{planID}/features
func (s *Server) handleAdminGetPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
view, err := s.Billing.GetPlanFeatures(r.Context(), planID)
if err != nil {
writePlanFeaturesErr(w, "could not load plan features", err)
return
}
w.Header().Set("Cache-Control", "private, no-store")
JSON(w, http.StatusOK, view)
}
// PUT /api/admin/plans/{planID}/features — replaces stored feature overrides.
func (s *Server) handleAdminPutPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
var body billing.PlanFeaturesUpdate
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Features == nil {
Error(w, http.StatusBadRequest, "features required")
return
}
view, err := s.Billing.SetPlanFeatures(r.Context(), planID, body.Features)
if err != nil {
writePlanFeaturesErr(w, "could not save plan features", err)
return
}
JSON(w, http.StatusOK, view)
}
// POST /api/admin/plans/{planID}/features/enable-all — sets every registry key true (custom packages).
func (s *Server) handleAdminEnableAllPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
view, err := s.Billing.EnableAllPlanFeatures(r.Context(), planID)
if err != nil {
writePlanFeaturesErr(w, "could not enable plan features", err)
return
}
JSON(w, http.StatusOK, view)
}
// POST /api/admin/plans/{planID}/features/disable-all — sets every registry key false.
func (s *Server) handleAdminDisableAllPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
view, err := s.Billing.DisableAllPlanFeatures(r.Context(), planID)
if err != nil {
writePlanFeaturesErr(w, "could not disable plan features", err)
return
}
JSON(w, http.StatusOK, view)
}
// GET /api/admin/feature-gates
func (s *Server) handleAdminGetFeatureGates(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
view, err := s.Billing.GetFeatureGates(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load feature gates")
return
}
w.Header().Set("Cache-Control", "private, no-store")
JSON(w, http.StatusOK, view)
}
// PUT /api/admin/feature-gates — partial upsert of section/feature master switches.
func (s *Server) handleAdminPutFeatureGates(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
var body billing.FeatureGatesUpdate
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Sections == nil && body.Features == nil {
Error(w, http.StatusBadRequest, "sections or features required")
return
}
var updatedBy *uuid.UUID
if uid, ok := UserIDFromContext(r.Context()); ok {
updatedBy = &uid
}
view, err := s.Billing.SetFeatureGates(r.Context(), body.Sections, body.Features, updatedBy)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update feature gates", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, view)
}
// PUT /api/admin/feature-gates/sections/{section} — enable/disable a section for ALL plans.
func (s *Server) handleAdminPutFeatureGateSection(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
section := strings.TrimSpace(chi.URLParam(r, "section"))
if section == "" {
Error(w, http.StatusBadRequest, "section required")
return
}
var body billing.SectionGateUpdate
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Enabled == nil {
Error(w, http.StatusBadRequest, "enabled required")
return
}
var updatedBy *uuid.UUID
if uid, ok := UserIDFromContext(r.Context()); ok {
updatedBy = &uid
}
view, err := s.Billing.SetSectionGate(r.Context(), section, *body.Enabled, updatedBy)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update section gate", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, view)
}
// writePlanFeaturesErr maps known billing client errors to the correct status
// (404 for missing plan; 400 for validation).
func writePlanFeaturesErr(w http.ResponseWriter, publicFallback string, err error) {
if errors.Is(err, billing.ErrPlanNotFound) {
Error(w, http.StatusNotFound, billing.ErrPlanNotFound.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, publicFallback, err, billing.ClientError)
}