mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package portal
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
|
|
"github.com/rs/zerolog/log"
|
|
stripe "github.com/stripe/stripe-go/v82"
|
|
billingportalsession "github.com/stripe/stripe-go/v82/billingportal/session"
|
|
)
|
|
|
|
type accountInfo struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
Kind registry.AccountKind `json:"kind"`
|
|
}
|
|
|
|
type workspaceSummaryItem struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
State registry.TenantState `json:"state"`
|
|
HealthCheckOK bool `json:"health_check_ok"`
|
|
SetupStatus string `json:"setup_status,omitempty"`
|
|
AgentCount *int `json:"agent_count,omitempty"`
|
|
AgentTokenCount *int `json:"agent_token_count,omitempty"`
|
|
UnusedAgentTokenCount *int `json:"unused_agent_token_count,omitempty"`
|
|
LastAgentSeenAt *time.Time `json:"last_agent_seen_at,omitempty"`
|
|
AlertRouteCount *int `json:"alert_route_count,omitempty"`
|
|
DisabledAlertRouteCount *int `json:"disabled_alert_route_count,omitempty"`
|
|
ActiveCriticalAlertCount *int `json:"active_critical_alert_count,omitempty"`
|
|
ActiveWarningAlertCount *int `json:"active_warning_alert_count,omitempty"`
|
|
ActiveAlertsUpdatedAt *time.Time `json:"active_alerts_updated_at,omitempty"`
|
|
ReportScheduleCount *int `json:"report_schedule_count,omitempty"`
|
|
DisabledReportScheduleCount *int `json:"disabled_report_schedule_count,omitempty"`
|
|
LastHealthCheck *time.Time `json:"last_health_check"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type dashboardSummary struct {
|
|
Total int `json:"total"`
|
|
Active int `json:"active"`
|
|
Healthy int `json:"healthy"`
|
|
Unhealthy int `json:"unhealthy"`
|
|
Suspended int `json:"suspended"`
|
|
Critical int `json:"critical"`
|
|
}
|
|
|
|
type dashboardResponse struct {
|
|
Account accountInfo `json:"account"`
|
|
Workspaces []workspaceSummaryItem `json:"workspaces"`
|
|
Summary dashboardSummary `json:"summary"`
|
|
}
|
|
|
|
type workspaceDetailResponse struct {
|
|
Account accountInfo `json:"account"`
|
|
Workspace *registry.Tenant `json:"workspace"`
|
|
}
|
|
|
|
func accountIDFromRequest(r *http.Request) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
if v := strings.TrimSpace(r.URL.Query().Get("account_id")); v != "" {
|
|
return v
|
|
}
|
|
// Convenience for future callers; spec says query param is fine for now.
|
|
if v := strings.TrimSpace(r.Header.Get("X-Account-ID")); v != "" {
|
|
return v
|
|
}
|
|
if v := strings.TrimSpace(r.Header.Get("X-Account-Id")); v != "" {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HandlePortalDashboard returns a portal-oriented dashboard response for an account.
|
|
// Route: GET /api/portal/dashboard?account_id=...
|
|
//
|
|
// Auth: control-plane session + account membership middleware.
|
|
func HandlePortalDashboard(reg *registry.TenantRegistry) http.HandlerFunc {
|
|
return HandlePortalDashboardWithSetupFacts(reg, nil)
|
|
}
|
|
|
|
func HandlePortalDashboardWithSetupFacts(reg *registry.TenantRegistry, setupFacts WorkspaceSetupFactReader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if reg == nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
accountID := accountIDFromRequest(r)
|
|
if accountID == "" {
|
|
http.Error(w, "missing account_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
a, err := reg.GetAccount(accountID)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if a == nil {
|
|
http.Error(w, "account not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
tenants, err := reg.ListByAccountID(accountID)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tenants == nil {
|
|
tenants = []*registry.Tenant{}
|
|
}
|
|
|
|
resp := dashboardResponse{
|
|
Account: accountInfo{
|
|
ID: a.ID,
|
|
DisplayName: a.DisplayName,
|
|
Kind: a.Kind,
|
|
},
|
|
Workspaces: make([]workspaceSummaryItem, 0, len(tenants)),
|
|
}
|
|
|
|
for _, t := range tenants {
|
|
if !isPortalVisibleTenant(t) {
|
|
continue
|
|
}
|
|
facts := workspaceSetupFactsForTenant(setupFacts, t.ID)
|
|
|
|
resp.Workspaces = append(resp.Workspaces, workspaceSummaryItem{
|
|
ID: t.ID,
|
|
DisplayName: t.DisplayName,
|
|
State: t.State,
|
|
HealthCheckOK: t.HealthCheckOK,
|
|
SetupStatus: workspaceSetupStatus(t.State, t.HealthCheckOK, t.LastHealthCheck, facts),
|
|
AgentCount: facts.AgentCount,
|
|
AgentTokenCount: facts.AgentTokenCount,
|
|
UnusedAgentTokenCount: facts.UnusedAgentTokenCount,
|
|
LastAgentSeenAt: facts.LastAgentSeenAt,
|
|
AlertRouteCount: facts.AlertRouteCount,
|
|
DisabledAlertRouteCount: facts.DisabledAlertRouteCount,
|
|
ActiveCriticalAlertCount: facts.ActiveCriticalAlertCount,
|
|
ActiveWarningAlertCount: facts.ActiveWarningAlertCount,
|
|
ActiveAlertsUpdatedAt: facts.ActiveAlertsUpdatedAt,
|
|
ReportScheduleCount: facts.ReportScheduleCount,
|
|
DisabledReportScheduleCount: facts.DisabledReportScheduleCount,
|
|
LastHealthCheck: t.LastHealthCheck,
|
|
CreatedAt: t.CreatedAt,
|
|
})
|
|
|
|
resp.Summary.Total++
|
|
if factCountIsPositive(facts.ActiveCriticalAlertCount) {
|
|
resp.Summary.Critical++
|
|
}
|
|
|
|
switch t.State {
|
|
case registry.TenantStateActive:
|
|
resp.Summary.Active++
|
|
if t.HealthCheckOK {
|
|
resp.Summary.Healthy++
|
|
} else {
|
|
resp.Summary.Unhealthy++
|
|
}
|
|
case registry.TenantStateSuspended:
|
|
resp.Summary.Suspended++
|
|
}
|
|
}
|
|
sortWorkspaceSummaries(resp.Workspaces)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
encodeJSON(w, resp)
|
|
}
|
|
}
|
|
|
|
// HandlePortalWorkspaceDetail returns a portal-oriented detail response for a single tenant.
|
|
// Route: GET /api/portal/workspaces/{tenant_id}?account_id=...
|
|
//
|
|
// Auth: control-plane session + account membership middleware.
|
|
func HandlePortalWorkspaceDetail(reg *registry.TenantRegistry) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if reg == nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
accountID := accountIDFromRequest(r)
|
|
tenantID := strings.TrimSpace(r.PathValue("tenant_id"))
|
|
if accountID == "" || tenantID == "" {
|
|
http.Error(w, "missing account_id or tenant_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
a, err := reg.GetAccount(accountID)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if a == nil {
|
|
http.Error(w, "account not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
t, err := reg.GetTenantForAccount(accountID, tenantID)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if t == nil {
|
|
http.Error(w, "tenant not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if !isPortalVisibleTenant(t) {
|
|
http.Error(w, "tenant not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
resp := workspaceDetailResponse{
|
|
Account: accountInfo{
|
|
ID: a.ID,
|
|
DisplayName: a.DisplayName,
|
|
Kind: a.Kind,
|
|
},
|
|
Workspace: t,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
encodeJSON(w, resp)
|
|
}
|
|
}
|
|
|
|
func isPortalVisibleTenant(t *registry.Tenant) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
return t.State != registry.TenantStateDeleted && t.State != registry.TenantStateDeleting
|
|
}
|
|
|
|
func sortWorkspaceSummaries(workspaces []workspaceSummaryItem) {
|
|
sort.SliceStable(workspaces, func(i, j int) bool {
|
|
leftCritical := derefInt(workspaces[i].ActiveCriticalAlertCount)
|
|
rightCritical := derefInt(workspaces[j].ActiveCriticalAlertCount)
|
|
if leftCritical != rightCritical {
|
|
return leftCritical > rightCritical
|
|
}
|
|
leftWarning := derefInt(workspaces[i].ActiveWarningAlertCount)
|
|
rightWarning := derefInt(workspaces[j].ActiveWarningAlertCount)
|
|
if leftWarning != rightWarning {
|
|
return leftWarning > rightWarning
|
|
}
|
|
if workspaces[i].HealthCheckOK != workspaces[j].HealthCheckOK {
|
|
return !workspaces[i].HealthCheckOK
|
|
}
|
|
return strings.ToLower(workspaces[i].DisplayName) < strings.ToLower(workspaces[j].DisplayName)
|
|
})
|
|
}
|
|
|
|
func derefInt(value *int) int {
|
|
if value == nil {
|
|
return -1
|
|
}
|
|
return *value
|
|
}
|
|
|
|
// HandlePortalBootstrap returns the renderer-owned Pulse Account bootstrap payload.
|
|
// Route: GET /api/portal/bootstrap
|
|
//
|
|
// Auth: control-plane session.
|
|
func HandlePortalBootstrap(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup) http.HandlerFunc {
|
|
return HandlePortalBootstrapWithSignupPath(sessionSvc, reg, commercialLookup, PortalSignupPath)
|
|
}
|
|
|
|
func HandlePortalBootstrapWithSignupPath(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup, signupPath string) http.HandlerFunc {
|
|
return HandlePortalBootstrapWithSignupPathAndSetupFacts(sessionSvc, reg, commercialLookup, signupPath, nil)
|
|
}
|
|
|
|
func HandlePortalBootstrapWithSignupPathAndSetupFacts(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup, signupPath string, setupFacts WorkspaceSetupFactReader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if sessionSvc == nil || reg == nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
claims, err := validatePortalSessionClaims(r, sessionSvc, reg)
|
|
if err != nil {
|
|
if errors.Is(err, errPortalAuthRequired) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
log.Error().Err(err).Msg("cloudcp.portal.bootstrap: validate session")
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
accounts, err := loadPortalAccountsForUserWithSetupFacts(reg, claims.UserID, setupFacts)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("user_id", claims.UserID).Msg("cloudcp.portal.bootstrap: load accounts")
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
encodeJSON(w, BuildBootstrapDataWithSignupPath(true, claims.Email, accounts, resolveSelfHostedCommercial(r.Context(), commercialLookup, claims.Email, accounts), signupPath))
|
|
}
|
|
}
|
|
|
|
// BillingPortalConfig holds the configuration for the billing portal redirect handler.
|
|
type BillingPortalConfig struct {
|
|
StripeAPIKey string
|
|
ReturnURL string // URL the user returns to after leaving the Stripe portal
|
|
}
|
|
|
|
// billingPortalHandler is the internal handler for Stripe billing portal session creation.
|
|
type billingPortalHandler struct {
|
|
reg *registry.TenantRegistry
|
|
cfg BillingPortalConfig
|
|
createSession func(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error)
|
|
}
|
|
|
|
// HandleBillingPortalRedirect creates a Stripe Customer Portal session and returns
|
|
// the redirect URL. The authenticated user must be an owner or admin of the account.
|
|
// Route: POST /api/portal/billing?account_id=...
|
|
//
|
|
// Auth: control-plane session + account membership middleware + owner/admin role check.
|
|
func HandleBillingPortalRedirect(reg *registry.TenantRegistry, cfg BillingPortalConfig) http.HandlerFunc {
|
|
h := &billingPortalHandler{
|
|
reg: reg,
|
|
cfg: cfg,
|
|
createSession: defaultCreateBillingPortalSession,
|
|
}
|
|
return h.serveHTTP
|
|
}
|
|
|
|
func (h *billingPortalHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if h.reg == nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Only account owners and admins may access billing.
|
|
role := registry.MemberRole(strings.TrimSpace(r.Header.Get("X-User-Role")))
|
|
if role != registry.MemberRoleOwner && role != registry.MemberRoleAdmin {
|
|
http.Error(w, "forbidden: billing access requires owner or admin role", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(h.cfg.StripeAPIKey) == "" {
|
|
log.Warn().Msg("cloudcp.portal: billing portal redirect called but Stripe API key not configured")
|
|
http.Error(w, "billing portal not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
accountID := accountIDFromRequest(r)
|
|
if accountID == "" {
|
|
http.Error(w, "missing account_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
sa, err := h.reg.GetStripeAccount(accountID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID).Msg("cloudcp.portal: lookup stripe account")
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if sa == nil || strings.TrimSpace(sa.StripeCustomerID) == "" {
|
|
http.Error(w, "no billing account found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey)
|
|
params := &stripe.BillingPortalSessionParams{
|
|
Customer: stripe.String(strings.TrimSpace(sa.StripeCustomerID)),
|
|
}
|
|
if returnURL := strings.TrimSpace(h.cfg.ReturnURL); returnURL != "" {
|
|
params.ReturnURL = stripe.String(returnURL)
|
|
}
|
|
|
|
session, err := h.createSession(params)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID).Msg("cloudcp.portal: create billing portal session")
|
|
http.Error(w, "failed to create billing portal session", http.StatusBadGateway)
|
|
return
|
|
}
|
|
if session == nil || strings.TrimSpace(session.URL) == "" {
|
|
log.Error().Str("account_id", accountID).Msg("cloudcp.portal: Stripe returned empty billing portal URL")
|
|
http.Error(w, "failed to create billing portal session", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
encodeJSON(w, map[string]string{"url": strings.TrimSpace(session.URL)})
|
|
}
|
|
|
|
func defaultCreateBillingPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
|
|
return billingportalsession.New(params)
|
|
}
|
|
|
|
func encodeJSON(w http.ResponseWriter, payload any) {
|
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
|
log.Error().Err(err).Msg("cloudcp.portal: encode JSON response")
|
|
}
|
|
}
|