package server import ( "database/sql" "errors" "fmt" "log/slog" "net/http" "strconv" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" 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" ) // maxGrantQuantity caps the CreateNonPlanGrant quantity field. Well under // int32's range so validation rejects the input before the int32(q) cast // can wrap or go negative (finding #31); also just a sane ceiling for a // per-org addon/usage/one-time quantity. const maxGrantQuantity = 1_000_000 // maxGrantReasonLength caps the free-text "Why this grant?" reason fields. // The columns these feed (grants.description, pool_provision_transitions. // reason) are TEXT with no DB-level bound; this is a UX guard, not a // constraint workaround (finding #48). const maxGrantReasonLength = 500 // poolMissingMessage is the breakage reason shown both where the composite // blocks the Issue Grant form (loadOrgEnrollmentData / the template) and // where IssueGrant refuses a POST that reaches the handler anyway // (ux-honest-surfaces: "a pool-less organization is presented as broken, // not empty" — one wording, so the block and its stated reason can never // drift apart). const poolMissingMessage = "This organization is missing its default resource pool. Every organization should have exactly one; without it, grant delivery is impossible until the pool is repaired." // grantConstraints maps the DB constraints a grant INSERT/UPDATE can hit to // the fields on the composite's grant forms, so violations render as friendly // field-level errors instead of leaking raw driver text (per // docs/operator-ux-conventions.md §4/§6 — constraint names live next to the // forms they belong to). Constraints an operator cannot plausibly trigger are // left to web.FieldErrorsFromDB's per-class fallback. var grantConstraints = web.ConstraintMessages{ "chk_grants_recipient": {Field: "", Message: "A grant must name exactly one recipient."}, "chk_grants_no_self_extend": {Field: "", Message: "A grant cannot extend itself."}, "chk_grants_reason_domain": {Field: "reason", Message: "Choose a valid reason."}, "chk_grants_default_iff_system_authored": {Field: "reason", Message: "The 'default' reason is reserved for system-authored grants."}, "grants_product_id_fkey": {Field: "product_id", Message: "The selected product no longer exists. Refresh the page and choose another."}, } // fieldErrorsBanner flattens translated FieldErrors into a single banner // message for actions without field-level rendering (the revoke buttons have // no form to attach a 422 FieldErrors re-render to). Prefers the form-level // "" message, else any field message — the translator sets exactly one entry. func fieldErrorsBanner(fe web.FieldErrors) string { if msg := fe.Get(""); msg != "" { return msg } for _, msg := range fe { return msg } return "The change was rejected by a data constraint." } // parseGrantValidUntil reads the optional "valid_until" field from a grant form. // The datetime-local input is a naive wall-clock time. When the browser supplies // valid_until_offset (Date.getTimezoneOffset() for the picked date, via // static/grant-valid-until-tz.js) the instant is resolved exactly regardless of // the server's timezone; otherwise the server's local zone is assumed. A time in // the past is rejected. Returns a zero NullTime and "" when the field is empty, // or a user-facing message on error. func parseGrantValidUntil(r *http.Request) (sql.NullTime, string) { v := r.FormValue("valid_until") if v == "" { return sql.NullTime{}, "" } var ( t time.Time err error ) if off := r.FormValue("valid_until_offset"); off != "" { if mins, aerr := strconv.Atoi(off); aerr == nil { // getTimezoneOffset() is minutes local is behind UTC: parse the naive // value as UTC, then add the offset to recover the true instant. if t, err = time.Parse("2006-01-02T15:04", v); err == nil { t = t.Add(time.Duration(mins) * time.Minute) } } else { t, err = time.ParseInLocation("2006-01-02T15:04", v, time.Local) } } else { t, err = time.ParseInLocation("2006-01-02T15:04", v, time.Local) } if err != nil { return sql.NullTime{}, "Use the date picker (YYYY-MM-DD HH:MM)." } if t.Before(time.Now()) { return sql.NullTime{}, "Choose a time in the future." } return sql.NullTime{Time: t, Valid: true}, "" } // 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 // Status is the pool's core.resource_pools.status value ("active" as // built today — invariant 10, resource-pools card — but rendered // honestly rather than assumed, so a future non-active pool is visibly // distinct instead of silently indistinguishable from a healthy one). Status string ActivatedAt string // Usage lists this pool's per-resource-key usage counters (used vs // limit), so a member-facing quota refusal is diagnosable from the // console (ux-honest-surfaces: "pool status and usage are visible on // the organization view"). Usage []PoolUsageViewModel HasAttachment bool // HasGrantDelivery reports whether the pool has an active grant-backed // provision — the precondition for ExtendGrant. The extend form renders // only when this is true, so its presence is a truthful signal that the // action can succeed (subscription-backed or ended deliveries would // otherwise offer a form that always fails the handler's target check). HasGrantDelivery bool } // PoolUsageViewModel is one per-resource usage counter row on the // org-detail pools panel. type PoolUsageViewModel struct { ResourceKey string Used int64 Limit int64 } // 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 } // grantReasonDomain is the operator-selectable subset of grants.grant_reason // (the 'default' value is system-authored only and never offered here). The // single issuance form's reason