// Advisor: read the org's open recommendations, apply a fix, or tell the // Advisor to stop suggesting something. // // Reads are gated on view_analytics (JWT) / READ_ANALYTICS (API key), because // a finding is a read of the org's sending posture. Applying a fix carries no // gate of its own: the fix runs through the AI tool registry, which enforces // the permission the underlying change actually requires. A member who can see // that a mailbox's cap is too high but cannot edit mailboxes gets a clear 403 // on apply rather than a hidden card. package handler import ( "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/app/advisor" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" ) // advisorReadMaxAge is how stale the org's findings may be before a read kicks // off a background refresh. Long enough that browsing the dashboard does not // trigger repeated evaluations, short enough that a fix you made this morning // is reflected by the time you look again. const advisorReadMaxAge = 30 * time.Minute // advisorOrg resolves the caller's org, or writes the error. func (h *Handler) advisorOrg(c *gin.Context) (uuid.UUID, bool) { if h.AdvisorService == nil { errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server")) return uuid.Nil, false } orgID := middleware.GetOrganizationID(c) if orgID == nil { errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) return uuid.Nil, false } return *orgID, true } // ListAdvisorFindings — GET /advisor/recommendations // // Filters: surface, category, entity_type + entity_id, status, limit. The // dashboard uses entity_type/entity_id for the inline strip on a campaign or // mailbox, and surface for a whole tab. func (h *Handler) ListAdvisorFindings(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } h.refreshAdvisor(c, orgID) filter := repository.AdvisorFindingFilter{ Surface: models.AdvisorSurface(c.Query("surface")), Category: models.AdvisorCategory(c.Query("category")), EntityType: c.Query("entity_type"), } if raw := c.Query("entity_id"); raw != "" { id, err := uuid.Parse(raw) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid entity_id")) return } filter.EntityID = &id } if raw := c.Query("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 1 || n > 200 { errx.JSON(c, errx.New(errx.BadRequest, "limit must be between 1 and 200")) return } filter.Limit = n } for _, s := range c.QueryArray("status") { status := models.AdvisorStatus(s) switch status { case models.AdvisorStatusOpen, models.AdvisorStatusSnoozed, models.AdvisorStatusDismissed, models.AdvisorStatusApplied, models.AdvisorStatusResolved: filter.Statuses = append(filter.Statuses, status) default: errx.JSON(c, errx.New(errx.BadRequest, "invalid status")) return } } list, xerr := h.AdvisorService.List(c.Request.Context(), orgID, filter) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"data": list}) } // GetAdvisorSummary — GET /advisor/summary // // The nav badges and the health score. Cheap enough to poll, but the client // invalidates it off the audit spine instead. func (h *Handler) GetAdvisorSummary(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } h.refreshAdvisor(c, orgID) summary, xerr := h.AdvisorService.Summary(c.Request.Context(), orgID) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, summary) } // RefreshAdvisor — POST /advisor/refresh // // Forces an evaluation now. Rate-limited by the write limiter on the group; // the loop keeps things current on its own, so this exists for the moment // after you have fixed something and want to see it clear. func (h *Handler) RefreshAdvisor(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } summary, err := h.AdvisorService.Evaluate(c.Request.Context(), orgID, "manual") if err != nil { errx.JSON(c, errx.InternalError()) return } c.JSON(http.StatusOK, summary) } // ApplyAdvisorFinding — POST /advisor/recommendations/:id/apply // // Applying twice is a no-op that returns the first outcome, so a retried // request is safe without an idempotency key. func (h *Handler) ApplyAdvisorFinding(c *gin.Context) { if h.AdvisorService == nil { errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server")) return } inv, xerr := h.jwtInvocation(c) if xerr != nil { errx.JSON(c, xerr) return } id, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid recommendation id")) return } f, xerr := h.AdvisorService.Apply(c.Request.Context(), inv, id) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, f) } // AgentFixAdvisorFinding — POST /advisor/recommendations/:id/agent-fix // // For the findings a settings change cannot resolve. Runs a bounded agent as // the calling member, inside a tool allowlist scoped to the finding's category, // and reports what it actually called rather than only what it says it did. func (h *Handler) AgentFixAdvisorFinding(c *gin.Context) { if h.AdvisorService == nil { errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server")) return } inv, xerr := h.jwtInvocation(c) if xerr != nil { errx.JSON(c, xerr) return } id, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid recommendation id")) return } res, xerr := h.AdvisorService.FixWithAgent(c.Request.Context(), inv, id) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, res) } // UndoAdvisorFinding — POST /advisor/recommendations/:id/undo func (h *Handler) UndoAdvisorFinding(c *gin.Context) { if h.AdvisorService == nil { errx.JSON(c, errx.New(errx.ServiceUnavailable, "the Advisor is not configured on this server")) return } inv, xerr := h.jwtInvocation(c) if xerr != nil { errx.JSON(c, xerr) return } id, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid recommendation id")) return } f, xerr := h.AdvisorService.Undo(c.Request.Context(), inv, id) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, f) } // SnoozeAdvisorFinding — POST /advisor/recommendations/:id/snooze func (h *Handler) SnoozeAdvisorFinding(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } userID, err := middleware.GetUserUUID(c) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } id, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid recommendation id")) return } var req models.AdvisorSnoozeRequest if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.ErrAdvisorSnoozeRange) return } if xerr := h.AdvisorService.Snooze(c.Request.Context(), orgID, userID, id, req.Days); xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // DismissAdvisorFinding — POST /advisor/recommendations/:id/dismiss // // A dismissal sticks until the underlying condition clears and later recurs, // so telling the Advisor "this is fine" does not have to be repeated weekly. func (h *Handler) DismissAdvisorFinding(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } userID, err := middleware.GetUserUUID(c) if err != nil { errx.JSON(c, errx.ErrUnauthorized) return } id, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid recommendation id")) return } var req models.AdvisorDismissRequest _ = c.ShouldBindJSON(&req) if xerr := h.AdvisorService.Dismiss(c.Request.Context(), orgID, userID, id, req.Reason); xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // SubmitAdvisorFeedback — POST /advisor/recommendations/:id/feedback func (h *Handler) SubmitAdvisorFeedback(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } userID, _ := middleware.GetUserUUID(c) id, err := uuid.Parse(c.Param("id")) if err != nil { errx.JSON(c, errx.New(errx.BadRequest, "invalid recommendation id")) return } var req struct { Helpful *bool `json:"helpful" binding:"required"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil || req.Helpful == nil { errx.JSON(c, errx.New(errx.BadRequest, "helpful is required")) return } if xerr := h.AdvisorService.Feedback(c.Request.Context(), orgID, userID, id, *req.Helpful, req.Reason); xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // GetAdvisorSettings — GET /advisor/settings func (h *Handler) GetAdvisorSettings(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } s, xerr := h.AdvisorService.GetSettings(c.Request.Context(), orgID) if xerr != nil { errx.JSON(c, xerr) return } c.JSON(http.StatusOK, s) } // UpdateAdvisorSettings — PATCH /advisor/settings func (h *Handler) UpdateAdvisorSettings(c *gin.Context) { orgID, ok := h.advisorOrg(c) if !ok { return } var req models.AdvisorSettings if err := c.ShouldBindJSON(&req); err != nil { errx.JSON(c, errx.ErrInvalid) return } // This route is JWT-only, so there is always a real member behind it to // attribute autopilot to. userID, err := middleware.GetUserUUID(c) if err != nil { errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) return } if xerr := h.AdvisorService.UpdateSettings(c.Request.Context(), orgID, userID, &req); xerr != nil { errx.JSON(c, xerr) return } h.auditOrg(c, models.AuditActionUpdate, models.AuditEntitySettings, nil, nil, map[string]string{"advisor": "settings"}) c.JSON(http.StatusOK, &req) } // refreshAdvisor kicks off a background re-evaluation when the org's findings // have gone stale. It does not block the response: the result arrives on every // open dashboard through the audit spine a moment later, the same way a // teammate's change does. func (h *Handler) refreshAdvisor(c *gin.Context, orgID uuid.UUID) { if h.AdvisorRepository == nil { return } advisor.RefreshIfStale(c.Request.Context(), h.AdvisorRepository, h.AdvisorService, orgID, advisorReadMaxAge) }