Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/careers/handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/careers/handler.go |
| Original SHA-256 | 79d4666a350835686e6fe36fe1e64e8c457ad4bd6743382d1379d2d496f3a7c3 |
| Git blob SHA | 4e7302e0001aadd804765bf425eba35919f59a19 |
| Reading-copy SHA-256 | 79d4666a350835686e6fe36fe1e64e8c457ad4bd6743382d1379d2d496f3a7c3 |
Recovered source transcript
Download complete source text ↓364 lines · 13.7 KB
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})
}