Files
warmbly/internal/api/handler/admin_organization.go
Matt 0f3552b1bf feat(admin): per-org limit overrides + product hard caps
Wire the write path for the override table from the previous migration:

  GET /admin/organizations/:id/overrides   view_organizations
  PUT /admin/organizations/:id/overrides   manage_organizations

PUT is a partial upsert — nil fields leave existing values untouched,
and 0 explicitly removes that column's override (back to plan default
or the product hard cap). Every write stamps granted_by/granted_at and
fires an admin audit log with the diff the admin asked for.

Introduce product-level hard caps in config/constants.go so plans that
advertise "unlimited" still have a real backstop: 200 mailboxes, 500
total campaigns, 100 active campaigns, 100 team members, 1M contacts,
1k daily campaign sends. GetEffectiveLimits resolves per-field as
override > 0 ?: plan ?: hard_cap and now never returns nil pointers,
so downstream limit checks compare against a concrete ceiling on every
plan tier. CanAddMember / CanAddCampaign / CanAddEmailAccount now call
GetEffectiveLimits instead of GetOrganizationLimits, so admin overrides
and the product hard cap both bite at runtime.

AdminOrgDetail surfaces three limit blocks side-by-side — plan, raw
override row (0 = inherit), and effective limits — so the UI can show
exactly where each enforced number came from. Slice 2 UI lands in the
next commit.

Daily creation throttles ("no 1000 new campaigns in one day even on an
unlimited plan") are explicitly out of scope; they need a per-day
counter, tracked as a TODO on the hard-cap block.
2026-05-28 08:54:07 +02:00

161 lines
4.3 KiB
Go

package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// AdminListOrganizations returns the paginated admin org listing.
func (h *Handler) AdminListOrganizations(c *gin.Context) {
var search models.AdminOrgSearch
if err := c.ShouldBindQuery(&search); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid query parameters"))
return
}
result, xerr := h.OrganizationService.SearchOrganizationsForAdmin(c.Request.Context(), &search)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, result)
}
// AdminGetOrganization returns the detail payload for a single org.
func (h *Handler) AdminGetOrganization(c *gin.Context) {
orgID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid organization ID"))
return
}
detail, xerr := h.OrganizationService.GetOrganizationAdminDetail(c.Request.Context(), orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, detail)
}
// AdminGetOrganizationMembers returns the members of an org with users.
func (h *Handler) AdminGetOrganizationMembers(c *gin.Context) {
orgID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid organization ID"))
return
}
members, xerr := h.OrganizationService.GetOrganizationMembersForAdmin(c.Request.Context(), orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, &models.AdminOrgMembersResult{Data: members})
}
// AdminGetOrgOverrides returns the override row for an org, or 200 with
// null when no admin has touched it yet. Read access requires only
// view_organizations.
func (h *Handler) AdminGetOrgOverrides(c *gin.Context) {
orgID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid organization ID"))
return
}
o, xerr := h.OrganizationService.GetLimitOverrides(c.Request.Context(), orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, o)
}
// AdminUpdateOrgOverrides upserts the override row. Audit row captures
// only the request body (which is the diff the admin asked for) plus
// the org id, not the post-write state — that's already on the
// override row's granted_by/granted_at/updated_at.
func (h *Handler) AdminUpdateOrgOverrides(c *gin.Context) {
adminID := middleware.GetAdminUserID(c)
if adminID == nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
orgID, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid organization ID"))
return
}
var req models.UpdateOrgOverridesRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
if !anyOverrideFieldSet(&req) {
errx.JSON(c, errx.New(errx.BadRequest, "no override fields provided"))
return
}
o, xerr := h.OrganizationService.SetLimitOverrides(c.Request.Context(), orgID, &req, *adminID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.AdminService.LogAdminAction(
c.Request.Context(),
*adminID,
"set_org_limit_overrides",
"organization",
&orgID,
overrideAuditDetails(&req),
c.ClientIP(),
c.Request.UserAgent(),
)
c.JSON(http.StatusOK, o)
}
func anyOverrideFieldSet(req *models.UpdateOrgOverridesRequest) bool {
return req.MaxCampaigns != nil ||
req.MaxActiveCampaigns != nil ||
req.MaxTeamMembers != nil ||
req.MaxEmailAccounts != nil ||
req.MaxContacts != nil ||
req.DailyCampaignLimit != nil ||
req.Notes != nil
}
func overrideAuditDetails(req *models.UpdateOrgOverridesRequest) map[string]any {
d := map[string]any{}
if req.MaxCampaigns != nil {
d["max_campaigns"] = *req.MaxCampaigns
}
if req.MaxActiveCampaigns != nil {
d["max_active_campaigns"] = *req.MaxActiveCampaigns
}
if req.MaxTeamMembers != nil {
d["max_team_members"] = *req.MaxTeamMembers
}
if req.MaxEmailAccounts != nil {
d["max_email_accounts"] = *req.MaxEmailAccounts
}
if req.MaxContacts != nil {
d["max_contacts"] = *req.MaxContacts
}
if req.DailyCampaignLimit != nil {
d["daily_campaign_limit"] = *req.DailyCampaignLimit
}
if req.Notes != nil {
d["notes"] = *req.Notes
}
return d
}