feat: add inbox capture workflow
This commit is contained in:
79
backend/internal/inbox/handlers.go
Normal file
79
backend/internal/inbox/handlers.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package inbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Register(router gin.IRouter) {
|
||||||
|
router.POST("/projects/:projectID/inbox", h.capture)
|
||||||
|
router.POST("/inbox/:id/analyze", h.analyze)
|
||||||
|
router.POST("/inbox/:id/confirm", h.confirm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) capture(c *gin.Context) {
|
||||||
|
projectID, err := strconv.ParseUint(c.Param("projectID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: 1, SourceType: input.SourceType, Title: input.Title, Body: input.Body})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) analyze(c *gin.Context) {
|
||||||
|
itemID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suggestions, err := h.service.Analyze(uint(itemID), 1)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"suggestions": suggestions})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) confirm(c *gin.Context) {
|
||||||
|
itemID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Suggestions []Suggestion `json:"suggestions"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Confirm(uint(itemID), input.Suggestions); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
100
backend/internal/inbox/service.go
Normal file
100
backend/internal/inbox/service.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package inbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaptureInput struct {
|
||||||
|
ProjectID uint
|
||||||
|
UserID uint
|
||||||
|
SourceType string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Suggestion struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Analyzer interface {
|
||||||
|
Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StaticAnalyzer struct {
|
||||||
|
Suggestions []Suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a StaticAnalyzer) Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error) {
|
||||||
|
return a.Suggestions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
analyzer Analyzer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(database *gorm.DB, analyzer Analyzer) *Service {
|
||||||
|
return &Service{db: database, analyzer: analyzer}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Capture(input CaptureInput) (*domain.InboxItem, error) {
|
||||||
|
if input.ProjectID == 0 || input.UserID == 0 {
|
||||||
|
return nil, errors.New("project and user are required")
|
||||||
|
}
|
||||||
|
if input.SourceType == "" {
|
||||||
|
return nil, errors.New("source type is required")
|
||||||
|
}
|
||||||
|
item := &domain.InboxItem{
|
||||||
|
ProjectID: input.ProjectID,
|
||||||
|
CreatedBy: input.UserID,
|
||||||
|
SourceType: input.SourceType,
|
||||||
|
Title: input.Title,
|
||||||
|
Body: input.Body,
|
||||||
|
Status: "open",
|
||||||
|
}
|
||||||
|
return item, s.db.Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) {
|
||||||
|
var item domain.InboxItem
|
||||||
|
if err := s.db.First(&item, itemID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.analyzer == nil {
|
||||||
|
return []Suggestion{}, nil
|
||||||
|
}
|
||||||
|
return s.analyzer.Analyze(item, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var item domain.InboxItem
|
||||||
|
if err := tx.First(&item, itemID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, suggestion := range selected {
|
||||||
|
switch suggestion.Kind {
|
||||||
|
case "task":
|
||||||
|
if err := tx.Create(&domain.Task{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "note":
|
||||||
|
if err := tx.Create(&domain.Note{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "source":
|
||||||
|
if err := tx.Create(&domain.Source{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("unsupported suggestion kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Model(&item).Update("status", "processed").Error
|
||||||
|
})
|
||||||
|
}
|
||||||
58
backend/internal/inbox/service_test.go
Normal file
58
backend/internal/inbox/service_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package inbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
service := NewService(database, StaticAnalyzer{
|
||||||
|
Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}},
|
||||||
|
})
|
||||||
|
item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
suggestions, err := service.Analyze(item.ID, 1)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, suggestions, 1)
|
||||||
|
var count int64
|
||||||
|
require.NoError(t, database.Model(&domain.Task{}).Count(&count).Error)
|
||||||
|
require.Equal(t, int64(0), count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
service := NewService(database, StaticAnalyzer{
|
||||||
|
Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}},
|
||||||
|
})
|
||||||
|
item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
suggestions, err := service.Analyze(item.ID, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = service.Confirm(item.ID, suggestions)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
var tasks []domain.Task
|
||||||
|
require.NoError(t, database.Find(&tasks).Error)
|
||||||
|
require.Len(t, tasks, 1)
|
||||||
|
require.Equal(t, "跟进报价", tasks[0].Title)
|
||||||
|
var reloaded domain.InboxItem
|
||||||
|
require.NoError(t, database.First(&reloaded, item.ID).Error)
|
||||||
|
require.Equal(t, "processed", reloaded.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, domain.AutoMigrate(database))
|
||||||
|
return database
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user