@@ -18,6 +18,8 @@ import ( |
| 18 | | 18 | |
| 19 | authpkg "github.com/tenseleyFlow/shithub/internal/auth" | 19 | authpkg "github.com/tenseleyFlow/shithub/internal/auth" |
| 20 | "github.com/tenseleyFlow/shithub/internal/auth/policy" | 20 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| | 21 | + "github.com/tenseleyFlow/shithub/internal/billing" |
| | 22 | + "github.com/tenseleyFlow/shithub/internal/entitlements" |
| 21 | "github.com/tenseleyFlow/shithub/internal/orgs" | 23 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 22 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" | 24 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 23 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | 25 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
@@ -25,6 +27,9 @@ import ( |
| 25 | "github.com/tenseleyFlow/shithub/internal/web/middleware" | 27 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 26 | ) | 28 | ) |
| 27 | | 29 | |
| | 30 | +// profilePinLimit is the org-side cap; PRO01 keeps orgs at gh's |
| | 31 | +// visible 6. User-side caps are resolved per-principal via |
| | 32 | +// entitlements (Free=6, Pro=100). |
| 28 | const profilePinLimit = 6 | 33 | const profilePinLimit = 6 |
| 29 | | 34 | |
| 30 | var ( | 35 | var ( |
@@ -91,7 +96,7 @@ func (h *Handlers) updateOrgPins(w http.ResponseWriter, r *http.Request, orgID i |
| 91 | } | 96 | } |
| 92 | | 97 | |
| 93 | candidates := h.publicOrgPinCandidates(ctx, org.ID, string(org.Slug)) | 98 | candidates := h.publicOrgPinCandidates(ctx, org.ID, string(org.Slug)) |
| 94 | - repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates) | 99 | + repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates, profilePinLimit) |
| 95 | if err != nil { | 100 | if err != nil { |
| 96 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") | 101 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 97 | return | 102 | return |
@@ -126,7 +131,12 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam |
| 126 | } | 131 | } |
| 127 | | 132 | |
| 128 | candidates := h.publicUserPinCandidates(ctx, user.ID) | 133 | candidates := h.publicUserPinCandidates(ctx, user.ID) |
| 129 | - repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates) | 134 | + maxPins, beyondFreeAllowed := h.userProfilePinCap(ctx, user.ID) |
| | 135 | + repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates, maxPins) |
| | 136 | + if errors.Is(err, errTooManyPins) { |
| | 137 | + h.handleUserPinOverflow(w, r, user.ID, beyondFreeAllowed) |
| | 138 | + return |
| | 139 | + } |
| 130 | if err != nil { | 140 | if err != nil { |
| 131 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") | 141 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 132 | return | 142 | return |
@@ -139,6 +149,57 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam |
| 139 | http.Redirect(w, r, "/"+url.PathEscape(user.Username)+"#pinned", http.StatusSeeOther) | 149 | http.Redirect(w, r, "/"+url.PathEscape(user.Username)+"#pinned", http.StatusSeeOther) |
| 140 | } | 150 | } |
| 141 | | 151 | |
| | 152 | +// userProfilePinCap resolves the entitled pin cap for a user. Returns |
| | 153 | +// (cap, beyondFreeAllowed). Falls back to the Free cap if entitlement |
| | 154 | +// resolution errors so a degraded billing path can't silently raise |
| | 155 | +// the limit. beyondFreeAllowed mirrors CanUse(FeatureProfilePinsBeyondFree) |
| | 156 | +// — the caller uses it to flavor the upgrade-banner copy and tells |
| | 157 | +// the report-only logger whether the deny would have triggered. |
| | 158 | +func (h *Handlers) userProfilePinCap(ctx context.Context, userID int64) (int64, bool) { |
| | 159 | + set, err := entitlements.ForPrincipal(ctx, entitlements.Deps{Pool: h.d.Pool}, billing.PrincipalForUser(userID)) |
| | 160 | + if err != nil { |
| | 161 | + h.d.Logger.WarnContext(ctx, "profile pins: load entitlements", "user_id", userID, "error", err) |
| | 162 | + return entitlements.FreeProfilePinsCap, false |
| | 163 | + } |
| | 164 | + decision := set.CanUse(entitlements.FeatureProfilePinsBeyondFree) |
| | 165 | + if decision.Allowed { |
| | 166 | + return entitlements.ProProfilePinsCap, true |
| | 167 | + } |
| | 168 | + return entitlements.FreeProfilePinsCap, false |
| | 169 | +} |
| | 170 | + |
| | 171 | +// handleUserPinOverflow renders the over-cap response for a personal |
| | 172 | +// profile pin submission. When the operator hasn't flipped the enforce |
| | 173 | +// flag (PRO05 report-only default), logs the would-deny and falls back |
| | 174 | +// to the pre-PRO07 400 response. With enforce on, returns 402 + an |
| | 175 | +// upgrade banner pointing at /settings/billing. |
| | 176 | +func (h *Handlers) handleUserPinOverflow(w http.ResponseWriter, r *http.Request, userID int64, beyondFreeAllowed bool) { |
| | 177 | + // Pro users that hit this branch are over the Pro cap (100) — that's |
| | 178 | + // a DB-sanity 400 regardless of enforcement. |
| | 179 | + if beyondFreeAllowed { |
| | 180 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| | 181 | + return |
| | 182 | + } |
| | 183 | + if !h.d.BillingEnforce.UserProfilePinsBeyondFree { |
| | 184 | + h.d.Logger.InfoContext(r.Context(), "entitlements.report_only_deny", |
| | 185 | + "principal", billing.PrincipalForUser(userID).String(), |
| | 186 | + "principal_kind", string(billing.SubjectKindUser), |
| | 187 | + "principal_id", userID, |
| | 188 | + "feature", string(entitlements.FeatureProfilePinsBeyondFree), |
| | 189 | + "reason", string(entitlements.ReasonUpgradeRequired), |
| | 190 | + "required_plan", "pro") |
| | 191 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| | 192 | + return |
| | 193 | + } |
| | 194 | + decision := entitlements.Decision{ |
| | 195 | + Feature: entitlements.FeatureProfilePinsBeyondFree, |
| | 196 | + RequiredPlan: billing.Plan("pro"), |
| | 197 | + Reason: entitlements.ReasonUpgradeRequired, |
| | 198 | + } |
| | 199 | + banner := decision.PrincipalUpgradeBanner("Pinned repositories", billing.PrincipalForUser(userID), "") |
| | 200 | + http.Error(w, banner.Message, banner.StatusCode) |
| | 201 | +} |
| | 202 | + |
| 142 | func (h *Handlers) orgPinData(ctx context.Context, orgID int64, orgSlug string, repos []orgProfileRepo) ([]orgProfileRepo, []profilePinCandidate) { | 203 | func (h *Handlers) orgPinData(ctx context.Context, orgID int64, orgSlug string, repos []orgProfileRepo) ([]orgProfileRepo, []profilePinCandidate) { |
| 143 | publicRepos := publicOrgProfileRepos(repos) | 204 | publicRepos := publicOrgProfileRepos(repos) |
| 144 | pinned := pinnedOrgRepos(publicRepos) | 205 | pinned := pinnedOrgRepos(publicRepos) |
@@ -244,7 +305,7 @@ func (h *Handlers) publicOrgPinCandidates(ctx context.Context, orgID int64, orgS |
| 244 | return out | 305 | return out |
| 245 | } | 306 | } |
| 246 | | 307 | |
| 247 | -func selectedProfilePinIDs(values []string, candidates []profilePinCandidate) ([]int64, error) { | 308 | +func selectedProfilePinIDs(values []string, candidates []profilePinCandidate, maxPins int64) ([]int64, error) { |
| 248 | allowed := make(map[int64]struct{}, len(candidates)) | 309 | allowed := make(map[int64]struct{}, len(candidates)) |
| 249 | for _, candidate := range candidates { | 310 | for _, candidate := range candidates { |
| 250 | allowed[candidate.ID] = struct{}{} | 311 | allowed[candidate.ID] = struct{}{} |
@@ -265,7 +326,7 @@ func selectedProfilePinIDs(values []string, candidates []profilePinCandidate) ([ |
| 265 | seen[repoID] = struct{}{} | 326 | seen[repoID] = struct{}{} |
| 266 | out = append(out, repoID) | 327 | out = append(out, repoID) |
| 267 | } | 328 | } |
| 268 | - if len(out) > profilePinLimit { | 329 | + if int64(len(out)) > maxPins { |
| 269 | return nil, errTooManyPins | 330 | return nil, errTooManyPins |
| 270 | } | 331 | } |
| 271 | return out, nil | 332 | return out, nil |