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, }) }