Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | internal/modules/recruiting/handler.go |
| Original SHA-256 | 174b0efcaa1e8936725cb05a57f01e8032774da275b89ea14ee4057f06dde0eb |
| Git blob SHA | 14f0f58a58ffba96c416213f1424d4e1c062199a |
| Reading-copy SHA-256 | 174b0efcaa1e8936725cb05a57f01e8032774da275b89ea14ee4057f06dde0eb |
Recovered source transcript
Download complete source text ↓859 lines · 25.6 KB
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 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)
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)
// 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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
}
writeJSON(w, 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) 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))
}