package recruiting import ( "encoding/json" "errors" "net/http" "sort" "strconv" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) type Handler struct{ svc *Service } func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes mounts the client and contact surface. Reads are open to every role because a recruiter, // a member and a viewer all need to see who the organization works with. Writes are gated on // requirement.write, which the matrix grants to owner, admin and recruiter. func (h *Handler) Routes(r chi.Router) { read := security.RequireCapability(security.CapCandidateRead) write := security.RequireCapability(security.CapRequirementEdit) r.With(read).Get("/clients", h.listClients) r.With(read).Get("/clients/{clientID}", h.getClient) r.With(write).Post("/clients", h.createClient) r.With(write).Patch("/clients/{clientID}", h.updateClient) r.With(write).Post("/clients/{clientID}/contacts", h.createContact) r.With(write).Patch("/contacts/{contactID}", h.updateContact) // Ownership. Claiming and releasing are ordinary recruiter actions; reassigning someone else's // account is a management decision and the service gates it on admin. r.With(write).Post("/clients/{clientID}/claim", h.claim) r.With(write).Post("/clients/{clientID}/release", h.release) r.With(write).Post("/clients/{clientID}/reassign", h.reassign) // Requisitions. Status is not editable through PATCH: it moves clocks and writes history, so it has // its own endpoint where the transition graph decides whether the move is legal. r.With(read).Get("/requisitions", h.listRequisitions) r.With(read).Get("/requisitions/{reqID}", h.getRequisition) r.With(write).Post("/requisitions", h.createRequisition) r.With(write).Patch("/requisitions/{reqID}", h.updateRequisition) r.With(write).Post("/requisitions/{reqID}/status", h.changeStatus) r.With(write).Post("/requisitions/{reqID}/claim", h.claimRequisition) r.With(write).Post("/requisitions/{reqID}/release", h.releaseRequisition) // The twin of the client reassign route above. Its absence meant a manager could move an account and // not the work on it, which is the half of a reassignment that actually matters. r.With(write).Post("/requisitions/{reqID}/reassign", h.reassignRequisition) // The candidate pipeline. A stage move is a POST to its own endpoint rather than a PATCH field, // for the same reason a requisition status is: it writes history and moves a clock, and the // database validates it under a row lock. r.With(read).Get("/requisitions/{reqID}/pipeline", h.pipelineBoard) r.With(write).Post("/requisitions/{reqID}/applications", h.submitApplication) r.With(read).Get("/applications", h.listApplications) r.With(read).Get("/applications/{appID}", h.getApplication) r.With(write).Post("/applications/{appID}/stage", h.moveStage) // The disposition vocabulary, served rather than hardcoded in the browser, so the UI cannot offer a // reason the database will refuse. r.With(read).Get("/dispositions", h.dispositions) // Interviews, offers and placements. All hang off an application, so a candidate considered for two // roles has two of each and neither role's economics can borrow the other's. r.With(read).Get("/applications/{appID}/assignment", h.getAssignment) // READING interviews sits at the read tier, and it was simply missing: they could be created and updated // and never listed, so the only way to see what was scheduled was to query the database. r.With(read).Get("/applications/{appID}/interviews", h.listInterviews) r.With(write).Post("/applications/{appID}/interviews", h.scheduleInterview) r.With(write).Patch("/interviews/{ivID}", h.updateInterview) r.With(write).Post("/applications/{appID}/offers", h.extendOffer) r.With(write).Post("/offers/{offerID}/status", h.respondToOffer) r.With(write).Post("/applications/{appID}/placement", h.createPlacement) r.With(write).Post("/placements/{plID}/end", h.endPlacement) r.With(read).Get("/placements", h.listPlacements) // Recruiter activity. The log is a record: it can be added to and its notes corrected, never // rewritten or deleted, which is what lets Phase 17 derive metrics from it. r.With(read).Get("/activities", h.listActivities) r.With(read).Get("/activities/summary", h.activitySummary) r.With(write).Post("/activities", h.logActivity) r.With(write).Patch("/activities/{actID}/note", h.correctActivityNote) // Tasks. The queue is bucketed by the organization working day, not a UTC day, so an evening task // due today does not read as due tomorrow. r.With(read).Get("/tasks/queue", h.taskQueue) r.With(read).Get("/tasks", h.listTasks) r.With(write).Post("/tasks", h.createTask) r.With(write).Patch("/tasks/{taskID}", h.updateTask) r.With(security.RequireCapability(security.CapOrgSettings)).Post("/time-zone", h.setTimeZone) // Analytics. Every figure is derived from rows a human created, and the time to fill keeps still // open orders in the calculation rather than quietly excluding them. r.With(read).Get("/analytics", h.analytics) // THE MANAGER VIEW. Same read tier as analytics: seeing how a desk is doing is not a privileged act, and // the scope resolution below already narrows to what the caller asked for while record scope narrows to // what they are allowed to see. r.With(read).Get("/manager", h.managerSummary) // The manager's own queue, and the bulk action that answers it. Both are scoped by the same resolver // the manager summary uses, so a capacity figure and an exception count always describe one team. r.With(read).Get("/exceptions", h.exceptions) r.With(write).Post("/desk/reassign", h.moveDesk) // Phase 4. Goals turn every figure above into attainment, and the forecast weights the open pipeline // by the firm's OWN conversion history rather than by constants nobody can defend. r.With(read).Get("/goals", h.listGoals) r.With(write).Post("/goals", h.setGoal) r.With(write).Delete("/goals/{goalID}", h.deleteGoal) r.With(read).Get("/forecast", h.forecast) // Reporting and exports. The export capability is separate from reading: taking a copy out of the // system is a different act from looking at it, and every export writes an audit row. r.With(read).Get("/reports", h.report) r.With(security.RequireCapability(security.CapExportRun)).Get("/reports/export", h.exportCSV) } func pathUUID(w http.ResponseWriter, r *http.Request, name, label string) (uuid.UUID, bool) { id, err := uuid.Parse(chi.URLParam(r, name)) if err != nil { writeErr(w, http.StatusBadRequest, "invalid "+label+" id", "") return uuid.UUID{}, false } return id, true } func (h *Handler) getAssignment(w http.ResponseWriter, r *http.Request) { id, ok := appIDParam(w, r) if !ok { return } out, err := h.svc.GetAssignment(r.Context(), id) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) scheduleInterview(w http.ResponseWriter, r *http.Request) { id, ok := appIDParam(w, r) if !ok { return } var in CreateInterviewInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.ScheduleInterview(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusCreated, out) } func (h *Handler) updateInterview(w http.ResponseWriter, r *http.Request) { id, ok := pathUUID(w, r, "ivID", "interview") if !ok { return } var in UpdateInterviewInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.UpdateInterview(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) extendOffer(w http.ResponseWriter, r *http.Request) { id, ok := appIDParam(w, r) if !ok { return } var in CreateOfferInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.ExtendOffer(r.Context(), id, in) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusCreated, out) } func (h *Handler) respondToOffer(w http.ResponseWriter, r *http.Request) { id, ok := pathUUID(w, r, "offerID", "offer") if !ok { return } var in OfferStatusInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.RespondToOffer(r.Context(), id, in) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) createPlacement(w http.ResponseWriter, r *http.Request) { id, ok := appIDParam(w, r) if !ok { return } var in CreatePlacementInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.CreatePlacement(r.Context(), id, in) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusCreated, out) } func (h *Handler) endPlacement(w http.ResponseWriter, r *http.Request) { id, ok := pathUUID(w, r, "plID", "placement") if !ok { return } var in EndPlacementInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.EndPlacement(r.Context(), id, in) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) listPlacements(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) out, err := h.svc.ListPlacements(r.Context(), q.Get("status"), limit, offset) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func appIDParam(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { id, err := uuid.Parse(chi.URLParam(r, "appID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid application id", "") return uuid.UUID{}, false } return id, true } func (h *Handler) pipelineBoard(w http.ResponseWriter, r *http.Request) { id, ok := reqIDParam(w, r) if !ok { return } out, err := h.svc.Board(r.Context(), id) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) submitApplication(w http.ResponseWriter, r *http.Request) { id, ok := reqIDParam(w, r) if !ok { return } var in CreateApplicationInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.Submit(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusCreated, out) } func (h *Handler) listApplications(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) out, err := h.svc.ListApplications(r.Context(), q.Get("candidate_id"), q.Get("stage"), limit, offset) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) getApplication(w http.ResponseWriter, r *http.Request) { id, ok := appIDParam(w, r) if !ok { return } out, err := h.svc.GetApplication(r.Context(), id) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) moveStage(w http.ResponseWriter, r *http.Request) { id, ok := appIDParam(w, r) if !ok { return } var in MoveStageInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.MoveStage(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } // dispositions serves the closed reason vocabulary with the outcome each code belongs to, so the form // can offer only the codes valid for the outcome being recorded. func (h *Handler) dispositions(w http.ResponseWriter, r *http.Request) { type item struct { Code string `json:"code"` Outcome string `json:"outcome"` Label string `json:"label"` } out := make([]item, 0, len(dispositionCodes)) for code, outcome := range dispositionCodes { out = append(out, item{Code: code, Outcome: outcome, Label: dispositionLabel(code)}) } sort.Slice(out, func(i, j int) bool { if out[i].Outcome != out[j].Outcome { return out[i].Outcome < out[j].Outcome } return out[i].Code < out[j].Code }) writeJSON(w, http.StatusOK, map[string]any{"dispositions": out, "stages": ladderView()}) } // reqID parses the path parameter, writing the 400 itself so each handler stays two lines. func reqIDParam(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { id, err := uuid.Parse(chi.URLParam(r, "reqID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid requisition id", "") return uuid.UUID{}, false } return id, true } func (h *Handler) listRequisitions(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) out, err := h.svc.ListRequisitions(r.Context(), q.Get("client_id"), q.Get("status"), q.Get("owner"), q.Get("q"), limit, offset) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) getRequisition(w http.ResponseWriter, r *http.Request) { id, ok := reqIDParam(w, r) if !ok { return } out, err := h.svc.GetRequisition(r.Context(), id) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) createRequisition(w http.ResponseWriter, r *http.Request) { var in CreateRequisitionInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.CreateRequisition(r.Context(), in) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusCreated, out) } func (h *Handler) updateRequisition(w http.ResponseWriter, r *http.Request) { id, ok := reqIDParam(w, r) if !ok { return } var in UpdateRequisitionInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.UpdateRequisition(r.Context(), id, in) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) changeStatus(w http.ResponseWriter, r *http.Request) { id, ok := reqIDParam(w, r) if !ok { return } var in ChangeStatusInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.ChangeStatus(r.Context(), id, in) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) claimRequisition(w http.ResponseWriter, r *http.Request) { id, ok := reqIDParam(w, r) if !ok { return } out, err := h.svc.ClaimRequisition(r.Context(), id) if err != nil { h.fail(w, err) return } h.writeMoney(w, r, http.StatusOK, out) } func (h *Handler) releaseRequisition(w http.ResponseWriter, r *http.Request) { id, ok := reqIDParam(w, r) if !ok { return } out, err := h.svc.ReleaseRequisition(r.Context(), id) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } const maxBody = 1 << 20 func decode(w http.ResponseWriter, r *http.Request, v any) error { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBody)) dec.DisallowUnknownFields() return dec.Decode(v) } func (h *Handler) listClients(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) out, err := h.svc.ListClients(r.Context(), q.Get("status"), q.Get("q"), q.Get("owner"), limit, offset) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) getClient(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "clientID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid client id", "") return } out, err := h.svc.GetClient(r.Context(), id) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) createClient(w http.ResponseWriter, r *http.Request) { var in CreateClientInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.CreateClient(r.Context(), in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusCreated, out) } func (h *Handler) updateClient(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "clientID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid client id", "") return } var in UpdateClientInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.UpdateClient(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) createContact(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "clientID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid client id", "") return } var in CreateContactInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.CreateContact(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusCreated, out) } func (h *Handler) updateContact(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "contactID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid contact id", "") return } var in UpdateContactInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.UpdateContact(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } // fail maps a domain error onto a status and, where the error names a field, returns that field so the // UI can mark the offending input rather than showing one sentence at the top of a form. // // 409 is used for two genuinely different situations and they are distinguished by message: a duplicate // value the caller can fix by changing input, and a concurrent edit the caller fixes by reloading. func (h *Handler) fail(w http.ResponseWriter, err error) { var dup *DuplicateError var val *ValidationError var notOwner *ErrNotOwner switch { case errors.As(err, ¬Owner): // 409 rather than 403: this is not a permissions failure, it is an account someone else holds. // The owner is named so the caller knows who to talk to. writeErrOwner(w, notOwner) case errors.As(err, &dup): // A person is not "in use". The generic phrasing fits a name or an identifier and reads badly for // anything else, so the one case that is a human gets its own sentence. if dup.Field == "candidate" { writeErr(w, http.StatusConflict, "that candidate is already on this job order", "candidate_id") return } writeErr(w, http.StatusConflict, "that "+dup.Field+" is already in use", dup.Field) case errors.As(err, &val): writeErr(w, http.StatusUnprocessableEntity, val.Message, val.Field) case errors.Is(err, ErrConflict): writeErr(w, http.StatusConflict, "the record was changed by someone else, reload and try again", "") case errors.Is(err, ErrNotFound): writeErr(w, http.StatusNotFound, "not found", "") case errors.Is(err, tenant.ErrNoTenant): writeErr(w, http.StatusForbidden, "no tenant context", "") case errors.Is(err, ErrNoDatabase): writeErr(w, http.StatusServiceUnavailable, "database unavailable", "") default: // A wrapped database error can carry table and column names, so it never reaches the caller. writeErr(w, http.StatusInternalServerError, "internal error", "") } } func (h *Handler) claim(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "clientID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid client id", "") return } out, err := h.svc.Claim(r.Context(), id) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) release(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "clientID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid client id", "") return } out, err := h.svc.Release(r.Context(), id) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) reassign(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "clientID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid client id", "") return } var in struct { OwnerUserID string `json:"owner_user_id"` } if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } to, err := uuid.Parse(in.OwnerUserID) if err != nil { writeErr(w, http.StatusUnprocessableEntity, "owner_user_id must be a user id", "owner_user_id") return } out, err := h.svc.Reassign(r.Context(), id, to) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, code int, msg, field string) { body := map[string]string{"error": msg} if field != "" { body["field"] = field } writeJSON(w, code, body) } // writeErrOwner returns the holder alongside the message so the UI can offer to contact them or, for an // admin, to reassign. func writeErrOwner(w http.ResponseWriter, e *ErrNotOwner) { writeJSON(w, http.StatusConflict, map[string]string{ "error": e.Error(), "field": "owner_user_id", "owner_user_id": e.OwnerUserID, "owner_name": e.OwnerName, }) } // --------------------------------------------------------------------------- // Recruiter activity // --------------------------------------------------------------------------- func (h *Handler) logActivity(w http.ResponseWriter, r *http.Request) { var in LogActivityInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.LogActivity(r.Context(), in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusCreated, out) } func (h *Handler) listActivities(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) out, err := h.svc.ListActivities(r.Context(), ActivityFilter{ Actor: q.Get("actor"), CandidateID: q.Get("candidate_id"), RequisitionID: q.Get("requisition_id"), ApplicationID: q.Get("application_id"), Kind: q.Get("kind"), Since: q.Get("since"), // Captured rows are opt in. A recruiter's own log means what they did, and mixing in everything // the platform recorded on their behalf would drown it. IncludeCaptured: q.Get("include_captured") == "true", }, limit, offset) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) correctActivityNote(w http.ResponseWriter, r *http.Request) { var in CorrectNoteInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.CorrectActivityNote(r.Context(), chi.URLParam(r, "actID"), in.Body) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) activitySummary(w http.ResponseWriter, r *http.Request) { out, err := h.svc.ActivitySummary(r.Context(), r.URL.Query().Get("since")) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } // --------------------------------------------------------------------------- // Tasks // --------------------------------------------------------------------------- func (h *Handler) taskQueue(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() out, err := h.svc.TaskQueueView(r.Context(), q.Get("assignee"), q.Get("include_closed") == "true") if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) out, err := h.svc.ListTasks(r.Context(), q.Get("candidate_id"), q.Get("requisition_id"), q.Get("application_id"), limit, offset) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) { var in CreateTaskInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.CreateTask(r.Context(), in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusCreated, out) } func (h *Handler) updateTask(w http.ResponseWriter, r *http.Request) { id, ok := pathUUID(w, r, "taskID", "task") if !ok { return } var in UpdateTaskInput if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.UpdateTask(r.Context(), id, in) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } // setTimeZone is gated on org settings rather than requirement.write. It changes what "today" means for // every count in the product, so it is an owner decision and not a recruiter one. func (h *Handler) setTimeZone(w http.ResponseWriter, r *http.Request) { var in struct { TimeZone string `json:"time_zone"` } if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } tz, err := h.svc.SetTimeZone(r.Context(), in.TimeZone) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"time_zone": tz}) } func (h *Handler) managerSummary(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() out, err := h.svc.ManagerSummary(r.Context(), q.Get("since"), q.Get("team_id"), q.Get("owner")) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) exceptions(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() out, err := h.svc.Exceptions(r.Context(), q.Get("team_id"), q.Get("owner")) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } // moveDesk reassigns one recruiter's whole book to another. // // The capability check lives in the service rather than in the route, deliberately. Every other ownership // route in this file is mounted on `write` and gates the management decision inside, and putting this one // alone behind a route level capability would mean two places to look when somebody asks why a manager was // refused. func (h *Handler) moveDesk(w http.ResponseWriter, r *http.Request) { var in struct { FromUserID string `json:"from_user_id"` ToUserID string `json:"to_user_id"` Include []string `json:"include"` } if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } out, err := h.svc.MoveDesk(r.Context(), in.FromUserID, in.ToUserID, in.Include) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) reassignRequisition(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "reqID")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid requisition id", "") return } var in struct { OwnerUserID string `json:"owner_user_id"` } if err := decode(w, r, &in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } to, err := uuid.Parse(in.OwnerUserID) if err != nil { writeErr(w, http.StatusUnprocessableEntity, "owner_user_id must be a user id", "owner_user_id") return } out, err := h.svc.ReassignRequisition(r.Context(), id, to) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) analytics(w http.ResponseWriter, r *http.Request) { out, err := h.svc.Analytics(r.Context(), r.URL.Query().Get("since")) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } func (h *Handler) report(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() out, err := h.svc.Report(r.Context(), Period(q.Get("period")), q.Get("since")) if err != nil { h.fail(w, err) return } writeJSON(w, http.StatusOK, out) } // exportCSV streams a report as a downloadable file. // // Gated on export.run, which is a distinct capability from reading the same data on screen: taking a // copy out of the system is a different act from looking at it, and the audit trail treats it that way. func (h *Handler) exportCSV(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() body, filename, err := h.svc.Export(r.Context(), ExportKind(q.Get("kind")), Period(q.Get("period")), q.Get("since")) if err != nil { h.fail(w, err) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") // The filename is generated, never taken from input, so there is nothing to inject into the header. w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) // A downloaded report is a snapshot of a moving system. Caching it would show a stale file that looks // current, and an intermediary caching tenant data is worse than that. w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(body)) } func (h *Handler) listInterviews(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "appID")) if err != nil { writeErr(w, http.StatusBadRequest, "that is not an application identifier", "") return } out, err := h.svc.ListInterviews(r.Context(), id) if err != nil { writeErr(w, http.StatusInternalServerError, "could not read interviews", "") return } writeJSON(w, http.StatusOK, map[string]any{"interviews": out, "count": len(out)}) }