Pulse/internal/api/org_lifecycle_handlers.go
2026-03-18 16:06:30 +00:00

284 lines
8.2 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/hosted"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
const defaultSoftDeleteRetentionDays = 30
// OrgPersistenceProvider defines persistence operations needed for org lifecycle handlers.
type OrgPersistenceProvider interface {
LoadOrganization(orgID string) (*models.Organization, error)
SaveOrganization(org *models.Organization) error
OrgExists(orgID string) bool
}
// OrgLifecycleHandlers provides hosted tenant lifecycle operations.
type OrgLifecycleHandlers struct {
persistence OrgPersistenceProvider
hostedMode bool
}
func NewOrgLifecycleHandlers(persistence OrgPersistenceProvider, hostedMode bool) *OrgLifecycleHandlers {
return &OrgLifecycleHandlers{
persistence: persistence,
hostedMode: hostedMode,
}
}
type suspendOrganizationRequest struct {
Reason string `json:"reason"`
}
type softDeleteOrganizationRequest struct {
RetentionDays *int `json:"retention_days"`
}
func (h *OrgLifecycleHandlers) HandleSuspendOrg(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !h.hostedMode {
http.NotFound(w, r)
return
}
if h.persistence == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
return
}
orgID := strings.TrimSpace(r.PathValue("id"))
if orgID == "default" {
writeErrorResponse(w, http.StatusBadRequest, "default_org_immutable", "Default organization cannot be suspended", nil)
return
}
org, err := h.loadOrganization(orgID)
if err != nil {
h.writeLoadOrgError(w, err)
return
}
var req suspendOrganizationRequest
if err := decodeOptionalLifecycleRequest(w, r, &req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil)
return
}
req.Reason = strings.TrimSpace(req.Reason)
oldStatus := models.NormalizeOrgStatus(org.Status)
if oldStatus == models.OrgStatusSuspended {
writeErrorResponse(w, http.StatusConflict, "already_suspended", "Organization is already suspended", nil)
return
}
now := time.Now().UTC()
org.Status = models.OrgStatusSuspended
org.SuspendedAt = &now
org.SuspendReason = req.Reason
if err := h.persistence.SaveOrganization(org); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update organization lifecycle", nil)
return
}
h.logLifecycleChange(r, org.ID, oldStatus, org.Status, req.Reason)
hosted.GetHostedMetrics().RecordLifecycleTransitionStatus(
hosted.ParseLifecycleMetricStatus(string(oldStatus)),
hosted.ParseLifecycleMetricStatus(string(org.Status)),
)
writeJSON(w, http.StatusOK, org)
}
func (h *OrgLifecycleHandlers) HandleUnsuspendOrg(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !h.hostedMode {
http.NotFound(w, r)
return
}
if h.persistence == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
return
}
orgID := strings.TrimSpace(r.PathValue("id"))
org, err := h.loadOrganization(orgID)
if err != nil {
h.writeLoadOrgError(w, err)
return
}
oldStatus := models.NormalizeOrgStatus(org.Status)
if oldStatus != models.OrgStatusSuspended {
writeErrorResponse(w, http.StatusConflict, "not_suspended", "Organization is not suspended", nil)
return
}
org.Status = models.OrgStatusActive
org.SuspendedAt = nil
org.SuspendReason = ""
if err := h.persistence.SaveOrganization(org); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update organization lifecycle", nil)
return
}
h.logLifecycleChange(r, org.ID, oldStatus, org.Status, "")
hosted.GetHostedMetrics().RecordLifecycleTransitionStatus(
hosted.ParseLifecycleMetricStatus(string(oldStatus)),
hosted.ParseLifecycleMetricStatus(string(org.Status)),
)
writeJSON(w, http.StatusOK, org)
}
func (h *OrgLifecycleHandlers) HandleSoftDeleteOrg(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !h.hostedMode {
http.NotFound(w, r)
return
}
if h.persistence == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "orgs_unavailable", "Organization persistence is not configured", nil)
return
}
orgID := strings.TrimSpace(r.PathValue("id"))
if orgID == "default" {
writeErrorResponse(w, http.StatusBadRequest, "default_org_immutable", "Default organization cannot be deleted", nil)
return
}
org, err := h.loadOrganization(orgID)
if err != nil {
h.writeLoadOrgError(w, err)
return
}
var req softDeleteOrganizationRequest
if err := decodeOptionalLifecycleRequest(w, r, &req); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil)
return
}
retentionDays := defaultSoftDeleteRetentionDays
if req.RetentionDays != nil {
if *req.RetentionDays <= 0 {
writeErrorResponse(w, http.StatusBadRequest, "invalid_retention_days", "Retention days must be greater than zero", nil)
return
}
retentionDays = *req.RetentionDays
}
oldStatus := models.NormalizeOrgStatus(org.Status)
if oldStatus == models.OrgStatusPendingDeletion {
writeErrorResponse(w, http.StatusConflict, "already_pending_deletion", "Organization is already pending deletion", nil)
return
}
now := time.Now().UTC()
org.Status = models.OrgStatusPendingDeletion
org.DeletionRequestedAt = &now
org.RetentionDays = retentionDays
if err := h.persistence.SaveOrganization(org); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update organization lifecycle", nil)
return
}
h.logLifecycleChange(r, org.ID, oldStatus, org.Status, "soft_delete")
hosted.GetHostedMetrics().RecordLifecycleTransitionStatus(
hosted.ParseLifecycleMetricStatus(string(oldStatus)),
hosted.ParseLifecycleMetricStatus(string(org.Status)),
)
writeJSON(w, http.StatusOK, org)
}
func decodeOptionalLifecycleRequest(w http.ResponseWriter, r *http.Request, out any) error {
r.Body = http.MaxBytesReader(w, r.Body, orgRequestBodyLimit)
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(out); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("decode lifecycle request body: %w", err)
}
return nil
}
func (h *OrgLifecycleHandlers) loadOrganization(orgID string) (*models.Organization, error) {
if !isValidOrganizationID(orgID) {
return nil, errOrgNotFound
}
if h.persistence == nil {
return nil, errors.New("organization persistence is not configured")
}
if orgID != "default" && !h.persistence.OrgExists(orgID) {
return nil, errOrgNotFound
}
org, err := h.persistence.LoadOrganization(orgID)
if err != nil {
return nil, fmt.Errorf("load organization %q: %w", orgID, err)
}
if org == nil {
return nil, errOrgNotFound
}
if org.ID == "" {
org.ID = orgID
}
if strings.TrimSpace(org.DisplayName) == "" {
org.DisplayName = org.ID
}
org.Status = models.NormalizeOrgStatus(org.Status)
normalizeOrganization(org)
return org, nil
}
func (h *OrgLifecycleHandlers) writeLoadOrgError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, errOrgNotFound):
writeErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found", nil)
default:
writeErrorResponse(w, http.StatusInternalServerError, "org_load_failed", "Failed to load organization", nil)
}
}
func (h *OrgLifecycleHandlers) logLifecycleChange(r *http.Request, orgID string, oldStatus, newStatus models.OrgStatus, reason string) {
actor := strings.TrimSpace(auth.GetUser(r.Context()))
if actor == "" {
if token := getAPITokenRecordFromRequest(r); token != nil {
if token.ID != "" {
actor = "token:" + token.ID
}
}
}
if actor == "" {
actor = "unknown"
}
log.Info().
Str("org_id", orgID).
Str("old_status", string(oldStatus)).
Str("new_status", string(newStatus)).
Str("reason", reason).
Str("actor", actor).
Msg("Organization lifecycle status changed")
}