Changeset 0.28.0.3 (#175)
This commit is contained in:
@@ -101,22 +101,70 @@ Standard HTTP status codes: 400 (bad request), 401 (not authenticated),
|
|||||||
403 (not authorized), 404 (not found), 409 (conflict), 500 (server error),
|
403 (not authorized), 404 (not found), 409 (conflict), 500 (server error),
|
||||||
502 (upstream provider failure).
|
502 (upstream provider failure).
|
||||||
|
|
||||||
### Pagination Envelope
|
### Response Envelopes
|
||||||
|
|
||||||
Endpoints that paginate return:
|
Three patterns. Every endpoint uses exactly one.
|
||||||
|
|
||||||
|
**List (returns an array)** → always wrap in `{"data": []}`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If paginated, pagination fields sit alongside `data`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": [...],
|
"data": [...],
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"per_page": 50,
|
"per_page": 50,
|
||||||
"total": 142,
|
"total": 142
|
||||||
"total_pages": 3
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Query params: `?page=1&per_page=50`. Not all list endpoints paginate —
|
Query params: `?page=1&per_page=50`. Not all list endpoints paginate —
|
||||||
some return `{ "data": [...] }` or a bare array in a named key.
|
unpaginated lists still use `{"data": [...]}`.
|
||||||
|
|
||||||
|
This is a hard rule: `data` is the only array wrapper key. No
|
||||||
|
domain-specific keys (`teams`, `members`, `configs`, etc.) for arrays.
|
||||||
|
Clients parse every list response identically.
|
||||||
|
|
||||||
|
**Single object (GET by ID, profile, health)** → return the object directly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "...",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No wrapping. The response *is* the resource.
|
||||||
|
|
||||||
|
**Composite (multiple named values, not an array)** → named keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": 5,
|
||||||
|
"pending": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used for stats, counts, status endpoints — anything returning
|
||||||
|
multiple named scalars or heterogeneous data. The key names are
|
||||||
|
domain-specific and documented per endpoint.
|
||||||
|
|
||||||
|
**Empty arrays** must serialize as `[]`, never `null`. Go handlers
|
||||||
|
must guard nil slices before serialization:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if result == nil {
|
||||||
|
result = []MyType{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
```
|
||||||
|
|
||||||
### ID Format
|
### ID Format
|
||||||
|
|
||||||
|
|||||||
@@ -3,35 +3,58 @@
|
|||||||
### My Teams
|
### My Teams
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/mine → { "teams": [...] }
|
GET /teams/mine → { "data": [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns teams the current user is a member of.
|
**Auth:** Authenticated user.
|
||||||
|
|
||||||
|
Returns teams the current user is a member of (active teams only).
|
||||||
|
|
||||||
|
Each team object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Engineering",
|
||||||
|
"description": "...",
|
||||||
|
"is_active": true,
|
||||||
|
"settings": "{}",
|
||||||
|
"my_role": "admin|member",
|
||||||
|
"member_count": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Team Administration
|
### Team Administration
|
||||||
|
|
||||||
Team-admin-scoped routes (require `RequireTeamAdmin` middleware):
|
|
||||||
|
|
||||||
**Team CRUD (platform admin):**
|
**Team CRUD (platform admin):**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /admin/teams → { "teams": [...] }
|
GET /admin/teams → { "data": [...], "total": N, "page": N, "per_page": N }
|
||||||
POST /admin/teams
|
POST /admin/teams ← { "name", "description" } → { "id", "name" }
|
||||||
GET /admin/teams/:id
|
GET /admin/teams/:id → team object (unwrapped)
|
||||||
PUT /admin/teams/:id
|
PUT /admin/teams/:id ← { "name"?, "description"?, "is_active"?, "settings"? }
|
||||||
DELETE /admin/teams/:id
|
DELETE /admin/teams/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
**Members:**
|
**Auth:** `RequireAdmin` (platform admin).
|
||||||
|
|
||||||
|
`POST` returns `201`. `PUT`/`DELETE` return `{"ok": true}`. `PUT` with
|
||||||
|
no fields returns `400`. Duplicate name returns `409`.
|
||||||
|
|
||||||
|
**Members (admin routes):**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /admin/teams/:id/members → { "members": [...] }
|
GET /admin/teams/:id/members → { "data": [...] }
|
||||||
POST /admin/teams/:id/members ← { "user_id", "role" }
|
POST /admin/teams/:id/members ← { "user_id", "role" } → { "id" }
|
||||||
PUT /admin/teams/:id/members/:memberId ← { "role" }
|
PUT /admin/teams/:id/members/:memberId ← { "role" }
|
||||||
DELETE /admin/teams/:id/members/:memberId
|
DELETE /admin/teams/:id/members/:memberId
|
||||||
```
|
```
|
||||||
|
|
||||||
**Team-scoped routes** (team admin, not platform admin):
|
**Auth:** `RequireAdmin`.
|
||||||
|
|
||||||
|
`role` must be `admin` or `member`. Duplicate membership returns `409`.
|
||||||
|
|
||||||
|
**Team-scoped routes** (team admin self-service):
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/:teamId/members
|
GET /teams/:teamId/members
|
||||||
@@ -40,47 +63,88 @@ PUT /teams/:teamId/members/:memberId
|
|||||||
DELETE /teams/:teamId/members/:memberId
|
DELETE /teams/:teamId/members/:memberId
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireTeamAdmin`.
|
||||||
|
|
||||||
|
Same handlers as admin routes; the `getTeamID()` helper reads either
|
||||||
|
`:id` or `:teamId`.
|
||||||
|
|
||||||
**Team Providers:**
|
**Team Providers:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/:teamId/providers → { "configs": [...] }
|
GET /teams/:teamId/providers → { "data": [...], "allow_team_providers": bool }
|
||||||
POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
|
POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
|
||||||
PUT /teams/:teamId/providers/:id
|
PUT /teams/:teamId/providers/:id
|
||||||
DELETE /teams/:teamId/providers/:id
|
DELETE /teams/:teamId/providers/:id
|
||||||
GET /teams/:teamId/providers/:id/models → { "models": [...] }
|
GET /teams/:teamId/providers/:id/models → { "models": [...], "provider": "name" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireTeamAdmin`.
|
||||||
|
|
||||||
|
`GET` returns provider configs scoped to the team. The `models` endpoint
|
||||||
|
performs a live query to the upstream provider.
|
||||||
|
|
||||||
**Team Models:**
|
**Team Models:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/:teamId/models → { "models": [...] }
|
GET /teams/:teamId/models → { "models": [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Available models for this team (global + team-scoped).
|
**Auth:** `RequireTeamAdmin`.
|
||||||
|
|
||||||
**Team Personas:** See §4.2.
|
Returns all models available to this team: global admin models with
|
||||||
|
`visibility IN ('enabled', 'team')` plus live-queried team provider
|
||||||
|
models with `source` field (`"global"` or `"team"`).
|
||||||
|
|
||||||
|
**Team Groups:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/groups → { "data": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireTeamAdmin`.
|
||||||
|
|
||||||
|
Returns groups scoped to this team.
|
||||||
|
|
||||||
|
**Team Personas:** See personas.md §Team Personas.
|
||||||
|
|
||||||
**Team Roles:**
|
**Team Roles:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/:teamId/roles → { "roles": [...] }
|
GET /teams/:teamId/roles → { "data": {...} }
|
||||||
PUT /teams/:teamId/roles/:role ← { "permissions": {...} }
|
PUT /teams/:teamId/roles/:role ← RoleConfig object
|
||||||
DELETE /teams/:teamId/roles/:role
|
DELETE /teams/:teamId/roles/:role
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireTeamAdmin`.
|
||||||
|
|
||||||
|
`GET` returns the team's `model_roles` settings map (composite object,
|
||||||
|
not an array). Initially empty `{}`. Each key is a role name, value is
|
||||||
|
a `RoleConfig`. `PUT` validates the role name. `DELETE` removes the
|
||||||
|
override, falling back to the global role definition.
|
||||||
|
|
||||||
**Team Audit:**
|
**Team Audit:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/:teamId/audit → paginated audit log
|
GET /teams/:teamId/audit → { "data": [...], "total": N, "page": N, "per_page": N }
|
||||||
GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types)
|
GET /teams/:teamId/audit/actions → { "actions": [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireTeamAdmin`.
|
||||||
|
|
||||||
|
Audit log is scoped to actions performed by team members. Supports
|
||||||
|
query filters: `?action=`, `?actor_id=`, `?resource_type=`.
|
||||||
|
|
||||||
**Team Usage:**
|
**Team Usage:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /teams/:teamId/usage → { "totals": {...}, "results": [...] }
|
GET /teams/:teamId/usage → { "totals": {...}, "results": [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireTeamAdmin`.
|
||||||
|
|
||||||
|
Returns usage against team-owned providers. Supports query params for
|
||||||
|
date range filtering.
|
||||||
|
|
||||||
### Groups & Resource Grants
|
### Groups & Resource Grants
|
||||||
|
|
||||||
Groups are ACL containers that decouple access from team membership.
|
Groups are ACL containers that decouple access from team membership.
|
||||||
@@ -90,45 +154,58 @@ scope.
|
|||||||
**My Groups:**
|
**My Groups:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /groups/mine → { "groups": [...] }
|
GET /groups/mine → { "data": [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** Authenticated user.
|
||||||
|
|
||||||
**Admin Group CRUD:**
|
**Admin Group CRUD:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /admin/groups → { "groups": [...] }
|
GET /admin/groups → { "data": [...] }
|
||||||
POST /admin/groups ← { "name", "scope", "team_id" }
|
POST /admin/groups ← { "name", "scope", "team_id"? }
|
||||||
GET /admin/groups/:id
|
GET /admin/groups/:id → group object (unwrapped)
|
||||||
PUT /admin/groups/:id
|
PUT /admin/groups/:id ← { "name"?, "description"?, "permissions"?, ... }
|
||||||
DELETE /admin/groups/:id
|
DELETE /admin/groups/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireAdmin`.
|
||||||
|
|
||||||
|
`POST` returns `201`. Duplicate name returns `409`. Deleting a
|
||||||
|
system-sourced group (e.g. Everyone) returns `400`.
|
||||||
|
|
||||||
**Group Members:**
|
**Group Members:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /admin/groups/:id/members → { "members": [...] }
|
GET /admin/groups/:id/members → { "data": [...] }
|
||||||
POST /admin/groups/:id/members ← { "user_id" }
|
POST /admin/groups/:id/members ← { "user_id" }
|
||||||
DELETE /admin/groups/:id/members/:userId
|
DELETE /admin/groups/:id/members/:userId
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireAdmin`.
|
||||||
|
|
||||||
**Resource Grants:**
|
**Resource Grants:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /admin/grants/:type/:id → { "grant": {...} }
|
GET /admin/grants/:type/:id → grant object (unwrapped)
|
||||||
PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] }
|
PUT /admin/grants/:type/:id ← { "grant_scope", "granted_groups"?: [...] }
|
||||||
DELETE /admin/grants/:type/:id
|
DELETE /admin/grants/:type/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireAdmin`.
|
||||||
|
|
||||||
`:type` is `persona` or `kb`. `:id` is the resource ID.
|
`:type` is `persona` or `kb`. `:id` is the resource ID.
|
||||||
|
|
||||||
Grant types:
|
Grant scopes:
|
||||||
|
|
||||||
| `grant_type` | Visibility |
|
| `grant_scope` | Visibility |
|
||||||
|--------------|-----------|
|
|---------------|-----------|
|
||||||
| `team_only` | Only the owning team |
|
| `team_only` | Only the owning team |
|
||||||
| `global` | All authenticated users |
|
| `global` | All authenticated users |
|
||||||
| `groups` | Members of specified groups |
|
| `groups` | Members of specified groups |
|
||||||
|
|
||||||
|
Setting `grant_scope: "groups"` without `granted_groups` returns `400`.
|
||||||
|
|
||||||
### Permissions
|
### Permissions
|
||||||
|
|
||||||
Groups carry fine-grained permissions. Permission resolution:
|
Groups carry fine-grained permissions. Permission resolution:
|
||||||
@@ -140,13 +217,18 @@ union of all group permissions + Everyone group permissions.
|
|||||||
GET /admin/permissions → { "permissions": ["model.use", "kb.read", ...] }
|
GET /admin/permissions → { "permissions": ["model.use", "kb.read", ...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Auth:** `RequireAdmin`.
|
||||||
|
|
||||||
**Get user's effective permissions:**
|
**Get user's effective permissions:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /admin/users/:id/permissions → { "permissions": [...], "groups": [...] }
|
GET /admin/users/:id/permissions → { "permissions": [...], "user_id": "...", "groups": [...] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the resolved permission set and contributing groups.
|
**Auth:** `RequireAdmin`.
|
||||||
|
|
||||||
|
Returns the resolved permission set and contributing group IDs
|
||||||
|
(always includes the Everyone group).
|
||||||
|
|
||||||
**Group permission fields** (set via `PUT /admin/groups/:id`):
|
**Group permission fields** (set via `PUT /admin/groups/:id`):
|
||||||
|
|
||||||
@@ -174,4 +256,3 @@ row needed). Editable by admins, cannot be deleted (`source=system`).
|
|||||||
resolved permission set. Returns 403 if the required permission is not present.
|
resolved permission set. Returns 403 if the required permission is not present.
|
||||||
|
|
||||||
See [enums.md](enums.md) for the full permission constant list.
|
See [enums.md](enums.md) for the full permission constant list.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
@@ -82,6 +83,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
Role: role,
|
Role: role,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
|
Handle: auth.UniqueHandle(c.Request.Context(), h.stores.Users, models.HandleFromName(req.Username)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
|
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -191,16 +192,15 @@ func (h *GroupHandler) DeleteGroup(c *gin.Context) {
|
|||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
err := h.stores.Groups.Delete(c.Request.Context(), id)
|
err := h.stores.Groups.Delete(c.Request.Context(), id)
|
||||||
if err == sql.ErrNoRows {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if errors.Is(err, store.ErrSystemGroup) {
|
||||||
// Store layer returns a plain error for system groups.
|
|
||||||
if err.Error() == "system groups cannot be deleted" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "system groups cannot be deleted"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -428,8 +428,9 @@ func (h *GroupHandler) ListPermissions(c *gin.Context) {
|
|||||||
// GET /api/v1/admin/users/:id/permissions
|
// GET /api/v1/admin/users/:id/permissions
|
||||||
func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
|
func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
perms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, userID)
|
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
|
||||||
return
|
return
|
||||||
@@ -440,5 +441,14 @@ func (h *GroupHandler) GetUserPermissions(c *gin.Context) {
|
|||||||
for p := range perms {
|
for p := range perms {
|
||||||
list = append(list, p)
|
list = append(list, p)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID})
|
|
||||||
|
// Contributing groups: explicit membership + Everyone
|
||||||
|
groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID)
|
||||||
|
if groupIDs == nil {
|
||||||
|
groupIDs = []string{}
|
||||||
|
}
|
||||||
|
// Everyone group always contributes
|
||||||
|
groupIDs = append(groupIDs, auth.EveryoneGroupID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"permissions": list, "user_id": userID, "groups": groupIDs})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,18 +170,33 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||||
|
|
||||||
// Team self-service (same route group as production)
|
// Team self-service (same route group as production)
|
||||||
teams := NewTeamHandler(nil)
|
teams := NewTeamHandler(stores, nil)
|
||||||
protected.GET("/teams/mine", teams.MyTeams)
|
protected.GET("/teams/mine", teams.MyTeams)
|
||||||
|
|
||||||
teamScoped := protected.Group("/teams/:teamId")
|
teamScoped := protected.Group("/teams/:teamId")
|
||||||
teamScoped.Use(middleware.RequireTeamAdmin())
|
teamScoped.Use(middleware.RequireTeamAdmin())
|
||||||
{
|
{
|
||||||
|
// Team members (team admin self-service)
|
||||||
|
teamScoped.GET("/members", teams.ListMembers)
|
||||||
|
teamScoped.POST("/members", teams.AddMember)
|
||||||
|
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||||
|
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||||
|
|
||||||
|
// Team providers
|
||||||
teamScoped.GET("/providers", teams.ListTeamProviders)
|
teamScoped.GET("/providers", teams.ListTeamProviders)
|
||||||
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
||||||
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
|
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
|
||||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||||
|
|
||||||
|
// Team audit
|
||||||
|
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||||
|
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||||
|
|
||||||
|
// Team groups
|
||||||
|
teamGroupH := NewGroupHandler(stores)
|
||||||
|
teamScoped.GET("/groups", teamGroupH.ListTeamGroups)
|
||||||
|
|
||||||
// Team usage
|
// Team usage
|
||||||
teamUsage := NewUsageHandler(stores)
|
teamUsage := NewUsageHandler(stores)
|
||||||
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
||||||
@@ -317,8 +332,12 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
admin.GET("/teams", teams.ListTeams)
|
admin.GET("/teams", teams.ListTeams)
|
||||||
admin.POST("/teams", teams.CreateTeam)
|
admin.POST("/teams", teams.CreateTeam)
|
||||||
admin.GET("/teams/:id", teams.GetTeam)
|
admin.GET("/teams/:id", teams.GetTeam)
|
||||||
|
admin.PUT("/teams/:id", teams.UpdateTeam)
|
||||||
|
admin.DELETE("/teams/:id", teams.DeleteTeam)
|
||||||
admin.GET("/teams/:id/members", teams.ListMembers)
|
admin.GET("/teams/:id/members", teams.ListMembers)
|
||||||
admin.POST("/teams/:id/members", teams.AddMember)
|
admin.POST("/teams/:id/members", teams.AddMember)
|
||||||
|
admin.PUT("/teams/:id/members/:memberId", teams.UpdateMember)
|
||||||
|
admin.DELETE("/teams/:id/members/:memberId", teams.RemoveMember)
|
||||||
admin.GET("/personas", personas.ListAdminPersonas)
|
admin.GET("/personas", personas.ListAdminPersonas)
|
||||||
admin.POST("/personas", personas.CreateAdminPersona)
|
admin.POST("/personas", personas.CreateAdminPersona)
|
||||||
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||||
@@ -340,6 +359,10 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
||||||
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
||||||
|
|
||||||
|
// Admin permissions (v0.16.0)
|
||||||
|
admin.GET("/permissions", groupAdm.ListPermissions)
|
||||||
|
admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions)
|
||||||
|
|
||||||
// User groups (v0.16.0)
|
// User groups (v0.16.0)
|
||||||
protected.GET("/groups/mine", groupAdm.MyGroups)
|
protected.GET("/groups/mine", groupAdm.MyGroups)
|
||||||
|
|
||||||
@@ -1906,10 +1929,14 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
|
t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
var roleList map[string]interface{}
|
var roleResp map[string]interface{}
|
||||||
decode(w, &roleList)
|
decode(w, &roleResp)
|
||||||
if len(roleList) != 0 {
|
roleData, _ := roleResp["data"].(map[string]interface{})
|
||||||
t.Fatalf("team roles should be empty initially, got %d", len(roleList))
|
if roleData == nil {
|
||||||
|
roleData = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
if len(roleData) != 0 {
|
||||||
|
t.Fatalf("team roles should be empty initially, got %d", len(roleData))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set team role override
|
// Set team role override
|
||||||
@@ -1926,8 +1953,13 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
|||||||
|
|
||||||
// List should now have override
|
// List should now have override
|
||||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||||
decode(w, &roleList)
|
roleResp = map[string]interface{}{}
|
||||||
if _, ok := roleList["utility"]; !ok {
|
decode(w, &roleResp)
|
||||||
|
roleData, _ = roleResp["data"].(map[string]interface{})
|
||||||
|
if roleData == nil || len(roleData) == 0 {
|
||||||
|
t.Fatal("team roles should have utility after update")
|
||||||
|
}
|
||||||
|
if _, ok := roleData["utility"]; !ok {
|
||||||
t.Fatal("team roles should have utility after update")
|
t.Fatal("team roles should have utility after update")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1939,11 +1971,14 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
|||||||
|
|
||||||
// List should be empty again
|
// List should be empty again
|
||||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||||
roleList = map[string]interface{}{} // reset — json.Unmarshal merges into existing maps
|
roleResp = map[string]interface{}{}
|
||||||
decode(w, &roleList)
|
decode(w, &roleResp)
|
||||||
if _, ok := roleList["utility"]; ok {
|
roleData, _ = roleResp["data"].(map[string]interface{})
|
||||||
|
if roleData != nil {
|
||||||
|
if _, ok := roleData["utility"]; ok {
|
||||||
t.Fatal("team roles should not have utility after delete")
|
t.Fatal("team roles should not have utility after delete")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -3383,3 +3418,564 @@ func TestIntegration_ChannelListWithTypeFilter(t *testing.T) {
|
|||||||
// Verify the request succeeded (200) — that's the regression test.
|
// Verify the request succeeded (200) — that's the regression test.
|
||||||
// Channel content verification is covered by other tests.
|
// Channel content verification is covered by other tests.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// TEAMS ICD AUDIT — Phase 2 Tests
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── Admin Team CRUD Lifecycle ──────────────
|
||||||
|
|
||||||
|
func TestAudit_AdminTeamCRUD(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
// ── Create ──
|
||||||
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||||
|
"name": "Audit Team", "description": "For audit testing",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create team: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var created map[string]interface{}
|
||||||
|
decode(w, &created)
|
||||||
|
teamID := created["id"].(string)
|
||||||
|
if created["name"] != "Audit Team" {
|
||||||
|
t.Fatalf("create response should have name, got %v", created["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List (data envelope) ──
|
||||||
|
w = h.request("GET", "/api/v1/admin/teams", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list teams: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
var listResp map[string]interface{}
|
||||||
|
decode(w, &listResp)
|
||||||
|
if _, ok := listResp["data"]; !ok {
|
||||||
|
t.Fatal("GET /admin/teams must return 'data' key (envelope convention)")
|
||||||
|
}
|
||||||
|
if _, ok := listResp["total"]; !ok {
|
||||||
|
t.Fatal("GET /admin/teams must return 'total' key (paginated)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get ──
|
||||||
|
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got map[string]interface{}
|
||||||
|
decode(w, &got)
|
||||||
|
if got["name"] != "Audit Team" {
|
||||||
|
t.Fatalf("get team name mismatch: %v", got["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get nonexistent ──
|
||||||
|
w = h.request("GET", "/api/v1/admin/teams/00000000-0000-0000-0000-000000000099", adminToken, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("get nonexistent team: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update ──
|
||||||
|
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
|
||||||
|
"name": "Renamed Team",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||||
|
decode(w, &got)
|
||||||
|
if got["name"] != "Renamed Team" {
|
||||||
|
t.Fatalf("after update, name should be 'Renamed Team', got %v", got["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update with no fields → 400 ──
|
||||||
|
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{})
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("update with no fields: want 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Duplicate name → 409 ──
|
||||||
|
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||||
|
"name": "Second Team",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create second team: %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = h.request("PUT", "/api/v1/admin/teams/"+teamID, adminToken, map[string]interface{}{
|
||||||
|
"name": "Second Team",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("duplicate name update: want 409, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ──
|
||||||
|
w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete team: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deleted
|
||||||
|
w = h.request("GET", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("deleted team: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete nonexistent → 404 ──
|
||||||
|
w = h.request("DELETE", "/api/v1/admin/teams/"+teamID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("delete nonexistent: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Member Update + Remove ───────────
|
||||||
|
|
||||||
|
func TestAudit_AdminMemberUpdateRemove(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
userID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||||
|
|
||||||
|
// Create team
|
||||||
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||||
|
"name": "MemberTest",
|
||||||
|
})
|
||||||
|
var team map[string]interface{}
|
||||||
|
decode(w, &team)
|
||||||
|
teamID := team["id"].(string)
|
||||||
|
|
||||||
|
// Add member
|
||||||
|
w = h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||||
|
map[string]string{"user_id": userID, "role": "member"})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("add member: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get member ID from listing
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken, nil)
|
||||||
|
var membersResp map[string]interface{}
|
||||||
|
decode(w, &membersResp)
|
||||||
|
members := membersResp["data"].([]interface{})
|
||||||
|
if len(members) != 1 {
|
||||||
|
t.Fatalf("expected 1 member, got %d", len(members))
|
||||||
|
}
|
||||||
|
memberID := members[0].(map[string]interface{})["id"].(string)
|
||||||
|
|
||||||
|
// Update role → admin
|
||||||
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken,
|
||||||
|
map[string]string{"role": "admin"})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nonexistent member → 404
|
||||||
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, "00000000-0000-0000-0000-000000000099"), adminToken,
|
||||||
|
map[string]string{"role": "member"})
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("update nonexistent member: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove member
|
||||||
|
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("remove member: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove nonexistent → 404
|
||||||
|
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/teams/%s/members/%s", teamID, memberID), adminToken, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("remove nonexistent: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── C1: Cross-Team Member Mutation ─────────
|
||||||
|
// BUG: UpdateMember/RemoveMember WHERE clause has no team_id constraint.
|
||||||
|
// A team admin for Team A can mutate membership in Team B by knowing
|
||||||
|
// the team_members.id primary key.
|
||||||
|
|
||||||
|
func TestAudit_CrossTeamMemberMutation(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
// Create two users
|
||||||
|
aliceID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), aliceID)
|
||||||
|
bobID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), bobID)
|
||||||
|
|
||||||
|
// Create Team A and Team B
|
||||||
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team A"})
|
||||||
|
var tA map[string]interface{}
|
||||||
|
decode(w, &tA)
|
||||||
|
teamAID := tA["id"].(string)
|
||||||
|
|
||||||
|
w = h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{"name": "Team B"})
|
||||||
|
var tB map[string]interface{}
|
||||||
|
decode(w, &tB)
|
||||||
|
teamBID := tB["id"].(string)
|
||||||
|
|
||||||
|
// Alice is admin of Team A
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamAID), adminToken,
|
||||||
|
map[string]string{"user_id": aliceID, "role": "admin"})
|
||||||
|
|
||||||
|
// Bob is member of Team B
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken,
|
||||||
|
map[string]string{"user_id": bobID, "role": "member"})
|
||||||
|
|
||||||
|
// Get Bob's team_members.id from Team B listing
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamBID), adminToken, nil)
|
||||||
|
var bMembers map[string]interface{}
|
||||||
|
decode(w, &bMembers)
|
||||||
|
bobMemberID := bMembers["data"].([]interface{})[0].(map[string]interface{})["id"].(string)
|
||||||
|
|
||||||
|
// Alice (Team A admin) tries to update Bob's role in Team B
|
||||||
|
// via the team-scoped route for Team A — should fail with 404 because
|
||||||
|
// bobMemberID does not belong to Team A.
|
||||||
|
aliceToken := makeToken(aliceID, "alice@test.com", "user")
|
||||||
|
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken,
|
||||||
|
map[string]string{"role": "admin"})
|
||||||
|
if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("C1 BUG: cross-team member update should be 404/403, got %d: %s\n"+
|
||||||
|
"UpdateMember WHERE clause has no team_id constraint — authorization bypass",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice (Team A admin) tries to remove Bob from Team B
|
||||||
|
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/members/%s", teamAID, bobMemberID), aliceToken, nil)
|
||||||
|
if w.Code != http.StatusNotFound && w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("C1 BUG: cross-team member delete should be 404/403, got %d: %s\n"+
|
||||||
|
"RemoveMember WHERE clause has no team_id constraint — authorization bypass",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team Audit Log ─────────────────────────
|
||||||
|
|
||||||
|
func TestAudit_TeamAuditLog(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
// Create team + add admin as team member so audit actions are scoped
|
||||||
|
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||||
|
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||||
|
"name": "AuditTeam",
|
||||||
|
})
|
||||||
|
var team map[string]interface{}
|
||||||
|
decode(w, &team)
|
||||||
|
teamID := team["id"].(string)
|
||||||
|
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||||
|
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||||
|
|
||||||
|
// ── Query audit log (team admin) ──
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit", teamID), teamAdminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("team audit log: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var auditResp map[string]interface{}
|
||||||
|
decode(w, &auditResp)
|
||||||
|
// Must use "data" envelope
|
||||||
|
if _, ok := auditResp["data"]; !ok {
|
||||||
|
t.Fatal("GET /teams/:teamId/audit must return 'data' key (envelope convention)")
|
||||||
|
}
|
||||||
|
if _, ok := auditResp["total"]; !ok {
|
||||||
|
t.Fatal("GET /teams/:teamId/audit must return 'total' key (paginated)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Query audit actions ──
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/audit/actions", teamID), teamAdminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("team audit actions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var actionsResp map[string]interface{}
|
||||||
|
decode(w, &actionsResp)
|
||||||
|
if _, ok := actionsResp["actions"]; !ok {
|
||||||
|
t.Fatal("GET /teams/:teamId/audit/actions must return 'actions' key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Permissions Listing ──────────────
|
||||||
|
|
||||||
|
func TestAudit_AdminPermissions(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
// ── List all permissions ──
|
||||||
|
w := h.request("GET", "/api/v1/admin/permissions", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list permissions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var permResp map[string]interface{}
|
||||||
|
decode(w, &permResp)
|
||||||
|
perms, ok := permResp["permissions"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("GET /admin/permissions must return 'permissions' key")
|
||||||
|
}
|
||||||
|
permList := perms.([]interface{})
|
||||||
|
if len(permList) == 0 {
|
||||||
|
t.Fatal("permissions list should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify known permissions are present
|
||||||
|
found := false
|
||||||
|
for _, p := range permList {
|
||||||
|
if p.(string) == "model.use" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("permissions list should contain 'model.use'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Effective Permissions ─────────────
|
||||||
|
|
||||||
|
func TestAudit_UserEffectivePermissions(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
userID := database.SeedTestUser(t, "charlie", "charlie@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||||
|
|
||||||
|
// ── Get permissions for user ──
|
||||||
|
w := h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", userID), adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get user permissions: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(w, &resp)
|
||||||
|
if _, ok := resp["permissions"]; !ok {
|
||||||
|
t.Fatal("GET /admin/users/:id/permissions must return 'permissions' key")
|
||||||
|
}
|
||||||
|
if _, ok := resp["user_id"]; !ok {
|
||||||
|
t.Fatal("GET /admin/users/:id/permissions must return 'user_id' key")
|
||||||
|
}
|
||||||
|
// H10: must include contributing groups
|
||||||
|
groups, ok := resp["groups"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("GET /admin/users/:id/permissions must return 'groups' key (contributing groups)")
|
||||||
|
}
|
||||||
|
groupList := groups.([]interface{})
|
||||||
|
// Should always contain the Everyone group
|
||||||
|
found := false
|
||||||
|
for _, g := range groupList {
|
||||||
|
if g.(string) == "00000000-0000-0000-0000-000000000001" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("groups should always include the Everyone group, got %v", groupList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get permissions for admin (should include their own set) ──
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/admin/users/%s/permissions", adminID), adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get admin permissions: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System Group Deletion Rejection ────────
|
||||||
|
|
||||||
|
func TestAudit_SystemGroupCannotBeDeleted(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
everyoneID := "00000000-0000-0000-0000-000000000001"
|
||||||
|
|
||||||
|
// Seed the Everyone group (TruncateAll wipes migration-seeded data)
|
||||||
|
_, err := database.TestDB.Exec(dialectSQL(`
|
||||||
|
INSERT INTO groups (id, name, description, scope, created_by, source, permissions)
|
||||||
|
VALUES ($1, $2, $3, $4, NULL, $5, $6)
|
||||||
|
`), everyoneID, "Everyone",
|
||||||
|
"Implicit group — all authenticated users receive these permissions.",
|
||||||
|
"global", "system", `["model.use","kb.read","channel.create"]`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed Everyone group: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to delete the Everyone system group → should fail with 400
|
||||||
|
w := h.request("DELETE", "/api/v1/admin/groups/"+everyoneID, adminToken, nil)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("delete system group: want 400, got %d: %s\n"+
|
||||||
|
"System groups (source=system) must be protected from deletion",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resource Grant DELETE Round-Trip ────────
|
||||||
|
|
||||||
|
func TestAudit_ResourceGrantDelete(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
// Create a persona to grant
|
||||||
|
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
|
||||||
|
"name": "Grant Test Persona",
|
||||||
|
"base_model_id": "test-model",
|
||||||
|
"scope": "global",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create persona: want 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var persona map[string]interface{}
|
||||||
|
decode(w, &persona)
|
||||||
|
personaID := persona["id"].(string)
|
||||||
|
|
||||||
|
// Set a grant
|
||||||
|
w = h.request("PUT", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken,
|
||||||
|
map[string]interface{}{
|
||||||
|
"grant_scope": "global",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the grant exists
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get grant: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
var grantResp map[string]interface{}
|
||||||
|
decode(w, &grantResp)
|
||||||
|
if grantResp["grant_scope"] != "global" {
|
||||||
|
t.Fatalf("grant_scope should be global, got %v", grantResp["grant_scope"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE the grant
|
||||||
|
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// After delete, GET should return default (team_only)
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/admin/grants/persona/%s", personaID), adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get after delete: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
decode(w, &grantResp)
|
||||||
|
if grantResp["grant_scope"] != "team_only" {
|
||||||
|
t.Fatalf("after delete, grant_scope should fall back to team_only, got %v", grantResp["grant_scope"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── H8: Team Providers Envelope ────────────
|
||||||
|
// BUG: ListTeamProviders returns {"providers": [...]} instead of {"data": [...]}.
|
||||||
|
|
||||||
|
func TestAudit_TeamProvidersEnvelope(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||||
|
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||||
|
"name": "ProvEnvTeam",
|
||||||
|
})
|
||||||
|
var team map[string]interface{}
|
||||||
|
decode(w, &team)
|
||||||
|
teamID := team["id"].(string)
|
||||||
|
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||||
|
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||||
|
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list team providers: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(w, &resp)
|
||||||
|
|
||||||
|
// Must use "data" not "providers"
|
||||||
|
if _, ok := resp["data"]; !ok {
|
||||||
|
t.Fatalf("H8 BUG: GET /teams/:teamId/providers should return 'data' key (envelope convention), "+
|
||||||
|
"got keys: %v", mapKeys(resp))
|
||||||
|
}
|
||||||
|
if _, ok := resp["allow_team_providers"]; !ok {
|
||||||
|
t.Fatal("GET /teams/:teamId/providers must include 'allow_team_providers'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── H9: Team Roles Envelope ────────────────
|
||||||
|
// BUG: ListTeamRoles returns bare map, not wrapped in {"data": ...}.
|
||||||
|
|
||||||
|
func TestAudit_TeamRolesEnvelope(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||||
|
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||||
|
"name": "RolesEnvTeam",
|
||||||
|
})
|
||||||
|
var team map[string]interface{}
|
||||||
|
decode(w, &team)
|
||||||
|
teamID := team["id"].(string)
|
||||||
|
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||||
|
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||||
|
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list team roles: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(w, &resp)
|
||||||
|
|
||||||
|
// Should be wrapped in {"data": {...}} per composite convention
|
||||||
|
if _, ok := resp["data"]; !ok {
|
||||||
|
t.Fatalf("H9 BUG: GET /teams/:teamId/roles should return 'data' key (composite convention), "+
|
||||||
|
"got keys: %v — bare map returned without wrapper", mapKeys(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team Groups Listing ────────────────────
|
||||||
|
|
||||||
|
func TestAudit_TeamGroupsListing(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), teamAdminID)
|
||||||
|
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||||
|
|
||||||
|
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||||
|
"name": "GroupsTeam",
|
||||||
|
})
|
||||||
|
var team map[string]interface{}
|
||||||
|
decode(w, &team)
|
||||||
|
teamID := team["id"].(string)
|
||||||
|
|
||||||
|
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||||
|
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||||
|
|
||||||
|
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/groups", teamID), teamAdminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list team groups: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
decode(w, &resp)
|
||||||
|
|
||||||
|
// Must use "data" envelope
|
||||||
|
if _, ok := resp["data"]; !ok {
|
||||||
|
t.Fatalf("GET /teams/:teamId/groups must return 'data' key, got keys: %v", mapKeys(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: map keys for diagnostic output ──
|
||||||
|
|
||||||
|
func mapKeys(m map[string]interface{}) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ func (h *RolesHandler) ListTeamRoles(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, roleOverrides)
|
c.JSON(http.StatusOK, gin.H{"data": roleOverrides})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTeamRole sets a team role override.
|
// UpdateTeamRole sets a team role override.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"providers": configs,
|
"data": configs,
|
||||||
"allow_team_providers": isTeamProvidersAllowed(teamID),
|
"allow_team_providers": isTeamProvidersAllowed(teamID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
|||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
ModelDefault string `json:"model_default,omitempty"`
|
ModelDefault string `json:"model_default,omitempty"`
|
||||||
Config map[string]interface{} `json:"config,omitempty"`
|
Config map[string]interface{} `json:"config,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
IsPrivate bool `json:"is_private,omitempty"`
|
IsPrivate bool `json:"is_private,omitempty"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -103,6 +104,12 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
|||||||
configJSON = string(b)
|
configJSON = string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headersJSON := "{}"
|
||||||
|
if req.Headers != nil {
|
||||||
|
b, _ := json.Marshal(req.Headers)
|
||||||
|
headersJSON = string(b)
|
||||||
|
}
|
||||||
|
|
||||||
// Encrypt the API key for team scope
|
// Encrypt the API key for team scope
|
||||||
var apiKeyEnc, keyNonce []byte
|
var apiKeyEnc, keyNonce []byte
|
||||||
if req.APIKey != "" {
|
if req.APIKey != "" {
|
||||||
@@ -120,11 +127,11 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
|||||||
|
|
||||||
id, err := database.InsertReturningID(`
|
id, err := database.InsertReturningID(`
|
||||||
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
|
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
|
||||||
api_key_enc, key_nonce, key_scope, model_default, config, is_private)
|
api_key_enc, key_nonce, key_scope, model_default, config, is_private, headers)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10, $11::jsonb)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, "team", teamID, req.Name, req.Provider, req.Endpoint,
|
`, "team", teamID, req.Name, req.Provider, req.Endpoint,
|
||||||
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate,
|
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate, headersJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WARN] Failed to create team provider: %v", err)
|
log.Printf("[WARN] Failed to create team provider: %v", err)
|
||||||
@@ -146,6 +153,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
|||||||
APIKey *string `json:"api_key,omitempty"`
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
ModelDefault *string `json:"model_default,omitempty"`
|
ModelDefault *string `json:"model_default,omitempty"`
|
||||||
Config map[string]interface{} `json:"config,omitempty"`
|
Config map[string]interface{} `json:"config,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
IsActive *bool `json:"is_active,omitempty"`
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
IsPrivate *bool `json:"is_private,omitempty"`
|
IsPrivate *bool `json:"is_private,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -165,10 +173,12 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
|||||||
// Build dynamic update using ? placeholders (works on both dialects)
|
// Build dynamic update using ? placeholders (works on both dialects)
|
||||||
setClauses := []string{"updated_at = " + database.Q("NOW()")}
|
setClauses := []string{"updated_at = " + database.Q("NOW()")}
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
|
fieldCount := 0
|
||||||
|
|
||||||
addSet := func(col string, val interface{}) {
|
addSet := func(col string, val interface{}) {
|
||||||
setClauses = append(setClauses, col+" = ?")
|
setClauses = append(setClauses, col+" = ?")
|
||||||
args = append(args, val)
|
args = append(args, val)
|
||||||
|
fieldCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
@@ -203,6 +213,15 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
|||||||
b, _ := json.Marshal(req.Config)
|
b, _ := json.Marshal(req.Config)
|
||||||
addSet("config", string(b))
|
addSet("config", string(b))
|
||||||
}
|
}
|
||||||
|
if req.Headers != nil {
|
||||||
|
b, _ := json.Marshal(req.Headers)
|
||||||
|
addSet("headers", string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fieldCount == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
args = append(args, providerID, teamID)
|
args = append(args, providerID, teamID)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Request types ───────────────────────────
|
// ── Request types ───────────────────────────
|
||||||
@@ -43,11 +45,12 @@ type updateMemberRequest struct {
|
|||||||
// ── Handler ─────────────────────────────────
|
// ── Handler ─────────────────────────────────
|
||||||
|
|
||||||
type TeamHandler struct{
|
type TeamHandler struct{
|
||||||
|
stores store.Stores
|
||||||
vault *crypto.KeyResolver
|
vault *crypto.KeyResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTeamHandler(vault *crypto.KeyResolver) *TeamHandler {
|
func NewTeamHandler(s store.Stores, vault *crypto.KeyResolver) *TeamHandler {
|
||||||
return &TeamHandler{vault: vault}
|
return &TeamHandler{stores: s, vault: vault}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin: List All Teams ───────────────────
|
// ── Admin: List All Teams ───────────────────
|
||||||
@@ -61,59 +64,29 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
|
|||||||
if perPage < 1 || perPage > 100 {
|
if perPage < 1 || perPage > 100 {
|
||||||
perPage = 50
|
perPage = 50
|
||||||
}
|
}
|
||||||
offset := (page - 1) * perPage
|
|
||||||
|
|
||||||
var total int
|
all, err := h.stores.Teams.List(c.Request.Context())
|
||||||
if err := database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM teams`)).Scan(&total); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count teams"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := database.DB.Query(database.Q(`
|
|
||||||
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
|
|
||||||
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
|
||||||
COALESCE(mc.cnt, 0) AS member_count
|
|
||||||
FROM teams t
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT team_id, COUNT(*) AS cnt FROM team_members GROUP BY team_id
|
|
||||||
) mc ON mc.team_id = t.id
|
|
||||||
ORDER BY t.name ASC
|
|
||||||
LIMIT $1 OFFSET $2
|
|
||||||
`), perPage, offset)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
if all == nil {
|
||||||
|
all = []models.Team{}
|
||||||
|
}
|
||||||
|
total := len(all)
|
||||||
|
|
||||||
var teams []gin.H
|
// In-memory pagination (teams are low-cardinality)
|
||||||
for rows.Next() {
|
offset := (page - 1) * perPage
|
||||||
var id, name, desc, createdBy, settings string
|
end := offset + perPage
|
||||||
var isActive bool
|
if offset > total {
|
||||||
var memberCount int
|
offset = total
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
if err := rows.Scan(&id, &name, &desc, &createdBy, &isActive, &settings,
|
|
||||||
database.ST(&createdAt), database.ST(&updatedAt), &memberCount); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
teams = append(teams, gin.H{
|
if end > total {
|
||||||
"id": id,
|
end = total
|
||||||
"name": name,
|
|
||||||
"description": desc,
|
|
||||||
"created_by": createdBy,
|
|
||||||
"is_active": isActive,
|
|
||||||
"settings": settings,
|
|
||||||
"member_count": memberCount,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if teams == nil {
|
|
||||||
teams = []gin.H{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"data": teams,
|
"data": all[offset:end],
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"per_page": perPage,
|
"per_page": perPage,
|
||||||
@@ -131,12 +104,13 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := database.InsertReturningID(`
|
team := &models.Team{
|
||||||
INSERT INTO teams (name, description, created_by)
|
Name: req.Name,
|
||||||
VALUES ($1, $2, $3)
|
Description: req.Description,
|
||||||
RETURNING id
|
CreatedBy: adminID,
|
||||||
`, req.Name, req.Description, adminID)
|
IsActive: true,
|
||||||
if err != nil {
|
}
|
||||||
|
if err := h.stores.Teams.Create(c.Request.Context(), team); err != nil {
|
||||||
if database.IsUniqueViolation(err) {
|
if database.IsUniqueViolation(err) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
|
||||||
return
|
return
|
||||||
@@ -145,8 +119,8 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name})
|
c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name})
|
||||||
AuditLog(c, "team.create", "team", id, map[string]interface{}{"name": req.Name})
|
AuditLog(c, "team.create", "team", team.ID, map[string]interface{}{"name": team.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin: Get Team ─────────────────────────
|
// ── Admin: Get Team ─────────────────────────
|
||||||
@@ -154,18 +128,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
|||||||
func (h *TeamHandler) GetTeam(c *gin.Context) {
|
func (h *TeamHandler) GetTeam(c *gin.Context) {
|
||||||
teamID := c.Param("id")
|
teamID := c.Param("id")
|
||||||
|
|
||||||
var name, desc, createdBy, settings string
|
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
|
||||||
var isActive bool
|
|
||||||
var memberCount int
|
|
||||||
var createdAt, updatedAt time.Time
|
|
||||||
|
|
||||||
err := database.DB.QueryRow(database.Q(`
|
|
||||||
SELECT t.name, t.description, t.created_by, t.is_active,
|
|
||||||
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
|
||||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id)
|
|
||||||
FROM teams t WHERE t.id = $1
|
|
||||||
`), teamID).Scan(&name, &desc, &createdBy, &isActive, &settings,
|
|
||||||
database.ST(&createdAt), database.ST(&updatedAt), &memberCount)
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||||
return
|
return
|
||||||
@@ -175,17 +138,7 @@ func (h *TeamHandler) GetTeam(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, team)
|
||||||
"id": teamID,
|
|
||||||
"name": name,
|
|
||||||
"description": desc,
|
|
||||||
"created_by": createdBy,
|
|
||||||
"is_active": isActive,
|
|
||||||
"settings": settings,
|
|
||||||
"member_count": memberCount,
|
|
||||||
"created_at": createdAt,
|
|
||||||
"updated_at": updatedAt,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin: Update Team ──────────────────────
|
// ── Admin: Update Team ──────────────────────
|
||||||
@@ -271,14 +224,20 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
||||||
teamID := c.Param("id")
|
teamID := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
res, err := database.DB.Exec(database.Q(`DELETE FROM teams WHERE id = $1`), teamID)
|
// Verify existence
|
||||||
if err != nil {
|
if _, err := h.stores.Teams.GetByID(ctx, teamID); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
if err := h.stores.Teams.Delete(ctx, teamID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,38 +250,13 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
|||||||
func (h *TeamHandler) ListMembers(c *gin.Context) {
|
func (h *TeamHandler) ListMembers(c *gin.Context) {
|
||||||
teamID := getTeamID(c)
|
teamID := getTeamID(c)
|
||||||
|
|
||||||
rows, err := database.DB.Query(database.Q(`
|
members, err := h.stores.Teams.ListMembers(c.Request.Context(), teamID)
|
||||||
SELECT tm.id, tm.user_id, tm.role, tm.joined_at,
|
|
||||||
u.email, COALESCE(u.display_name, '') AS display_name, u.role AS user_role
|
|
||||||
FROM team_members tm
|
|
||||||
JOIN users u ON u.id = tm.user_id
|
|
||||||
WHERE tm.team_id = $1
|
|
||||||
ORDER BY tm.role ASC, u.email ASC
|
|
||||||
`), teamID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var members []gin.H
|
|
||||||
for rows.Next() {
|
|
||||||
var id, userID, role, email, displayName, userRole, joinedAt string
|
|
||||||
if err := rows.Scan(&id, &userID, &role, &joinedAt, &email, &displayName, &userRole); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
members = append(members, gin.H{
|
|
||||||
"id": id,
|
|
||||||
"user_id": userID,
|
|
||||||
"role": role,
|
|
||||||
"joined_at": joinedAt,
|
|
||||||
"email": email,
|
|
||||||
"display_name": displayName,
|
|
||||||
"user_role": userRole,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if members == nil {
|
if members == nil {
|
||||||
members = []gin.H{}
|
members = []models.TeamMember{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": members})
|
c.JSON(http.StatusOK, gin.H{"data": members})
|
||||||
@@ -377,6 +311,7 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
|
|||||||
// ── Members: Update Role ────────────────────
|
// ── Members: Update Role ────────────────────
|
||||||
|
|
||||||
func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
memberID := c.Param("memberId")
|
memberID := c.Param("memberId")
|
||||||
|
|
||||||
var req updateMemberRequest
|
var req updateMemberRequest
|
||||||
@@ -386,8 +321,8 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res, err := database.DB.Exec(database.Q(`
|
res, err := database.DB.Exec(database.Q(`
|
||||||
UPDATE team_members SET role = $1 WHERE id = $2
|
UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3
|
||||||
`), req.Role, memberID)
|
`), req.Role, memberID, teamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
||||||
return
|
return
|
||||||
@@ -406,9 +341,10 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
|
|||||||
// ── Members: Remove ─────────────────────────
|
// ── Members: Remove ─────────────────────────
|
||||||
|
|
||||||
func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
memberID := c.Param("memberId")
|
memberID := c.Param("memberId")
|
||||||
|
|
||||||
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1`), memberID)
|
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1 AND team_id = $2`), memberID, teamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
|
||||||
return
|
return
|
||||||
@@ -429,42 +365,13 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
|
|||||||
func (h *TeamHandler) MyTeams(c *gin.Context) {
|
func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
|
|
||||||
rows, err := database.DB.Query(database.Q(`
|
teams, err := h.stores.Teams.ListForUser(c.Request.Context(), userID)
|
||||||
SELECT t.id, t.name, t.description, t.is_active,
|
|
||||||
COALESCE(t.settings, '{}'),
|
|
||||||
tm.role AS my_role,
|
|
||||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
|
|
||||||
FROM teams t
|
|
||||||
JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = $1
|
|
||||||
WHERE t.is_active = true
|
|
||||||
ORDER BY t.name ASC
|
|
||||||
`), userID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var teams []gin.H
|
|
||||||
for rows.Next() {
|
|
||||||
var id, name, desc, settings, myRole string
|
|
||||||
var isActive bool
|
|
||||||
var memberCount int
|
|
||||||
if err := rows.Scan(&id, &name, &desc, &isActive, &settings, &myRole, &memberCount); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
teams = append(teams, gin.H{
|
|
||||||
"id": id,
|
|
||||||
"name": name,
|
|
||||||
"description": desc,
|
|
||||||
"is_active": isActive,
|
|
||||||
"settings": settings,
|
|
||||||
"my_role": myRole,
|
|
||||||
"member_count": memberCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if teams == nil {
|
if teams == nil {
|
||||||
teams = []gin.H{}
|
teams = []models.Team{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"data": teams})
|
c.JSON(http.StatusOK, gin.H{"data": teams})
|
||||||
@@ -581,11 +488,6 @@ func getTeamID(c *gin.Context) string {
|
|||||||
return c.Param("id")
|
return c.Param("id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// isUniqueViolation checks if a PG/SQLite error is a unique constraint violation.
|
|
||||||
func isUniqueViolation(err error) bool {
|
|
||||||
return database.IsUniqueViolation(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsTeamAdmin checks if a user is an admin of the given team.
|
// IsTeamAdmin checks if a user is an admin of the given team.
|
||||||
func IsTeamAdmin(userID, teamID string) bool {
|
func IsTeamAdmin(userID, teamID string) bool {
|
||||||
var role string
|
var role string
|
||||||
@@ -706,11 +608,6 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
|||||||
ORDER BY al.created_at DESC
|
ORDER BY al.created_at DESC
|
||||||
` + limitOffset
|
` + limitOffset
|
||||||
|
|
||||||
if database.IsPostgres() {
|
|
||||||
// Re-convert placeholders for the full query
|
|
||||||
query = convertPlaceholders(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := database.DB.Query(query, args...)
|
rows, err := database.DB.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||||
@@ -727,7 +624,7 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
|||||||
ResourceID *string `json:"resource_id"`
|
ResourceID *string `json:"resource_id"`
|
||||||
Metadata string `json:"metadata"`
|
Metadata string `json:"metadata"`
|
||||||
IPAddress *string `json:"ip_address"`
|
IPAddress *string `json:"ip_address"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := make([]entry, 0)
|
entries := make([]entry, 0)
|
||||||
@@ -735,7 +632,7 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
|||||||
var e entry
|
var e entry
|
||||||
var actorName sql.NullString
|
var actorName sql.NullString
|
||||||
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action,
|
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action,
|
||||||
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil {
|
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, database.ST(&e.CreatedAt)); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if actorName.Valid {
|
if actorName.Valid {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
|||||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||||
|
|
||||||
// Teams (admin routes for creating teams + adding members)
|
// Teams (admin routes for creating teams + adding members)
|
||||||
teamH := NewTeamHandler(nil)
|
teamH := NewTeamHandler(stores, nil)
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||||
admin.POST("/teams", teamH.CreateTeam)
|
admin.POST("/teams", teamH.CreateTeam)
|
||||||
|
|||||||
@@ -857,7 +857,7 @@ func main() {
|
|||||||
protected.GET("/memories/count", memH.MemoryCount)
|
protected.GET("/memories/count", memH.MemoryCount)
|
||||||
|
|
||||||
// Teams (user: my teams)
|
// Teams (user: my teams)
|
||||||
teams := handlers.NewTeamHandler(keyResolver)
|
teams := handlers.NewTeamHandler(stores, keyResolver)
|
||||||
protected.GET("/teams/mine", teams.MyTeams)
|
protected.GET("/teams/mine", teams.MyTeams)
|
||||||
|
|
||||||
// Groups (user: my groups — v0.16.0)
|
// Groups (user: my groups — v0.16.0)
|
||||||
@@ -995,7 +995,7 @@ func main() {
|
|||||||
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
|
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
|
||||||
|
|
||||||
// Teams (admin)
|
// Teams (admin)
|
||||||
teamAdm := handlers.NewTeamHandler(keyResolver)
|
teamAdm := handlers.NewTeamHandler(stores, keyResolver)
|
||||||
admin.GET("/teams", teamAdm.ListTeams)
|
admin.GET("/teams", teamAdm.ListTeams)
|
||||||
admin.POST("/teams", teamAdm.CreateTeam)
|
admin.POST("/teams", teamAdm.CreateTeam)
|
||||||
admin.GET("/teams/:id", teamAdm.GetTeam)
|
admin.GET("/teams/:id", teamAdm.GetTeam)
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ type Team struct {
|
|||||||
IsActive bool `json:"is_active" db:"is_active"`
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||||
MemberCount int `json:"member_count,omitempty"` // computed
|
MemberCount int `json:"member_count,omitempty"` // computed
|
||||||
|
MyRole string `json:"my_role,omitempty"` // computed, user context
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamMember struct {
|
type TeamMember struct {
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Sentinel Errors ─────────────────────────
|
||||||
|
|
||||||
|
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
|
||||||
|
var ErrSystemGroup = errors.New("system groups cannot be deleted")
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// STORES — Data Access Layer
|
// STORES — Data Access Layer
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -197,6 +203,7 @@ type TeamStore interface {
|
|||||||
Update(ctx context.Context, id string, fields map[string]interface{}) error
|
Update(ctx context.Context, id string, fields map[string]interface{}) error
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
List(ctx context.Context) ([]models.Team, error)
|
List(ctx context.Context) ([]models.Team, error)
|
||||||
|
ListForUser(ctx context.Context, userID string) ([]models.Team, error) // active teams with MyRole
|
||||||
|
|
||||||
// Members
|
// Members
|
||||||
AddMember(ctx context.Context, teamID, userID, role string) error
|
AddMember(ctx context.Context, teamID, userID, role string) error
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── GroupStore ──────────────────────────────
|
// ── GroupStore ──────────────────────────────
|
||||||
@@ -118,7 +119,7 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if source == "system" {
|
if source == "system" {
|
||||||
return fmt.Errorf("system groups cannot be deleted")
|
return store.ErrSystemGroup
|
||||||
}
|
}
|
||||||
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
|
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -87,6 +87,36 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
|
||||||
|
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
||||||
|
tm.role AS my_role,
|
||||||
|
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
|
||||||
|
FROM teams t
|
||||||
|
JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = $1
|
||||||
|
WHERE t.is_active = true
|
||||||
|
ORDER BY t.name ASC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.Team
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Team
|
||||||
|
var settingsJSON []byte
|
||||||
|
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
|
||||||
|
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MyRole, &t.MemberCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal(settingsJSON, &t.Settings)
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Members ─────────────────────────────────
|
// ── Members ─────────────────────────────────
|
||||||
|
|
||||||
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
|
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ func (s *GroupStore) Delete(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if source == "system" {
|
if source == "system" {
|
||||||
return fmt.Errorf("system groups cannot be deleted")
|
return store.ErrSystemGroup
|
||||||
}
|
}
|
||||||
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
|
res, err := DB.ExecContext(ctx, "DELETE FROM groups WHERE id = ?", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -94,6 +94,36 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
|
||||||
|
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
|
||||||
|
tm.role AS my_role,
|
||||||
|
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
|
||||||
|
FROM teams t
|
||||||
|
JOIN team_members tm ON tm.team_id = t.id AND tm.user_id = ?
|
||||||
|
WHERE t.is_active = 1
|
||||||
|
ORDER BY t.name ASC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.Team
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Team
|
||||||
|
var settingsJSON []byte
|
||||||
|
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
|
||||||
|
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MyRole, &t.MemberCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal(settingsJSON, &t.Settings)
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Members ─────────────────────────────────
|
// ── Members ─────────────────────────────────
|
||||||
|
|
||||||
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
|
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ Object.assign(UI, {
|
|||||||
// Handle policy check
|
// Handle policy check
|
||||||
const allowed = resp.allow_team_providers !== false;
|
const allowed = resp.allow_team_providers !== false;
|
||||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||||
if (!allowed && !(resp.providers || []).length) {
|
if (!allowed && !(resp.data || resp.providers || []).length) {
|
||||||
throw { message: 'Team provider management is disabled by your administrator', _empty: true };
|
throw { message: 'Team provider management is disabled by your administrator', _empty: true };
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
|
|||||||
Reference in New Issue
Block a user