2026-08-09 22:47:43 +02:00
|
|
|
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
|
2026-08-09 22:47:43 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
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
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|