package recruitmatch import ( "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/missionworkforce/missionmatch/internal/modules/matching" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler serves the recruitMATCH namespace. It holds the shared matching handler so the candidate and // requirement pickers answer under this prefix exactly as they do under screenMATCH, slateMATCH and // missionMATCH. That reuse is what makes the four tools one product rather than four silos. type Handler struct { core *matching.Handler svc *Service } func NewHandler(core *matching.Handler, svc *Service) *Handler { return &Handler{core: core, svc: svc} } // Routes mounts the dashboard read plus the shared pickers. The dashboard is gated on candidate.read // because the payload exposes candidate counts and clearance distribution, the same data class as // GET /candidates. That capability includes every role down to viewer, so the whole organization can see // its own recruiting picture. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapCandidateRead)).Get("/dashboard", h.get) h.core.ReadRoutes(r) } func (h *Handler) get(w http.ResponseWriter, r *http.Request) { asOf := h.svc.clock() if q := r.URL.Query().Get("as_of"); q != "" { t, err := time.Parse("2006-01-02", q) if err != nil { // Deliberately a 400 rather than a silent fallback to now. A caller who asked for a specific // day and quietly received today would draw conclusions from the wrong window. writeJSONErr(w, http.StatusBadRequest, "as_of must be formatted 2006-01-02") return } asOf = t } v, err := h.svc.RecruiterDashboard(r.Context(), asOf) if err != nil { switch { case errors.Is(err, tenant.ErrNoTenant): writeJSONErr(w, http.StatusForbidden, "no tenant context") case errors.Is(err, ErrNoDatabase): writeJSONErr(w, http.StatusServiceUnavailable, "database unavailable") default: // The wrapped error can carry table and column names, so it is logged upstream and never // returned to the caller. writeJSONErr(w, http.StatusInternalServerError, "internal error") } return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { return } } func writeJSONErr(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) }