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"}) } }