package server import ( "database/sql" "errors" "fmt" "log/slog" "net/http" "net/url" "sort" "strconv" "strings" "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 // enrollmentListCap bounds the composite's Members list to its most // recent rows (maintainer 2026-08-23; the grants ledger and Tier changes // graduated from this cap to the governed-list pager in the 2026-08-24 // design round). The composite is a summary, not a report, so the list // only needs enough rows to answer "who is in this org right now". const enrollmentListCap = 16 // capEnrollmentList truncates items to the most recent enrollmentListCap // entries, returning the (possibly unchanged) slice, the pre-truncation // count, and whether truncation happened. The template renders the honest // "Showing the latest 16 of N" line only when wasCapped is true, so a // caller must pass items already ordered most-recent-first (see the // ordering note on each call site below -- callers whose backing query // sorts oldest-first must reverse or re-sort before calling this). func capEnrollmentList[T any](items []T) (capped []T, total int, wasCapped bool) { total = len(items) if total <= enrollmentListCap { return items, total, false } return items[:enrollmentListCap], total, true } // pageSliceClamped windows an already-loaded, already-ordered slice to // the params' page (operatorListPageSize rows), clamping a past-the-end // page back to 1 like FetchPage does for SQL loaders. The composite's // embedded lists are org-scoped and modest, so Go-side windowing over the // full set is simpler than teaching each backing query LIMIT/OFFSET. func pageSliceClamped[T any](items []T, p *ListParams) (window []T, total int) { total = len(items) start := int(p.Offset()) if start >= total && p.Page > 1 { p.Page = 1 start = 0 } end := start + int(p.Limit()) if end > total { end = total } if start > total { start = total } return items[start:end], total } // siblingListState collects the request's query params EXCEPT the given // list's own (prefix q/page and, when named, its facet param), for // ListNav.Extra: an embedded list's links then preserve its sibling // lists' state on the shared org-detail URL. func siblingListState(r *http.Request, ownPrefix string, ownFacetParams ...string) url.Values { own := map[string]bool{ownPrefix + "q": true, ownPrefix + "page": true, ownPrefix + "per": true} for _, f := range ownFacetParams { own[f] = true } v := url.Values{} for k, vals := range r.URL.Query() { if own[k] { continue } for _, val := range vals { if val != "" { v.Add(k, val) } } } return v } // 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}, "" } // PoolRungViewModel is one ladder placement of a delivery: which ladder, // at what rank, since when. Rendered as a badge pill under the delivery's // product line. type PoolRungViewModel struct { LadderName string Rank int32 ActivatedAt string } // PoolDeliveryViewModel is one delivery (provision) a pool holds, // grouped: the product renders ONCE with the single Extend control, and // its ladder placements render as sub-pills (maintainer design round // 2026-08-24: the delivery is the act-on unit; rungs are facts about it. // This replaced one-line-per-position, which showed a shared product N // times and needed a "Same delivery" disambiguation marker). type PoolDeliveryViewModel struct { ProvisionID string ProductName string ProductID string // GrantBacked is the precondition for extending: a subscription-backed // delivery renders its Extend control disabled with the reason. GrantBacked bool Rungs []PoolRungViewModel } // PoolEnrollmentViewModel represents a pool's current enrollment state. type PoolEnrollmentViewModel struct { PoolID string PoolName string PoolType string // 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 // Deliveries lists every delivery (provision) this pool holds, each // grouping its ladder placements. One Extend control and one panel per // delivery; a shared product (a tier on several ladders) is ONE // delivery with several rung pills. Deliveries []PoolDeliveryViewModel // 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 } // PoolUsageViewModel is one per-resource usage counter row on the // org-detail pools panel. type PoolUsageViewModel struct { ResourceKey string Used int64 Limit int64 } // TransitionHistoryViewModel is one row of the Tier changes table // (formerly "Position history"; maintainer design round 2026-08-24: // operators cannot decode "position" or integer ranks, so rows carry the // ladder key, a humanized verb, and tier NAMES resolved from the ladder's // current shape, with "rank N" as the fallback when a historical rank no // longer exists on the ladder). type TransitionHistoryViewModel struct { TransitionID string LadderName string // ChangeLabel is the humanized transition_type (Started / Upgraded / // Downgraded / Transferred / Ended); ChangeTooltip explains the one // verb that needs it (Transferred). ChangeLabel string ChangeTooltip string // FromLabel / ToLabel are tier names (best effort), "rank N" when the // rank is not on the ladder's current shape, or the empty-value marker // for the side a Started/Ended row does not have. FromLabel string ToLabel string ActorType string ActorName string Reason string EffectiveAt string } // transitionChangeLabels maps the transition_type enum to operator // vocabulary. transfer gets a tooltip: same rank, different funding // source (model card invariant: callers never choose the type; confer // derives it from ranks). var transitionChangeLabels = map[string]string{ "initiate": "Started", "upgrade": "Upgraded", "downgrade": "Downgraded", "transfer": "Transferred", "end": "Ended", } // TierOption represents a selectable tier for force-transition. type TierOption struct { LadderID 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