mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-25 16:01:57 +00:00
177 lines
5.4 KiB
Go
177 lines
5.4 KiB
Go
// Inbox agent draft review (M10). The inbox agent drafts a suggested reply on an
|
|
// inbound human reply and persists it here awaiting a human decision. These
|
|
// endpoints let a person list pending drafts, approve-and-send one (through the
|
|
// normal reply path), or discard it. The agent never sends: approving is the
|
|
// only path that transmits.
|
|
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/emailsend"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// ListAgentDrafts — GET /unibox/agent-drafts
|
|
// Returns the org's pending inbox-agent drafts, newest first.
|
|
func (h *Handler) ListAgentDrafts(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if !h.gateUnibox(c) {
|
|
return
|
|
}
|
|
if h.AIDraftRepo == nil {
|
|
c.JSON(http.StatusOK, gin.H{"data": []models.AIThreadDraft{}})
|
|
return
|
|
}
|
|
drafts, err := h.AIDraftRepo.ListPendingDrafts(c.Request.Context(), *orgID, 100)
|
|
if err != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": drafts})
|
|
}
|
|
|
|
// ApproveAgentDraft — POST /unibox/agent-drafts/:id/approve
|
|
// Sends the draft (optionally with an edited body) through the normal reply path
|
|
// and marks it approved. The status flip is claimed BEFORE sending so two
|
|
// approvals can never double-send.
|
|
func (h *Handler) ApproveAgentDraft(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
userID, uerr := middleware.GetUserUUID(c)
|
|
if uerr != nil {
|
|
errx.Handle(c, errx.ErrUser)
|
|
return
|
|
}
|
|
if !h.gateUnibox(c) {
|
|
return
|
|
}
|
|
draftID, perr := uuid.Parse(c.Param("id"))
|
|
if perr != nil {
|
|
errx.Handle(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
if h.AIDraftRepo == nil {
|
|
errx.Handle(c, errx.New(errx.ServiceUnavailable, "the inbox agent is not configured"))
|
|
return
|
|
}
|
|
|
|
// Optional edited body (the Edit-then-send path). Empty keeps the draft body.
|
|
var req struct {
|
|
Body string `json:"body"`
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
draft, derr := h.AIDraftRepo.GetDraft(c.Request.Context(), *orgID, draftID)
|
|
if derr != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
if draft == nil {
|
|
errx.Handle(c, errx.New(errx.NotFound, "draft not found"))
|
|
return
|
|
}
|
|
if draft.Status != models.AIDraftPending {
|
|
errx.Handle(c, errx.New(errx.Conflict, "this draft was already handled"))
|
|
return
|
|
}
|
|
|
|
// Claim it: only the first approver flips pending -> approved and proceeds.
|
|
claimed, cerr := h.AIDraftRepo.SetDraftStatus(c.Request.Context(), *orgID, draftID, models.AIDraftApproved)
|
|
if cerr != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
if !claimed {
|
|
errx.Handle(c, errx.New(errx.Conflict, "this draft was already handled"))
|
|
return
|
|
}
|
|
|
|
body := strings.TrimSpace(req.Body)
|
|
if body == "" {
|
|
body = draft.Body
|
|
}
|
|
sendReq := &emailsend.SendEmailRequest{
|
|
To: []string{draft.ToAddr},
|
|
Subject: draft.Subject,
|
|
BodyPlain: body,
|
|
ThreadID: draft.ThreadID,
|
|
SendMode: "instant",
|
|
}
|
|
if draft.InReplyTo != "" {
|
|
sendReq.InReplyTo = []string{draft.InReplyTo}
|
|
}
|
|
|
|
resp, xerr := h.EmailSendService.SendEmail(c.Request.Context(), userID, *orgID, draft.EmailAccountID, sendReq)
|
|
if xerr != nil {
|
|
// Send failed after we claimed the draft (pending -> approved). Return it
|
|
// to pending so the human can retry rather than silently losing it. The
|
|
// draft is now in the 'approved' state, so this needs the approved->pending
|
|
// revert (SetDraftStatus only transitions FROM pending). If the revert
|
|
// itself fails, the draft is stuck 'approved' and invisible to review — log
|
|
// it so that is observable rather than a silent loss.
|
|
if reverted, rerr := h.AIDraftRepo.RevertApprovedToPending(c.Request.Context(), *orgID, draftID); rerr != nil || !reverted {
|
|
log.Error().Err(rerr).Str("draft_id", draftID.String()).Bool("reverted", reverted).Msg("inbox agent: failed to revert draft to pending after send failure")
|
|
}
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionSend, models.AuditEntityUnibox, &draft.EmailAccountID, nil, map[string]string{
|
|
"source": "inbox_agent",
|
|
"thread_id": draft.ThreadID,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// DiscardAgentDraft — POST /unibox/agent-drafts/:id/discard
|
|
// Dismisses a pending draft without sending.
|
|
func (h *Handler) DiscardAgentDraft(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if !h.gateUnibox(c) {
|
|
return
|
|
}
|
|
draftID, perr := uuid.Parse(c.Param("id"))
|
|
if perr != nil {
|
|
errx.Handle(c, errx.ErrUuid)
|
|
return
|
|
}
|
|
if h.AIDraftRepo == nil {
|
|
errx.Handle(c, errx.New(errx.ServiceUnavailable, "the inbox agent is not configured"))
|
|
return
|
|
}
|
|
ok, err := h.AIDraftRepo.SetDraftStatus(c.Request.Context(), *orgID, draftID, models.AIDraftDiscarded)
|
|
if err != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
if !ok {
|
|
errx.Handle(c, errx.New(errx.NotFound, "draft not found or already handled"))
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityUnibox, nil, nil, map[string]string{
|
|
"source": "inbox_agent",
|
|
"action": "discard_draft",
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"status": "discarded"})
|
|
}
|