package matching import ( "encoding/json" "errors" "net/http" "strconv" "strings" "time" "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 EVERY matching endpoint on r at its flat path (already wrapped by the session // resolver). It is the alias surface: it keeps the pre split paths (POST /match, /slates/..., etc.) // working unchanged while the three tool routers mount the same handlers under their namespaces. It is // the sum of the four tool route groups below, so a path appears in exactly one group and the flat and // namespaced mounts share one handler. 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) { h.ScreenRoutes(r) h.SlateRoutes(r) h.ReadRoutes(r) h.IntelRoutes(r) h.MissionRoutes(r) } // ScreenRoutes are the screenMATCH endpoints: single candidate qualification, the pool ranked against // one requirement, the Opportunity Fit for one candidate, the Mission Check resume flow, and the // candidate writes that flow saves. POST /match is the original alias of POST /screen's raw result. func (h *Handler) ScreenRoutes(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.CapSearchRun)).Get("/requirements/{requirementID}/candidates", h.getRankedCandidates) r.With(security.RequireCapability(security.CapSearchRun)).Get("/candidates/{candidateID}/opportunities", h.getOpportunities) // Mission Check (W1): scoring an in memory resume is a search.run AEDT output; the optional Save // Candidate is a recruiter write. Both ride the same session auth, RBAC, and audit as every surface. r.With(security.RequireCapability(security.CapSearchRun)).Post("/mission-check/opportunities", h.postMissionCheckOpportunities) r.With(security.RequireCapability(security.CapSearchRun)).Post("/mission-check/screen", h.postMissionCheckJD) r.With(security.RequireCapability(security.CapCandidateWrite)).Post("/candidates", h.postCreateCandidate) r.With(security.RequireCapability(security.CapCandidateWrite)).Patch("/candidates/{candidateID}/clearance-provenance", h.patchClearanceProvenance) } // SlateRoutes are the slateMATCH endpoints: generate a slate, the human approve or reject of a member, // and the frozen decision trace for a member. func (h *Handler) SlateRoutes(r chi.Router) { 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("/requirements/{requirementID}/slates", h.getSlates) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/slates/{slateID}", h.getSlateDetail) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/slates/{slateID}/proposal-readiness", h.getProposalReadiness) // The branded CSV is a bulk egress of candidate PII, so it needs export.run (owner, admin, recruiter), // not the read capability every role holds. The egress is also recorded in the audit (ExportSlate). r.With(security.RequireCapability(security.CapExportRun)).Get("/slates/{slateID}/export", h.exportSlate) } // ReadRoutes are the shared candidate and requirement pickers every tool needs to choose a target. func (h *Handler) ReadRoutes(r chi.Router) { r.With(security.RequireCapability(security.CapCandidateRead)).Get("/candidates", h.getCandidates) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/candidates/{candidateID}", h.getCandidate) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/requirements", h.getRequirements) r.With(security.RequireCapability(security.CapCandidateRead)).Get("/requirements/{requirementID}", h.getRequirement) } // IntelRoutes is the missionMATCH Candidates Intelligence View (the whole pool scored once, a // screening stage AEDT output, so it needs search.run). func (h *Handler) IntelRoutes(r chi.Router) { r.With(security.RequireCapability(security.CapSearchRun)).Get("/candidates/intelligence", h.getCandidateIntelligence) } // MissionRoutes are the missionMATCH reads that consume the stored artifacts: who can support a mission // today, read from the persisted screenMATCH evaluations (never a rescore). It surfaces frozen scores, // so it needs search.run like the other AEDT read surfaces. func (h *Handler) MissionRoutes(r chi.Router) { r.With(security.RequireCapability(security.CapSearchRun)).Get("/requirements/{requirementID}/support", h.getWhoCanSupport) } 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 Note string `json:"note,omitempty"` // optional reviewer note } 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, req.Note); 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)) } func (h *Handler) getSlates(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.ListSlates(r.Context(), reqID) if err != nil { writeServiceError(w, err) return } if out == nil { out = []SlateSummaryView{} } writeJSON(w, http.StatusOK, out) } func (h *Handler) getSlateDetail(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 } out, err := h.svc.SlateDetail(r.Context(), slateID) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) getProposalReadiness(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 } out, err := h.svc.ProposalReadiness(r.Context(), slateID) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) exportSlate(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 } csvText, err := h.svc.ExportSlate(r.Context(), slateID) if err != nil { writeServiceError(w, err) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=\"slate-"+slateID.String()+".csv\"") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(csvText)) } func (h *Handler) getWhoCanSupport(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.WhoCanSupport(r.Context(), reqID) if err != nil { writeServiceError(w, err) return } if out == nil { out = []SupportView{} } writeJSON(w, http.StatusOK, out) } // 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) } func (h *Handler) getCandidate(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "candidateID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid candidate id") return } c, err := h.svc.GetCandidate(r.Context(), id) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, candidateListView(c)) } func (h *Handler) getRequirement(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "requirementID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid requirement id") return } rq, err := h.svc.GetRequirement(r.Context(), id) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, requirementListView(rq)) } // ---------- Mission Check (W1) ---------- type missionCheckOpportunitiesRequest struct { ResumeText string `json:"resume_text"` Limit int `json:"limit"` } func (h *Handler) postMissionCheckOpportunities(w http.ResponseWriter, r *http.Request) { var req missionCheckOpportunitiesRequest if !decodeJSON(w, r, &req) { return } if strings.TrimSpace(req.ResumeText) == "" { writeError(w, http.StatusBadRequest, "resume_text is required") return } out, err := h.svc.MissionCheckOpportunities(r.Context(), req.ResumeText, req.Limit) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, out) } type missionCheckJDRequest struct { ResumeText string `json:"resume_text"` JobDescriptionText string `json:"job_description_text"` } func (h *Handler) postMissionCheckJD(w http.ResponseWriter, r *http.Request) { var req missionCheckJDRequest if !decodeJSON(w, r, &req) { return } if strings.TrimSpace(req.ResumeText) == "" || strings.TrimSpace(req.JobDescriptionText) == "" { writeError(w, http.StatusBadRequest, "resume_text and job_description_text are required") return } out, err := h.svc.MissionCheckJD(r.Context(), req.ResumeText, req.JobDescriptionText) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, out) } type createCandidateRequest struct { FullName string `json:"full_name"` ClearanceLevel string `json:"clearance_level"` ClearancePoly string `json:"clearance_poly"` ClearanceStatus string `json:"clearance_status"` YearsExperience int `json:"years_experience"` Location string `json:"location"` Remote bool `json:"remote"` Relocatable bool `json:"relocatable"` Available bool `json:"available"` Skills []string `json:"skills"` Certifications []string `json:"certifications"` CustomerExperience []string `json:"customer_experience"` } func (h *Handler) postCreateCandidate(w http.ResponseWriter, r *http.Request) { var req createCandidateRequest if !decodeJSON(w, r, &req) { return } if strings.TrimSpace(req.FullName) == "" { writeError(w, http.StatusBadRequest, "full_name is required") return } id, err := h.svc.SaveCandidate(r.Context(), SaveCandidateInput(req)) if err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusCreated, map[string]string{"id": id.String()}) } type setProvenanceRequest struct { Provenance string `json:"provenance"` // self_reported | sponsor_verified | document | continuous_vetting VerifiedAt *string `json:"verified_at,omitempty"` // optional YYYY-MM-DD, the FSO verification date } func (h *Handler) patchClearanceProvenance(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "candidateID")) if err != nil { writeError(w, http.StatusBadRequest, "invalid candidate id") return } var req setProvenanceRequest if !decodeJSON(w, r, &req) { return } var va *time.Time if req.VerifiedAt != nil && *req.VerifiedAt != "" { t, perr := time.Parse("2006-01-02", *req.VerifiedAt) if perr != nil { writeError(w, http.StatusBadRequest, "verified_at must be YYYY-MM-DD") return } va = &t } if err := h.svc.SetClearanceProvenance(r.Context(), id, req.Provenance, va); err != nil { writeServiceError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } // ---------- 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") case errors.Is(err, ErrNoExtractor): writeError(w, http.StatusServiceUnavailable, "mission check extraction 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 }