From b2c03be001598e00932977b9de564b936ddf096c Mon Sep 17 00:00:00 2001 From: xcaliber Date: Fri, 13 Mar 2026 23:47:06 +0000 Subject: [PATCH] Changeset 0.28.2.3 (#186) --- docs/ICD/projects.md | 256 +++++++++++++++++--- server/handlers/files.go | 3 + server/handlers/projects_test.go | 372 ++++++++++++++++++++++++++++++ server/handlers/team_providers.go | 3 +- 4 files changed, 597 insertions(+), 37 deletions(-) create mode 100644 server/handlers/projects_test.go diff --git a/docs/ICD/projects.md b/docs/ICD/projects.md index 3ff7ba0..9471890 100644 --- a/docs/ICD/projects.md +++ b/docs/ICD/projects.md @@ -4,84 +4,268 @@ knowledge bases, notes, and files. They follow the scope model (personal, team, global). -### Project CRUD +All endpoints require authentication (`Authorization: Bearer `). +Admin endpoints additionally require `role=admin`. -``` -GET /projects → { "data": [...] } -POST /projects ← { "name", "description", "scope", "team_id", "persona_id", "system_prompt" } -GET /projects/:id → project object -PUT /projects/:id ← partial update -DELETE /projects/:id -``` - -Project object: +## Project Object ```json { "id": "uuid", "name": "Q3 Research", "description": "...", + "color": "#3b82f6", + "icon": "folder", "scope": "personal|team|global", "owner_id": "uuid", "team_id": "uuid|null", - "persona_id": "uuid|null", - "system_prompt": "...|null", + "workspace_id": "uuid|null", "is_archived": false, + "settings": {}, "channel_count": 5, - "created_at": "...", - "updated_at": "..." + "kb_count": 2, + "note_count": 3, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z" } ``` -### Channel Association +| Field | Type | Notes | +|-------|------|-------| +| `id` | uuid | PK, auto-generated | +| `name` | string | Required, max 200 chars | +| `description` | string | Optional | +| `color` | string? | CSS hex color, max 7 chars | +| `icon` | string? | Icon identifier, max 50 chars | +| `scope` | enum | `personal`, `team`, `global` | +| `owner_id` | uuid | Set from JWT, immutable | +| `team_id` | uuid? | Set for team-scoped projects | +| `workspace_id` | uuid? | Linked workspace | +| `is_archived` | bool | Default `false` | +| `settings` | object | JSONB, default `{}` | +| `channel_count` | int | Computed, omitted when 0 | +| `kb_count` | int | Computed, omitted when 0 | +| `note_count` | int | Computed, omitted when 0 | +| `created_at` | datetime | Auto-set | +| `updated_at` | datetime | Auto-updated on change | + +## Project CRUD + +### List Projects ``` -GET /projects/:id/channels → { "data": [channel objects] } -POST /projects/:id/channels ← { "channel_id": "uuid" } -DELETE /projects/:id/channels/:channelId +GET /projects → { "data": [...] } +``` + +Query params: `?include_archived=true` to include archived projects. + +Returns projects the user can access: personal (own), team (member), +and global. Ordered by name. + +### Create Project + +``` +POST /projects → 201, project object +``` + +```json +{ + "name": "My Project", + "description": "Optional description", + "color": "#3b82f6", + "icon": "folder" +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `name` | yes | Max 200 chars | +| `description` | no | | +| `color` | no | CSS hex | +| `icon` | no | Icon identifier | + +Scope is set to `personal` and `owner_id` is taken from the JWT. +Returns the created project object (bare, no envelope). + +### Get Project + +``` +GET /projects/:id → project object +``` + +Returns bare project object (no envelope). 404 if not found or +not accessible. + +### Update Project + +``` +PUT /projects/:id → updated project object +``` + +Partial update — only supplied fields are changed. + +```json +{ + "name": "New Name", + "description": "Updated", + "color": "#ef4444", + "icon": "star", + "is_archived": true, + "workspace_id": "uuid|null", + "settings": { "key": "value" } +} +``` + +Settings are merged (overlay, not replace). Returns the refreshed +project object after update. + +### Delete Project + +``` +DELETE /projects/:id → { "message": "project deleted" } +``` + +Only the project owner or an admin can delete. Channels get +`project_id` set to NULL; junction rows cascade. + +--- + +## Channel Association + +``` +GET /projects/:id/channels → { "data": [...] } +POST /projects/:id/channels ← { "channel_id", "position" } +DELETE /projects/:id/channels/:channelId → { "message": "channel removed" } PUT /projects/:id/channels/reorder ← { "channel_ids": ["uuid1", "uuid2"] } ``` A channel can only belong to one project. `POST` performs an atomic -move if the channel is already in a different project. +move if the channel is already in a different project. The user must +own the channel being added. -### KB Association +**Channel association object:** + +```json +{ + "project_id": "uuid", + "channel_id": "uuid", + "position": 0, + "folder": "", + "added_at": "2025-01-15T10:30:00Z" +} +``` + +**POST request:** + +| Field | Required | Notes | +|-------|----------|-------| +| `channel_id` | yes | UUID of channel to add | +| `position` | no | Sort order (default 0) | + +--- + +## KB Association ``` -GET /projects/:id/knowledge-bases → { "data": [KB objects] } -POST /projects/:id/knowledge-bases ← { "kb_id": "uuid" } -DELETE /projects/:id/knowledge-bases/:kbId +GET /projects/:id/knowledge-bases → { "data": [...] } +POST /projects/:id/knowledge-bases ← { "kb_id", "auto_search" } +DELETE /projects/:id/knowledge-bases/:kbId → { "message": "KB removed" } ``` KBs bound to a project are automatically available to every channel -in that project (see §5.7 resolution chain). +in that project (resolution chain injection at completion time). +Upsert on conflict — re-posting updates `auto_search`. -### Note Association +**KB association object:** -``` -GET /projects/:id/notes → { "data": [note objects] } -POST /projects/:id/notes ← { "note_id": "uuid" } -DELETE /projects/:id/notes/:noteId +```json +{ + "project_id": "uuid", + "kb_id": "uuid", + "auto_search": true, + "added_at": "2025-01-15T10:30:00Z", + "name": "KB Name" +} ``` -### Project Files +`name` is enriched via JOIN from `knowledge_bases.name`. + +**POST request:** + +| Field | Required | Notes | +|-------|----------|-------| +| `kb_id` | yes | UUID of knowledge base | +| `auto_search` | no | Default `false` | + +--- + +## Note Association ``` -GET /projects/:id/files → { "files": [...] } +GET /projects/:id/notes → { "data": [...] } +POST /projects/:id/notes ← { "note_id" } +DELETE /projects/:id/notes/:noteId → { "message": "note removed" } +``` + +**Note association object:** + +```json +{ + "project_id": "uuid", + "note_id": "uuid", + "added_at": "2025-01-15T10:30:00Z", + "title": "Note Title" +} +``` + +`title` is enriched via JOIN from `notes.title`. +Insert uses ON CONFLICT DO NOTHING (idempotent). + +--- + +## Project Files + +``` +GET /projects/:id/files → { "files": [...], "count": N } POST /projects/:id/files ← multipart/form-data ``` Project-level file uploads (distinct from workspace files and channel -attachments). +attachments). Note: `GET` uses `"files"` key, not `"data"`. -### Admin Project Management +--- + +## Admin Project Management ``` GET /admin/projects → { "data": [...] } -DELETE /admin/projects/:id +DELETE /admin/projects/:id → { "message": "project deleted" } ``` -Cross-instance visibility for platform admins. BYOK personal projects -remain private (admin can delete but not read content). +Query params: `?include_archived=true`. + +Cross-instance visibility for platform admins. Admin list returns +an enriched object with extra fields: + +```json +{ + "id": "uuid", + "name": "...", + "description": "...", + "scope": "personal", + "owner_id": "uuid", + "team_id": null, + "is_archived": false, + "created_at": "...", + "updated_at": "...", + "channel_count": 5, + "kb_count": 2, + "note_count": 3, + "owner_name": "jdoe" +} +``` + +Note: admin list object omits `color`, `icon`, `workspace_id`, +and `settings` (uses a local struct, not the full model). --- diff --git a/server/handlers/files.go b/server/handlers/files.go index ce2a389..fde35ff 100644 --- a/server/handlers/files.go +++ b/server/handlers/files.go @@ -618,6 +618,9 @@ func (h *FileHandler) ListByProject(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } + if files == nil { + files = []models.File{} + } c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)}) } diff --git a/server/handlers/projects_test.go b/server/handlers/projects_test.go new file mode 100644 index 0000000..3411b14 --- /dev/null +++ b/server/handlers/projects_test.go @@ -0,0 +1,372 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/config" + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/middleware" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" + postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" + sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" +) + +// ── Project Test Harness ────────────────── + +type projectHarness struct { + *testHarness + stores store.Stores + userToken string + userID string + adminToken string + adminID string +} + +func setupProjectHarness(t *testing.T) *projectHarness { + t.Helper() + database.RequireTestDB(t) + database.TruncateAll(t) + + cfg := &config.Config{ + JWTSecret: testJWTSecret, + BasePath: "", + } + + var stores store.Stores + if database.IsSQLite() { + stores = sqlite.NewStores(database.TestDB) + } else { + stores = postgres.NewStores(database.TestDB) + } + + r := gin.New() + api := r.Group("/api/v1") + protected := api.Group("") + protected.Use(middleware.Auth(cfg)) + + projH := NewProjectHandler(stores) + protected.GET("/projects", projH.List) + protected.POST("/projects", projH.Create) + protected.GET("/projects/:id", projH.Get) + protected.PUT("/projects/:id", projH.Update) + protected.DELETE("/projects/:id", projH.Delete) + protected.POST("/projects/:id/channels", projH.AddChannel) + protected.DELETE("/projects/:id/channels/:channelId", projH.RemoveChannel) + protected.GET("/projects/:id/channels", projH.ListChannels) + protected.PUT("/projects/:id/channels/reorder", projH.ReorderChannels) + protected.POST("/projects/:id/knowledge-bases", projH.AddKB) + protected.DELETE("/projects/:id/knowledge-bases/:kbId", projH.RemoveKB) + protected.GET("/projects/:id/knowledge-bases", projH.ListKBs) + protected.POST("/projects/:id/notes", projH.AddNote) + protected.DELETE("/projects/:id/notes/:noteId", projH.RemoveNote) + protected.GET("/projects/:id/notes", projH.ListNotes) + + // Admin routes + admin := api.Group("/admin") + admin.Use(middleware.Auth(cfg)) + admin.Use(middleware.RequireAdmin()) + admin.GET("/projects", projH.AdminList) + admin.DELETE("/projects/:id", projH.Delete) + + userID := database.SeedTestUser(t, "projuser", "projuser@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) + userToken := makeToken(userID, "projuser@test.com", "user") + + adminID := database.SeedTestUser(t, "projadmin", "projadmin@test.com") + database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, role = 'admin' WHERE id = $1"), adminID) + adminToken := makeToken(adminID, "projadmin@test.com", "admin") + + return &projectHarness{ + testHarness: &testHarness{router: r, t: t}, + stores: stores, + userToken: userToken, + userID: userID, + adminToken: adminToken, + adminID: adminID, + } +} + +// seedProject creates a project via the store and returns it. +func (h *projectHarness) seedProject(name, ownerID string) *models.Project { + h.t.Helper() + p := &models.Project{ + Name: name, + Scope: models.ScopePersonal, + OwnerID: ownerID, + } + if err := h.stores.Projects.Create(context.Background(), p); err != nil { + h.t.Fatalf("seedProject: %v", err) + } + return p +} + +// ── GET /projects — envelope ────────────── + +func TestProjects_List_Empty_Envelope(t *testing.T) { + h := setupProjectHarness(t) + + resp := h.request("GET", "/api/v1/projects", h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(resp.Body.Bytes(), &body) + + data, ok := body["data"] + if !ok { + t.Fatal("GET /projects must return {\"data\": [...]}, missing \"data\" key") + } + arr, ok := data.([]interface{}) + if !ok { + t.Fatalf("data should be an array, got %T", data) + } + if len(arr) != 0 { + t.Errorf("expected empty array, got %d items", len(arr)) + } +} + +func TestProjects_List_WithData_Shape(t *testing.T) { + h := setupProjectHarness(t) + h.seedProject("test-proj", h.userID) + + resp := h.request("GET", "/api/v1/projects", h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(resp.Body.Bytes(), &body) + + data, ok := body["data"].([]interface{}) + if !ok { + t.Fatal("data must be an array") + } + if len(data) != 1 { + t.Fatalf("expected 1 project, got %d", len(data)) + } + + proj := data[0].(map[string]interface{}) + for _, key := range []string{"id", "name", "scope", "owner_id", "is_archived", "created_at", "updated_at"} { + if _, exists := proj[key]; !exists { + t.Errorf("missing field %q in project object", key) + } + } + if proj["name"] != "test-proj" { + t.Errorf("name: got %v, want test-proj", proj["name"]) + } +} + +// ── POST /projects — create ────────────── + +func TestProjects_Create_201_Shape(t *testing.T) { + h := setupProjectHarness(t) + + resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{ + "name": "new-project", + "description": "test desc", + "color": "#ff0000", + "icon": "star", + }) + if resp.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(resp.Body.Bytes(), &body) + + // Bare object, not wrapped in "data" + if _, exists := body["data"]; exists { + t.Error("POST /projects should return bare object, not wrapped in 'data'") + } + if body["name"] != "new-project" { + t.Errorf("name: got %v, want new-project", body["name"]) + } + if body["scope"] != "personal" { + t.Errorf("scope should be forced to personal, got %v", body["scope"]) + } + if body["owner_id"] != h.userID { + t.Errorf("owner_id: got %v, want %s", body["owner_id"], h.userID) + } + if body["color"] != "#ff0000" { + t.Errorf("color: got %v, want #ff0000", body["color"]) + } + if body["icon"] != "star" { + t.Errorf("icon: got %v, want star", body["icon"]) + } +} + +func TestProjects_Create_RequiresName(t *testing.T) { + h := setupProjectHarness(t) + + resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{ + "description": "no name", + }) + if resp.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing name, got %d", resp.Code) + } +} + +// ── GET /projects/:id — single object ───── + +func TestProjects_Get_Shape(t *testing.T) { + h := setupProjectHarness(t) + p := h.seedProject("detail-proj", h.userID) + + resp := h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(resp.Body.Bytes(), &body) + + // Bare object + if _, exists := body["data"]; exists { + t.Error("GET /:id should return object directly, not wrapped in 'data'") + } + if body["id"] != p.ID { + t.Errorf("id: got %v, want %s", body["id"], p.ID) + } + // Computed counts use omitempty — absent when zero, present as number when nonzero. + // With a fresh project all counts are 0, so they'll be omitted. Verify that if + // present they're numeric (the AdminList test covers nonzero count visibility). + for _, key := range []string{"channel_count", "kb_count", "note_count"} { + if val, exists := body[key]; exists { + if _, ok := val.(float64); !ok { + t.Errorf("%s should be numeric, got %T", key, val) + } + } + } +} + +func TestProjects_Get_NotFound(t *testing.T) { + h := setupProjectHarness(t) + + resp := h.request("GET", "/api/v1/projects/00000000-0000-0000-0000-000000000000", h.userToken, nil) + if resp.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.Code) + } +} + +// ── PUT /projects/:id — update ──────────── + +func TestProjects_Update_ReturnsRefreshed(t *testing.T) { + h := setupProjectHarness(t) + p := h.seedProject("old-name", h.userID) + + resp := h.request("PUT", "/api/v1/projects/"+p.ID, h.userToken, map[string]interface{}{ + "name": "new-name", + }) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(resp.Body.Bytes(), &body) + + if body["name"] != "new-name" { + t.Errorf("name should be updated to new-name, got %v", body["name"]) + } + if body["id"] != p.ID { + t.Errorf("id mismatch: got %v, want %s", body["id"], p.ID) + } +} + +// ── DELETE /projects/:id ────────────────── + +func TestProjects_Delete_Shape(t *testing.T) { + h := setupProjectHarness(t) + p := h.seedProject("doomed", h.userID) + + resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(resp.Body.Bytes(), &body) + + if body["message"] != "project deleted" { + t.Errorf("expected message 'project deleted', got %v", body["message"]) + } + + // Confirm actually deleted + resp = h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil) + if resp.Code != http.StatusNotFound { + t.Errorf("project should be gone, got %d", resp.Code) + } +} + +func TestProjects_Delete_ForbiddenForNonOwner(t *testing.T) { + h := setupProjectHarness(t) + + otherID := database.SeedTestUser(t, "other", "other@test.com") + p := h.seedProject("others-proj", otherID) + + resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil) + // Non-owner, non-admin user should not be able to access (404 from loadAndAuthorize) + if resp.Code != http.StatusNotFound { + t.Fatalf("expected 404 for other user's personal project, got %d", resp.Code) + } +} + +// ── Auth ────────────────────────────────── + +func TestProjects_RequiresAuth(t *testing.T) { + h := setupProjectHarness(t) + + resp := h.request("GET", "/api/v1/projects", "", nil) + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without token, got %d", resp.Code) + } +} + +// ── Admin List ──────────────────────────── + +func TestProjects_AdminList_Envelope(t *testing.T) { + h := setupProjectHarness(t) + h.seedProject("admin-visible", h.userID) + + resp := h.request("GET", "/api/v1/admin/projects", h.adminToken, nil) + if resp.Code != http.StatusOK { + t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) + } + + var body map[string]interface{} + json.Unmarshal(resp.Body.Bytes(), &body) + + data, ok := body["data"].([]interface{}) + if !ok { + t.Fatal("admin list must return {\"data\": [...]}") + } + if len(data) < 1 { + t.Fatal("expected at least 1 project in admin list") + } + + proj := data[0].(map[string]interface{}) + // Admin list has extra owner_name field + if _, exists := proj["owner_name"]; !exists { + t.Error("admin list project should include owner_name") + } + for _, key := range []string{"channel_count", "kb_count", "note_count"} { + if _, exists := proj[key]; !exists { + t.Errorf("admin list missing computed field %q", key) + } + } +} + +func TestProjects_AdminList_ForbiddenForUser(t *testing.T) { + h := setupProjectHarness(t) + + resp := h.request("GET", "/api/v1/admin/projects", h.userToken, nil) + if resp.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin, got %d", resp.Code) + } +} diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go index 9c7dc20..48e6daa 100644 --- a/server/handlers/team_providers.go +++ b/server/handlers/team_providers.go @@ -324,6 +324,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { type modelInfo struct { ID string `json:"id"` + Type string `json:"type"` Capabilities models.ModelCapabilities `json:"capabilities"` } @@ -331,7 +332,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { for _, m := range modelList { caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities, nil) caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps) - out = append(out, modelInfo{ID: m.ID, Capabilities: caps}) + out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps}) } c.JSON(http.StatusOK, gin.H{"models": out, "provider": name})