Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/opportunities/capture_handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/opportunities/capture_handler.go |
| Original SHA-256 | 77084af73895e898eaa7e980e313c7bb153de35fd987087babc5579d90a03e9b |
| Git blob SHA | 013f2d49671035d6d9eedb127f9b23ec7329a723 |
| Reading-copy SHA-256 | 77084af73895e898eaa7e980e313c7bb153de35fd987087babc5579d90a03e9b |
Recovered source transcript
Download complete source text ↓268 lines · 10.9 KB
package opportunities
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// CaptureRoutes mounts the tenant tracked opportunity capture surface. cmd/api mounts it under the
// slateMATCH tool entitlement, and every route additionally requires opportunity.manage. The paths sit
// under /opportunities/tracked because the public cache read already owns /opportunities/{noticeID}, and
// chi cannot host two different wildcard names at one path position.
func (h *Handler) CaptureRoutes(r chi.Router) {
manage := security.RequireCapability(security.CapOpportunityManage)
r.With(manage).Post("/opportunities/tracked", h.createTracked)
r.With(manage).Get("/opportunities/tracked", h.listTracked)
r.With(manage).Get("/opportunities/tracked/{id}", h.getTracked)
r.With(manage).Patch("/opportunities/tracked/{id}", h.patchTracked)
// The scope review queue. Reviewing is recruiter and above; it shapes what a requirement will say but
// cannot make one live.
review := security.RequireCapability(security.CapScopeReview)
r.With(review).Get("/opportunities/tracked/{id}/scope", h.listScope)
r.With(review).Post("/opportunities/tracked/{id}/scope/items", h.addManualScope)
r.With(review).Patch("/opportunities/tracked/{id}/scope/items/{item_id}", h.reviewScopeItem)
// SPLIT AND MERGE, the two review actions Phase 2 item 4 names that had no operations. They are POSTs
// rather than PATCHes because each one writes SEVERAL rows in one transaction and returns a different
// shape from the single item PATCH: a split returns its children, a merge returns the survivor.
r.With(review).Post("/opportunities/tracked/{id}/scope/items/{item_id}/split", h.splitScopeItem)
r.With(review).Post("/opportunities/tracked/{id}/scope/items/{item_id}/merge", h.mergeScopeItems)
r.With(review).Get("/opportunities/tracked/{id}/requirement-drafts", h.listDrafts)
// The amendment view, the read side of Section 4 item 11 and the screen Gate C names.
r.With(review).Get("/opportunities/tracked/{id}/amendments", h.amendmentView)
// GENERATION AND MANUAL COMPLETION, Phase 2 item 5. Both sit at scope.review, one tier BELOW the promotion
// gate, and that placement is the point: a reviewer may shape a draft all day and still cannot make it
// live. Only requirement.approve crosses into what the scoring engine can see.
r.With(review).Post("/opportunities/tracked/{id}/requirement-drafts/generate", h.generateDrafts)
r.With(review).Patch("/requirement-drafts/{id}", h.patchDraft)
// THE PROMOTION GATE. These two routes are the only way anything a machine derived becomes visible to
// the scoring engine, so they are held at requirement.approve, one tier above review. The paths are the
// directive's own, which collide with nothing.
approve := security.RequireCapability(security.CapRequirementApprove)
r.With(approve).Post("/requirement-drafts/{id}/approve", h.approveDraft)
r.With(approve).Post("/requirement-drafts/{id}/reject", h.rejectDraft)
}
func (h *Handler) listScope(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.ListScope(r.Context(), chi.URLParam(r, "id"), strings.TrimSpace(r.URL.Query().Get("status")))
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *Handler) addManualScope(w http.ResponseWriter, r *http.Request) {
var in ManualScopeInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := h.svc.AddManualScope(r.Context(), chi.URLParam(r, "id"), in)
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
func (h *Handler) reviewScopeItem(w http.ResponseWriter, r *http.Request) {
var in ScopeReview
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := h.svc.ReviewScopeItem(r.Context(), chi.URLParam(r, "item_id"), in)
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// amendmentView returns the version history, the amendment alerts, and the approved requirements whose
// lineage runs back through this opportunity, which is the only part of the picture with money attached.
func (h *Handler) amendmentView(w http.ResponseWriter, r *http.Request) {
out, err := h.svc.AmendmentViewFor(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// generateDrafts derives one requirement draft per accepted staffing scope item that lacks one. It is
// idempotent, so a reviewer can press it after every batch of acceptances without producing duplicates.
func (h *Handler) generateDrafts(w http.ResponseWriter, r *http.Request) {
created, skipped, err := h.svc.GenerateDrafts(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"created": created, "skipped_existing": skipped})
}
// patchDraft is a reviewer completing a draft by hand, which is what screen 5 exists for when the document
// simply does not state a required field.
func (h *Handler) patchDraft(w http.ResponseWriter, r *http.Request) {
var in DraftPatch
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := h.svc.PatchDraft(r.Context(), chi.URLParam(r, "id"), in)
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// splitScopeItem divides one reviewed item into several, each inheriting the parent's citation.
func (h *Handler) splitScopeItem(w http.ResponseWriter, r *http.Request) {
var in struct {
Parts []SplitPart `json:"parts"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := h.svc.SplitScopeItem(r.Context(), chi.URLParam(r, "item_id"), in.Parts)
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"children": out})
}
// mergeScopeItems folds other items into this one, which survives and keeps its own citation.
func (h *Handler) mergeScopeItems(w http.ResponseWriter, r *http.Request) {
var in struct {
AbsorbIDs []string `json:"absorb_item_ids"`
Title string `json:"title"`
Description string `json:"description"`
ScopeType string `json:"scope_type"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := h.svc.MergeScopeItems(r.Context(), chi.URLParam(r, "item_id"), in.AbsorbIDs,
in.Title, in.Description, in.ScopeType)
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) listDrafts(w http.ResponseWriter, r *http.Request) {
out, err := h.svc.ListDrafts(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// approveDraft is the AEDT decision endpoint. The service takes FOR UPDATE on the draft, so racing calls
// elect one winner and the losers receive the winner's requirement with already_promoted set rather than a
// duplicate. The approver's identity comes from the validated session, never from the request body.
func (h *Handler) approveDraft(w http.ResponseWriter, r *http.Request) {
out, err := h.svc.ApproveDraft(r.Context(), chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, ErrDraftRejected) {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) rejectDraft(w http.ResponseWriter, r *http.Request) {
if err := h.svc.RejectDraft(r.Context(), chi.URLParam(r, "id")); err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
}
func (h *Handler) createTracked(w http.ResponseWriter, r *http.Request) {
var in TrackedOppInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := h.svc.CreateTracked(r.Context(), in)
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
func (h *Handler) listTracked(w http.ResponseWriter, r *http.Request) {
out, err := h.svc.ListTracked(r.Context(), strings.TrimSpace(r.URL.Query().Get("status")), intParam(r, "limit", defaultLimit))
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) getTracked(w http.ResponseWriter, r *http.Request) {
out, err := h.svc.GetTracked(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (h *Handler) patchTracked(w http.ResponseWriter, r *http.Request) {
var p TrackedOppPatch
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
out, err := h.svc.PatchTracked(r.Context(), chi.URLParam(r, "id"), p)
if err != nil {
writeCaptureErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// writeCaptureErr maps the capture path errors: a bad field is 400, a missing or cross org row is 404 (RLS
// hides another tenant's row so it reads as not found), a missing pool is 503, a missing org context is 401,
// and any integrity constraint violation (a bad reference such as an unknown capture owner) is a 400.
func writeCaptureErr(w http.ResponseWriter, err error) {
var pgErr *pgconn.PgError
switch {
case errors.Is(err, ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, pgx.ErrNoRows):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "opportunity not found"})
case errors.Is(err, ErrNoDatabase):
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not configured"})
case errors.Is(err, tenant.ErrNoTenant):
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no organization context"})
case errors.As(err, &pgErr) && strings.HasPrefix(pgErr.Code, "23"):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reference or value"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}