Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
214 lines
6.5 KiB
Go
214 lines
6.5 KiB
Go
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,
|
|
})
|
|
}
|