From 03a5c52b7270607b0496485c8d32ab8270830fec Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sun, 19 Jul 2026 21:46:43 +0800 Subject: [PATCH] Align backend workbench API with prototype --- backend/internal/logic/projects/handlers.go | 20 + backend/internal/logic/projects/service.go | 416 ++++++++++++++++++ .../internal/logic/projects/service_test.go | 67 +++ backend/internal/models/cron_plan.go | 23 + backend/internal/models/identity.go | 17 + backend/internal/models/new.go | 2 + backend/internal/models/project_channel.go | 20 + ...7-19-backend-workbench-alignment-design.md | 79 ++++ 8 files changed, 644 insertions(+) create mode 100644 backend/internal/models/cron_plan.go create mode 100644 backend/internal/models/project_channel.go create mode 100644 docs/superpowers/specs/2026-07-19-backend-workbench-alignment-design.md diff --git a/backend/internal/logic/projects/handlers.go b/backend/internal/logic/projects/handlers.go index b397e19..d9e3d4f 100644 --- a/backend/internal/logic/projects/handlers.go +++ b/backend/internal/logic/projects/handlers.go @@ -20,6 +20,7 @@ func (h *Handler) Register(router gin.IRouter) { router.POST("/projects", h.createProject) router.GET("/projects", h.listProjects) router.GET("/projects/:id/dashboard", h.dashboard) + router.GET("/projects/:id/workspace", h.workspace) } func (h *Handler) createProject(c *gin.Context) { @@ -76,3 +77,22 @@ func (h *Handler) dashboard(c *gin.Context) { } c.JSON(http.StatusOK, dashboard) } + +func (h *Handler) workspace(c *gin.Context) { + userID, ok := auth.CurrentUserID(c) + if !ok { + 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"}) + return + } + workspace, err := h.service.Workspace(userID, uint(projectID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"}) + return + } + c.JSON(http.StatusOK, workspace) +} diff --git a/backend/internal/logic/projects/service.go b/backend/internal/logic/projects/service.go index 80ffc3f..c180550 100644 --- a/backend/internal/logic/projects/service.go +++ b/backend/internal/logic/projects/service.go @@ -2,7 +2,9 @@ package projects import ( "errors" + "fmt" "strings" + "time" "senlinai-agent/backend/internal/models" ) @@ -18,6 +20,84 @@ type Dashboard struct { RecentSessionCount int64 `json:"recent_session_count"` } +type WorkbenchProject struct { + ID uint `json:"id"` + Name string `json:"name"` + 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"` + 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"` +} + func NewService() *Service { return &Service{} } @@ -72,3 +152,339 @@ func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) { } 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, + Description: project.Description, + Initials: projectInitials(project.Name), + UnreadCount: counts.inbox, + }, + Channels: channels, + Tags: tags, + RecentSessions: aiSessions, + Inbox: inboxItems, + Tasks: tasks, + AISessions: aiSessions, + NotesSources: notesSources, + CronPlans: cronPlans, + }, 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 + } + items := make([]WorkTask, 0, len(tasks)) + for _, task := range tasks { + owner, err := userDisplayName(task.AssigneeID, task.CreatedBy) + if err != nil { + return nil, err + } + items = append(items, WorkTask{ + ID: fmt.Sprint(task.ID), + Title: task.Title, + Summary: task.Description, + Completed: task.Status == "done", + Owner: owner, + Due: displayOptionalTime(task.DueAt), + Tag: "", + }) + } + return items, 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 { + return "P" + } + for i := len(words) - 1; i >= 0; i-- { + word := strings.Trim(words[i], "#_-") + if isShortCode(word) { + return strings.ToUpper(word) + } + } + if len(words) == 1 { + runes := []rune(words[0]) + if len(runes) == 1 { + return strings.ToUpper(string(runes[0])) + } + return strings.ToUpper(string(runes[:2])) + } + return strings.ToUpper(string([]rune(words[0])[0]) + string([]rune(words[len(words)-1])[0])) +} + +func isShortCode(value string) bool { + runes := []rune(value) + if len(runes) == 0 || len(runes) > 3 { + return false + } + hasDigit := false + for _, r := range runes { + if r >= '0' && r <= '9' { + hasDigit = true + continue + } + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + continue + } + return false + } + 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 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 a8a95b1..72043e6 100644 --- a/backend/internal/logic/projects/service_test.go +++ b/backend/internal/logic/projects/service_test.go @@ -3,6 +3,7 @@ package projects import ( "fmt" "testing" + "time" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" @@ -59,6 +60,72 @@ func TestDashboardRejectsProjectOwnedByAnotherUser(t *testing.T) { require.Error(t, err) } +func TestWorkspaceMatchesFrontendContract(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.CreateProject(1, "Project A1", "Client strategy workspace") + require.NoError(t, err) + other, err := service.CreateProject(1, "Project A2", "") + require.NoError(t, err) + nextRun := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) + + require.NoError(t, database.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "UI"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "knowledge"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: project.ID, CreatedBy: 1, SourceType: "Manual", Title: "Collect notes", Body: "Turn pasted research into tasks.", Status: "open"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: other.ID, CreatedBy: 1, SourceType: "Manual", Title: "Other notes", Body: "Hidden", Status: "open"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Confirm IA", Description: "Review channel layout.", Status: "open", DueAt: &nextRun}).Error) + require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Archive notes", Description: "Move outdated notes.", Status: "done"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI prototype critique", Context: "Discuss channel behavior."}).Error) + require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com"}).Error) + require.NoError(t, database.Create(&models.SenlinAgentProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap-a1", SortOrder: 7}).Error) + require.NoError(t, database.Create(&models.SenlinAgentProjectChannel{ProjectID: other.ID, Title: "Other Board", Icon: "link", URL: "https://example.com/other", SortOrder: 7}).Error) + 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) + + require.NoError(t, err) + require.Equal(t, project.ID, 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.Len(t, workspace.Channels, 7) + require.Equal(t, "overview", workspace.Channels[0].Type) + require.Equal(t, "inbox", workspace.Channels[1].Type) + require.Equal(t, int64(1), *workspace.Channels[1].Count) + require.Equal(t, "custom_link", workspace.Channels[6].Type) + require.Equal(t, "Roadmap Board", workspace.Channels[6].Title) + require.Equal(t, "https://example.com/roadmap-a1", workspace.Channels[6].URL) + require.Len(t, workspace.Inbox, 1) + require.Equal(t, "Manual", workspace.Inbox[0].Source) + require.Equal(t, "Turn pasted research into tasks.", workspace.Inbox[0].Summary) + require.Len(t, workspace.Tasks, 2) + require.False(t, workspace.Tasks[0].Completed) + require.Equal(t, "David", workspace.Tasks[0].Owner) + require.Len(t, workspace.AISessions, 1) + require.Len(t, workspace.RecentSessions, 1) + require.Len(t, workspace.NotesSources, 2) + require.Equal(t, "note", workspace.NotesSources[0].Kind) + require.Equal(t, "link", workspace.NotesSources[1].Kind) + require.Len(t, workspace.CronPlans, 1) + require.True(t, workspace.CronPlans[0].Enabled) + require.Equal(t, "David", workspace.CronPlans[0].Owner) +} + +func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) { + newTestDB(t) + service := NewService() + project, err := service.CreateProject(2, "Beta", "") + require.NoError(t, err) + + _, err = service.Workspace(1, project.ID) + + require.Error(t, err) +} + func newTestDB(t *testing.T) *gorm.DB { t.Helper() database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) diff --git a/backend/internal/models/cron_plan.go b/backend/internal/models/cron_plan.go new file mode 100644 index 0000000..9569a4b --- /dev/null +++ b/backend/internal/models/cron_plan.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type SenlinAgentCronPlan struct { + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + ProjectID uint `gorm:"index;not null"` + ProjectIdentity string `gorm:"type:char(36);index"` + CreatedBy uint `gorm:"index;not null"` + CreatedByIdentity string `gorm:"type:char(36);index"` + Title string `gorm:"not null"` + Schedule string `gorm:"not null"` + NextRunAt *time.Time + Enabled bool `gorm:"not null;default:false"` + LastResult string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentCronPlan) TableName() string { + return "senlin_agent_cron_plans" +} diff --git a/backend/internal/models/identity.go b/backend/internal/models/identity.go index 885f1e6..400ac7f 100644 --- a/backend/internal/models/identity.go +++ b/backend/internal/models/identity.go @@ -87,6 +87,23 @@ func (m *SenlinAgentTag) BeforeCreate(tx *gorm.DB) error { return resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity) } +func (m *SenlinAgentProjectChannel) BeforeCreate(tx *gorm.DB) error { + if err := ensureIdentity(&m.Identity); err != nil { + return err + } + return resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity) +} + +func (m *SenlinAgentCronPlan) BeforeCreate(tx *gorm.DB) error { + if err := ensureIdentity(&m.Identity); err != nil { + return err + } + if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil { + return err + } + return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity) +} + func (m *SenlinAgentProjectEvent) BeforeCreate(tx *gorm.DB) error { if err := ensureIdentity(&m.Identity); err != nil { return err diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go index 0e90f72..74b6ba4 100644 --- a/backend/internal/models/new.go +++ b/backend/internal/models/new.go @@ -31,6 +31,8 @@ func AutoMigrate(database *gorm.DB) error { &SenlinAgentSource{}, &SenlinAgentAISession{}, &SenlinAgentTag{}, + &SenlinAgentProjectChannel{}, + &SenlinAgentCronPlan{}, &SenlinAgentProjectEvent{}, &SenlinAgentAIKey{}, &SenlinAgentAICallLog{}, diff --git a/backend/internal/models/project_channel.go b/backend/internal/models/project_channel.go new file mode 100644 index 0000000..9ef4b5b --- /dev/null +++ b/backend/internal/models/project_channel.go @@ -0,0 +1,20 @@ +package models + +import "time" + +type SenlinAgentProjectChannel struct { + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + ProjectID uint `gorm:"index;not null"` + ProjectIdentity string `gorm:"type:char(36);index"` + Title string `gorm:"not null"` + Icon string `gorm:"not null;default:link"` + URL string `gorm:"not null"` + SortOrder int `gorm:"not null;default:0"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SenlinAgentProjectChannel) TableName() string { + return "senlin_agent_project_channels" +} diff --git a/docs/superpowers/specs/2026-07-19-backend-workbench-alignment-design.md b/docs/superpowers/specs/2026-07-19-backend-workbench-alignment-design.md new file mode 100644 index 0000000..61ca3cd --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-backend-workbench-alignment-design.md @@ -0,0 +1,79 @@ +# Backend Workbench Alignment Design + +## Goal + +Align the Go backend with the latest Svelte workbench prototype. The frontend currently models a Discord-like project workbench with dynamic projects, project-scoped channels, and channel-specific first-screen data. + +## API Contract + +Add `GET /api/projects/:id/workspace`. + +The response intentionally matches `apps/web/src/features/workbench/types.ts` and uses camelCase field names: + +- `project` +- `channels` +- `tags` +- `recentSessions` +- `inbox` +- `tasks` +- `aiSessions` +- `notesSources` +- `cronPlans` + +## Project + +The project payload contains: + +- `id` +- `name` +- `description` +- `initials` +- `unreadCount` + +`unreadCount` is the count of open inbox items in the requested project. + +## Channels + +System channels are derived by the backend for every project: + +- `overview` +- `inbox` +- `tasks` +- `ai_sessions` +- `notes_sources` +- `cron` + +Custom channels are stored in the database as project-scoped URL shortcuts: + +- `title` +- `icon` +- `url` +- `sortOrder` + +System channel counts are derived from project data. Custom channels do not have counts. + +## Channel Data + +The workspace response includes first-screen channel data: + +- Inbox items map to mail-like message rows. +- Tasks map to todo-style cards with a `completed` boolean. +- AI sessions map to session list rows. +- Notes and sources are merged into one attachment/file-management list. +- Cron plans are metadata-only scheduled plans. + +Cron plans do not execute autonomous agent work in this MVP. + +## Access Control + +The endpoint only returns a workspace when the authenticated user owns the project. This follows the existing protected router and project dashboard behavior. + +## Tests + +Backend tests should verify: + +- The workspace rejects projects owned by another user. +- The workspace returns camelCase fields aligned with the frontend type. +- System channel counts are scoped to the requested project. +- Custom channels and Cron plans are project-scoped. +