package careers import ( "encoding/json" "errors" "io" "log" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/security" ) type Handler struct{ svc *Service } func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // PublicRoutes are UNAUTHENTICATED and are the only surface in this product a stranger can reach that touches // a tenant's data. They are mounted behind their own rate limiter in cmd/api, they take no cookie, and they // return only fields that were written for publication. // // THERE IS NO CSRF HERE, deliberately and correctly: CSRF protects a request that carries ambient authority, // and these carry none. There is no session to ride. What protects them is that they can only ever read a // PUBLISHED posting and only ever write a row into job_applications, which is a staging table nothing // downstream trusts. func (h *Handler) PublicRoutes(r chi.Router) { r.Get("/careers/{slug}", h.publicList) r.Get("/careers/{slug}/postings/{token}", h.publicPosting) r.Post("/careers/{slug}/postings/{token}/apply", h.publicApply) // Interview self scheduling. Same public shape as a job posting: an opaque token, no session, and one // 404 for every reason a link might not work. r.Get("/careers/{slug}/schedule/{token}", h.publicOffer) r.Post("/careers/{slug}/schedule/{token}/book", h.publicBook) } // Routes are the tenant side. Publishing is requirement.write, the tier that already owns a requisition, // because a posting is the public face of one. Reviewing inbound applications is candidate.write, because // deciding what happens to a person who applied is recruiting work. func (h *Handler) Routes(r chi.Router) { read := security.RequireCapability(security.CapCandidateRead) write := security.RequireCapability(security.CapCandidateWrite) publish := security.RequireCapability(security.CapRequirementEdit) r.With(read).Get("/job-postings", h.listPostings) r.With(publish).Post("/job-postings", h.createPosting) r.With(publish).Post("/job-postings/{postingID}/status", h.setStatus) r.With(read).Get("/job-applications", h.listApplications) r.With(write).Post("/job-applications/{appID}/review", h.review) r.With(write).Post("/job-applications/{appID}/convert", h.convert) r.With(read).Get("/interview-offers", h.listOffers) r.With(write).Post("/interview-offers", h.createOffer) r.With(write).Post("/interview-offers/{offerID}/withdraw", h.withdrawOffer) } func (h *Handler) publicOffer(w http.ResponseWriter, r *http.Request) { o, err := h.svc.GetPublicOffer(r.Context(), chi.URLParam(r, "slug"), chi.URLParam(r, "token")) if err != nil { h.failPublic(w, err) return } writeJSON(w, http.StatusOK, o) } func (h *Handler) publicBook(w http.ResponseWriter, r *http.Request) { var in struct { SlotID string `json:"slot_id"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid request") return } when, err := h.svc.BookSlot(r.Context(), chi.URLParam(r, "slug"), chi.URLParam(r, "token"), in.SlotID, clientIP(r)) if err != nil { h.failPublic(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "booked": true, "scheduled_at": when, "note": "your interview is confirmed, a calendar invitation has been sent if an address is on file", }) } func (h *Handler) listOffers(w http.ResponseWriter, r *http.Request) { out, err := h.svc.ListOffers(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "could not read scheduling offers") return } writeJSON(w, http.StatusOK, map[string]any{"offers": out, "count": len(out)}) } func (h *Handler) createOffer(w http.ResponseWriter, r *http.Request) { var in OfferInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body") return } id, token, err := h.svc.CreateOffer(r.Context(), in, clientIP(r), r.UserAgent()) if err != nil { if strings.Contains(err.Error(), "careers:") { writeErr(w, http.StatusUnprocessableEntity, strings.TrimPrefix(err.Error(), "careers: ")) return } // The cause is LOGGED, not returned. A generic message to the caller is right; a generic message in // the server log is how a 500 becomes an afternoon of guessing. log.Printf("careers: create offer: %v", err) writeErr(w, http.StatusInternalServerError, "could not create the scheduling offer") return } writeJSON(w, http.StatusCreated, map[string]any{ "id": id, "public_token": token, "note": "send the candidate the scheduling link, the interview is created when they pick a time", }) } func (h *Handler) withdrawOffer(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "offerID")) if err != nil { writeErr(w, http.StatusBadRequest, "that is not an offer identifier") return } if err := h.svc.WithdrawOffer(r.Context(), id, clientIP(r), r.UserAgent()); err != nil { if errors.Is(err, ErrNotFound) { writeErr(w, http.StatusNotFound, "no such open offer") return } writeErr(w, http.StatusInternalServerError, "could not withdraw the offer") return } writeJSON(w, http.StatusOK, map[string]any{"withdrawn": true}) } func (h *Handler) publicList(w http.ResponseWriter, r *http.Request) { posts, employer, err := h.svc.ListPublicPostings(r.Context(), chi.URLParam(r, "slug")) if err != nil { h.failPublic(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "employer": employer, "postings": posts, "count": len(posts), }) } func (h *Handler) publicPosting(w http.ResponseWriter, r *http.Request) { p, err := h.svc.GetPublicPosting(r.Context(), chi.URLParam(r, "slug"), chi.URLParam(r, "token")) if err != nil { h.failPublic(w, err) return } writeJSON(w, http.StatusOK, p) } // MaxApplyBytes bounds a public upload. A resume is tens of kilobytes; ten megabytes is generous and small // enough that an unauthenticated endpoint cannot be used to fill a volume. const MaxApplyBytes = 10 << 20 func (h *Handler) publicApply(w http.ResponseWriter, r *http.Request) { // The body is bounded BEFORE it is parsed, because this is the one endpoint anybody on the internet can // post to and the reader is the only place a flood can be stopped cheaply. r.Body = http.MaxBytesReader(w, r.Body, MaxApplyBytes+(1<<20)) var in Submission if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { if err := r.ParseMultipartForm(4 << 20); err != nil { writeErr(w, http.StatusRequestEntityTooLarge, "the submission is too large") return } in = Submission{ FullName: r.FormValue("full_name"), Email: r.FormValue("email"), Phone: r.FormValue("phone"), Location: r.FormValue("location"), CoverNote: r.FormValue("cover_note"), Clearance: r.FormValue("clearance"), Source: r.FormValue("source"), ConsentText: r.FormValue("consent_text"), Consented: r.FormValue("consented") == "true", } if file, hdr, ferr := r.FormFile("resume"); ferr == nil { defer file.Close() b, rerr := io.ReadAll(io.LimitReader(file, MaxApplyBytes+1)) if rerr != nil || len(b) > MaxApplyBytes { writeErr(w, http.StatusRequestEntityTooLarge, "that resume is too large") return } in.ResumeBytes, in.ResumeName, in.ResumeType = b, hdr.Filename, hdr.Header.Get("Content-Type") } } else { if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid submission") return } } in.SubmittedIP = clientIP(r) id, err := h.svc.Apply(r.Context(), chi.URLParam(r, "slug"), chi.URLParam(r, "token"), in) if err != nil { h.failPublic(w, err) return } // The response says only that it arrived. It carries no candidate identifier, no indication of whether // this person is already known, and nothing about the organization's pipeline, because a public endpoint // that answers those questions answers them for anybody. writeJSON(w, http.StatusCreated, map[string]any{ "received": true, "reference": id, "note": "your application has been received, the recruiting team will be in touch if there is a fit", }) } func (h *Handler) failPublic(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrSlotTaken): // Distinguishable from a dead link, deliberately: the candidate needs to know to pick another time // rather than to ask whether the link is broken. writeErr(w, http.StatusConflict, "that time has just been taken, please choose another") case errors.Is(err, ErrOfferUnavailable): writeErr(w, http.StatusNotFound, "that scheduling link is no longer available") case errors.Is(err, ErrNotFound), errors.Is(err, ErrPostingClosed): // ONE ANSWER for a missing employer, a missing posting, a draft posting and a closed one. A // distinguishable response would let anybody map which customers exist and which roles they are // quietly working. writeErr(w, http.StatusNotFound, "that job posting is not available") case errors.Is(err, ErrAlreadyApplied): // Deliberately unrevealing: it does not confirm anything about the person, only that this posting // already has a submission under that address. writeErr(w, http.StatusConflict, "an application for this posting has already been submitted with that email address") case errors.Is(err, ErrInvalid): writeErr(w, http.StatusUnprocessableEntity, strings.TrimPrefix(err.Error(), "careers: ")) case errors.Is(err, ErrNoDatabase): writeErr(w, http.StatusServiceUnavailable, "applications are temporarily unavailable") default: writeErr(w, http.StatusInternalServerError, "could not accept the application") } } func (h *Handler) listPostings(w http.ResponseWriter, r *http.Request) { out, err := h.svc.ListPostings(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "could not read postings") return } writeJSON(w, http.StatusOK, map[string]any{"postings": out, "count": len(out)}) } func (h *Handler) createPosting(w http.ResponseWriter, r *http.Request) { var in PostingInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body") return } id, token, err := h.svc.CreatePosting(r.Context(), in) if err != nil { if strings.Contains(err.Error(), "careers:") { writeErr(w, http.StatusUnprocessableEntity, strings.TrimPrefix(err.Error(), "careers: ")) return } writeErr(w, http.StatusInternalServerError, "could not create the posting") return } writeJSON(w, http.StatusCreated, map[string]any{ "id": id, "public_token": token, "status": "draft", "note": "the posting is a DRAFT and is not public yet, publish it when the copy is right", }) } func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "postingID")) if err != nil { writeErr(w, http.StatusBadRequest, "that is not a posting identifier") return } var in struct { Status string `json:"status"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body") return } if err := h.svc.SetPostingStatus(r.Context(), id, in.Status, clientIP(r), r.UserAgent()); err != nil { if errors.Is(err, ErrNotFound) { writeErr(w, http.StatusNotFound, "no such posting") return } writeErr(w, http.StatusUnprocessableEntity, strings.TrimPrefix(err.Error(), "careers: ")) return } writeJSON(w, http.StatusOK, map[string]any{"status": in.Status}) } func (h *Handler) listApplications(w http.ResponseWriter, r *http.Request) { out, err := h.svc.ListApplications(r.Context(), r.URL.Query().Get("status")) if err != nil { writeErr(w, http.StatusInternalServerError, "could not read applications") return } writeJSON(w, http.StatusOK, map[string]any{"applications": out, "count": len(out)}) } func (h *Handler) review(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 } var in struct { Status string `json:"status"` Reason string `json:"reason"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body") return } if err := h.svc.ReviewApplication(r.Context(), id, in.Status, in.Reason, clientIP(r), r.UserAgent()); err != nil { if errors.Is(err, ErrNotFound) { writeErr(w, http.StatusNotFound, "no such application, or it has already been converted") return } writeErr(w, http.StatusUnprocessableEntity, strings.TrimPrefix(err.Error(), "careers: ")) return } writeJSON(w, http.StatusOK, map[string]any{"status": in.Status}) } func (h *Handler) convert(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 } candID, err := h.svc.ConvertApplication(r.Context(), id, clientIP(r), r.UserAgent()) if err != nil { if errors.Is(err, ErrNotFound) { writeErr(w, http.StatusNotFound, "no such application, or it has already been converted") return } writeErr(w, http.StatusInternalServerError, "could not convert the application") return } writeJSON(w, http.StatusOK, map[string]any{"candidate_id": candID, "status": "converted"}) } func clientIP(r *http.Request) string { if v := r.Header.Get("X-Forwarded-For"); v != "" { if i := strings.IndexByte(v, ','); i > 0 { return strings.TrimSpace(v[:i]) } return strings.TrimSpace(v) } return "" } 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 string) { writeJSON(w, code, map[string]string{"error": msg}) }