@@ -18,6 +18,8 @@ import ( |
| 18 | 18 | |
| 19 | 19 | authpkg "github.com/tenseleyFlow/shithub/internal/auth" |
| 20 | 20 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 21 | + "github.com/tenseleyFlow/shithub/internal/billing" |
| 22 | + "github.com/tenseleyFlow/shithub/internal/entitlements" |
| 21 | 23 | "github.com/tenseleyFlow/shithub/internal/orgs" |
| 22 | 24 | orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" |
| 23 | 25 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
@@ -25,6 +27,9 @@ import ( |
| 25 | 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 | 33 | const profilePinLimit = 6 |
| 29 | 34 | |
| 30 | 35 | var ( |
@@ -91,7 +96,7 @@ func (h *Handlers) updateOrgPins(w http.ResponseWriter, r *http.Request, orgID i |
| 91 | 96 | } |
| 92 | 97 | |
| 93 | 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 | 100 | if err != nil { |
| 96 | 101 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 97 | 102 | return |
@@ -126,7 +131,12 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam |
| 126 | 131 | } |
| 127 | 132 | |
| 128 | 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 | 140 | if err != nil { |
| 131 | 141 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "") |
| 132 | 142 | return |
@@ -139,6 +149,57 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam |
| 139 | 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 | 203 | func (h *Handlers) orgPinData(ctx context.Context, orgID int64, orgSlug string, repos []orgProfileRepo) ([]orgProfileRepo, []profilePinCandidate) { |
| 143 | 204 | publicRepos := publicOrgProfileRepos(repos) |
| 144 | 205 | pinned := pinnedOrgRepos(publicRepos) |
@@ -244,7 +305,7 @@ func (h *Handlers) publicOrgPinCandidates(ctx context.Context, orgID int64, orgS |
| 244 | 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 | 309 | allowed := make(map[int64]struct{}, len(candidates)) |
| 249 | 310 | for _, candidate := range candidates { |
| 250 | 311 | allowed[candidate.ID] = struct{}{} |
@@ -265,7 +326,7 @@ func selectedProfilePinIDs(values []string, candidates []profilePinCandidate) ([ |
| 265 | 326 | seen[repoID] = struct{}{} |
| 266 | 327 | out = append(out, repoID) |
| 267 | 328 | } |
| 268 | | - if len(out) > profilePinLimit { |
| 329 | + if int64(len(out)) > maxPins { |
| 269 | 330 | return nil, errTooManyPins |
| 270 | 331 | } |
| 271 | 332 | return out, nil |