package matching import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler exposes the matching/slate API. Routes are mounted under a tenant-scoped group. type Handler struct{ svc *Service } // NewHandler builds the HTTP handler. func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes registers the API endpoints on r (already wrapped by the session resolver). Every person // scoring surface is an AEDT output and carries a capability check, so authorization is enforced at // the API from the validated session, never hidden in the UI. Scoring and ranking need search.run; // the slate write and the human decision need slate.write; reads need candidate.read. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapSearchRun)).Post("/match", h.postMatch) r.With(security.RequireCapability(security.CapSearchRun)).Post("/screen", h.postScreen) r.With(security.RequireCapability(security.CapSlateEdit)).Post("/slates/generate", h.postGenerate) r.With(security.RequireCapability(security.CapSlateEdit)).Patch("/slates/{slateID}/members/{candidateID}/decision", h.postDecision) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/slates/{slateID}/members/{candidateID}/trace", h.getTrace) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/candidates", h.getCandidates) r.With(security.RequireCapability(security.CapSearchRun)).Get("/candidates/intelligence", h.getCandidateIntelligence) r.With(security.RequireCapability(security.CapSearchRun)).Get("/candidates/{candidateID}/opportunities", h.getOpportunities) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/requirements", h.getRequirements) r.With(security.RequireCapability(security.CapSearchRun)).Get("/requirements/{requirementID}/candidates", h.getRankedCandidates) } type matchRequest struct { CandidateID uuid.UUID `json:"candidate_id"` RequirementID uuid.UUID `json:"requirement_id"` } func (h *Handler) postMatch(w http.ResponseWriter, r *http.Request) { var req matchRequest if !decodeJSON(w, r, &req) { return } if req.CandidateID == uuid.Nil || req.RequirementID == uuid.Nil { writeError(w, http.StatusBadRequest, "candidate_id and requirement_id are required") return } res, err := h.svc.ScreenCandidate(r.Context(), req.CandidateID, req.RequirementID) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, matchResultView(res)) } func (h *Handler) postScreen(w http.ResponseWriter, r *http.Request) { var req matchRequest if !decodeJSON(w, r, &req) { return } if req.CandidateID == uuid.Nil || req.RequirementID == uuid.Nil { writeError(w, http.StatusBadRequest, "candidate_id and requirement_id are required") return } view, err := h.svc.Screen(r.Context(), req.CandidateID, req.RequirementID) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, view) } func (h *Handler) getRankedCandidates(w http.ResponseWriter, r *http.Request) { reqID, err := uuid.Parse(chi.URLParam(r, "requirementID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid requirement id") return } out, err := h.svc.RankCandidates(r.Context(), reqID) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) getOpportunities(w http.ResponseWriter, r *http.Request) { candidateID, err := uuid.Parse(chi.URLParam(r, "candidateID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid candidate id") return } limit := 10 if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 100 { limit = v } fits, err := h.svc.OpportunitiesForCandidate(r.Context(), candidateID, limit) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, fits) } type generateRequest struct { RequirementID uuid.UUID `json:"requirement_id"` Name string `json:"name"` MaxCandidates int32 `json:"max_candidates"` } func (h *Handler) postGenerate(w http.ResponseWriter, r *http.Request) { var req generateRequest if !decodeJSON(w, r, &req) { return } if req.RequirementID == uuid.Nil { writeError(w, http.StatusBadRequest, "requirement_id is required") return } out, err := h.svc.GenerateSlate(r.Context(), GenerateSlateInput{ RequirementID: req.RequirementID, Name: req.Name, MaxCandidates: req.MaxCandidates, CreatedBy: principalID(r), }) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusCreated, out) } type decisionRequest struct { Decision string `json:"decision"` // approved or rejected } func (h *Handler) postDecision(w http.ResponseWriter, r *http.Request) { slateID, err := uuid.Parse(chi.URLParam(r, "slateID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid slate id") return } candidateID, err := uuid.Parse(chi.URLParam(r, "candidateID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid candidate id") return } var req decisionRequest if !decodeJSON(w, r, &req) { return } if err := h.svc.DecideSlateMember(r.Context(), slateID, candidateID, req.Decision); err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": req.Decision}) } func (h *Handler) getTrace(w http.ResponseWriter, r *http.Request) { slateID, err := uuid.Parse(chi.URLParam(r, "slateID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid slate id") return } candidateID, err := uuid.Parse(chi.URLParam(r, "candidateID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid candidate id") return } m, err := h.svc.SlateMemberTrace(r.Context(), slateID, candidateID) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, slateMemberTraceView(m)) } // principalID returns the validated session user id for the actor on a decision or generation. func principalID(r *http.Request) *uuid.UUID { if p, ok := tenant.UserFromContext(r.Context()); ok { if id, err := uuid.Parse(p.UserID); err == nil { return &id } } return nil } func (h *Handler) getCandidateIntelligence(w http.ResponseWriter, r *http.Request) { limit := 0 if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil { limit = v } out, err := h.svc.CandidateIntelligence(r.Context(), limit) if err != nil { writeServiceError(w, err) return } if out == nil { out = []CandidateIntelligenceView{} } writeJSON(w, http.StatusOK, out) } func (h *Handler) getCandidates(w http.ResponseWriter, r *http.Request) { limit, offset := pageParams(r) rows, err := h.svc.ListCandidates(r.Context(), limit, offset) if err != nil { writeServiceError(w, err) return } out := make([]CandidateListView, len(rows)) for i, c := range rows { out[i] = candidateListView(c) } writeJSON(w, http.StatusOK, out) } func (h *Handler) getRequirements(w http.ResponseWriter, r *http.Request) { limit, offset := pageParams(r) rows, err := h.svc.ListRequirements(r.Context(), limit, offset) if err != nil { writeServiceError(w, err) return } out := make([]RequirementListView, len(rows)) for i, rq := range rows { out[i] = requirementListView(rq) } writeJSON(w, http.StatusOK, out) } // ---------- helpers ---------- func pageParams(r *http.Request) (limit, offset int32) { limit, offset = 50, 0 if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 500 { limit = int32(v) } if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v >= 0 { offset = int32(v) } return limit, offset } func decodeJSON(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 { writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) 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 writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // writeServiceError maps service/tenant errors to HTTP status without leaking internals. func writeServiceError(w http.ResponseWriter, err error) { switch { case errors.Is(err, tenant.ErrNoTenant): writeError(w, http.StatusForbidden, "no validated tenant") case errors.Is(err, ErrNotFound): writeError(w, http.StatusNotFound, "not found") case errors.Is(err, ErrBadRequest): writeError(w, http.StatusBadRequest, "bad request") case errors.Is(err, ErrNoDatabase): writeError(w, http.StatusServiceUnavailable, "database not configured") default: writeError(w, http.StatusInternalServerError, "internal error") } } // SlateMemberTraceView is the audit and candidate facing explanation of a frozen decision: the per // component trace, the human rationale, the gap summary, the decision, and the model version that // produced it. The trace and gap summary are passed through as the stored JSON. type SlateMemberTraceView struct { CandidateID string `json:"candidate_id"` TotalScore string `json:"total_score"` Band string `json:"band"` Explanation string `json:"explanation"` Trace json.RawMessage `json:"trace"` GapSummary json.RawMessage `json:"gap_summary"` Decision string `json:"decision"` ModelVersion string `json:"model_version"` } func slateMemberTraceView(m dbgen.SlateMember) SlateMemberTraceView { v := SlateMemberTraceView{ CandidateID: m.CandidateID.String(), TotalScore: m.TotalScore.StringFixed(2), Band: m.Band, Explanation: m.Explanation, Trace: json.RawMessage(m.Trace), GapSummary: json.RawMessage(m.GapSummary), Decision: m.Decision, } if m.ModelVersion != nil { v.ModelVersion = *m.ModelVersion } return v }