Files
descrybe/apps/api/internal/httpapi/admin_orgs_handlers.go
T

214 lines
6.5 KiB
Go
Raw Normal View History

package httpapi
import (
"net/http"
"strconv"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/google/uuid"
)
// handleAdminListUsers returns a paginated user directory for platform admins.
// Query: limit, offset, q|search, staff_only, active_only, inactive_only.
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
limit, offset := ParseLimitOffset(r)
qSearch := QuerySearch(r)
staffOnly := QueryTruthy(r, "staff_only")
activeOnly := QueryTruthy(r, "active_only")
inactiveOnly := QueryTruthy(r, "inactive_only")
where := "WHERE 1=1"
args := make([]any, 0, 6)
next := 1
addArg := func(v any) string {
args = append(args, v)
placeholder := "$" + strconv.Itoa(next)
next++
return placeholder
}
if staffOnly {
where += " AND (is_platform_admin = true OR staff_role IS NOT NULL)"
}
if activeOnly && !inactiveOnly {
where += " AND is_active = true"
}
if inactiveOnly && !activeOnly {
where += " AND is_active = false"
}
if qSearch != "" {
p := addArg("%" + qSearch + "%")
where += " AND (email ILIKE " + p + " OR COALESCE(name, '') ILIKE " + p + ")"
}
var total int
if err := s.Pool.QueryRow(r.Context(), "SELECT COUNT(*) FROM users "+where, args...).Scan(&total); err != nil {
Error(w, http.StatusInternalServerError, "count failed")
return
}
limitP := addArg(limit)
offsetP := addArg(offset)
rows, err := s.Pool.Query(r.Context(), `
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active, created_at
FROM users `+where+`
ORDER BY created_at DESC LIMIT `+limitP+` OFFSET `+offsetP, args...)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
defer rows.Close()
type row struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name"`
MustSetPassword bool `json:"must_set_password"`
IsPlatformAdmin bool `json:"is_platform_admin"`
StaffRole *string `json:"staff_role,omitempty"`
ResolvedRole string `json:"resolved_role,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt any `json:"created_at"`
}
out := make([]row, 0)
for rows.Next() {
var u row
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive, &u.CreatedAt); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
stored := ""
if u.StaffRole != nil {
stored = *u.StaffRole
}
u.ResolvedRole = auth.ResolveStaffRole(u.IsPlatformAdmin, stored)
out = append(out, u)
}
if err := rows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"users": out,
"total": total,
"limit": limit,
"offset": offset,
})
}
// handleAdminListCompanies returns paginated companies with active plan summary.
// Query: limit, offset, q|search, without_active_plan, without_api_keys.
// without_api_keys filters tenants with no non-revoked keys (cutover reissue inventory).
func (s *Server) handleAdminListCompanies(w http.ResponseWriter, r *http.Request) {
limit, offset := ParseLimitOffset(r)
withoutPlan := QueryTruthy(r, "without_active_plan")
withoutAPIKeys := QueryTruthy(r, "without_api_keys")
qSearch := QuerySearch(r)
where := "WHERE c.id <> $1"
args := []any{platformsettings.SystemCompanyID}
next := 2
addArg := func(v any) string {
args = append(args, v)
placeholder := "$" + strconv.Itoa(next)
next++
return placeholder
}
if withoutPlan {
where += `
AND NOT EXISTS (
SELECT 1 FROM company_plans cp0
WHERE cp0.company_id = c.id AND cp0.is_active = true
)`
}
if withoutAPIKeys {
where += `
AND NOT EXISTS (
SELECT 1 FROM api_keys k0
WHERE k0.company_id = c.id AND k0.revoked_at IS NULL
)`
}
if qSearch != "" {
p := addArg("%" + qSearch + "%")
where += " AND (c.name ILIKE " + p + " OR c.id::text ILIKE " + p + ")"
}
var total int
if err := s.Pool.QueryRow(r.Context(), "SELECT COUNT(*) FROM companies c "+where, args...).Scan(&total); err != nil {
Error(w, http.StatusInternalServerError, "count failed")
return
}
limitP := addArg(limit)
offsetP := addArg(offset)
q := `
SELECT c.id, c.name, c.language, c.created_at,
COALESCE(cb.total_credits, 0), COALESCE(cb.used_credits, 0),
cp.plan_id IS NOT NULL AS has_active_plan,
cp.plan_id, p.name, COALESCE(p.is_custom, false),
EXISTS (
SELECT 1 FROM api_keys k
WHERE k.company_id = c.id AND k.revoked_at IS NULL
) AS has_api_key
FROM companies c
LEFT JOIN credit_balances cb ON cb.company_id = c.id
LEFT JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true
LEFT JOIN plans p ON p.id = cp.plan_id
` + where + `
ORDER BY c.created_at DESC LIMIT ` + limitP + ` OFFSET ` + offsetP
rows, err := s.Pool.Query(r.Context(), q, args...)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
defer rows.Close()
type row struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt any `json:"created_at"`
TotalCredits int `json:"total_credits"`
UsedCredits int `json:"used_credits"`
HasActivePlan bool `json:"has_active_plan"`
PlanID *int64 `json:"plan_id,omitempty"`
PlanName *string `json:"plan_name,omitempty"`
PlanIsCustom bool `json:"plan_is_custom,omitempty"`
PlanIsLegacy bool `json:"plan_is_legacy,omitempty"`
HasAPIKey bool `json:"has_api_key"`
}
out := make([]row, 0)
for rows.Next() {
var c row
var planID *int64
var planName *string
var isCustom bool
if err := rows.Scan(&c.ID, &c.Name, &c.Language, &c.CreatedAt, &c.TotalCredits, &c.UsedCredits, &c.HasActivePlan, &planID, &planName, &isCustom, &c.HasAPIKey); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
c.PlanID = planID
c.PlanName = planName
c.PlanIsCustom = isCustom
if planName != nil {
c.PlanIsLegacy = billing.IsLegacyPlan(*planName, false)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"companies": out,
"total": total,
"limit": limit,
"offset": offset,
"without_active_plan": withoutPlan,
"without_api_keys": withoutAPIKeys,
})
}