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})
|
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").
|
// Providers lists linked OAuth providers (e.g. "google", "facebook").
|
||||||
HasPassword bool `json:"has_password"`
|
HasPassword bool `json:"has_password"`
|
||||||
Providers []string `json:"providers"`
|
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.
|
// 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)
|
||||||
|
}
|
||||||
@@ -75,6 +75,13 @@ type User struct {
|
|||||||
// BannedAt suspends the account when set: RequireAuth rejects the session
|
// BannedAt suspends the account when set: RequireAuth rejects the session
|
||||||
// and LogIn refuses to issue a new one. nil = in good standing.
|
// and LogIn refuses to issue a new one. nil = in good standing.
|
||||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
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
|
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||||
@@ -85,9 +92,11 @@ func (u User) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(&struct {
|
return json.Marshal(&struct {
|
||||||
alias
|
alias
|
||||||
HasPassword bool `json:"has_password"`
|
HasPassword bool `json:"has_password"`
|
||||||
|
Limits UserLimits `json:"limits"`
|
||||||
}{
|
}{
|
||||||
alias: alias(u),
|
alias: alias(u),
|
||||||
HasPassword: u.PasswordHash != nil,
|
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,
|
(u.password_hash IS NOT NULL) AS has_password,
|
||||||
(SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[])
|
(SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[])
|
||||||
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
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
|
COUNT(*) OVER() AS total
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
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
|
total := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u model.AdminUser
|
var u model.AdminUser
|
||||||
|
var bookmarkLimit, publicCollLimit, privateCollLimit *int
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
|
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.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)
|
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)
|
out = append(out, u)
|
||||||
}
|
}
|
||||||
return out, total, rows.Err()
|
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
|
// UserExists reports whether a user id is real (used before sending a
|
||||||
// notification to it).
|
// notification to it).
|
||||||
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
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,
|
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
email_verified_at, token_version, created_at, updated_at, country,
|
email_verified_at, token_version, created_at, updated_at, country,
|
||||||
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
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
|
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
||||||
// scanUser works for single-row and iterated reads alike.
|
// 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.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||||
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
||||||
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
&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}/notify", s.adminH.NotifyUser)
|
||||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||||
|
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||||
r.Get("/posts", s.adminH.ListPosts)
|
r.Get("/posts", s.adminH.ListPosts)
|
||||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
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)
|
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
|
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||||
// html/template, so admin-entered text can't inject markup.
|
// 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">
|
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">
|
||||||
|
|||||||
+22
-46
@@ -3,19 +3,16 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Plan caps for the social features. Updates here propagate everywhere via
|
// Plan identifiers. The numeric caps live in model.ResolveLimits (so the self
|
||||||
// EnforceBookmarkLimit / EnforceCollectionLimit.
|
// user payload and these enforcers agree), and per-user admin overrides take
|
||||||
|
// precedence over the plan default.
|
||||||
const (
|
const (
|
||||||
PlanFree = "free"
|
PlanFree = "free"
|
||||||
PlanPremium = "premium"
|
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.
|
// 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 }
|
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||||
|
|
||||||
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
||||||
// bookmark, or a PlanLimitError otherwise.
|
// bookmark, or a PlanLimitError otherwise. The cap is the user's effective
|
||||||
func EnforceBookmarkLimit(plan string, currentCount int) error {
|
// bookmark limit (plan default, or an admin override). A negative cap means
|
||||||
limit := bookmarkLimitFor(plan)
|
// unlimited.
|
||||||
if currentCount >= limit {
|
func EnforceBookmarkLimit(u *model.User, currentCount int) error {
|
||||||
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
|
limit := u.EffectiveLimits().Bookmarks
|
||||||
|
if limit >= 0 && currentCount >= limit {
|
||||||
|
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: u.Plan}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnforceCollectionLimit returns nil if the user is allowed to create one
|
// EnforceCollectionLimit returns nil if the user is allowed to create one more
|
||||||
// more collection of the given visibility.
|
// collection of the given visibility. The cap is the user's effective
|
||||||
//
|
// public/private collection limit (plan default, or an admin override); a
|
||||||
// `isPublic=true` → checks against public-collection cap
|
// negative cap means unlimited.
|
||||||
// `isPublic=false` → checks against private-collection cap
|
func EnforceCollectionLimit(u *model.User, isPublic bool, currentCount int) error {
|
||||||
// Premium users have no cap → always nil.
|
l := u.EffectiveLimits()
|
||||||
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
|
limit, resource := l.PrivateCollections, "private collections"
|
||||||
if plan == PlanPremium {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
limit := freeCollectionPerVisibilityLimit
|
|
||||||
if currentCount >= limit {
|
|
||||||
resource := "private collections"
|
|
||||||
if isPublic {
|
if isPublic {
|
||||||
resource = "public collections"
|
limit, resource = l.PublicCollections, "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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
|
if err := EnforceBookmarkLimit(u, current); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,7 +602,7 @@ func (s *CollectionService) Create(ctx context.Context, userID, title string, de
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
|
if err := EnforceCollectionLimit(u, isPublic, current); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
|
if err := EnforceCollectionLimit(u, *isPublic, current); err != nil {
|
||||||
return nil, err
|
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