diff --git a/internal/handler/admin.go b/internal/handler/admin.go index dac408b..416f2d5 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -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}) +} diff --git a/internal/model/admin.go b/internal/model/admin.go index a656e82..356d544 100644 --- a/internal/model/admin.go +++ b/internal/model/admin.go @@ -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. diff --git a/internal/model/limits.go b/internal/model/limits.go new file mode 100644 index 0000000..b811bb7 --- /dev/null +++ b/internal/model/limits.go @@ -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) +} diff --git a/internal/model/user.go b/internal/model/user.go index a147e2e..ffc3af7 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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(), }) } diff --git a/internal/repository/admin_repo.go b/internal/repository/admin_repo.go index 97ad562..127b36e 100644 --- a/internal/repository/admin_repo.go +++ b/internal/repository/admin_repo.go @@ -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) { diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index ad32ea5..61cb9d4 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -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, ) } diff --git a/internal/server/server.go b/internal/server/server.go index 19f5450..387b239 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index 7f2886a..a03e157 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -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(`
diff --git a/internal/service/plan.go b/internal/service/plan.go index 55f8653..b2b164a 100644 --- a/internal/service/plan.go +++ b/internal/service/plan.go @@ -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 -} diff --git a/internal/service/social_service.go b/internal/service/social_service.go index cc63230..8369f97 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -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 } } diff --git a/migrations/0042_user_limit_overrides.down.sql b/migrations/0042_user_limit_overrides.down.sql new file mode 100644 index 0000000..de733f6 --- /dev/null +++ b/migrations/0042_user_limit_overrides.down.sql @@ -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; diff --git a/migrations/0042_user_limit_overrides.up.sql b/migrations/0042_user_limit_overrides.up.sql new file mode 100644 index 0000000..79b07f1 --- /dev/null +++ b/migrations/0042_user_limit_overrides.up.sql @@ -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) + );