Align backend workbench API with prototype
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
23
backend/internal/models/cron_plan.go
Normal file
23
backend/internal/models/cron_plan.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,8 @@ func AutoMigrate(database *gorm.DB) error {
|
||||
&SenlinAgentSource{},
|
||||
&SenlinAgentAISession{},
|
||||
&SenlinAgentTag{},
|
||||
&SenlinAgentProjectChannel{},
|
||||
&SenlinAgentCronPlan{},
|
||||
&SenlinAgentProjectEvent{},
|
||||
&SenlinAgentAIKey{},
|
||||
&SenlinAgentAICallLog{},
|
||||
|
||||
20
backend/internal/models/project_channel.go
Normal file
20
backend/internal/models/project_channel.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user