package staffing import ( "encoding/json" "errors" "io" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler serves the W3 staffing recommendation endpoints under the tenant scoped group, behind the // session resolver, CSRF, RBAC, and audit (the same chain as every other module). type Handler struct{ svc *Service } // NewHandler builds the handler. func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes mounts the four endpoints with their capabilities. Generating a run WRITES recommendation rows, // so the two generating endpoints are gated on the write capability staffing.recommend (not a read // capability). The decision reuses the slate decision authority; reads reuse candidate.read. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapStaffingRecommend)).Post("/recommendations/gap/{requirementID}", h.gap) r.With(security.RequireCapability(security.CapStaffingRecommend)).Post("/recommendations/portfolio", h.portfolio) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/recommendations/proactive", h.proactive) r.With(security.RequireCapability(security.CapSlateEdit)).Post("/recommendations/{recommendationID}/decision", h.decide) } func (h *Handler) gap(w http.ResponseWriter, r *http.Request) { reqID, err := uuid.Parse(chi.URLParam(r, "requirementID")) if err != nil { writeErr(w, validationErr("requirement_id", "must be a uuid")) return } var in GapRequest if !decodeOptional(w, r, &in) { return } out, err := h.svc.GenerateGap(r.Context(), reqID, in) writeOrErr(w, out, err) } func (h *Handler) portfolio(w http.ResponseWriter, r *http.Request) { var in PortfolioRequest if !decodeOptional(w, r, &in) { return } out, err := h.svc.GeneratePortfolio(r.Context(), in) writeOrErr(w, out, err) } func (h *Handler) proactive(w http.ResponseWriter, r *http.Request) { limit := atoiDefault(r.URL.Query().Get("limit"), 0) offset := atoiDefault(r.URL.Query().Get("offset"), 0) out, err := h.svc.ListProactive(r.Context(), limit, offset) writeOrErr(w, out, err) } func (h *Handler) decide(w http.ResponseWriter, r *http.Request) { recID, err := uuid.Parse(chi.URLParam(r, "recommendationID")) if err != nil { writeErr(w, validationErr("recommendation_id", "must be a uuid")) return } var in DecisionRequest if !decode(w, r, &in) { return } out, err := h.svc.Decide(r.Context(), recID, in) writeOrErr(w, out, err) } // ---------- the one typed error envelope ---------- type errorBody struct { Error errorEnvelope `json:"error"` } type errorEnvelope struct { Code string `json:"code"` Message string `json:"message"` Fields map[string]string `json:"fields,omitempty"` } func writeOrErr(w http.ResponseWriter, v any, err error) { if err != nil { writeErr(w, err) return } writeJSON(w, http.StatusOK, v) } func writeErr(w http.ResponseWriter, err error) { var verr *ValidationError if errors.As(err, &verr) { writeJSON(w, http.StatusUnprocessableEntity, errorBody{errorEnvelope{Code: "validation", Message: "invalid request parameters", Fields: verr.Fields}}) return } status, code, msg := http.StatusInternalServerError, "internal", "internal error" switch { case errors.Is(err, ErrNotFound): status, code, msg = http.StatusNotFound, "not_found", "requirement not found or not open" case errors.Is(err, ErrConflict): status, code, msg = http.StatusConflict, "conflict", "recommendation already decided or superseded" case errors.Is(err, ErrProblemTooLarge): status, code, msg = http.StatusRequestEntityTooLarge, "problem_too_large", "the portfolio problem exceeds the size cap" case errors.Is(err, ErrSolveBudget): status, code, msg = http.StatusServiceUnavailable, "solve_timeout", "the portfolio solve exceeded its time budget" case errors.Is(err, ErrNoDatabase): status, code, msg = http.StatusServiceUnavailable, "no_database", "database not configured" case errors.Is(err, tenant.ErrNoTenant): status, code, msg = http.StatusForbidden, "no_tenant", "no validated tenant" } writeJSON(w, status, errorBody{errorEnvelope{Code: code, Message: msg}}) } // decode requires a JSON body (the decision endpoint). func decode(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { writeJSON(w, http.StatusBadRequest, errorBody{errorEnvelope{Code: "bad_request", Message: "invalid request body"}}) return false } return true } // decodeOptional accepts an empty body (the generating endpoints take optional overrides). func decodeOptional(w http.ResponseWriter, r *http.Request, dst any) bool { if r.Body == nil || r.ContentLength == 0 { return true } dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { if errors.Is(err, io.EOF) { return true } writeJSON(w, http.StatusBadRequest, errorBody{errorEnvelope{Code: "bad_request", Message: "invalid request body"}}) return false } return true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func atoiDefault(s string, def int) int { if s == "" { return def } n, err := strconv.Atoi(s) if err != nil { return def } return n }