package httpx import ( "context" "net/http" "time" "go.mongodb.org/mongo-driver/v2/bson" "bountyboard/internal/store" ) func (s *Server) routesAdmin(mux *http.ServeMux) { adm := func(h http.HandlerFunc) http.Handler { return s.authedRole("admin", h) } mux.Handle("GET /api/v1/admin/users", adm(s.handleAdminListUsers)) mux.Handle("PATCH /api/v1/admin/users/{id}", adm(s.handleAdminPatchUser)) mux.Handle("DELETE /api/v1/admin/users/{id}", adm(s.handleAdminDeleteUser)) mux.Handle("GET /api/v1/admin/customers", adm(s.handleAdminListCustomers)) mux.Handle("POST /api/v1/admin/customers", adm(s.handleAdminCreateCustomer)) mux.Handle("GET /api/v1/admin/customers/{id}", adm(s.handleAdminGetCustomer)) mux.Handle("PATCH /api/v1/admin/customers/{id}", adm(s.handleAdminPatchCustomer)) mux.Handle("DELETE /api/v1/admin/customers/{id}", adm(s.handleAdminDeleteCustomer)) mux.Handle("POST /api/v1/admin/customers/test-connection", adm(s.handleAdminTestConnectionUnsaved)) mux.Handle("POST /api/v1/admin/customers/{id}/test-connection", adm(s.handleAdminTestConnection)) mux.Handle("POST /api/v1/admin/customers/{id}/sync-now", adm(s.handleAdminSyncNow)) mux.Handle("GET /api/v1/admin/settings", adm(s.handleAdminGetSettings)) mux.Handle("PATCH /api/v1/admin/settings", adm(s.handleAdminPatchSettings)) mux.Handle("GET /api/v1/admin/audit-log", adm(s.handleAdminAuditLog)) mux.Handle("GET /api/v1/admin/service-status", adm(s.handleAdminServiceStatus)) } // audit records a privileged mutation; failure is logged, never fatal. func (s *Server) audit(r *http.Request, action, entity, entityID string, diff map[string]any) { err := s.store.Audit(r.Context(), store.AuditEntry{ ActorID: CurrentUser(r.Context()).ID, ActorIP: ClientIP(r.Context()).String(), Action: action, Entity: entity, EntityID: entityID, Diff: diff, }) if err != nil { s.log.Error("audit write failed", "action", action, "err", err) } } func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) { st, err := s.store.GetSettings(r.Context()) if err != nil { s.internalError(w, r, "get settings", err) return } writeJSON(w, http.StatusOK, map[string]any{"settings": st}) } func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request) { var req struct { BrandingName *string `json:"brandingName"` AtomizerBaseURLOverride *string `json:"atomizerBaseUrlOverride"` HideCompetingClaims *bool `json:"hideCompetingClaims"` DefaultPollIntervalSec *int `json:"defaultPollIntervalSec"` DefaultCustomerBudget *float64 `json:"defaultCustomerBudget"` } if !decodeJSON(w, r, &req) { return } set := bson.M{} if req.BrandingName != nil { set["brandingName"] = *req.BrandingName } if req.AtomizerBaseURLOverride != nil { set["atomizerBaseUrlOverride"] = *req.AtomizerBaseURLOverride } if req.HideCompetingClaims != nil { set["hideCompetingClaims"] = *req.HideCompetingClaims } if req.DefaultPollIntervalSec != nil { if *req.DefaultPollIntervalSec < 10 { writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s") return } set["defaultPollIntervalSec"] = *req.DefaultPollIntervalSec } if req.DefaultCustomerBudget != nil { set["defaultCustomerBudget"] = *req.DefaultCustomerBudget } if len(set) == 0 { writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields") return } st, err := s.store.PatchSettings(r.Context(), set) if err != nil { s.internalError(w, r, "patch settings", err) return } s.audit(r, "settings.update", "settings", "app", map[string]any{"set": set}) writeJSON(w, http.StatusOK, map[string]any{"settings": st}) } func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit := atoiDefault(q.Get("limit"), 50) if limit <= 0 || limit > 200 { limit = 50 } entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"), q.Get("cursor"), limit) if err != nil { s.internalError(w, r, "list audit", err) return } // Only advertise a next cursor when the page is full; otherwise this is the // last page and a further fetch would return nothing (the "click twice" bug). next := "" if len(entries) == limit { next = entries[len(entries)-1].ID } writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next}) } // serviceStatus is the ยง11.17 admin panel payload. Breaker and queue fields // are filled in by the phases that introduce them. type externalStatus struct { Name string `json:"name"` URL string `json:"url"` Healthy bool `json:"healthy"` LatencyMs int64 `json:"latencyMs"` Error string `json:"error,omitempty"` Breaker string `json:"breaker"` } func (s *Server) handleAdminServiceStatus(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second) defer cancel() atomizer := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx)) performer := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL) if s.atomizer != nil { atomizer.Breaker = s.atomizer.BreakerState() } if s.performer != nil { performer.Breaker = s.performer.BreakerState() } mongoOK := true mongoErr := "" if err := s.store.Ping(ctx); err != nil { mongoOK, mongoErr = false, err.Error() } out := map[string]any{ "mongo": map[string]any{"healthy": mongoOK, "error": mongoErr}, "atomizer": atomizer, "workPerformer": performer, "syncWorkers": s.syncStatuses(), "jobs": s.jobStatuses(), } writeJSON(w, http.StatusOK, out) } func (s *Server) probeExternal(ctx context.Context, name, baseURL string) externalStatus { st := externalStatus{Name: name, URL: baseURL, Breaker: "unknown"} start := time.Now() req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz", nil) if err != nil { st.Error = err.Error() return st } resp, err := healthProbeClient.Do(req) st.LatencyMs = time.Since(start).Milliseconds() if err != nil { st.Error = err.Error() return st } resp.Body.Close() st.Healthy = resp.StatusCode == http.StatusOK if !st.Healthy { st.Error = resp.Status } return st } var healthProbeClient = &http.Client{Timeout: 3 * time.Second} func atoiDefault(s string, def int) int { if s == "" { return def } var n int for _, c := range s { if c < '0' || c > '9' { return def } n = n*10 + int(c-'0') } return n }