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.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user