From 18b2f30024ba4e7c70ecec99558f598bedf7a007 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 18 Jul 2026 15:57:44 +0800 Subject: [PATCH] feat: add project workspace service --- backend/internal/projects/handlers.go | 62 +++++++++++++++++++ backend/internal/projects/service.go | 72 +++++++++++++++++++++++ backend/internal/projects/service_test.go | 57 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 backend/internal/projects/handlers.go create mode 100644 backend/internal/projects/service.go create mode 100644 backend/internal/projects/service_test.go diff --git a/backend/internal/projects/handlers.go b/backend/internal/projects/handlers.go new file mode 100644 index 0000000..9ff7ae6 --- /dev/null +++ b/backend/internal/projects/handlers.go @@ -0,0 +1,62 @@ +package projects + +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", h.createProject) + router.GET("/projects", h.listProjects) + router.GET("/projects/:id/dashboard", h.dashboard) +} + +func (h *Handler) createProject(c *gin.Context) { + var input struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + project, err := h.service.CreateProject(1, input.Name, input.Description) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, project) +} + +func (h *Handler) listProjects(c *gin.Context) { + projects, err := h.service.ListProjects(1) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) + return + } + c.JSON(http.StatusOK, projects) +} + +func (h *Handler) dashboard(c *gin.Context) { + projectID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"}) + return + } + dashboard, err := h.service.Dashboard(uint(projectID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load dashboard"}) + return + } + c.JSON(http.StatusOK, dashboard) +} diff --git a/backend/internal/projects/service.go b/backend/internal/projects/service.go new file mode 100644 index 0000000..b50ba78 --- /dev/null +++ b/backend/internal/projects/service.go @@ -0,0 +1,72 @@ +package projects + +import ( + "errors" + "strings" + + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +type Service struct { + db *gorm.DB +} + +type Dashboard struct { + ProjectID uint `json:"project_id"` + PendingInboxCount int64 `json:"pending_inbox_count"` + OpenTaskCount int64 `json:"open_task_count"` + RecentNoteCount int64 `json:"recent_note_count"` + RecentSessionCount int64 `json:"recent_session_count"` +} + +func NewService(database *gorm.DB) *Service { + return &Service{db: database} +} + +func (s *Service) CreateProject(ownerID uint, name string, description string) (*domain.Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("project name is required") + } + project := &domain.Project{OwnerID: ownerID, Name: name, Description: description} + return project, s.db.Create(project).Error +} + +func (s *Service) ListProjects(ownerID uint) ([]domain.Project, error) { + var projects []domain.Project + err := s.db.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error + return projects, err +} + +func (s *Service) CreateTag(projectID uint, name string) (*domain.Tag, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("tag name is required") + } + tag := &domain.Tag{ProjectID: projectID, Name: name} + return tag, s.db.Create(tag).Error +} + +func (s *Service) ListTags(projectID uint) ([]domain.Tag, error) { + var tags []domain.Tag + err := s.db.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error + return tags, err +} + +func (s *Service) Dashboard(projectID uint) (Dashboard, error) { + dashboard := Dashboard{ProjectID: projectID} + if err := s.db.Model(&domain.InboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil { + return dashboard, err + } + if err := s.db.Model(&domain.Task{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil { + return dashboard, err + } + if err := s.db.Model(&domain.Note{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil { + return dashboard, err + } + if err := s.db.Model(&domain.AISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil { + return dashboard, err + } + return dashboard, nil +} diff --git a/backend/internal/projects/service_test.go b/backend/internal/projects/service_test.go new file mode 100644 index 0000000..3c16fd0 --- /dev/null +++ b/backend/internal/projects/service_test.go @@ -0,0 +1,57 @@ +package projects + +import ( + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "senlinai-agent/backend/internal/domain" +) + +func TestProjectTagsAreScopedToProject(t *testing.T) { + database := newTestDB(t) + service := NewService(database) + first, err := service.CreateProject(1, "Alpha", "") + require.NoError(t, err) + second, err := service.CreateProject(1, "Beta", "") + require.NoError(t, err) + + _, err = service.CreateTag(first.ID, "重要") + require.NoError(t, err) + _, err = service.CreateTag(second.ID, "重要") + require.NoError(t, err) + + firstTags, err := service.ListTags(first.ID) + require.NoError(t, err) + require.Len(t, firstTags, 1) + require.Equal(t, first.ID, firstTags[0].ProjectID) +} + +func TestDashboardCountsOnlyRequestedProject(t *testing.T) { + database := newTestDB(t) + service := NewService(database) + first, err := service.CreateProject(1, "Alpha", "") + require.NoError(t, err) + second, err := service.CreateProject(1, "Beta", "") + require.NoError(t, err) + require.NoError(t, database.Create(&domain.InboxItem{ProjectID: first.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error) + require.NoError(t, database.Create(&domain.InboxItem{ProjectID: second.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error) + require.NoError(t, database.Create(&domain.Task{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error) + require.NoError(t, database.Create(&domain.Task{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error) + + dashboard, err := service.Dashboard(first.ID) + + require.NoError(t, err) + require.Equal(t, int64(1), dashboard.PendingInboxCount) + require.Equal(t, int64(1), dashboard.OpenTaskCount) +} + +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 +}