Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/opportunities/teaming_handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/opportunities/teaming_handler.go |
| Original SHA-256 | f5665033677486117d2807c41fcfda4b971438943451b48c1c5b1e0d0dfa0d12 |
| Git blob SHA | e9c7b83d2357d9f01850bc6b221c21122497ba8d |
| Reading-copy SHA-256 | f5665033677486117d2807c41fcfda4b971438943451b48c1c5b1e0d0dfa0d12 |
Recovered source transcript
Download complete source text ↓203 lines · 7.1 KB
package opportunities
import (
"encoding/json"
"errors"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/missionworkforce/missionmatch/internal/platform/security"
)
// TeamingRoutes mounts the partner and key personnel surface. It rides the SAME capability as the rest of
// capture, opportunity.manage, because deciding who you bid with is the same act as deciding what you bid on.
func (h *Handler) TeamingRoutes(r chi.Router) {
manage := security.RequireCapability(security.CapOpportunityManage)
r.With(manage).Get("/opportunities/tracked/{id}/partners", h.listPartners)
r.With(manage).Post("/opportunities/tracked/{id}/partners", h.addPartner)
r.With(manage).Patch("/teaming-partners/{partnerID}", h.updatePartner)
r.With(manage).Get("/opportunities/tracked/{id}/key-personnel", h.listCommitments)
r.With(manage).Get("/requirements/{reqID}/key-personnel", h.listCommitmentsForRequirement)
r.With(manage).Post("/key-personnel", h.addCommitment)
r.With(manage).Patch("/key-personnel/{commitmentID}", h.updateCommitment)
// THE CAPTURE GATE. The same capability again: deciding whether to bid is the same act as deciding what
// to bid on and who to bid it with, and splitting the permission would mean a capture lead who can add a
// teaming partner cannot record the decision that partner was chosen for.
r.With(manage).Get("/capture/review", h.captureReview)
r.With(manage).Get("/opportunities/tracked/{id}/gate", h.listGateDecisions)
r.With(manage).Post("/opportunities/tracked/{id}/gate", h.recordGateDecision)
}
func (h *Handler) listPartners(w http.ResponseWriter, r *http.Request) {
id, ok := teamingID(w, r, "id")
if !ok {
return
}
out, err := h.svc.ListPartners(r.Context(), id)
if err != nil {
writeTeamingErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"partners": out, "count": len(out)})
}
func (h *Handler) addPartner(w http.ResponseWriter, r *http.Request) {
id, ok := teamingID(w, r, "id")
if !ok {
return
}
var in PartnerInput
if !decodeTeaming(w, r, &in) {
return
}
newID, err := h.svc.AddPartner(r.Context(), id, in)
if err != nil {
writeTeamingErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": newID})
}
func (h *Handler) updatePartner(w http.ResponseWriter, r *http.Request) {
id, ok := teamingID(w, r, "partnerID")
if !ok {
return
}
var in PartnerInput
if !decodeTeaming(w, r, &in) {
return
}
if err := h.svc.UpdatePartner(r.Context(), id, in); err != nil {
writeTeamingErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (h *Handler) listCommitments(w http.ResponseWriter, r *http.Request) {
id, ok := teamingID(w, r, "id")
if !ok {
return
}
out, err := h.svc.ListCommitments(r.Context(), id)
if err != nil {
writeTeamingErr(w, err)
return
}
// The lapsed count is reported alongside, because it is the number a bid review opens this screen to
// find and counting it in the browser would mean every screen counted it slightly differently.
lapsed := 0
for _, c := range out {
if c.Lapsed {
lapsed++
}
}
writeJSON(w, http.StatusOK, map[string]any{
"commitments": out, "count": len(out), "lapsed": lapsed,
})
}
func (h *Handler) listCommitmentsForRequirement(w http.ResponseWriter, r *http.Request) {
id, ok := teamingID(w, r, "reqID")
if !ok {
return
}
out, err := h.svc.ListCommitmentsForRequirement(r.Context(), id)
if err != nil {
writeTeamingErr(w, err)
return
}
lapsed := 0
for _, c := range out {
if c.Lapsed {
lapsed++
}
}
writeJSON(w, http.StatusOK, map[string]any{"commitments": out, "count": len(out), "lapsed": lapsed})
}
func (h *Handler) addCommitment(w http.ResponseWriter, r *http.Request) {
var in CommitmentInput
if !decodeTeaming(w, r, &in) {
return
}
newID, err := h.svc.AddCommitment(r.Context(), in)
if err != nil {
writeTeamingErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": newID})
}
func (h *Handler) updateCommitment(w http.ResponseWriter, r *http.Request) {
id, ok := teamingID(w, r, "commitmentID")
if !ok {
return
}
var in CommitmentInput
if !decodeTeaming(w, r, &in) {
return
}
if err := h.svc.UpdateCommitment(r.Context(), id, in); err != nil {
writeTeamingErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func teamingID(w http.ResponseWriter, r *http.Request, param string) (uuid.UUID, bool) {
id, err := uuid.Parse(chi.URLParam(r, param))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "that is not an identifier"})
return uuid.Nil, false
}
return id, true
}
func decodeTeaming(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(v); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"})
return false
}
return true
}
// writeTeamingErr maps the module's sentinels onto statuses, and carries the SENTENCE rather than a generic
// message. Every one of these is a rule the database encodes, and a capture manager who trips one can fix it
// in seconds if told which.
func writeTeamingErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrPartnerExists):
writeJSON(w, http.StatusConflict, map[string]string{"error": ErrPartnerExists.Error()})
case errors.Is(err, ErrRequirementMissing):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": ErrRequirementMissing.Error()})
case errors.Is(err, ErrCommitmentNeedsPerson):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": ErrCommitmentNeedsPerson.Error()})
case errors.Is(err, ErrSignedNeedsDate):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": ErrSignedNeedsDate.Error()})
case errors.Is(err, ErrGateUnknown), errors.Is(err, ErrDecisionUnknown), errors.Is(err, ErrRationaleRequired),
errors.Is(err, ErrRationaleTooLong), errors.Is(err, ErrBandUnknown), errors.Is(err, ErrWinProbRange):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
case errors.Is(err, ErrGateUnknown), errors.Is(err, ErrDecisionUnknown), errors.Is(err, ErrRationaleRequired),
errors.Is(err, ErrRationaleTooLong), errors.Is(err, ErrBandUnknown), errors.Is(err, ErrWinProbRange):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
case errors.Is(err, ErrInvalidInput):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
case errors.Is(err, ErrNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, ErrNoDatabase):
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "unavailable on this node"})
default:
// The cause is LOGGED and a generic message is 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, which is exactly
// what the missing staging grant cost before this line existed.
log.Printf("opportunities: unmapped teaming error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}