Files

323 lines
9.8 KiB
Go
Raw Permalink Normal View History

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
2026-08-17 00:39:25 +02:00
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++
2026-08-17 00:39:25 +02:00
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
2026-08-17 00:39:25 +02:00
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
2026-08-17 00:39:25 +02:00
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
}