Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user