package alerts import ( "encoding/json" "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler serves the proactive alert UI surface: list the org's alerts (GET) and flip an alert's status // (PATCH). Both routes are always mounted (this is a live owner facing feature, not flag gated): the watch // loop surfaces actionable events the moment the sweep detects them, and the owner reads and clears them. The // caller and the validated org come from the session resolver and RBAC middleware the tenant group already // applies, never a client supplied header or body field. type Handler struct{ svc *Service } // NewHandler builds the handler. func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes mounts the alert endpoints under the tenant scoped /api group. Listing alerts is a read over the // org's own data, gated on search.run (the read tier every role holds, the same tier as candidate.read and // the brain query). Flipping an alert's status is a lightweight workflow write, gated on slate.write (the // recruiter and up tier that edits a slate and records a bid outcome), so a viewer can see the alerts but // only a working role clears them. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapSearchRun)).Get("/alerts", h.list) r.With(security.RequireCapability(security.CapSlateEdit)).Patch("/alerts/{id}", h.setStatus) } func (h *Handler) list(w http.ResponseWriter, r *http.Request) { // The optional status filter is a query parameter; an empty value lists every status. A non empty value // outside the closed set is a clean 422 from the service. status := r.URL.Query().Get("status") out, err := h.svc.ListAlerts(r.Context(), status) if err != nil { writeErr(w, err) return } if out == nil { out = []Alert{} } writeJSON(w, http.StatusOK, out) } func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } var in struct { Status string `json:"status"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } out, err := h.svc.SetAlertStatus(r.Context(), id, in.Status) if err != nil { writeErr(w, err) return } writeJSON(w, http.StatusOK, out) } // writeErr maps a service error to a status. A bad status is a 422, a missing alert is a 404, an unscoped // request is a 403, and an unconfigured database is a 503; everything else is a 500 with no internal detail // leaked. func writeErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrInvalidStatus): writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "status must be one of unread, read, dismissed"}) case errors.Is(err, ErrNotFound): writeJSON(w, http.StatusNotFound, map[string]string{"error": "alert not found"}) case errors.Is(err, tenant.ErrNoTenant): writeJSON(w, http.StatusForbidden, map[string]string{"error": "no validated tenant"}) case errors.Is(err, ErrNoDatabase): writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database not configured"}) default: writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }