package eeo import ( "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler serves the adverse impact report and its publication ready export. Both are gated to the // compliance capability (owner and admin) and mount under the tenant group, so they ride the validated // session, RBAC, and RLS like every other surface. type Handler struct{ svc *Service } // NewHandler builds the handler. func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes mounts the compliance reporting endpoints. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapComplianceRead)).Get("/compliance/adverse-impact", h.report) r.With(security.RequireCapability(security.CapComplianceRead)).Get("/compliance/adverse-impact/export", h.export) } func (h *Handler) report(w http.ResponseWriter, r *http.Request) { rep, err := h.svc.AdverseImpact(r.Context(), time.Now()) if err != nil { writeErr(w, err) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(rep) } func (h *Handler) export(w http.ResponseWriter, r *http.Request) { rep, err := h.svc.AdverseImpact(r.Context(), time.Now()) if err != nil { writeErr(w, err) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="adverse_impact_report.txt"`) _, _ = w.Write([]byte(renderText(rep))) } func writeErr(w http.ResponseWriter, err error) { status := http.StatusInternalServerError msg := "internal error" switch { case errors.Is(err, ErrNoCompliance): status, msg = http.StatusServiceUnavailable, "compliance reporting not configured" case errors.Is(err, tenant.ErrNoTenant): status, msg = http.StatusForbidden, "no validated tenant" } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } // renderText produces the publication ready summary: source, the date range covered, the method, and // each cut, with flagged groups called out. No dashes in the prose. func renderText(rep Report) string { var b strings.Builder b.WriteString("ADVERSE IMPACT REPORT\n") b.WriteString("Generated at: " + rep.GeneratedAt + "\n") b.WriteString("Data covered: " + dash(rep.DataFrom) + " to " + dash(rep.DataTo) + "\n\n") b.WriteString("SOURCE\n" + wrap(rep.Source) + "\n\n") b.WriteString("METHOD\n" + wrap(rep.Method) + "\n\n") writeSection(&b, rep.Selection) writeSection(&b, rep.Screening) b.WriteString("This report supplies the data for an independent bias audit run by the deploying employer. It is not itself the audit, and vendor tooling does not transfer the employer's liability.\n") return b.String() } func writeSection(b *strings.Builder, sec AnalysisSection) { b.WriteString(strings.ToUpper(sec.Stage) + "\n") b.WriteString(fmt.Sprintf("Candidates analyzed: %d. Unknown demographic category: %d.\n", sec.TotalN, sec.UnknownCount)) writeCut(b, "By sex", sec.ByGender) writeCut(b, "By race or ethnicity", sec.ByRace) writeCut(b, "Intersectional (sex by race or ethnicity)", sec.Intersectional) b.WriteString("\n") } func writeCut(b *strings.Builder, title string, rows []ImpactRow) { b.WriteString(" " + title + "\n") if len(rows) == 0 { b.WriteString(" No data.\n") return } for _, r := range rows { flag := "" if r.Flagged { flag = " FLAGGED below 0.80" } b.WriteString(fmt.Sprintf(" %-28s selected %d of %d, rate %s, impact ratio %s%s\n", r.Group, r.Selected, r.Considered, r.Rate, r.ImpactRatio, flag)) } } func wrap(s string) string { return s } func dash(s string) string { if s == "" { return "not available" } return s }