diff --git a/backend/internal/logic/projects/dto.go b/backend/internal/logic/projects/dto.go index c12fc53..95c2a69 100644 --- a/backend/internal/logic/projects/dto.go +++ b/backend/internal/logic/projects/dto.go @@ -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, diff --git a/backend/internal/logic/projects/handlers.go b/backend/internal/logic/projects/handlers.go index 8f8ff8b..a7ab025 100644 --- a/backend/internal/logic/projects/handlers.go +++ b/backend/internal/logic/projects/handlers.go @@ -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) diff --git a/backend/internal/logic/projects/object_crud.go b/backend/internal/logic/projects/object_crud.go new file mode 100644 index 0000000..3345259 --- /dev/null +++ b/backend/internal/logic/projects/object_crud.go @@ -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 +} diff --git a/backend/internal/logic/projects/service.go b/backend/internal/logic/projects/service.go index 170c1e1..407def8 100644 --- a/backend/internal/logic/projects/service.go +++ b/backend/internal/logic/projects/service.go @@ -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 -} diff --git a/backend/internal/logic/projects/service_test.go b/backend/internal/logic/projects/service_test.go index 1c322de..49ab100 100644 --- a/backend/internal/logic/projects/service_test.go +++ b/backend/internal/logic/projects/service_test.go @@ -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 +} diff --git a/backend/internal/logic/projects/workspace.go b/backend/internal/logic/projects/workspace.go new file mode 100644 index 0000000..a1f3593 --- /dev/null +++ b/backend/internal/logic/projects/workspace.go @@ -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 +} diff --git a/backend/internal/logic/projects/workspace_test.go b/backend/internal/logic/projects/workspace_test.go new file mode 100644 index 0000000..133dfe4 --- /dev/null +++ b/backend/internal/logic/projects/workspace_test.go @@ -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) +} diff --git a/backend/internal/seed/demo_test.go b/backend/internal/seed/demo_test.go index f90ee36..ef90984 100644 --- a/backend/internal/seed/demo_test.go +++ b/backend/internal/seed/demo_test.go @@ -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)