package incidentapi import ( "encoding/json" "errors" "io" "net/http" "strconv" "strings" "github.com/itworx/pulse/internal/audit" "github.com/itworx/pulse/internal/auth" "github.com/itworx/pulse/internal/correlation" "github.com/itworx/pulse/internal/incident" "github.com/itworx/pulse/internal/problem" ) type Handler struct { Store incident.Store Audit audit.Store } func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { principal, ok := auth.PrincipalFromContext(r.Context()) if !ok { fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.") return } path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/incidents"), "/") if path == "" && r.Method == http.MethodGet { h.list(w, r) return } parts := strings.Split(path, "/") if len(parts) == 1 && parts[0] != "" && r.Method == http.MethodPatch { if !auth.Allows(principal.Role, auth.PermissionOperate) { fail(w, r, http.StatusForbidden, "FORBIDDEN", "Incident ownership is not allowed for this role.") return } h.updateOwner(w, r, parts[0], principal.Subject) return } if len(parts) == 2 && parts[1] == "notes" && (r.Method == http.MethodGet || r.Method == http.MethodPost) { if r.Method == http.MethodPost && !auth.Allows(principal.Role, auth.PermissionOperate) { fail(w, r, http.StatusForbidden, "FORBIDDEN", "Incident notes are not allowed for this role.") return } if r.Method == http.MethodGet { h.listNotes(w, r, parts[0]) } else { h.addNote(w, r, parts[0], principal.Subject) } return } if len(parts) == 1 && parts[0] != "" && r.Method == http.MethodGet { h.get(w, r, parts[0]) return } if len(parts) == 3 && parts[1] == "alerts" && parts[2] != "" && (r.Method == http.MethodPost || r.Method == http.MethodDelete) { if !auth.Allows(principal.Role, auth.PermissionOperate) { fail(w, r, http.StatusForbidden, "FORBIDDEN", "Incident association is not allowed for this role.") return } if r.Method == http.MethodPost { h.associate(w, r, parts[0], parts[2], principal.Subject) return } h.disassociate(w, r, parts[0], parts[2], principal.Subject) return } fail(w, r, http.StatusNotFound, "NOT_FOUND", "Incident route not found.") } func (h Handler) list(w http.ResponseWriter, r *http.Request) { limit := 100 if value := r.URL.Query().Get("limit"); value != "" { parsed, err := strconv.Atoi(value) if err != nil || parsed < 1 || parsed > 100 { fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The incident limit must be between 1 and 100.") return } limit = parsed } status := incident.Status(r.URL.Query().Get("status")) items, err := h.Store.List(r.Context(), limit, status) if err != nil { h.repositoryFailure(w, r, err) return } write(w, http.StatusOK, map[string]any{"items": items}) } func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) { item, err := h.Store.Get(r.Context(), id) if err != nil { h.repositoryFailure(w, r, err) return } notes, notesErr := h.Store.ListNotes(r.Context(), id, 100) if notesErr != nil { h.repositoryFailure(w, r, notesErr) return } item.Notes = notes write(w, http.StatusOK, map[string]any{"incident": item}) } func (h Handler) associate(w http.ResponseWriter, r *http.Request, incidentID, alertID, actor string) { var request struct { Rationale string `json:"rationale"` Confidence float64 `json:"confidence"` } if err := decode(r, &request); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_ASSOCIATION", "The incident association document is invalid.") return } if request.Confidence == 0 { request.Confidence = 1 } item, duplicate, err := h.Store.AssociateAlert(r.Context(), incidentID, alertID, strings.TrimSpace(request.Rationale), request.Confidence, actor) if err != nil { h.repositoryFailure(w, r, err) return } if h.Audit != nil { result := "success" if duplicate { result = "idempotent" } if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.alert.associate", ResourceType: "incident", ResourceID: incidentID, Result: result, CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"alertId": alertID, "rationale": request.Rationale, "confidence": request.Confidence, "manual": true, "duplicate": duplicate}}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } } write(w, http.StatusOK, map[string]any{"association": item, "duplicate": duplicate}) } func (h Handler) disassociate(w http.ResponseWriter, r *http.Request, incidentID, alertID, actor string) { if err := h.Store.DisassociateAlert(r.Context(), incidentID, alertID); err != nil { h.repositoryFailure(w, r, err) return } if h.Audit != nil { if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.alert.disassociate", ResourceType: "incident", ResourceID: incidentID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"alertId": alertID, "manual": true}}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } } w.WriteHeader(http.StatusNoContent) } func (h Handler) updateOwner(w http.ResponseWriter, r *http.Request, id, actor string) { var request struct { OwnerUserID string `json:"ownerUserId"` Revision int64 `json:"revision"` } if err := decode(r, &request); err != nil || request.Revision < 1 { fail(w, r, http.StatusBadRequest, "INVALID_INCIDENT", "A valid owner and revision document is required.") return } item, err := h.Store.UpdateOwner(r.Context(), id, strings.TrimSpace(request.OwnerUserID), request.Revision) if err != nil { h.repositoryFailure(w, r, err) return } if h.Audit != nil { if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.owner.update", ResourceType: "incident", ResourceID: id, Result: "success", CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"ownerUserId": request.OwnerUserID, "revision": item.Revision}}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } } write(w, http.StatusOK, map[string]any{"incident": item}) } func (h Handler) listNotes(w http.ResponseWriter, r *http.Request, id string) { items, err := h.Store.ListNotes(r.Context(), id, 100) if err != nil { h.repositoryFailure(w, r, err) return } write(w, http.StatusOK, map[string]any{"items": items}) } func (h Handler) addNote(w http.ResponseWriter, r *http.Request, id, actor string) { var request struct { Body string `json:"body"` } if err := decode(r, &request); err != nil { fail(w, r, http.StatusBadRequest, "INVALID_NOTE", "The incident note is invalid.") return } note, err := h.Store.AddNote(r.Context(), id, actor, request.Body) if err != nil { h.repositoryFailure(w, r, err) return } if h.Audit != nil { if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: "incident.note.create", ResourceType: "incident", ResourceID: id, Result: "success", CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"noteId": note.ID}}); err != nil { fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.") return } } write(w, http.StatusCreated, map[string]any{"note": note}) } func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, incident.ErrInvalid): fail(w, r, http.StatusBadRequest, "INVALID_INCIDENT", "The incident request is invalid.") case errors.Is(err, incident.ErrNotFound): fail(w, r, http.StatusNotFound, "NOT_FOUND", "The incident was not found.") case errors.Is(err, incident.ErrConflict): fail(w, r, http.StatusConflict, "CONFLICT", "The incident changed before this operation was applied.") case errors.Is(err, incident.ErrUnavailable): fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Incidents are unavailable.") default: fail(w, r, http.StatusInternalServerError, "INCIDENT_REQUEST_FAILED", "The incident request failed.") } } func decode(r *http.Request, target any) error { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20+1)) if err != nil { return err } defer r.Body.Close() if len(body) > 1<<20 { return errors.New("request too large") } decoder := json.NewDecoder(strings.NewReader(string(body))) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } var extra any if err := decoder.Decode(&extra); err != io.EOF { return errors.New("multiple JSON values") } return nil } func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) { problem.Write(w, r, status, code, http.StatusText(status), detail, nil) } func write(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) }