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 }