feat: enhance limits
This commit is contained in:
@@ -326,3 +326,37 @@ func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, ban
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"banned": banned})
|
||||
}
|
||||
|
||||
// SetUserLimits — POST /admin/users/{id}/limits with body
|
||||
// { "bookmark_limit": 10|null, "public_collection_limit": …, "private_collection_limit": … }.
|
||||
// Each field is optional: a null (or omitted) value resets that limit to the
|
||||
// plan default; a number caps that resource for this user regardless of plan.
|
||||
func (h *AdminHandler) SetUserLimits(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var in struct {
|
||||
BookmarkLimit *int `json:"bookmark_limit"`
|
||||
PublicCollectionLimit *int `json:"public_collection_limit"`
|
||||
PrivateCollectionLimit *int `json:"private_collection_limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
const maxLimit = 100000
|
||||
for _, v := range []*int{in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit} {
|
||||
if v != nil && (*v < 0 || *v > maxLimit) {
|
||||
response.Error(w, http.StatusBadRequest, "each limit must be between 0 and 100000")
|
||||
return
|
||||
}
|
||||
}
|
||||
ok, err := h.svc.SetUserLimits(r.Context(), id, in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update limits")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"updated": true})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ type AdminUser struct {
|
||||
// Providers lists linked OAuth providers (e.g. "google", "facebook").
|
||||
HasPassword bool `json:"has_password"`
|
||||
Providers []string `json:"providers"`
|
||||
|
||||
// Effective save limits in force for this user (plan default merged with any
|
||||
// admin override); -1 means unlimited. The admin UI pre-fills the "Edit
|
||||
// limits" modal with these.
|
||||
BookmarkLimit int `json:"bookmark_limit"`
|
||||
PublicCollectionLimit int `json:"public_collection_limit"`
|
||||
PrivateCollectionLimit int `json:"private_collection_limit"`
|
||||
}
|
||||
|
||||
// AdminPost is a row in the admin posts table.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package model
|
||||
|
||||
// Plan-default save limits (-1 = unlimited). Per-user override columns on the
|
||||
// users row take precedence when non-nil; admins set them from the panel.
|
||||
const (
|
||||
planPremium = "premium"
|
||||
|
||||
defaultFreeBookmarkLimit = 1
|
||||
defaultPremiumBookmarkLimit = 5
|
||||
defaultFreeCollectionLimit = 5 // per visibility; premium = unlimited
|
||||
)
|
||||
|
||||
// UserLimits are the effective per-resource caps in force for a user (-1 means
|
||||
// unlimited). Surfaced on the self user payload so the app can render
|
||||
// "X / Y used" and gate create actions without hardcoding the numbers.
|
||||
type UserLimits struct {
|
||||
Bookmarks int `json:"bookmarks"`
|
||||
PublicCollections int `json:"public_collections"`
|
||||
PrivateCollections int `json:"private_collections"`
|
||||
}
|
||||
|
||||
// ResolveLimits computes the effective caps from a plan plus optional per-user
|
||||
// overrides (nil = use the plan default). Shared by the self payload and the
|
||||
// admin user listing so both agree on what's in force.
|
||||
func ResolveLimits(plan string, bookmark, publicCollection, privateCollection *int) UserLimits {
|
||||
premium := plan == planPremium
|
||||
|
||||
bm := defaultFreeBookmarkLimit
|
||||
if premium {
|
||||
bm = defaultPremiumBookmarkLimit
|
||||
}
|
||||
pub, priv := defaultFreeCollectionLimit, defaultFreeCollectionLimit
|
||||
if premium {
|
||||
pub, priv = -1, -1
|
||||
}
|
||||
if bookmark != nil {
|
||||
bm = *bookmark
|
||||
}
|
||||
if publicCollection != nil {
|
||||
pub = *publicCollection
|
||||
}
|
||||
if privateCollection != nil {
|
||||
priv = *privateCollection
|
||||
}
|
||||
return UserLimits{Bookmarks: bm, PublicCollections: pub, PrivateCollections: priv}
|
||||
}
|
||||
|
||||
// EffectiveLimits resolves this user's caps (plan default + any overrides).
|
||||
func (u *User) EffectiveLimits() UserLimits {
|
||||
return ResolveLimits(u.Plan, u.BookmarkLimit, u.PublicCollectionLimit, u.PrivateCollectionLimit)
|
||||
}
|
||||
+10
-1
@@ -75,6 +75,13 @@ type User struct {
|
||||
// BannedAt suspends the account when set: RequireAuth rejects the session
|
||||
// and LogIn refuses to issue a new one. nil = in good standing.
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
|
||||
// Per-user save-limit overrides (admin-settable; nil = use the plan
|
||||
// default). Never serialized raw — surfaced only via the derived `limits`
|
||||
// object in MarshalJSON so the app can render/enforce the right caps.
|
||||
BookmarkLimit *int `json:"-"`
|
||||
PublicCollectionLimit *int `json:"-"`
|
||||
PrivateCollectionLimit *int `json:"-"`
|
||||
}
|
||||
|
||||
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||
@@ -84,10 +91,12 @@ func (u User) MarshalJSON() ([]byte, error) {
|
||||
type alias User
|
||||
return json.Marshal(&struct {
|
||||
alias
|
||||
HasPassword bool `json:"has_password"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
Limits UserLimits `json:"limits"`
|
||||
}{
|
||||
alias: alias(u),
|
||||
HasPassword: u.PasswordHash != nil,
|
||||
Limits: u.EffectiveLimits(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
(u.password_hash IS NOT NULL) AS has_password,
|
||||
(SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[])
|
||||
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
||||
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM users u
|
||||
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
||||
@@ -55,16 +56,39 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var u model.AdminUser
|
||||
var bookmarkLimit, publicCollLimit, privateCollLimit *int
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
|
||||
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
||||
&u.HasPassword, &u.Providers, &total); err != nil {
|
||||
&u.HasPassword, &u.Providers,
|
||||
&bookmarkLimit, &publicCollLimit, &privateCollLimit, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||
}
|
||||
// Resolve plan default + overrides so the panel shows what's in force.
|
||||
lim := model.ResolveLimits(u.Plan, bookmarkLimit, publicCollLimit, privateCollLimit)
|
||||
u.BookmarkLimit = lim.Bookmarks
|
||||
u.PublicCollectionLimit = lim.PublicCollections
|
||||
u.PrivateCollectionLimit = lim.PrivateCollections
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// SetUserLimits writes the per-user save-limit overrides (nil clears one back
|
||||
// to the plan default). Returns false if the id doesn't exist.
|
||||
func (r *AdminRepo) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET bookmark_limit = $2,
|
||||
public_collection_limit = $3,
|
||||
private_collection_limit = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, userID, bookmark, publicCollection, privateCollection)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin set user limits: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// UserExists reports whether a user id is real (used before sending a
|
||||
// notification to it).
|
||||
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
||||
|
||||
@@ -36,7 +36,8 @@ func NewUserRepo(pg *Postgres) *UserRepo {
|
||||
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at, country,
|
||||
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
||||
preferred_chanting_time, peak_active_time, banned_at`
|
||||
preferred_chanting_time, peak_active_time, banned_at,
|
||||
bookmark_limit, public_collection_limit, private_collection_limit`
|
||||
|
||||
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
||||
// scanUser works for single-row and iterated reads alike.
|
||||
@@ -51,6 +52,7 @@ func scanUser(row rowScanner, u *model.User) error {
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
||||
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
||||
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1003,6 +1003,7 @@ func (s *Server) setupRouter() {
|
||||
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||
r.Get("/posts", s.adminH.ListPosts)
|
||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||
|
||||
@@ -157,6 +157,12 @@ func (s *AdminService) SetUserBanned(ctx context.Context, userID string, banned
|
||||
return s.repo.SetUserBanned(ctx, userID, banned)
|
||||
}
|
||||
|
||||
// SetUserLimits writes per-user save-limit overrides (a nil field resets that
|
||||
// limit to the plan default). found=false when the id is unknown.
|
||||
func (s *AdminService) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
|
||||
return s.repo.SetUserLimits(ctx, userID, bookmark, publicCollection, privateCollection)
|
||||
}
|
||||
|
||||
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||
// html/template, so admin-entered text can't inject markup.
|
||||
var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
|
||||
+23
-47
@@ -3,19 +3,16 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// Plan caps for the social features. Updates here propagate everywhere via
|
||||
// EnforceBookmarkLimit / EnforceCollectionLimit.
|
||||
// Plan identifiers. The numeric caps live in model.ResolveLimits (so the self
|
||||
// user payload and these enforcers agree), and per-user admin overrides take
|
||||
// precedence over the plan default.
|
||||
const (
|
||||
PlanFree = "free"
|
||||
PlanPremium = "premium"
|
||||
|
||||
freeBookmarkLimit = 1
|
||||
premiumBookmarkLimit = 5
|
||||
|
||||
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
|
||||
// Premium has no collection cap.
|
||||
)
|
||||
|
||||
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
|
||||
@@ -38,50 +35,29 @@ func (e *PlanLimitError) Error() string {
|
||||
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||
|
||||
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
||||
// bookmark, or a PlanLimitError otherwise.
|
||||
func EnforceBookmarkLimit(plan string, currentCount int) error {
|
||||
limit := bookmarkLimitFor(plan)
|
||||
if currentCount >= limit {
|
||||
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
|
||||
// bookmark, or a PlanLimitError otherwise. The cap is the user's effective
|
||||
// bookmark limit (plan default, or an admin override). A negative cap means
|
||||
// unlimited.
|
||||
func EnforceBookmarkLimit(u *model.User, currentCount int) error {
|
||||
limit := u.EffectiveLimits().Bookmarks
|
||||
if limit >= 0 && currentCount >= limit {
|
||||
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: u.Plan}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnforceCollectionLimit returns nil if the user is allowed to create one
|
||||
// more collection of the given visibility.
|
||||
//
|
||||
// `isPublic=true` → checks against public-collection cap
|
||||
// `isPublic=false` → checks against private-collection cap
|
||||
// Premium users have no cap → always nil.
|
||||
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
|
||||
if plan == PlanPremium {
|
||||
return nil
|
||||
// EnforceCollectionLimit returns nil if the user is allowed to create one more
|
||||
// collection of the given visibility. The cap is the user's effective
|
||||
// public/private collection limit (plan default, or an admin override); a
|
||||
// negative cap means unlimited.
|
||||
func EnforceCollectionLimit(u *model.User, isPublic bool, currentCount int) error {
|
||||
l := u.EffectiveLimits()
|
||||
limit, resource := l.PrivateCollections, "private collections"
|
||||
if isPublic {
|
||||
limit, resource = l.PublicCollections, "public collections"
|
||||
}
|
||||
limit := freeCollectionPerVisibilityLimit
|
||||
if currentCount >= limit {
|
||||
resource := "private collections"
|
||||
if isPublic {
|
||||
resource = "public collections"
|
||||
}
|
||||
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
|
||||
if limit >= 0 && currentCount >= limit {
|
||||
return &PlanLimitError{Resource: resource, Limit: limit, Plan: u.Plan}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bookmarkLimitFor(plan string) int {
|
||||
if plan == PlanPremium {
|
||||
return premiumBookmarkLimit
|
||||
}
|
||||
return freeBookmarkLimit
|
||||
}
|
||||
|
||||
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
|
||||
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
|
||||
|
||||
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
|
||||
func CollectionLimitFor(plan string) int {
|
||||
if plan == PlanPremium {
|
||||
return -1
|
||||
}
|
||||
return freeCollectionPerVisibilityLimit
|
||||
}
|
||||
|
||||
@@ -538,7 +538,7 @@ func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceT
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
|
||||
if err := EnforceBookmarkLimit(u, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -602,7 +602,7 @@ func (s *CollectionService) Create(ctx context.Context, userID, title string, de
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
|
||||
if err := EnforceCollectionLimit(u, isPublic, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
||||
@@ -628,7 +628,7 @@ func (s *CollectionService) Update(ctx context.Context, id, userID string, title
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
|
||||
if err := EnforceCollectionLimit(u, *isPublic, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_limit_overrides_nonneg;
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS bookmark_limit,
|
||||
DROP COLUMN IF EXISTS public_collection_limit,
|
||||
DROP COLUMN IF EXISTS private_collection_limit;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Per-user save-limit overrides, set by admins from the panel. NULL means
|
||||
-- "use the plan default" (free: 1 bookmark, 5 public + 5 private collections;
|
||||
-- premium: 5 bookmarks, unlimited collections). A non-NULL value caps that
|
||||
-- resource for this specific user, regardless of plan.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS bookmark_limit INT,
|
||||
ADD COLUMN IF NOT EXISTS public_collection_limit INT,
|
||||
ADD COLUMN IF NOT EXISTS private_collection_limit INT;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_limit_overrides_nonneg CHECK (
|
||||
(bookmark_limit IS NULL OR bookmark_limit >= 0) AND
|
||||
(public_collection_limit IS NULL OR public_collection_limit >= 0) AND
|
||||
(private_collection_limit IS NULL OR private_collection_limit >= 0)
|
||||
);
|
||||
Reference in New Issue
Block a user