package bidoutcomes import ( "encoding/json" "errors" "net/http" "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 M7 bid outcomes recording API: record (POST) and update (PATCH). It is mounted ONLY // when the MM_BID_OUTCOMES_ENABLED flag is on, so the feature ships dormant; when off the routes are never // registered and a request to them is a plain 404 from the router. type Handler struct{ svc *Service } // NewHandler builds the handler. func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes mounts the bid outcomes endpoints under the tenant scoped /api group. Recording and updating a bid // outcome is a proposal team write action, so both are gated on slate.write (the same authority tier that // edits a slate and generates staffing recommendations): owner, admin, and recruiter. The caller and the // validated org come from the session resolver and RBAC middleware the group already applies, never a // client supplied header or body field. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapSlateEdit)).Post("/bid-outcomes", h.record) r.With(security.RequireCapability(security.CapSlateEdit)).Patch("/bid-outcomes/{id}", h.update) } func (h *Handler) record(w http.ResponseWriter, r *http.Request) { var in RecordInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } out, err := h.svc.RecordBidOutcome(r.Context(), in) if err != nil { writeErr(w, err) return } writeJSON(w, http.StatusCreated, out) } func (h *Handler) update(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } var in UpdateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } out, err := h.svc.UpdateBidOutcome(r.Context(), id, in) if err != nil { writeErr(w, err) return } writeJSON(w, http.StatusOK, out) } // writeErr maps a service error to a status. A bad result enum or a malformed id is a 422, a missing row is // a 404, an unscoped request is a 403, and an unconfigured database is a 503; everything else is a 500 with // no internal detail leaked. func writeErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrInvalidResult): writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "result must be one of win, loss, no_bid, pending"}) case errors.Is(err, ErrInvalidID): writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "an id field must be a uuid"}) case errors.Is(err, ErrNotFound): writeJSON(w, http.StatusNotFound, map[string]string{"error": "bid outcome not found"}) case errors.Is(err, tenant.ErrNoTenant): writeJSON(w, http.StatusForbidden, map[string]string{"error": "no validated tenant"}) case errors.Is(err, ErrNoDatabase): writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not configured"}) default: writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }