package httpapi import ( "context" "errors" "log" "net/http" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/mail" "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" ) const ( adminSetPasswordBulkLimit = 100 adminSetPasswordReqPerMin = 5 adminSetPasswordSendPerMin = 60 ) // handleAdminListUsers / handleAdminListCompanies live in admin_orgs_handlers.go. // handleAdminReadiness returns cutover hypercare counts for platform admins (P1-15). // GET /api/admin/readiness // // companies_without_api_keys counts tenants with zero non-revoked keys. Legacy // api_keys were never ETL'd — this is the reissue inventory (not a fake migration). func (s *Server) handleAdminReadiness(w http.ResponseWriter, r *http.Request) { if s.Pool == nil { Error(w, http.StatusServiceUnavailable, "database unavailable") return } ctx := r.Context() var mustSetPassword, withoutAdmin, withoutPlan, withoutAPIKeys int64 if err := s.Pool.QueryRow(ctx, ` SELECT (SELECT COUNT(*) FROM users WHERE must_set_password = true AND is_active = true), (SELECT COUNT(*) FROM companies c WHERE c.id <> $1 AND NOT EXISTS ( SELECT 1 FROM memberships m WHERE m.company_id = c.id AND m.role = 'admin' AND m.status = 'active' )), (SELECT COUNT(*) FROM companies c WHERE c.id <> $1 AND NOT EXISTS ( SELECT 1 FROM company_plans cp WHERE cp.company_id = c.id AND cp.is_active = true )), (SELECT COUNT(*) FROM companies c WHERE c.id <> $1 AND NOT EXISTS ( SELECT 1 FROM api_keys k WHERE k.company_id = c.id AND k.revoked_at IS NULL )) `, platformsettings.SystemCompanyID).Scan(&mustSetPassword, &withoutAdmin, &withoutPlan, &withoutAPIKeys); err != nil { Error(w, http.StatusInternalServerError, "readiness counts failed") return } JSON(w, http.StatusOK, map[string]any{ "must_set_password": mustSetPassword, "companies_without_admin": withoutAdmin, "companies_without_plan": withoutPlan, "companies_without_api_keys": withoutAPIKeys, }) } func (s *Server) handleAdminListJobs(w http.ResponseWriter, r *http.Request) { limit, offset := ParseLimitOffset(r) rows, err := s.Pool.Query(r.Context(), ` SELECT id, company_id, status, total_products, processed_products, error, created_at, updated_at FROM processing_jobs ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } defer rows.Close() type row struct { ID uuid.UUID `json:"id"` CompanyID uuid.UUID `json:"company_id"` Status string `json:"status"` TotalProducts int `json:"total_products"` ProcessedProducts int `json:"processed_products"` Error *string `json:"error"` CreatedAt any `json:"created_at"` UpdatedAt any `json:"updated_at"` } out := make([]row, 0) for rows.Next() { var j row if err := rows.Scan(&j.ID, &j.CompanyID, &j.Status, &j.TotalProducts, &j.ProcessedProducts, &j.Error, &j.CreatedAt, &j.UpdatedAt); err != nil { Error(w, http.StatusInternalServerError, "scan failed") return } if j.Error != nil && *j.Error != "" { redacted := processing.TruncateError(errors.New(*j.Error)) j.Error = &redacted } out = append(out, j) } JSON(w, http.StatusOK, map[string]any{"jobs": out, "limit": limit, "offset": offset}) } func (s *Server) handleAdminStuckCleanup(w http.ResponseWriter, r *http.Request) { res, err := processing.CleanupStuck(r.Context(), s.Pool) if err != nil { Error(w, http.StatusInternalServerError, "cleanup failed") return } JSON(w, http.StatusOK, map[string]any{ "jobs_marked_failed": res.JobsMarkedFailed, "products_reset": res.ProductsReset, "sync_jobs_marked_failed": res.SyncJobsMarkedFailed, }) } func (s *Server) handleAdminOrphanProcessedReport(w http.ResponseWriter, r *http.Request) { res, err := processing.ReportOrphanProcessed(r.Context(), s.Pool) if err != nil { Error(w, http.StatusInternalServerError, "orphan report failed") return } JSON(w, http.StatusOK, res) } func (s *Server) handleAdminOrphanProcessedCleanup(w http.ResponseWriter, r *http.Request) { confirm := r.URL.Query().Get("confirm") == "true" var body struct { Confirm bool `json:"confirm"` } if err := DecodeJSONOptional(r, &body); err == nil && body.Confirm { confirm = true } res, err := processing.CleanupOrphanProcessed(r.Context(), s.Pool, confirm) if err != nil { if errors.Is(err, processing.ErrOrphanCleanupEmpty) || errors.Is(err, processing.ErrOrphanCleanupA1Protected) { if msg, ok := processing.ClientError(err); ok { Error(w, http.StatusConflict, msg) return } } Error(w, http.StatusInternalServerError, "orphan cleanup failed") return } if !confirm { JSON(w, http.StatusOK, map[string]any{ "ok": true, "dry_run": true, "deleted": 0, "message": "pass confirm=true (query or JSON body) to delete; report only", "report": res, }) return } JSON(w, http.StatusOK, res) } func (s *Server) ensureAdminSetPasswordLimiters() { s.adminSetPasswordOnce.Do(func() { s.adminSetPasswordReqRL = newSlidingWindowLimiter(adminSetPasswordReqPerMin, time.Minute) s.adminSetPasswordSendRL = newSlidingWindowLimiter(adminSetPasswordSendPerMin, time.Minute) }) } func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http.Request) { if s.Mail == nil || s.Auth == nil { Error(w, http.StatusServiceUnavailable, "mailer unavailable") return } adminID, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } s.ensureAdminSetPasswordLimiters() reqKey := "admin-set-password:" + adminID.String() if !s.adminSetPasswordReqRL.allow(reqKey) { w.Header().Set("Retry-After", "60") Error(w, http.StatusTooManyRequests, "rate limit exceeded") return } var body struct { UserID *uuid.UUID `json:"user_id"` } if err := DecodeJSONOptional(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } var targets []uuid.UUID if body.UserID != nil { targets = []uuid.UUID{*body.UserID} } else { users, err := s.Auth.ListUsersNeedingPassword(r.Context(), adminSetPasswordBulkLimit) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } for _, u := range users { targets = append(targets, u.ID) } } smtpOn := s.Mail.Enabled() sent := 0 issued := 0 skippedSynthetic := 0 skippedIneligible := 0 skippedRateLimited := 0 skippedSend := 0 var singleToken string var singleMode string singleUser := body.UserID != nil for _, uid := range targets { sendKey := "admin-set-password-send:" + adminID.String() if !s.adminSetPasswordSendRL.allow(sendKey) { skippedRateLimited++ if singleUser { w.Header().Set("Retry-After", "60") Error(w, http.StatusTooManyRequests, "rate limit exceeded") return } continue } token, email, mode, err := s.issueSetPasswordDelivery(r.Context(), uid) if err != nil { switch { case errors.Is(err, auth.ErrSyntheticEmail): skippedSynthetic++ default: skippedIneligible++ } continue } issued++ var msg mail.Message if mode == "invite" { msg = mail.MigratedSetPasswordMessage(s.Config.WebOrigin, email, token) } else { msg = mail.SetPasswordMessage(s.Config.WebOrigin, email, token) } if err := s.Mail.Send(msg); err != nil { log.Printf("admin set-password send failed user_id=%s", uid) skippedSend++ if singleUser { // Still return the one-time link so admins can share it while impersonating / offline SMTP. singleToken = token singleMode = mode } continue } if smtpOn { sent++ } else if singleUser { // Share token only for single-user reissue when SMTP is off (no email in response). singleToken = token singleMode = mode } } skipped := skippedSynthetic + skippedIneligible + skippedRateLimited + skippedSend resp := map[string]any{ "sent": sent, "issued": issued, "skipped": skipped, "skipped_synthetic": skippedSynthetic, "skipped_ineligible": skippedIneligible, "skipped_rate_limited": skippedRateLimited, "skipped_send": skippedSend, "smtp_enabled": smtpOn, "mode": "invite", } if singleToken != "" { resp["token"] = singleToken if singleMode == "hmac" { resp["accept_url"] = mail.SetPasswordURL(s.Config.WebOrigin, singleToken) } else { resp["accept_url"] = mail.AcceptInviteURL(s.Config.WebOrigin, singleToken) } } JSON(w, http.StatusOK, resp) } // issueSetPasswordDelivery prefers a durable invite; falls back to HMAC when the user // still needs a password but has no active membership. Never logs email or token. func (s *Server) issueSetPasswordDelivery(ctx context.Context, userID uuid.UUID) (token, email, mode string, err error) { inv, err := s.Auth.ReissueSetPasswordInvite(ctx, userID, 0) if err == nil { return inv.Token, inv.Email, "invite", nil } if errors.Is(err, auth.ErrSyntheticEmail) { return "", "", "", err } if !errors.Is(err, auth.ErrNotEligibleSetPassword) && !errors.Is(err, auth.ErrUserNotFound) { log.Printf("admin set-password invite failed user_id=%s", userID) return "", "", "", err } u, gerr := s.Auth.GetUser(ctx, userID) if gerr != nil || !u.MustSetPassword || !u.IsActive { return "", "", "", auth.ErrNotEligibleSetPassword } if auth.IsSyntheticLegacyEmail(u.Email) { return "", "", "", auth.ErrSyntheticEmail } token, terr := auth.IssueSetPasswordToken(s.Config.TokenSigningSecret, u.ID, 0) if terr != nil { log.Printf("admin hmac set-password token failed user_id=%s", userID) return "", "", "", auth.ErrNotEligibleSetPassword } return token, u.Email, "hmac", nil }