refactor(backend): isolate workspace aggregation
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package projects
|
||||
|
||||
import "senlinai-agent/backend/internal/models"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
// ProjectDTO 是项目 API 的稳定响应;ID 使用公开 identity,避免泄漏数据库自增主键。
|
||||
type ProjectDTO struct {
|
||||
@@ -30,6 +34,146 @@ type UpdateProjectRequest struct {
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
DueAt *time.Time
|
||||
Tag string
|
||||
}
|
||||
|
||||
type UpdateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
Completed bool
|
||||
NextProjectID uint
|
||||
Tag string
|
||||
}
|
||||
|
||||
type CreateFileSourceInput struct {
|
||||
Title string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type CreateCronPlanInput struct {
|
||||
Title string
|
||||
Schedule string
|
||||
Enabled bool
|
||||
NextRunAt *time.Time
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// WorkspaceDTO 是项目工作区首屏聚合响应,所有数据库对象均以公开 identity 关联。
|
||||
type WorkspaceDTO struct {
|
||||
Project WorkspaceProjectDTO `json:"project"`
|
||||
Channels []WorkspaceChannelDTO `json:"channels"`
|
||||
Tags []WorkspaceTagDTO `json:"tags"`
|
||||
RecentSessions []WorkspaceAISessionDTO `json:"recentSessions"`
|
||||
Inbox []WorkspaceInboxDTO `json:"inbox"`
|
||||
Tasks []WorkspaceTaskDTO `json:"tasks"`
|
||||
AISessions []WorkspaceAISessionDTO `json:"aiSessions"`
|
||||
NotesSources []WorkspaceNoteSourceDTO `json:"notesSources"`
|
||||
CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"`
|
||||
}
|
||||
|
||||
// WorkspaceProjectDTO 补充工作区导航所需的项目摘要和未读计数。
|
||||
type WorkspaceProjectDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Icon string `json:"icon"`
|
||||
Background string `json:"background"`
|
||||
Description string `json:"description"`
|
||||
Initials string `json:"initials"`
|
||||
UnreadCount int64 `json:"unreadCount"`
|
||||
}
|
||||
|
||||
// WorkspaceChannelDTO 描述系统频道或项目内自定义链接频道。
|
||||
type WorkspaceChannelDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
// WorkspaceTagDTO 是项目范围内的真实标签,不包含仅用于展示的虚拟标签。
|
||||
type WorkspaceTagDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WorkspaceInboxDTO 是工作区 Inbox 行;Time 保留 UTC 时间语义供前端格式化。
|
||||
type WorkspaceInboxDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Source string `json:"source"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Tag string `json:"tag"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
// WorkspaceTaskDTO 是工作区任务响应,标签和项目关联均使用 identity。
|
||||
type WorkspaceTaskDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Completed bool `json:"completed"`
|
||||
Owner string `json:"owner"`
|
||||
Due *time.Time `json:"due"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CompletedAt *time.Time `json:"completedAt"`
|
||||
TagID *string `json:"tagId"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// WorkspaceAISessionDTO 是工作区 AI 会话摘要。
|
||||
type WorkspaceAISessionDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
References []string `json:"references"`
|
||||
}
|
||||
|
||||
// WorkspaceNoteSourceDTO 将笔记和资料合并为统一的工作区列表项。
|
||||
type WorkspaceNoteSourceDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Tag string `json:"tag"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// WorkspaceCronPlanDTO 是只展示元数据、不执行自主 Agent 的计划任务摘要。
|
||||
type WorkspaceCronPlanDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Schedule string `json:"schedule"`
|
||||
NextRun *time.Time `json:"nextRun"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastResult string `json:"lastResult"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
func projectDTO(project models.SenlinAgentProject) ProjectDTO {
|
||||
return ProjectDTO{
|
||||
ID: project.Identity,
|
||||
|
||||
@@ -140,14 +140,13 @@ func (h *Handler) workspace(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
projectIdentity, ok := httpx.IdentityParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspace, err := h.service.Workspace(userID, uint(projectID))
|
||||
workspace, err := h.service.Workspace(userID, projectIdentity)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"})
|
||||
writeProjectError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, workspace)
|
||||
|
||||
198
backend/internal/logic/projects/object_crud.go
Normal file
198
backend/internal/logic/projects/object_crud.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("task title is required")
|
||||
}
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
status = "open"
|
||||
}
|
||||
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &models.SenlinAgentTask{
|
||||
ProjectID: projectID, CreatedBy: ownerID, TagID: tagID, Title: title,
|
||||
Description: strings.TrimSpace(input.Description), Status: status, DueAt: input.DueAt,
|
||||
}
|
||||
return task, models.DBService.Create(task).Error
|
||||
}
|
||||
|
||||
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var task models.SenlinAgentTask
|
||||
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextProjectID := input.NextProjectID
|
||||
if nextProjectID == 0 {
|
||||
nextProjectID = task.ProjectID
|
||||
}
|
||||
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("task title is required")
|
||||
}
|
||||
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projectIdentity, err := projectIdentity(nextProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagIdentity, err := optionalTagIdentity(tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
if input.Completed {
|
||||
status = "done"
|
||||
} else {
|
||||
status = "open"
|
||||
}
|
||||
}
|
||||
task.ProjectID = nextProjectID
|
||||
task.ProjectIdentity = projectIdentity
|
||||
task.Title = title
|
||||
task.Description = strings.TrimSpace(input.Description)
|
||||
task.Status = status
|
||||
task.TagID = tagID
|
||||
task.TagIdentity = tagIdentity
|
||||
if err := models.DBService.Save(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("source title is required")
|
||||
}
|
||||
filePath := strings.TrimSpace(input.FilePath)
|
||||
if filePath == "" {
|
||||
return nil, errors.New("source file path is required")
|
||||
}
|
||||
source := &models.SenlinAgentSource{ProjectID: projectID, CreatedBy: ownerID, Kind: "file", Title: title, FilePath: filePath}
|
||||
return source, models.DBService.Create(source).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("cron plan title is required")
|
||||
}
|
||||
schedule := strings.TrimSpace(input.Schedule)
|
||||
if schedule == "" {
|
||||
return nil, errors.New("cron schedule is required")
|
||||
}
|
||||
plan := &models.SenlinAgentCronPlan{
|
||||
ProjectID: projectID, CreatedBy: ownerID, Title: title, Schedule: schedule,
|
||||
Enabled: input.Enabled, NextRunAt: input.NextRunAt, LastResult: "Not run yet",
|
||||
}
|
||||
return plan, models.DBService.Create(plan).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.CreateTag(projectID, name)
|
||||
}
|
||||
|
||||
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("tag name is required")
|
||||
}
|
||||
var existing models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
|
||||
if err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
|
||||
return tag, models.DBService.Create(tag).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListTags(projectID)
|
||||
}
|
||||
|
||||
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
var tags []models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
tag, err := s.CreateTag(projectID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag.ID, nil
|
||||
}
|
||||
|
||||
func ensureProjectOwner(ownerID uint, projectID uint) error {
|
||||
var count int64
|
||||
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("project not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func projectIdentity(projectID uint) (string, error) {
|
||||
var project models.SenlinAgentProject
|
||||
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return project.Identity, nil
|
||||
}
|
||||
|
||||
func optionalTagIdentity(tagID *uint) (*string, error) {
|
||||
if tagID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var tag models.SenlinAgentTag
|
||||
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag.Identity, nil
|
||||
}
|
||||
@@ -2,137 +2,13 @@ package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type WorkbenchProject struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Icon string `json:"icon"`
|
||||
Background string `json:"background"`
|
||||
Description string `json:"description"`
|
||||
Initials string `json:"initials"`
|
||||
UnreadCount int64 `json:"unreadCount"`
|
||||
}
|
||||
|
||||
type WorkbenchChannel struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID uint `json:"projectID"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type InboxMessage struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Tag string `json:"tag"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type WorkTask struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Completed bool `json:"completed"`
|
||||
Owner string `json:"owner"`
|
||||
Due string `json:"due"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CompletedAt string `json:"completedAt"`
|
||||
Duration string `json:"duration"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
type AISessionItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
References []string `json:"references"`
|
||||
}
|
||||
|
||||
type NoteSourceItem struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Tag string `json:"tag"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type CronPlan struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Schedule string `json:"schedule"`
|
||||
NextRun string `json:"nextRun"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastResult string `json:"lastResult"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
type ProjectWorkspace struct {
|
||||
Project WorkbenchProject `json:"project"`
|
||||
Channels []WorkbenchChannel `json:"channels"`
|
||||
Tags []string `json:"tags"`
|
||||
RecentSessions []AISessionItem `json:"recentSessions"`
|
||||
Inbox []InboxMessage `json:"inbox"`
|
||||
Tasks []WorkTask `json:"tasks"`
|
||||
AISessions []AISessionItem `json:"aiSessions"`
|
||||
NotesSources []NoteSourceItem `json:"notesSources"`
|
||||
CronPlans []CronPlan `json:"cronPlans"`
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
DueAt *time.Time
|
||||
Tag string
|
||||
}
|
||||
|
||||
type UpdateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
Completed bool
|
||||
NextProjectID uint
|
||||
Tag string
|
||||
}
|
||||
|
||||
type CreateFileSourceInput struct {
|
||||
Title string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type CreateCronPlanInput struct {
|
||||
Title string
|
||||
Schedule string
|
||||
Enabled bool
|
||||
NextRunAt *time.Time
|
||||
}
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
@@ -152,11 +28,8 @@ func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectReques
|
||||
identifier = projectInitials(name)
|
||||
}
|
||||
project := &models.SenlinAgentProject{
|
||||
OwnerID: ownerID,
|
||||
Name: name,
|
||||
Identifier: identifier,
|
||||
Icon: strings.TrimSpace(input.Icon),
|
||||
Background: strings.TrimSpace(input.Background),
|
||||
OwnerID: ownerID, Name: name, Identifier: identifier,
|
||||
Icon: strings.TrimSpace(input.Icon), Background: strings.TrimSpace(input.Background),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
}
|
||||
if err := projectWriteError(models.DBService.Create(project).Error); err != nil {
|
||||
@@ -238,527 +111,6 @@ func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProje
|
||||
return projectWriteError(models.DBService.Model(project).Updates(updates).Error)
|
||||
}
|
||||
|
||||
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("task title is required")
|
||||
}
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
status = "open"
|
||||
}
|
||||
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &models.SenlinAgentTask{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
TagID: tagID,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
Status: status,
|
||||
DueAt: input.DueAt,
|
||||
}
|
||||
return task, models.DBService.Create(task).Error
|
||||
}
|
||||
|
||||
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var task models.SenlinAgentTask
|
||||
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextProjectID := input.NextProjectID
|
||||
if nextProjectID == 0 {
|
||||
nextProjectID = task.ProjectID
|
||||
}
|
||||
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("task title is required")
|
||||
}
|
||||
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projectIdentity, err := projectIdentity(nextProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagIdentity, err := optionalTagIdentity(tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
if input.Completed {
|
||||
status = "done"
|
||||
} else {
|
||||
status = "open"
|
||||
}
|
||||
}
|
||||
task.ProjectID = nextProjectID
|
||||
task.ProjectIdentity = projectIdentity
|
||||
task.Title = title
|
||||
task.Description = strings.TrimSpace(input.Description)
|
||||
task.Status = status
|
||||
task.TagID = tagID
|
||||
task.TagIdentity = tagIdentity
|
||||
if err := models.DBService.Save(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("source title is required")
|
||||
}
|
||||
filePath := strings.TrimSpace(input.FilePath)
|
||||
if filePath == "" {
|
||||
return nil, errors.New("source file path is required")
|
||||
}
|
||||
source := &models.SenlinAgentSource{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
Kind: "file",
|
||||
Title: title,
|
||||
FilePath: filePath,
|
||||
}
|
||||
return source, models.DBService.Create(source).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("cron plan title is required")
|
||||
}
|
||||
schedule := strings.TrimSpace(input.Schedule)
|
||||
if schedule == "" {
|
||||
return nil, errors.New("cron schedule is required")
|
||||
}
|
||||
plan := &models.SenlinAgentCronPlan{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
Title: title,
|
||||
Schedule: schedule,
|
||||
Enabled: input.Enabled,
|
||||
NextRunAt: input.NextRunAt,
|
||||
LastResult: "Not run yet",
|
||||
}
|
||||
return plan, models.DBService.Create(plan).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.CreateTag(projectID, name)
|
||||
}
|
||||
|
||||
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("tag name is required")
|
||||
}
|
||||
var existing models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
|
||||
if err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
|
||||
return tag, models.DBService.Create(tag).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListTags(projectID)
|
||||
}
|
||||
|
||||
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
var tags []models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
tag, err := s.CreateTag(projectID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
dashboard := Dashboard{ProjectID: projectID}
|
||||
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, error) {
|
||||
var project models.SenlinAgentProject
|
||||
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
|
||||
counts, err := s.workspaceCounts(projectID)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
tags, err := s.workspaceTags(projectID)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
channels, err := s.workspaceChannels(projectID, counts)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
inboxItems, err := s.workspaceInbox(projectID)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
tasks, err := s.workspaceTasks(projectID)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
aiSessions, err := s.workspaceAISessions(projectID)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
notesSources, err := s.workspaceNotesSources(projectID)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
cronPlans, err := s.workspaceCronPlans(projectID)
|
||||
if err != nil {
|
||||
return ProjectWorkspace{}, err
|
||||
}
|
||||
|
||||
return ProjectWorkspace{
|
||||
Project: WorkbenchProject{
|
||||
ID: project.ID,
|
||||
Name: project.Name,
|
||||
Identifier: project.Identifier,
|
||||
Icon: project.Icon,
|
||||
Background: project.Background,
|
||||
Description: project.Description,
|
||||
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
|
||||
UnreadCount: counts.inbox,
|
||||
},
|
||||
Channels: channels,
|
||||
Tags: tags,
|
||||
RecentSessions: aiSessions,
|
||||
Inbox: inboxItems,
|
||||
Tasks: tasks,
|
||||
AISessions: aiSessions,
|
||||
NotesSources: notesSources,
|
||||
CronPlans: cronPlans,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureProjectOwner(ownerID uint, projectID uint) error {
|
||||
var count int64
|
||||
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("project not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type workspaceCounts struct {
|
||||
inbox int64
|
||||
tasks int64
|
||||
aiSessions int64
|
||||
notesSources int64
|
||||
cronPlans int64
|
||||
}
|
||||
|
||||
func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
|
||||
var counts workspaceCounts
|
||||
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&counts.inbox).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&counts.tasks).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
var noteCount int64
|
||||
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(¬eCount).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
var sourceCount int64
|
||||
if err := models.DBService.Model(&models.SenlinAgentSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.notesSources = noteCount + sourceCount
|
||||
if err := models.DBService.Model(&models.SenlinAgentCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceTags(projectID uint) ([]string, error) {
|
||||
var tags []models.SenlinAgentTag
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := []string{"all"}
|
||||
for _, tag := range tags {
|
||||
names = append(names, tag.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceChannels(projectID uint, counts workspaceCounts) ([]WorkbenchChannel, error) {
|
||||
total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans
|
||||
channels := []WorkbenchChannel{
|
||||
{ID: fmt.Sprintf("%d-overview", projectID), ProjectID: projectID, Type: "overview", Title: "Overview", Icon: "home", Count: &total, SortOrder: 1},
|
||||
{ID: fmt.Sprintf("%d-inbox", projectID), ProjectID: projectID, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: &counts.inbox, SortOrder: 2},
|
||||
{ID: fmt.Sprintf("%d-tasks", projectID), ProjectID: projectID, Type: "tasks", Title: "Work Plan", Icon: "list", Count: &counts.tasks, SortOrder: 3},
|
||||
{ID: fmt.Sprintf("%d-ai", projectID), ProjectID: projectID, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: &counts.aiSessions, SortOrder: 4},
|
||||
{ID: fmt.Sprintf("%d-notes", projectID), ProjectID: projectID, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: &counts.notesSources, SortOrder: 5},
|
||||
{ID: fmt.Sprintf("%d-cron", projectID), ProjectID: projectID, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: &counts.cronPlans, SortOrder: 6},
|
||||
}
|
||||
var customChannels []models.SenlinAgentProjectChannel
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("sort_order asc, id asc").Find(&customChannels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, channel := range customChannels {
|
||||
channels = append(channels, WorkbenchChannel{
|
||||
ID: fmt.Sprintf("%d-custom-%d", projectID, channel.ID),
|
||||
ProjectID: projectID,
|
||||
Type: "custom_link",
|
||||
Title: channel.Title,
|
||||
Icon: channel.Icon,
|
||||
URL: channel.URL,
|
||||
SortOrder: channel.SortOrder,
|
||||
})
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceInbox(projectID uint) ([]InboxMessage, error) {
|
||||
var items []models.SenlinAgentInboxItem
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages := make([]InboxMessage, 0, len(items))
|
||||
for _, item := range items {
|
||||
messages = append(messages, InboxMessage{
|
||||
ID: fmt.Sprint(item.ID),
|
||||
Source: item.SourceType,
|
||||
Title: item.Title,
|
||||
Summary: item.Body,
|
||||
Status: item.Status,
|
||||
Tag: "",
|
||||
Time: displayTime(item.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
||||
var tasks []models.SenlinAgentTask
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagNames, err := taskTagNames(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkTask, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
completed := task.Status == "done"
|
||||
completedAt := ""
|
||||
duration := ""
|
||||
if completed {
|
||||
completedAt = displayTime(task.UpdatedAt)
|
||||
duration = displayDuration(task.CreatedAt, task.UpdatedAt)
|
||||
}
|
||||
items = append(items, WorkTask{
|
||||
ID: fmt.Sprint(task.ID),
|
||||
Title: task.Title,
|
||||
Summary: task.Description,
|
||||
Completed: completed,
|
||||
Owner: owner,
|
||||
Due: displayOptionalTime(task.DueAt),
|
||||
CreatedAt: displayTime(task.CreatedAt),
|
||||
CompletedAt: completedAt,
|
||||
Duration: duration,
|
||||
Tag: tagNames[task.ID],
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func taskTagNames(tasks []models.SenlinAgentTask) (map[uint]string, error) {
|
||||
names := make(map[uint]string)
|
||||
tagIDs := make([]uint, 0)
|
||||
for _, task := range tasks {
|
||||
if task.TagID != nil {
|
||||
tagIDs = append(tagIDs, *task.TagID)
|
||||
}
|
||||
}
|
||||
if len(tagIDs) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var tags []models.SenlinAgentTag
|
||||
if err := models.DBService.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagByID := make(map[uint]string, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagByID[tag.ID] = tag.Name
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.TagID != nil {
|
||||
names[task.ID] = tagByID[*task.TagID]
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func projectIdentity(projectID uint) (string, error) {
|
||||
var project models.SenlinAgentProject
|
||||
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return project.Identity, nil
|
||||
}
|
||||
|
||||
func optionalTagIdentity(tagID *uint) (*string, error) {
|
||||
if tagID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var tag models.SenlinAgentTag
|
||||
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tag.Identity, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) {
|
||||
var sessions []models.SenlinAgentAISession
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]AISessionItem, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
items = append(items, AISessionItem{
|
||||
ID: fmt.Sprint(session.ID),
|
||||
Title: session.Title,
|
||||
Summary: session.Context,
|
||||
UpdatedAt: displayTime(session.UpdatedAt),
|
||||
References: []string{},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceNotesSources(projectID uint) ([]NoteSourceItem, error) {
|
||||
var notes []models.SenlinAgentNote
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]NoteSourceItem, 0, len(notes))
|
||||
for _, note := range notes {
|
||||
items = append(items, NoteSourceItem{
|
||||
ID: fmt.Sprintf("note-%d", note.ID),
|
||||
Kind: "note",
|
||||
Title: note.Title,
|
||||
UpdatedAt: displayTime(note.UpdatedAt),
|
||||
Tag: "",
|
||||
Source: "Markdown",
|
||||
})
|
||||
}
|
||||
|
||||
var sources []models.SenlinAgentSource
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, source := range sources {
|
||||
items = append(items, NoteSourceItem{
|
||||
ID: fmt.Sprintf("%s-%d", source.Kind, source.ID),
|
||||
Kind: source.Kind,
|
||||
Title: source.Title,
|
||||
UpdatedAt: displayTime(source.UpdatedAt),
|
||||
Tag: "",
|
||||
Source: sourceDisplayName(source),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceCronPlans(projectID uint) ([]CronPlan, error) {
|
||||
var plans []models.SenlinAgentCronPlan
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("enabled desc, updated_at desc, id desc").Limit(50).Find(&plans).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]CronPlan, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
owner, err := userDisplayName(nil, plan.CreatedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, CronPlan{
|
||||
ID: fmt.Sprint(plan.ID),
|
||||
Title: plan.Title,
|
||||
Schedule: plan.Schedule,
|
||||
NextRun: displayOptionalTime(plan.NextRunAt),
|
||||
Enabled: plan.Enabled,
|
||||
LastResult: defaultString(plan.LastResult, "Not run yet"),
|
||||
Owner: owner,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func projectInitials(name string) string {
|
||||
words := strings.Fields(name)
|
||||
if len(words) == 0 {
|
||||
@@ -798,67 +150,3 @@ func isShortCode(value string) bool {
|
||||
}
|
||||
return hasDigit
|
||||
}
|
||||
|
||||
func displayTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func displayOptionalTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return displayTime(*value)
|
||||
}
|
||||
|
||||
func displayDuration(start time.Time, end time.Time) string {
|
||||
if start.IsZero() || end.IsZero() || end.Before(start) {
|
||||
return ""
|
||||
}
|
||||
duration := end.Sub(start)
|
||||
days := int(duration.Hours()) / 24
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%d 天", days)
|
||||
}
|
||||
hours := int(duration.Hours())
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d 小时", hours)
|
||||
}
|
||||
minutes := int(duration.Minutes())
|
||||
if minutes > 0 {
|
||||
return fmt.Sprintf("%d 分钟", minutes)
|
||||
}
|
||||
return "1 分钟内"
|
||||
}
|
||||
|
||||
func sourceDisplayName(source models.SenlinAgentSource) string {
|
||||
switch source.Kind {
|
||||
case "file":
|
||||
return "Attachment"
|
||||
case "link":
|
||||
return "URL"
|
||||
default:
|
||||
return strings.Title(source.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
|
||||
userID := createdBy
|
||||
if assigneeID != nil && *assigneeID != 0 {
|
||||
userID = *assigneeID
|
||||
}
|
||||
var user models.SenlinAgentUser
|
||||
if err := models.DBService.Select("display_name").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return fmt.Sprintf("User %d", userID), nil
|
||||
}
|
||||
return user.DisplayName, nil
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
|
||||
require.Equal(t, "#165DFF", project.Background)
|
||||
require.Equal(t, "RSS 采集和线索沉淀", project.Description)
|
||||
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
workspace, err := service.Workspace(1, project.Identity)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "EXP", workspace.Project.Identifier)
|
||||
require.Equal(t, "compass", workspace.Project.Icon)
|
||||
@@ -160,14 +160,14 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
|
||||
require.NoError(t, database.Create(&models.SenlinAgentCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly inbox review reminder", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, LastResult: "Not run yet"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentCronPlan{ProjectID: other.ID, CreatedBy: 1, Title: "Other cron", Schedule: "0 9 * * 2", Enabled: true}).Error)
|
||||
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
workspace, err := service.Workspace(1, project.Identity)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, project.ID, workspace.Project.ID)
|
||||
require.Equal(t, project.Identity, workspace.Project.ID)
|
||||
require.Equal(t, "Project A1", workspace.Project.Name)
|
||||
require.Equal(t, "A1", workspace.Project.Initials)
|
||||
require.Equal(t, int64(1), workspace.Project.UnreadCount)
|
||||
require.Equal(t, []string{"all", "UI", "knowledge"}, workspace.Tags)
|
||||
require.Equal(t, []string{"UI", "knowledge"}, workspaceTagNames(workspace.Tags))
|
||||
require.Len(t, workspace.Channels, 7)
|
||||
require.Equal(t, "overview", workspace.Channels[0].Type)
|
||||
require.Equal(t, "inbox", workspace.Channels[1].Type)
|
||||
@@ -198,7 +198,7 @@ func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
project, err := service.CreateProject(2, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Workspace(1, project.ID)
|
||||
_, err = service.Workspace(1, project.Identity)
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -226,12 +226,12 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
||||
require.Equal(t, "open", task.Status)
|
||||
require.NotNil(t, task.DueAt)
|
||||
require.NotNil(t, task.TagID)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
workspace, err := service.Workspace(1, project.Identity)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspace.Tasks, 1)
|
||||
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
|
||||
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
|
||||
require.Equal(t, []string{"all", "客户反馈"}, workspace.Tags)
|
||||
require.Equal(t, []string{"客户反馈"}, workspaceTagNames(workspace.Tags))
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
@@ -264,10 +264,10 @@ func TestUpdateTaskPersistsTagAndCompletion(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Draft proposal v2", updated.Title)
|
||||
require.Equal(t, "done", updated.Status)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
workspace, err := service.Workspace(1, project.Identity)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "重要", workspace.Tasks[0].Tag)
|
||||
require.Equal(t, []string{"all", "客户", "重要"}, workspace.Tags)
|
||||
require.Equal(t, []string{"客户", "重要"}, workspaceTagNames(workspace.Tags))
|
||||
}
|
||||
|
||||
func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
|
||||
@@ -281,9 +281,9 @@ func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, project.ID, tag.ProjectID)
|
||||
require.Equal(t, "Design", tag.Name)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
workspace, err := service.Workspace(1, project.Identity)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"all", "Design"}, workspace.Tags)
|
||||
require.Equal(t, []string{"Design"}, workspaceTagNames(workspace.Tags))
|
||||
}
|
||||
|
||||
func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
@@ -341,7 +341,7 @@ func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) {
|
||||
require.Equal(t, "0 9 * * *", plan.Schedule)
|
||||
require.True(t, plan.Enabled)
|
||||
require.NotNil(t, plan.NextRunAt)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
workspace, err := service.Workspace(1, project.Identity)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspace.CronPlans, 1)
|
||||
require.Equal(t, "Daily inbox sweep", workspace.CronPlans[0].Title)
|
||||
@@ -355,3 +355,11 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
|
||||
func workspaceTagNames(tags []WorkspaceTagDTO) []string {
|
||||
names := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
names = append(names, tag.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
342
backend/internal/logic/projects/workspace.go
Normal file
342
backend/internal/logic/projects/workspace.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type workspaceCounts struct {
|
||||
inbox int64
|
||||
tasks int64
|
||||
aiSessions int64
|
||||
notesSources int64
|
||||
cronPlans int64
|
||||
}
|
||||
|
||||
// Dashboard 保留旧概览查询给尚未迁移的 handler;所有统计仍严格限定项目所有者。
|
||||
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
dashboard := Dashboard{ProjectID: projectID}
|
||||
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
// Workspace 在确认项目归属后执行首屏聚合;后续子查询只接收已授权的内部主键。
|
||||
func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO, error) {
|
||||
project, err := FindOwnedProject(ownerID, projectIdentity)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
|
||||
counts, err := s.workspaceCounts(project.ID)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
tags, err := s.workspaceTags(project.ID)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
channels, err := s.workspaceChannels(project.ID, project.Identity, counts)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
inboxItems, err := s.workspaceInbox(project.ID, project.Identity)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
tasks, err := s.workspaceTasks(project.ID, project.Identity)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
aiSessions, err := s.workspaceAISessions(project.ID, project.Identity)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
notesSources, err := s.workspaceNotesSources(project.ID, project.Identity)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
cronPlans, err := s.workspaceCronPlans(project.ID, project.Identity)
|
||||
if err != nil {
|
||||
return WorkspaceDTO{}, err
|
||||
}
|
||||
|
||||
return WorkspaceDTO{
|
||||
Project: WorkspaceProjectDTO{
|
||||
ID: project.Identity,
|
||||
Name: project.Name,
|
||||
Identifier: project.Identifier,
|
||||
Icon: project.Icon,
|
||||
Background: project.Background,
|
||||
Description: project.Description,
|
||||
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
|
||||
UnreadCount: counts.inbox,
|
||||
},
|
||||
Channels: channels,
|
||||
Tags: tags,
|
||||
RecentSessions: aiSessions,
|
||||
Inbox: inboxItems,
|
||||
Tasks: tasks,
|
||||
AISessions: aiSessions,
|
||||
NotesSources: notesSources,
|
||||
CronPlans: cronPlans,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
|
||||
var counts workspaceCounts
|
||||
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&counts.inbox).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&counts.tasks).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
var noteCount int64
|
||||
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(¬eCount).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
var sourceCount int64
|
||||
if err := models.DBService.Model(&models.SenlinAgentSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.notesSources = noteCount + sourceCount
|
||||
if err := models.DBService.Model(&models.SenlinAgentCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceTags(projectID uint) ([]WorkspaceTagDTO, error) {
|
||||
var tags []models.SenlinAgentTag
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkspaceTagDTO, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
items = append(items, WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) {
|
||||
total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans
|
||||
channels := []WorkspaceChannelDTO{
|
||||
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: &total, SortOrder: 1},
|
||||
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: &counts.inbox, SortOrder: 2},
|
||||
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: &counts.tasks, SortOrder: 3},
|
||||
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: &counts.aiSessions, SortOrder: 4},
|
||||
{ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: &counts.notesSources, SortOrder: 5},
|
||||
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: &counts.cronPlans, SortOrder: 6},
|
||||
}
|
||||
var customChannels []models.SenlinAgentProjectChannel
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("sort_order asc, id asc").Find(&customChannels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, channel := range customChannels {
|
||||
channels = append(channels, WorkspaceChannelDTO{
|
||||
ID: channel.Identity, ProjectID: projectIdentity, Type: "custom_link", Title: channel.Title,
|
||||
Icon: channel.Icon, URL: channel.URL, SortOrder: channel.SortOrder,
|
||||
})
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceInbox(projectID uint, projectIdentity string) ([]WorkspaceInboxDTO, error) {
|
||||
var records []models.SenlinAgentInboxItem
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkspaceInboxDTO, 0, len(records))
|
||||
for _, record := range records {
|
||||
items = append(items, WorkspaceInboxDTO{
|
||||
ID: record.Identity, ProjectID: projectIdentity, Source: record.SourceType, Title: record.Title,
|
||||
Summary: record.Body, Status: record.Status, Time: record.UpdatedAt.UTC(),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceTasks(projectID uint, projectIdentity string) ([]WorkspaceTaskDTO, error) {
|
||||
var tasks []models.SenlinAgentTask
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags, err := workspaceTaskTags(projectID, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkspaceTaskDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
completed := task.Status == "done"
|
||||
var completedAt *time.Time
|
||||
if completed {
|
||||
completedAt = utcOptionalTime(&task.UpdatedAt)
|
||||
}
|
||||
item := WorkspaceTaskDTO{
|
||||
ID: task.Identity, ProjectID: projectIdentity, Title: task.Title, Summary: task.Description,
|
||||
Completed: completed, Owner: owner, Due: utcOptionalTime(task.DueAt), CreatedAt: task.CreatedAt.UTC(), CompletedAt: completedAt,
|
||||
}
|
||||
if tag, ok := tags[task.ID]; ok {
|
||||
item.TagID = &tag.ID
|
||||
item.Tag = tag.Name
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// workspaceTaskTags 同时限定 tag 主键和项目主键,避免损坏数据把其他项目标签带入任务响应。
|
||||
func workspaceTaskTags(projectID uint, tasks []models.SenlinAgentTask) (map[uint]WorkspaceTagDTO, error) {
|
||||
tagIDs := make([]uint, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if task.TagID != nil {
|
||||
tagIDs = append(tagIDs, *task.TagID)
|
||||
}
|
||||
}
|
||||
result := make(map[uint]WorkspaceTagDTO)
|
||||
if len(tagIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var tags []models.SenlinAgentTag
|
||||
if err := models.DBService.Where("project_id = ? AND id IN ?", projectID, tagIDs).Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[uint]WorkspaceTagDTO, len(tags))
|
||||
for _, tag := range tags {
|
||||
byID[tag.ID] = WorkspaceTagDTO{ID: tag.Identity, Name: tag.Name}
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.TagID != nil {
|
||||
if tag, ok := byID[*task.TagID]; ok {
|
||||
result[task.ID] = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceAISessions(projectID uint, projectIdentity string) ([]WorkspaceAISessionDTO, error) {
|
||||
var sessions []models.SenlinAgentAISession
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkspaceAISessionDTO, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
items = append(items, WorkspaceAISessionDTO{
|
||||
ID: session.Identity, ProjectID: projectIdentity, Title: session.Title, Summary: session.Context,
|
||||
UpdatedAt: session.UpdatedAt.UTC(), References: []string{},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceNotesSources(projectID uint, projectIdentity string) ([]WorkspaceNoteSourceDTO, error) {
|
||||
var notes []models.SenlinAgentNote
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkspaceNoteSourceDTO, 0, len(notes))
|
||||
for _, note := range notes {
|
||||
items = append(items, WorkspaceNoteSourceDTO{
|
||||
ID: note.Identity, ProjectID: projectIdentity, Kind: "note", Title: note.Title,
|
||||
UpdatedAt: note.UpdatedAt.UTC(), Source: "Markdown",
|
||||
})
|
||||
}
|
||||
|
||||
var sources []models.SenlinAgentSource
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, source := range sources {
|
||||
items = append(items, WorkspaceNoteSourceDTO{
|
||||
ID: source.Identity, ProjectID: projectIdentity, Kind: source.Kind, Title: source.Title,
|
||||
UpdatedAt: source.UpdatedAt.UTC(), Source: sourceDisplayName(source),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) workspaceCronPlans(projectID uint, projectIdentity string) ([]WorkspaceCronPlanDTO, error) {
|
||||
var plans []models.SenlinAgentCronPlan
|
||||
if err := models.DBService.Where("project_id = ?", projectID).Order("enabled desc, updated_at desc, id desc").Limit(50).Find(&plans).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkspaceCronPlanDTO, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
owner, err := userDisplayName(nil, plan.CreatedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, WorkspaceCronPlanDTO{
|
||||
ID: plan.Identity, ProjectID: projectIdentity, Title: plan.Title, Schedule: plan.Schedule,
|
||||
NextRun: utcOptionalTime(plan.NextRunAt), Enabled: plan.Enabled,
|
||||
LastResult: defaultString(plan.LastResult, "Not run yet"), Owner: owner,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func utcOptionalTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
utc := value.UTC()
|
||||
return &utc
|
||||
}
|
||||
|
||||
func sourceDisplayName(source models.SenlinAgentSource) string {
|
||||
switch source.Kind {
|
||||
case "file":
|
||||
return "Attachment"
|
||||
case "link":
|
||||
return "URL"
|
||||
default:
|
||||
return source.Kind
|
||||
}
|
||||
}
|
||||
|
||||
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
|
||||
userID := createdBy
|
||||
if assigneeID != nil && *assigneeID != 0 {
|
||||
userID = *assigneeID
|
||||
}
|
||||
var user models.SenlinAgentUser
|
||||
if err := models.DBService.Select("display_name").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "未知用户", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return user.DisplayName, nil
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
105
backend/internal/logic/projects/workspace_test.go
Normal file
105
backend/internal/logic/projects/workspace_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService()
|
||||
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
||||
project, err := service.CreateProjectWithInput(1, CreateProjectRequest{
|
||||
Name: "Project A1", Identifier: "A1", Icon: "folder", Background: "#165DFF", Description: "Client strategy workspace",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
other, err := service.CreateProject(1, "Project A2", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
projectTag := models.SenlinAgentTag{ProjectID: project.ID, Name: "UI"}
|
||||
otherTag := models.SenlinAgentTag{ProjectID: other.ID, Name: "UI"}
|
||||
require.NoError(t, database.Create(&projectTag).Error)
|
||||
require.NoError(t, database.Create(&otherTag).Error)
|
||||
|
||||
localZone := time.FixedZone("UTC+8", 8*60*60)
|
||||
createdAt := time.Date(2026, 7, 20, 10, 30, 0, 0, localZone)
|
||||
updatedAt := createdAt.Add(90 * time.Minute)
|
||||
nextRun := time.Date(2026, 7, 21, 9, 0, 0, 0, localZone)
|
||||
inbox := models.SenlinAgentInboxItem{ProjectID: project.ID, CreatedBy: 1, SourceType: "Manual", Title: "Collect notes", Body: "Turn research into tasks.", Status: "open", CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, TagID: &projectTag.ID, Title: "Confirm IA", Description: "Review channel layout.", Status: "done", DueAt: &nextRun, CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
session := models.SenlinAgentAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI critique", Context: "Discuss channel behavior.", CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
note := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes", CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
source := models.SenlinAgentSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com", CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
channel := models.SenlinAgentProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap", SortOrder: 7, CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
plan := models.SenlinAgentCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly review", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
for _, record := range []any{&inbox, &task, &session, ¬e, &source, &channel, &plan} {
|
||||
require.NoError(t, database.Create(record).Error)
|
||||
}
|
||||
|
||||
workspace, err := service.Workspace(1, project.Identity)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, project.Identity, workspace.Project.ID)
|
||||
require.Equal(t, []WorkspaceTagDTO{{ID: projectTag.Identity, Name: "UI"}}, workspace.Tags)
|
||||
require.Len(t, workspace.Channels, 7)
|
||||
require.Equal(t, project.Identity, workspace.Channels[0].ProjectID)
|
||||
require.Equal(t, channel.Identity, workspace.Channels[6].ID)
|
||||
require.Equal(t, project.Identity, workspace.Channels[6].ProjectID)
|
||||
|
||||
require.Len(t, workspace.Inbox, 1)
|
||||
require.Equal(t, inbox.Identity, workspace.Inbox[0].ID)
|
||||
require.Equal(t, project.Identity, workspace.Inbox[0].ProjectID)
|
||||
require.Equal(t, updatedAt.UTC(), workspace.Inbox[0].Time)
|
||||
|
||||
require.Len(t, workspace.Tasks, 1)
|
||||
require.Equal(t, task.Identity, workspace.Tasks[0].ID)
|
||||
require.Equal(t, project.Identity, workspace.Tasks[0].ProjectID)
|
||||
require.Equal(t, projectTag.Identity, *workspace.Tasks[0].TagID)
|
||||
require.Equal(t, "UI", workspace.Tasks[0].Tag)
|
||||
require.Equal(t, nextRun.UTC(), *workspace.Tasks[0].Due)
|
||||
require.Equal(t, createdAt.UTC(), workspace.Tasks[0].CreatedAt)
|
||||
require.Equal(t, updatedAt.UTC(), *workspace.Tasks[0].CompletedAt)
|
||||
|
||||
require.Len(t, workspace.AISessions, 1)
|
||||
require.Equal(t, session.Identity, workspace.AISessions[0].ID)
|
||||
require.Equal(t, project.Identity, workspace.AISessions[0].ProjectID)
|
||||
require.Equal(t, updatedAt.UTC(), workspace.AISessions[0].UpdatedAt)
|
||||
require.Equal(t, session.Identity, workspace.RecentSessions[0].ID)
|
||||
|
||||
require.Len(t, workspace.NotesSources, 2)
|
||||
require.Equal(t, note.Identity, workspace.NotesSources[0].ID)
|
||||
require.Equal(t, source.Identity, workspace.NotesSources[1].ID)
|
||||
for _, item := range workspace.NotesSources {
|
||||
require.Equal(t, project.Identity, item.ProjectID)
|
||||
require.Equal(t, updatedAt.UTC(), item.UpdatedAt)
|
||||
}
|
||||
|
||||
require.Len(t, workspace.CronPlans, 1)
|
||||
require.Equal(t, plan.Identity, workspace.CronPlans[0].ID)
|
||||
require.Equal(t, project.Identity, workspace.CronPlans[0].ProjectID)
|
||||
require.Equal(t, nextRun.UTC(), *workspace.CronPlans[0].NextRun)
|
||||
|
||||
payload, err := json.Marshal(workspace)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(payload), `"createdAt":"2026-07-20T02:30:00Z"`)
|
||||
require.Contains(t, string(payload), `"updatedAt":"2026-07-20T04:00:00Z"`)
|
||||
require.Contains(t, string(payload), `"nextRun":"2026-07-21T01:00:00Z"`)
|
||||
require.NotContains(t, string(payload), fmt.Sprintf(`"id":%d`, task.ID))
|
||||
require.NotContains(t, string(payload), "duration")
|
||||
}
|
||||
|
||||
func TestWorkspaceRejectsProjectOwnedByAnotherUserByIdentity(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(2, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Workspace(1, project.Identity)
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func TestDemoSeedCreatesFrontendWorkspaceData(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
workspace, err := projects.NewService().Workspace(result.User.ID, result.Projects[0].ID)
|
||||
workspace, err := projects.NewService().Workspace(result.User.ID, result.Projects[0].Identity)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "项目 A1", workspace.Project.Name)
|
||||
require.GreaterOrEqual(t, len(workspace.Inbox), 4)
|
||||
|
||||
Reference in New Issue
Block a user