Add default_plan_ladder_id with a forward data migration and update the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and tests; add GetTierByLadderRank and pool/provision query helpers. Add a CSP-safe confirm-action modal and wire operator actions to it. Close plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE pool lock to prevent ladder races.
613 lines
20 KiB
Go
613 lines
20 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
wf "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
|
|
"github.com/google/uuid"
|
|
"go.temporal.io/sdk/client"
|
|
)
|
|
|
|
// PoolEnrollmentViewModel represents a pool's current enrollment state.
|
|
type PoolEnrollmentViewModel struct {
|
|
PoolID string
|
|
PoolName string
|
|
PoolType string
|
|
LadderKey string
|
|
TierName string
|
|
ProductID string
|
|
Rank int32
|
|
ActivatedAt string
|
|
HasAttachment bool
|
|
}
|
|
|
|
// TransitionHistoryViewModel represents a single transition row for display.
|
|
type TransitionHistoryViewModel struct {
|
|
TransitionID string
|
|
TransitionType string
|
|
FromRank string
|
|
ToRank string
|
|
ActorType string
|
|
ActorName string
|
|
Reason string
|
|
EffectiveAt string
|
|
}
|
|
|
|
// TierOption represents a selectable tier for force-transition.
|
|
type TierOption struct {
|
|
LadderID string
|
|
LadderKey string
|
|
ProductID string
|
|
ProductName string
|
|
Rank int32
|
|
}
|
|
|
|
// TrialProductOption represents a plan product available for trial grants.
|
|
type TrialProductOption struct {
|
|
ProductID string
|
|
Name string
|
|
LadderID string
|
|
Rank int32
|
|
}
|
|
|
|
// OrgEnrollmentData holds data for the enrollment detail partial.
|
|
type OrgEnrollmentData struct {
|
|
OrgID string
|
|
OrgName string
|
|
OrgSlug string
|
|
Pools []PoolEnrollmentViewModel
|
|
Transitions []TransitionHistoryViewModel
|
|
GrantProducts []TrialProductOption
|
|
ActiveGrants []GrantViewModel
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// GetOrgEnrollment handles GET /partials/operator/organizations/{orgID}/enrollment
|
|
func (h *OperatorPartialsHandler) GetOrgEnrollment(w http.ResponseWriter, r *http.Request) {
|
|
orgID := r.PathValue("orgID")
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "")
|
|
}
|
|
|
|
// IssueGrant handles POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant
|
|
func (h *OperatorPartialsHandler) IssueGrant(w http.ResponseWriter, r *http.Request) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderOrgEnrollmentPage(w, r, r.PathValue("orgID"), "", "Unauthorized")
|
|
return
|
|
}
|
|
|
|
orgID := r.PathValue("orgID")
|
|
poolID := r.PathValue("poolID")
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
productID := r.FormValue("product_id")
|
|
validUntilStr := r.FormValue("valid_until")
|
|
reason := r.FormValue("reason")
|
|
|
|
if productID == "" || reason == "" {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product and reason are required")
|
|
return
|
|
}
|
|
|
|
var validUntil sql.NullTime
|
|
grantReason := "manual"
|
|
if validUntilStr != "" {
|
|
vt, err := time.Parse("2006-01-02T15:04", validUntilStr)
|
|
if err != nil {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid date format")
|
|
return
|
|
}
|
|
validUntil = sql.NullTime{Time: vt, Valid: true}
|
|
grantReason = "trial"
|
|
}
|
|
|
|
// Resolve ladder from product (eliminates need for client-side hidden field)
|
|
ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), productID)
|
|
if err != nil || len(ladders) == 0 {
|
|
h.Logger.Error("failed to resolve ladder for product", slog.Any("error", err), slog.String("product_id", productID))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Selected product is not attached to any ladder")
|
|
return
|
|
}
|
|
ladderID := ladders[0].PlanLadderID
|
|
|
|
// Resolve target tier
|
|
tier, err := h.BillingQ.GetTier(r.Context(), billing.GetTierParams{
|
|
PlanLadderID: ladderID,
|
|
ProductID: productID,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to resolve target tier", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid target tier")
|
|
return
|
|
}
|
|
|
|
product, err := h.BillingQ.GetProductByID(r.Context(), productID)
|
|
if err != nil {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product not found")
|
|
return
|
|
}
|
|
if !product.EntitlementSetID.Valid {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product has no entitlement set")
|
|
return
|
|
}
|
|
|
|
tx, err := h.Database.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Row-level locking: serialize concurrent grant issuance on the same pool
|
|
// to prevent race conditions in ladder state resolution.
|
|
_, err = tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
|
|
return
|
|
}
|
|
|
|
grant, err := entitlements.New(tx).CreateGrant(r.Context(), entitlements.CreateGrantParams{
|
|
ProductID: uuid.NullUUID{UUID: uuid.MustParse(productID), Valid: true},
|
|
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(orgID), Valid: true},
|
|
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
GrantReason: grantReason,
|
|
Quantity: 1,
|
|
ValidUntil: validUntil,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to create grant", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to create grant: "+err.Error())
|
|
return
|
|
}
|
|
|
|
actor := entitlements.TransitionActor{
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
Reason: reason,
|
|
}
|
|
|
|
_, err = entitlements.Transition(r.Context(), tx, poolID, entitlements.TransitionTarget{
|
|
LadderID: tier.PlanLadderID,
|
|
ProductID: productID,
|
|
Source: entitlements.TransitionSource{
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
|
|
EntitlementSetID: product.EntitlementSetID.UUID.String(),
|
|
Quantity: 1,
|
|
},
|
|
}, actor)
|
|
if err != nil {
|
|
h.Logger.Error("transition failed", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transition failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
|
|
return
|
|
}
|
|
|
|
// Wire Temporal schedule for trial grant expiration
|
|
if grantReason == "trial" && h.TemporalClient != nil && validUntil.Valid {
|
|
workflowOptions := client.StartWorkflowOptions{
|
|
ID: "grant-expiration-" + grant.GrantID,
|
|
TaskQueue: queues.Main,
|
|
}
|
|
_, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{
|
|
GrantID: grant.GrantID,
|
|
ValidUntil: validUntil.Time,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Warn("failed to schedule grant expiration workflow", slog.Any("error", err), slog.String("grant_id", grant.GrantID))
|
|
}
|
|
}
|
|
|
|
msg := "Grant issued successfully."
|
|
if grantReason == "trial" {
|
|
msg = "Trial grant issued successfully."
|
|
}
|
|
h.renderOrgEnrollmentPage(w, r, orgID, msg, "")
|
|
}
|
|
|
|
// ExtendGrant handles POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend
|
|
// — issues a new grant on the pool's current tier and records a transition_type='extend' row.
|
|
func (h *OperatorPartialsHandler) ExtendGrant(w http.ResponseWriter, r *http.Request) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderOrgEnrollmentPage(w, r, r.PathValue("orgID"), "", "Unauthorized")
|
|
return
|
|
}
|
|
|
|
orgID := r.PathValue("orgID")
|
|
poolID := r.PathValue("poolID")
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
reason := r.FormValue("reason")
|
|
if reason == "" {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Reason is required")
|
|
return
|
|
}
|
|
|
|
// Optional valid_until: when set, the new grant expires at that time and a
|
|
// Temporal expiration workflow is scheduled. When omitted, the extension
|
|
// is open-ended (comp/gift use case).
|
|
var validUntil sql.NullTime
|
|
if vu := r.FormValue("valid_until"); vu != "" {
|
|
t, err := time.Parse("2006-01-02T15:04", vu)
|
|
if err != nil {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid date format")
|
|
return
|
|
}
|
|
validUntil = sql.NullTime{Time: t, Valid: true}
|
|
}
|
|
|
|
// Resolve the pool's current active attachment to determine target tier.
|
|
attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(r.Context(), poolID)
|
|
if err != nil || len(attachments) == 0 {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool has no active plan tier to extend")
|
|
return
|
|
}
|
|
att := attachments[0]
|
|
|
|
product, err := h.BillingQ.GetProductByID(r.Context(), att.ProductID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to resolve product for attachment", slog.Any("error", err), slog.String("product_id", att.ProductID))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product not found")
|
|
return
|
|
}
|
|
if !product.EntitlementSetID.Valid {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Product has no entitlement set")
|
|
return
|
|
}
|
|
|
|
tx, err := h.Database.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Row-level locking: serialize concurrent extend operations on the same pool.
|
|
_, err = tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", poolID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
|
|
return
|
|
}
|
|
|
|
// Re-verify attachment still exists after lock (could have been ended by concurrent request).
|
|
postLockAttachments, err := entitlements.New(tx).GetActiveAttachmentsByPool(r.Context(), poolID)
|
|
if err != nil || len(postLockAttachments) == 0 {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool tier changed during request; please refresh")
|
|
return
|
|
}
|
|
if postLockAttachments[0].ProductID != att.ProductID {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool tier changed during request; please refresh")
|
|
return
|
|
}
|
|
|
|
// Find the prior grant so we can chain the new one.
|
|
provision, err := entitlements.New(tx).GetPoolProvisionByProvisionID(r.Context(), postLockAttachments[0].ProvisionID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to resolve provision for attachment", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Provision not found")
|
|
return
|
|
}
|
|
|
|
grantReason := "manual"
|
|
if validUntil.Valid {
|
|
grantReason = "trial"
|
|
}
|
|
grant, err := entitlements.New(tx).CreateGrant(r.Context(), entitlements.CreateGrantParams{
|
|
ProductID: uuid.NullUUID{UUID: uuid.MustParse(att.ProductID), Valid: true},
|
|
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(orgID), Valid: true},
|
|
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
GrantReason: grantReason,
|
|
Quantity: 1,
|
|
ValidUntil: validUntil,
|
|
ExtendsGrantID: provision.GrantID,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to create grant", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to create grant: "+err.Error())
|
|
return
|
|
}
|
|
|
|
actor := entitlements.TransitionActor{
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
Reason: reason,
|
|
}
|
|
|
|
_, err = entitlements.Transition(r.Context(), tx, poolID, entitlements.TransitionTarget{
|
|
Extend: true,
|
|
LadderID: att.PlanLadderID,
|
|
ProductID: att.ProductID,
|
|
Source: entitlements.TransitionSource{
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
|
|
EntitlementSetID: product.EntitlementSetID.UUID.String(),
|
|
Quantity: 1,
|
|
},
|
|
}, actor)
|
|
if err != nil {
|
|
h.Logger.Error("transition extend failed", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transition failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
|
|
return
|
|
}
|
|
|
|
// Schedule expiration workflow when the extension has a deadline.
|
|
if validUntil.Valid && h.TemporalClient != nil {
|
|
workflowOptions := client.StartWorkflowOptions{
|
|
ID: "grant-expiration-" + grant.GrantID,
|
|
TaskQueue: queues.Main,
|
|
}
|
|
if _, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, wf.GrantExpirationWorkflow, wf.GrantExpirationInput{
|
|
GrantID: grant.GrantID,
|
|
ValidUntil: validUntil.Time,
|
|
}); err != nil {
|
|
h.Logger.Warn("failed to schedule extension expiration workflow", slog.Any("error", err), slog.String("grant_id", grant.GrantID))
|
|
}
|
|
}
|
|
|
|
msg := "Tier extended successfully."
|
|
if validUntil.Valid {
|
|
msg = "Tier extended; new expiration scheduled."
|
|
}
|
|
h.renderOrgEnrollmentPage(w, r, orgID, msg, "")
|
|
}
|
|
|
|
// RevokeGrantAndTransition handles POST /partials/operator/grants/{grantID}/revoke-and-transition
|
|
func (h *OperatorPartialsHandler) RevokeGrantAndTransition(w http.ResponseWriter, r *http.Request) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderOrgEnrollmentPage(w, r, r.FormValue("org_id"), "", "Unauthorized")
|
|
return
|
|
}
|
|
|
|
grantID := r.PathValue("grantID")
|
|
orgID := r.FormValue("org_id")
|
|
|
|
if orgID == "" {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Organization ID is required")
|
|
return
|
|
}
|
|
|
|
// Pre-tx peek to find the provision's pool. State may change between this
|
|
// read and the lock — we re-resolve the ladder attachment after the lock
|
|
// to make the decision authoritative.
|
|
grantUUID, err := uuid.Parse(grantID)
|
|
if err != nil {
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Invalid grant ID")
|
|
return
|
|
}
|
|
provision, err := h.EntitlementsQ.GetPoolProvisionByGrantID(r.Context(), uuid.NullUUID{UUID: grantUUID, Valid: true})
|
|
if err != nil || provision.ProvisionID == "" {
|
|
h.Logger.Error("failed to get pool provision for grant", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Provision not found")
|
|
return
|
|
}
|
|
|
|
tx, err := h.Database.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transaction failed")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Lock ordering: pool → grant → provision → ladder.
|
|
if _, err := tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", provision.PoolID); err != nil {
|
|
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Pool lock failed")
|
|
return
|
|
}
|
|
|
|
q := entitlements.New(tx)
|
|
|
|
if _, err = q.RevokeGrant(r.Context(), entitlements.RevokeGrantParams{
|
|
GrantID: grantID,
|
|
RevokedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
RevocationReason: sql.NullString{String: "operator_revocation", Valid: true},
|
|
}); err != nil {
|
|
h.Logger.Error("failed to revoke grant", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Failed to revoke grant: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Transition(End) ends the prior provision itself, captures from_rank, and
|
|
// re-applies the org-type default. Do NOT pre-end the provision — that
|
|
// erases the from_rank context Transition needs to classify the transition
|
|
// as a downgrade vs. initiate.
|
|
if _, err = entitlements.Transition(r.Context(), tx, provision.PoolID, entitlements.TransitionTarget{End: true},
|
|
entitlements.TransitionActor{
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
Reason: "operator_revocation",
|
|
}); err != nil {
|
|
h.Logger.Error("transition failed after revocation", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Transition failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "", "Commit failed")
|
|
return
|
|
}
|
|
|
|
h.renderOrgEnrollmentPage(w, r, orgID, "Grant revoked and transition completed.", "")
|
|
}
|
|
|
|
func (h *OperatorPartialsHandler) renderOrgEnrollmentPage(w http.ResponseWriter, r *http.Request, orgID string, success string, errMsg string) {
|
|
data := OrgEnrollmentData{
|
|
OrgID: orgID,
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
|
|
org, err := h.OrgQ.GetOrganizationByID(r.Context(), orgID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to get organization", slog.Any("error", err), slog.String("org_id", orgID))
|
|
data.Error = "Organization not found"
|
|
h.Templates.Render(w, "operator_enrollment.html", data)
|
|
return
|
|
}
|
|
data.OrgName = org.Name
|
|
data.OrgSlug = org.Slug
|
|
|
|
// Load pools for the org
|
|
pools, err := h.EntitlementsQ.GetResourcePoolsByOrgID(r.Context(), orgID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list pools", slog.Any("error", err), slog.String("org_id", orgID))
|
|
data.Error = "Failed to load pools"
|
|
}
|
|
|
|
poolVMs := make([]PoolEnrollmentViewModel, len(pools))
|
|
for i, pool := range pools {
|
|
vm := PoolEnrollmentViewModel{
|
|
PoolID: pool.PoolID,
|
|
PoolName: pool.Name,
|
|
PoolType: pool.PoolType,
|
|
}
|
|
|
|
// Get active attachment for this pool
|
|
attachments, err := h.EntitlementsQ.GetActiveAttachmentsByPool(r.Context(), pool.PoolID)
|
|
if err == nil && len(attachments) > 0 {
|
|
att := attachments[0]
|
|
vm.HasAttachment = true
|
|
vm.ProductID = att.ProductID
|
|
vm.ActivatedAt = att.ActivatedAt.Format("Jan 2, 2006 3:04 PM")
|
|
|
|
// Resolve ladder and tier name
|
|
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), att.PlanLadderID); err == nil {
|
|
vm.LadderKey = ladder.LadderKey
|
|
}
|
|
if product, err := h.BillingQ.GetProductByID(r.Context(), att.ProductID); err == nil {
|
|
vm.TierName = product.Name
|
|
}
|
|
if tier, err := h.BillingQ.GetTier(r.Context(), billing.GetTierParams{
|
|
PlanLadderID: att.PlanLadderID,
|
|
ProductID: att.ProductID,
|
|
}); err == nil {
|
|
vm.Rank = tier.Rank
|
|
}
|
|
}
|
|
|
|
poolVMs[i] = vm
|
|
}
|
|
data.Pools = poolVMs
|
|
|
|
// Load transitions for all pools
|
|
for _, pool := range pools {
|
|
transitions, err := h.EntitlementsQ.ListTransitionsByPool(r.Context(), pool.PoolID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, trn := range transitions {
|
|
actorName := ""
|
|
if trn.ActorID.Valid {
|
|
if person, err := h.IdentityQ.GetPersonByID(r.Context(), trn.ActorID.UUID.String()); err == nil {
|
|
actorName = person.DisplayName
|
|
}
|
|
}
|
|
fromRank := "—"
|
|
if trn.FromRank.Valid {
|
|
fromRank = strconv.Itoa(int(trn.FromRank.Int32))
|
|
}
|
|
toRank := "—"
|
|
if trn.ToRank.Valid {
|
|
toRank = strconv.Itoa(int(trn.ToRank.Int32))
|
|
}
|
|
reason := ""
|
|
if trn.Reason.Valid {
|
|
reason = trn.Reason.String
|
|
}
|
|
data.Transitions = append(data.Transitions, TransitionHistoryViewModel{
|
|
TransitionID: trn.TransitionID,
|
|
TransitionType: trn.TransitionType,
|
|
FromRank: fromRank,
|
|
ToRank: toRank,
|
|
ActorType: trn.ActorType,
|
|
ActorName: actorName,
|
|
Reason: reason,
|
|
EffectiveAt: trn.EffectiveAt.Format("Jan 2, 2006 3:04 PM"),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Load published plan products available for grant issuance
|
|
products, err := h.BillingQ.ListActiveProducts(r.Context())
|
|
if err == nil {
|
|
for _, p := range products {
|
|
if p.ProductType.Valid && p.ProductType.String != "" {
|
|
continue // skip non-plan products
|
|
}
|
|
if p.LifecycleStatus != "published" {
|
|
continue
|
|
}
|
|
// Resolve ladder/rank for this product
|
|
if tiers, err := h.BillingQ.ListLaddersByProduct(r.Context(), p.ProductID); err == nil && len(tiers) > 0 {
|
|
data.GrantProducts = append(data.GrantProducts, TrialProductOption{
|
|
ProductID: p.ProductID,
|
|
Name: p.Name,
|
|
LadderID: tiers[0].PlanLadderID,
|
|
Rank: tiers[0].Rank,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load active grants for the org for revocation
|
|
orgUUID, err := uuid.Parse(orgID)
|
|
if err == nil {
|
|
grants, err := h.EntitlementsQ.ListGrantsByOrgID(r.Context(), uuid.NullUUID{UUID: orgUUID, Valid: true})
|
|
if err == nil {
|
|
for _, g := range grants {
|
|
if g.Status != "active" {
|
|
continue
|
|
}
|
|
productName := ""
|
|
if g.ProductID.Valid {
|
|
if p, err := h.BillingQ.GetProductByID(r.Context(), g.ProductID.UUID.String()); err == nil {
|
|
productName = p.Name
|
|
}
|
|
}
|
|
data.ActiveGrants = append(data.ActiveGrants, GrantViewModel{
|
|
GrantID: g.GrantID,
|
|
ProductName: productName,
|
|
GrantReason: g.GrantReason,
|
|
Quantity: g.Quantity,
|
|
CreatedAt: g.CreatedAt.Format("Jan 2, 2006"),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
h.Templates.Render(w, "operator_enrollment.html", data)
|
|
}
|