2026-07-21 14:58:25 +08:00
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
}
// 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 ( & noteCount ) . 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
}
2026-07-21 15:07:54 +08:00
// 当前模型没有真实完成时间字段, 不能用更新时间伪装; CompletedAt 保持 nil。
2026-07-21 14:58:25 +08:00
item := WorkspaceTaskDTO {
ID : task . Identity , ProjectID : projectIdentity , Title : task . Title , Summary : task . Description ,
2026-07-21 15:07:54 +08:00
Completed : task . Status == "done" , Owner : owner , Due : utcOptionalTime ( task . DueAt ) , CreatedAt : task . CreatedAt . UTC ( ) ,
2026-07-21 14:58:25 +08:00
}
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 ( & notes ) . 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
}