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(`