Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/matching/handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/matching/handler.go |
| Original SHA-256 | 59ff914e460bf12f54a340cbbd463eb42adb7d5e1b58d6a572c3183e8a8d141e |
| Git blob SHA | ffc6befa26c8fd236ef4504dfdf8b1d8e70b6fe5 |
| Reading-copy SHA-256 | 59ff914e460bf12f54a340cbbd463eb42adb7d5e1b58d6a572c3183e8a8d141e |
Recovered source transcript
Download complete source text ↓1,160 lines · 42.3 KB
package matching
import (
"encoding/json"
"errors"
"fmt"
"io"
"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)
// The access history: continuous vetting enrollment and the dates the break in access window turns on.
// candidate.write, the same tier as the provenance beside it, because both change how placeable a person
// is presented as being.
r.With(security.RequireCapability(security.CapCandidateWrite)).Patch("/candidates/{candidateID}/access", h.patchCandidateAccess)
// Assigning a candidate to a desk. candidate.write rather than a management capability, because putting
// a candidate on your own desk is ordinary recruiting work, and under a narrowed record scope a
// recruiter who could not assign would be unable to take responsibility for anybody they sourced.
r.With(security.RequireCapability(security.CapCandidateWrite)).Put("/candidates/{candidateID}/owner", h.putCandidateOwner)
}
// 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\"")
// This file carries candidate PII. no-store keeps it out of shared caches and browser history, and
// nosniff stops a browser reinterpreting it as something executable. The recruiting exporter already
// sets both; this one did not.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
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"`
// CONTACT DETAILS. The candidates table has carried email and headline since migration 0002 and the
// insert has always written them, but this request struct never exposed either, so a candidate created
// through the API could not be emailed. That went unnoticed until outbound correspondence was built and
// tested against a real record.
Email string `json:"email"`
Headline string `json:"headline"`
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")
case errors.Is(err, ErrNoticeRequired):
// 422 and the sentence, not 500 and "internal error". A recruiter meeting this refusal needs to know
// it is a rule their organization switched on and exactly what closes it, or the product looks broken
// at the precise moment it is behaving correctly.
writeError(w, http.StatusUnprocessableEntity, ErrNoticeRequired.Error())
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
}
// putCandidateOwner assigns a candidate to a person's desk. A null or absent owner returns them to the shared
// pool, which is deliberate rather than an error: an unowned candidate is visible to everybody by default, so
// releasing one is how a departing recruiter's bench stays workable instead of becoming invisible.
func (h *Handler) putCandidateOwner(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "candidateID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a candidate identifier"})
return
}
var in struct {
OwnerUserID *string `json:"owner_user_id"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"})
return
}
var owner *uuid.UUID
if in.OwnerUserID != nil && strings.TrimSpace(*in.OwnerUserID) != "" {
parsed, perr := uuid.Parse(strings.TrimSpace(*in.OwnerUserID))
if perr != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "that is not a user identifier", "field": "owner_user_id"})
return
}
owner = &parsed
}
if err := h.svc.SetCandidateOwner(r.Context(), id, owner, r.Header.Get("X-Forwarded-For"), r.UserAgent()); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"candidate_id": id.String(), "owner_user_id": in.OwnerUserID})
}
// CandidateDocumentRoutes serves the file on a person.
//
// UPLOAD IS candidate.write and READ IS candidate.read, the same tiers as the record the file belongs to.
// A separate capability would have meant a recruiter who can edit a candidate but cannot attach their resume,
// which is not a distinction anybody in this industry makes.
func (h *Handler) CandidateDocumentRoutes(r chi.Router) {
read := security.RequireCapability(security.CapCandidateRead)
write := security.RequireCapability(security.CapCandidateWrite)
r.With(read).Get("/candidates/{candidateID}/documents", h.listCandidateDocuments)
r.With(write).Post("/candidates/{candidateID}/documents", h.uploadCandidateDocument)
r.With(read).Get("/candidate-documents/{documentID}/content", h.downloadCandidateDocument)
// There is no DELETE. A document is SUPERSEDED, because what was sent to a client six months ago has to
// stay recoverable, and a record that can be deleted is a record that cannot be audited.
r.With(write).Post("/candidate-documents/{documentID}/supersede", h.supersedeCandidateDocument)
}
func (h *Handler) listCandidateDocuments(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "candidateID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a candidate identifier"})
return
}
docs, err := h.svc.ListCandidateDocuments(r.Context(), id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"documents": docs, "count": len(docs)})
}
// uploadCandidateDocument accepts a multipart form. The FILE is the payload; document_type is a form field.
// Multipart rather than JSON with base64, because a resume is a file and making a recruiter's browser encode
// one costs a third of the bytes for nothing.
func (h *Handler) uploadCandidateDocument(w http.ResponseWriter, r *http.Request) {
candID := chi.URLParam(r, "candidateID")
if _, err := uuid.Parse(candID); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a candidate identifier"})
return
}
// The body is bounded BEFORE it is parsed, so an oversized upload is refused at the reader rather than
// after it has been buffered.
r.Body = http.MaxBytesReader(w, r.Body, MaxCandidateDocBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "the upload is too large or is not a valid form"})
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "attach the file as the form field named file", "field": "file"})
return
}
defer file.Close()
body, err := io.ReadAll(io.LimitReader(file, MaxCandidateDocBytes+1))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not read the upload"})
return
}
doc, err := h.svc.UploadCandidateDocument(r.Context(), CandidateDocInput{
CandidateID: candID,
DocumentType: r.FormValue("document_type"),
FileName: hdr.Filename,
MediaType: hdr.Header.Get("Content-Type"),
Bytes: body,
}, r.Header.Get("X-Forwarded-For"), r.UserAgent())
if err != nil {
writeDocError(w, err)
return
}
writeJSON(w, http.StatusCreated, doc)
}
// downloadCandidateDocument serves the bytes. EVERY HEADER HERE IS LOAD BEARING.
//
// The Content-Type is the type the SERVER recorded at upload after checking it against a closed allowlist,
// never anything the request supplied. nosniff stops a browser second guessing that. The Content-Disposition
// is ATTACHMENT, so a document is downloaded rather than rendered in the origin, which is what keeps a
// malicious file from executing as the application. The restrictive policy means that even if a format
// slipped through, the response could not load a script or a remote resource. Cache-Control is private
// because this is a named person's file and must never land in a shared cache.
func (h *Handler) downloadCandidateDocument(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "documentID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a document identifier"})
return
}
b, meta, err := h.svc.DownloadCandidateDocument(r.Context(), id)
if err != nil {
writeDocError(w, err)
return
}
w.Header().Set("Content-Type", meta.MediaType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Disposition", `attachment; filename="`+meta.FileName+`"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func (h *Handler) supersedeCandidateDocument(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "documentID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a document identifier"})
return
}
if err := h.svc.SupersedeCandidateDocument(r.Context(), id,
r.Header.Get("X-Forwarded-For"), r.UserAgent()); err != nil {
writeDocError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"superseded": true})
}
func writeDocError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNoStorage):
writeJSON(w, http.StatusServiceUnavailable,
map[string]string{"error": "document storage is not configured on this node"})
case errors.Is(err, ErrCandidateUnknown):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such candidate"})
case errors.Is(err, ErrDocNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such document"})
case errors.Is(err, ErrDocDuplicate):
writeJSON(w, http.StatusConflict, map[string]string{"error": "that exact file is already on this candidate"})
case errors.Is(err, ErrDocEmpty):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "the file is empty", "field": "file"})
case errors.Is(err, ErrDocTooLarge):
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": err.Error()})
case errors.Is(err, ErrDocMediaType):
writeJSON(w, http.StatusUnsupportedMediaType,
map[string]string{"error": "that file type is not accepted, send a PDF, Word document, RTF, text or image"})
case errors.Is(err, ErrDocTypeInvalid):
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": err.Error(), "field": "document_type"})
default:
writeServiceError(w, err)
}
}
// SearchAndListRoutes serves the recruiter's working surface: search, saved searches, lists, and the bulk
// actions that move people between them.
//
// SEARCH IS search.run, the capability that already gated match and screen, because a filtered search over
// the bench is the same class of act. LISTS AND BULK ARE candidate.write, because putting somebody on a
// shortlist or on a desk changes the record.
func (h *Handler) SearchAndListRoutes(r chi.Router) {
read := security.RequireCapability(security.CapCandidateRead)
write := security.RequireCapability(security.CapCandidateWrite)
search := security.RequireCapability(security.CapSearchRun)
r.With(search).Post("/candidates/search", h.postCandidateSearch)
r.With(search).Get("/saved-searches", h.getSavedSearches)
r.With(search).Post("/saved-searches", h.postSavedSearch)
r.With(search).Post("/saved-searches/{searchID}/run", h.runSavedSearch)
r.With(search).Delete("/saved-searches/{searchID}", h.deleteSavedSearch)
r.With(read).Get("/candidate-lists", h.getLists)
r.With(read).Get("/candidates/{candidateID}/lists", h.getListsForCandidate)
r.With(write).Post("/candidate-lists", h.postList)
r.With(write).Post("/candidate-lists/{listID}/members", h.postListMembers)
r.With(write).Delete("/candidate-lists/{listID}/members", h.deleteListMembers)
r.With(write).Post("/candidates/bulk/owner", h.postBulkOwner)
}
func (h *Handler) postCandidateSearch(w http.ResponseWriter, r *http.Request) {
var q CandidateQuery
if !decodeJSON(w, r, &q) {
return
}
res, err := h.svc.SearchCandidates(r.Context(), q)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) getSavedSearches(w http.ResponseWriter, r *http.Request) {
out, err := h.svc.ListSavedSearches(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"saved_searches": out, "count": len(out)})
}
func (h *Handler) postSavedSearch(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Visibility string `json:"visibility"`
Pinned bool `json:"pinned"`
Criteria CandidateQuery `json:"criteria"`
}
if !decodeJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Name) == "" || len(req.Name) > 120 {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a saved search needs a name of up to 120 characters", "field": "name"})
return
}
if req.Visibility == "" {
req.Visibility = "private"
}
if req.Visibility != "private" && req.Visibility != "shared" {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a saved search is private or shared", "field": "visibility"})
return
}
id, err := h.svc.SaveSearch(r.Context(), req.Name, req.Visibility, req.Criteria, req.Pinned)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
writeJSON(w, http.StatusConflict, map[string]string{"error": "you already have a saved search with that name"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (h *Handler) runSavedSearch(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "searchID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a search identifier"})
return
}
limit, offset := pageParams(r)
res, err := h.svc.RunSavedSearch(r.Context(), id, int(limit), int(offset))
if err != nil {
if errors.Is(err, ErrSearchUnknown) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such saved search"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) deleteSavedSearch(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "searchID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a search identifier"})
return
}
if err := h.svc.DeleteSavedSearch(r.Context(), id); err != nil {
if errors.Is(err, ErrSearchUnknown) {
// 404 whether it does not exist or belongs to somebody else. A distinguishable answer would let
// one person enumerate another's private searches by name.
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such saved search"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
func (h *Handler) getLists(w http.ResponseWriter, r *http.Request) {
out, err := h.svc.ListLists(r.Context(), r.URL.Query().Get("include_archived") == "true")
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"lists": out, "count": len(out)})
}
func (h *Handler) getListsForCandidate(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "candidateID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a candidate identifier"})
return
}
out, err := h.svc.ListsForCandidate(r.Context(), id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"lists": out, "count": len(out)})
}
func (h *Handler) postList(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Purpose string `json:"purpose"`
Visibility string `json:"visibility"`
}
if !decodeJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Name) == "" || len(req.Name) > 120 {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a list needs a name of up to 120 characters", "field": "name"})
return
}
if req.Purpose == "" {
req.Purpose = "shortlist"
}
if !validListPurpose[req.Purpose] {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "unknown list purpose", "field": "purpose"})
return
}
if req.Visibility == "" {
req.Visibility = "shared"
}
if req.Visibility != "private" && req.Visibility != "shared" {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "a list is private or shared", "field": "visibility"})
return
}
id, err := h.svc.CreateList(r.Context(), req.Name, req.Description, req.Purpose, req.Visibility)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
writeJSON(w, http.StatusConflict, map[string]string{"error": "a list with that name already exists"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
}
var validListPurpose = map[string]bool{
"shortlist": true, "bench": true, "pipeline": true, "do_not_contact": true, "other": true,
}
func (h *Handler) postListMembers(w http.ResponseWriter, r *http.Request) {
listID, ids, note, ok := h.bulkParams(w, r)
if !ok {
return
}
res, err := h.svc.AddToList(r.Context(), listID, ids, note,
r.Header.Get("X-Forwarded-For"), r.UserAgent())
if err != nil {
writeBulkError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) deleteListMembers(w http.ResponseWriter, r *http.Request) {
listID, ids, _, ok := h.bulkParams(w, r)
if !ok {
return
}
res, err := h.svc.RemoveFromList(r.Context(), listID, ids,
r.Header.Get("X-Forwarded-For"), r.UserAgent())
if err != nil {
writeBulkError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) postBulkOwner(w http.ResponseWriter, r *http.Request) {
var req struct {
CandidateIDs []string `json:"candidate_ids"`
OwnerUserID *string `json:"owner_user_id"`
}
if !decodeJSON(w, r, &req) {
return
}
ids, ok := parseIDList(w, req.CandidateIDs)
if !ok {
return
}
var owner *uuid.UUID
if req.OwnerUserID != nil && strings.TrimSpace(*req.OwnerUserID) != "" {
parsed, perr := uuid.Parse(strings.TrimSpace(*req.OwnerUserID))
if perr != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "that is not a user identifier", "field": "owner_user_id"})
return
}
owner = &parsed
}
res, err := h.svc.BulkAssignOwner(r.Context(), ids, owner,
r.Header.Get("X-Forwarded-For"), r.UserAgent())
if err != nil {
writeBulkError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (h *Handler) bulkParams(w http.ResponseWriter, r *http.Request) (uuid.UUID, []uuid.UUID, string, bool) {
listID, err := uuid.Parse(chi.URLParam(r, "listID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not a list identifier"})
return uuid.Nil, nil, "", false
}
var req struct {
CandidateIDs []string `json:"candidate_ids"`
Note string `json:"note"`
}
if !decodeJSON(w, r, &req) {
return uuid.Nil, nil, "", false
}
ids, ok := parseIDList(w, req.CandidateIDs)
if !ok {
return uuid.Nil, nil, "", false
}
return listID, ids, req.Note, true
}
// parseIDList refuses the WHOLE batch if any identifier is malformed, rather than silently dropping the bad
// one. A bulk action that quietly did thirty nine of forty is the kind of thing nobody notices until the
// fortieth person never gets called.
func parseIDList(w http.ResponseWriter, in []string) ([]uuid.UUID, bool) {
if len(in) == 0 {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "name at least one candidate", "field": "candidate_ids"})
return nil, false
}
if len(in) > MaxBulkIDs {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
"error": fmt.Sprintf("at most %d records in one action, this asked for %d", MaxBulkIDs, len(in)),
"field": "candidate_ids"})
return nil, false
}
out := make([]uuid.UUID, 0, len(in))
for _, s := range in {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "one of the identifiers is not valid, nothing was changed",
"field": "candidate_ids"})
return nil, false
}
out = append(out, id)
}
return out, true
}
func writeBulkError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrListUnknown):
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
case errors.Is(err, ErrTooManyIDs):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
default:
writeServiceError(w, err)
}
}
// MergeRoutes serve duplicate detection and merging.
//
// FINDING duplicates is candidate.read: it is a report. MERGING is org.settings.write, the owner tier,
// deliberately higher than ordinary candidate.write. A merge is not reversible by the person who did it, it
// rewrites who owns whose history, and on a shared bench a recruiter merging two people they believe are the
// same is exactly the mistake nobody notices until a client asks why a submission disappeared.
func (h *Handler) MergeRoutes(r chi.Router) {
read := security.RequireCapability(security.CapCandidateRead)
merge := security.RequireCapability(security.CapOrgSettings)
r.With(read).Get("/candidates/duplicates", h.findDuplicates)
r.With(merge).Post("/candidates/merge", h.mergeCandidates)
}
func (h *Handler) findDuplicates(w http.ResponseWriter, r *http.Request) {
limit, _ := pageParams(r)
out, err := h.svc.FindDuplicates(r.Context(), int(limit))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"duplicates": out, "count": len(out),
"note": "these are SUGGESTIONS. A strong match is the same email address; a weak one is the same " +
"name and location, which two different people genuinely share. A human confirms every merge.",
})
}
func (h *Handler) mergeCandidates(w http.ResponseWriter, r *http.Request) {
var req struct {
KeepID string `json:"keep_id"`
MergeID string `json:"merge_id"`
Reason string `json:"reason"`
}
if !decodeJSON(w, r, &req) {
return
}
keep, err := uuid.Parse(strings.TrimSpace(req.KeepID))
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "keep_id must be a candidate identifier", "field": "keep_id"})
return
}
merge, err := uuid.Parse(strings.TrimSpace(req.MergeID))
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]string{"error": "merge_id must be a candidate identifier", "field": "merge_id"})
return
}
res, err := h.svc.MergeCandidates(r.Context(), keep, merge, req.Reason,
r.Header.Get("X-Forwarded-For"), r.UserAgent())
if err != nil {
switch {
case errors.Is(err, ErrMergeSame):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
case errors.Is(err, ErrMergeUnknown):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such candidate"})
case errors.Is(err, ErrAlreadyMerged):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
default:
writeServiceError(w, err)
}
return
}
writeJSON(w, http.StatusOK, res)
}