Changeset 0.17.1 (#76)

This commit is contained in:
2026-02-28 01:40:31 +00:00
parent c9141a6896
commit 856dc9b0ac
64 changed files with 8037 additions and 1657 deletions

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -63,14 +64,14 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
offset := (page - 1) * perPage
var total int
if err := database.DB.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&total); err != nil {
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(`
rows, err := database.DB.Query(database.Q(`
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
COALESCE(t.settings::text, '{}'), t.created_at, t.updated_at,
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
COALESCE(mc.cnt, 0) AS member_count
FROM teams t
LEFT JOIN (
@@ -78,7 +79,7 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
) mc ON mc.team_id = t.id
ORDER BY t.name ASC
LIMIT $1 OFFSET $2
`, perPage, offset)
`), perPage, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
@@ -90,9 +91,9 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
var id, name, desc, createdBy, settings string
var isActive bool
var memberCount int
var createdAt, updatedAt sql.NullTime
var createdAt, updatedAt time.Time
if err := rows.Scan(&id, &name, &desc, &createdBy, &isActive, &settings,
&createdAt, &updatedAt, &memberCount); err != nil {
database.ST(&createdAt), database.ST(&updatedAt), &memberCount); err != nil {
continue
}
teams = append(teams, gin.H{
@@ -103,8 +104,8 @@ func (h *TeamHandler) ListTeams(c *gin.Context) {
"is_active": isActive,
"settings": settings,
"member_count": memberCount,
"created_at": createdAt.Time,
"updated_at": updatedAt.Time,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
if teams == nil {
@@ -130,14 +131,13 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
return
}
var id string
err := database.DB.QueryRow(`
id, err := database.InsertReturningID(`
INSERT INTO teams (name, description, created_by)
VALUES ($1, $2, $3)
RETURNING id
`, req.Name, req.Description, adminID).Scan(&id)
`, req.Name, req.Description, adminID)
if err != nil {
if isUniqueViolation(err) {
if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
return
}
@@ -157,15 +157,15 @@ func (h *TeamHandler) GetTeam(c *gin.Context) {
var name, desc, createdBy, settings string
var isActive bool
var memberCount int
var createdAt, updatedAt sql.NullTime
var createdAt, updatedAt time.Time
err := database.DB.QueryRow(`
err := database.DB.QueryRow(database.Q(`
SELECT t.name, t.description, t.created_by, t.is_active,
COALESCE(t.settings::text, '{}'), t.created_at, t.updated_at,
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,
&createdAt, &updatedAt, &memberCount)
`), teamID).Scan(&name, &desc, &createdBy, &isActive, &settings,
database.ST(&createdAt), database.ST(&updatedAt), &memberCount)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
@@ -183,8 +183,8 @@ func (h *TeamHandler) GetTeam(c *gin.Context) {
"is_active": isActive,
"settings": settings,
"member_count": memberCount,
"created_at": createdAt.Time,
"updated_at": updatedAt.Time,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
@@ -204,23 +204,32 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
args := []interface{}{}
argN := 1
if req.Name != nil {
sets = append(sets, "name = $"+strconv.Itoa(argN))
args = append(args, *req.Name)
addArg := func(col string, val interface{}) {
if database.IsSQLite() {
sets = append(sets, col+" = ?")
} else {
sets = append(sets, col+" = $"+strconv.Itoa(argN))
}
args = append(args, val)
argN++
}
if req.Name != nil {
addArg("name", *req.Name)
}
if req.Description != nil {
sets = append(sets, "description = $"+strconv.Itoa(argN))
args = append(args, *req.Description)
argN++
addArg("description", *req.Description)
}
if req.IsActive != nil {
sets = append(sets, "is_active = $"+strconv.Itoa(argN))
args = append(args, *req.IsActive)
argN++
addArg("is_active", *req.IsActive)
}
if req.Settings != nil {
sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
if database.IsSQLite() {
// SQLite: json_patch for merge
sets = append(sets, "settings = json_patch(COALESCE(settings, '{}'), ?)")
} else {
sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
}
args = append(args, *req.Settings)
argN++
}
@@ -230,19 +239,19 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
return
}
query := "UPDATE teams SET "
for i, s := range sets {
if i > 0 {
query += ", "
}
query += s
var whereClause string
if database.IsSQLite() {
whereClause = " WHERE id = ?"
} else {
whereClause = " WHERE id = $" + strconv.Itoa(argN)
}
query += " WHERE id = $" + strconv.Itoa(argN)
args = append(args, teamID)
query := "UPDATE teams SET " + strings.Join(sets, ", ") + whereClause
res, err := database.DB.Exec(query, args...)
if err != nil {
if isUniqueViolation(err) {
if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"})
return
}
@@ -263,7 +272,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
teamID := c.Param("id")
res, err := database.DB.Exec(`DELETE FROM teams WHERE id = $1`, teamID)
res, err := database.DB.Exec(database.Q(`DELETE FROM teams WHERE id = $1`), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
@@ -282,14 +291,14 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) {
func (h *TeamHandler) ListMembers(c *gin.Context) {
teamID := getTeamID(c)
rows, err := database.DB.Query(`
rows, err := database.DB.Query(database.Q(`
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)
`), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
@@ -298,8 +307,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) {
var members []gin.H
for rows.Next() {
var id, userID, role, email, displayName, userRole string
var joinedAt sql.NullTime
var id, userID, role, email, displayName, userRole, joinedAt string
if err := rows.Scan(&id, &userID, &role, &joinedAt, &email, &displayName, &userRole); err != nil {
continue
}
@@ -307,7 +315,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) {
"id": id,
"user_id": userID,
"role": role,
"joined_at": joinedAt.Time,
"joined_at": joinedAt,
"email": email,
"display_name": displayName,
"user_role": userRole,
@@ -333,27 +341,26 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
// Verify team exists
var exists bool
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`, teamID).Scan(&exists)
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`), teamID).Scan(&exists)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
// Verify user exists
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, req.UserID).Scan(&exists)
database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`), req.UserID).Scan(&exists)
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
return
}
var id string
err := database.DB.QueryRow(`
id, err := database.InsertReturningID(`
INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3)
RETURNING id
`, teamID, req.UserID, req.Role).Scan(&id)
`, teamID, req.UserID, req.Role)
if err != nil {
if isUniqueViolation(err) {
if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "user is already a member"})
return
}
@@ -378,9 +385,9 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
return
}
res, err := database.DB.Exec(`
res, err := database.DB.Exec(database.Q(`
UPDATE team_members SET role = $1 WHERE id = $2
`, req.Role, memberID)
`), req.Role, memberID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
@@ -401,7 +408,7 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
func (h *TeamHandler) RemoveMember(c *gin.Context) {
memberID := c.Param("memberId")
res, err := database.DB.Exec(`DELETE FROM team_members WHERE id = $1`, memberID)
res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1`), memberID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"})
return
@@ -422,16 +429,16 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
func (h *TeamHandler) MyTeams(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
rows, err := database.DB.Query(database.Q(`
SELECT t.id, t.name, t.description, t.is_active,
COALESCE(t.settings::text, '{}'),
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)
`), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
@@ -484,7 +491,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
models := make([]availableModel, 0)
// ── 1. Global admin models (synced in model_catalog) ──
rows, err := database.DB.Query(`
rows, err := database.DB.Query(database.Q(`
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
ac.provider, ac.name as provider_name
FROM model_catalog mc
@@ -492,7 +499,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
WHERE mc.visibility IN ('enabled', 'team')
AND ac.is_active = true AND ac.scope = 'global'
ORDER BY ac.name, mc.model_id
`)
`))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
@@ -510,11 +517,11 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
}
// ── 2. Team provider models (live query) ──
teamRows, err := database.DB.Query(`
teamRows, err := database.DB.Query(database.Q(`
SELECT id, name, provider, endpoint, api_key_enc, headers
FROM provider_configs
WHERE scope = 'team' AND owner_id = $1 AND is_active = true
`, teamID)
`), teamID)
if err == nil {
defer teamRows.Close()
for teamRows.Next() {
@@ -574,29 +581,26 @@ func getTeamID(c *gin.Context) string {
return c.Param("id")
}
// isUniqueViolation checks if a PG error is a unique constraint violation.
// isUniqueViolation checks if a PG/SQLite error is a unique constraint violation.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "duplicate key")
return database.IsUniqueViolation(err)
}
// IsTeamAdmin checks if a user is an admin of the given team.
func IsTeamAdmin(userID, teamID string) bool {
var role string
err := database.DB.QueryRow(`
err := database.DB.QueryRow(database.Q(`
SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2
`, teamID, userID).Scan(&role)
`), teamID, userID).Scan(&role)
return err == nil && role == "admin"
}
// IsTeamMember checks if a user belongs to the given team (any role).
func IsTeamMember(userID, teamID string) bool {
var exists bool
database.DB.QueryRow(`
database.DB.QueryRow(database.Q(`
SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)
`, teamID, userID).Scan(&exists)
`), teamID, userID).Scan(&exists)
return exists
}
@@ -610,24 +614,36 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
// Check if user belongs to any team with require_private_providers policy
var requiresPrivate bool
err := database.DB.QueryRow(`
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = $1
AND t.is_active = true
AND t.settings->>'require_private_providers' = 'true'
)
`, userID).Scan(&requiresPrivate)
var query string
if database.IsSQLite() {
query = `
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = ?
AND t.is_active = 1
AND json_extract(t.settings, '$.require_private_providers') = 'true'
)`
} else {
query = `
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = $1
AND t.is_active = true
AND t.settings->>'require_private_providers' = 'true'
)`
}
err := database.DB.QueryRow(query, userID).Scan(&requiresPrivate)
if err != nil || !requiresPrivate {
return nil
}
// User is in a restricted team — verify the config is private
var isPrivate bool
err = database.DB.QueryRow(`
err = database.DB.QueryRow(database.Q(`
SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1
`, configID).Scan(&isPrivate)
`), configID).Scan(&isPrivate)
if err != nil {
return nil // config lookup failed, allow (fail open)
}
@@ -639,33 +655,33 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
// ── Team Audit Log (scoped to team members) ─
// ListTeamAuditLog returns paginated audit entries where the actor is a member
// of the specified team. Team admins see only their team's activity; system
// admins see everything (but the scoping still applies via the same query).
// GET /api/v1/teams/:teamId/audit?page=1&per_page=50&action=...&actor_id=...&resource_type=...
func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
teamID := c.Param("teamId")
page, perPage, offset := parsePagination(c)
// Build filter clauses — always scoped to team members
where := "WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"
// Build filter clauses — always scoped to team members.
// Use ? placeholders and convert for Postgres if needed.
clauses := []string{"al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"}
args := []interface{}{teamID}
argN := 2
if action := c.Query("action"); action != "" {
where += " AND al.action = $" + strconv.Itoa(argN)
clauses = append(clauses, "al.action = ?")
args = append(args, action)
argN++
}
if actorID := c.Query("actor_id"); actorID != "" {
where += " AND al.actor_id = $" + strconv.Itoa(argN)
clauses = append(clauses, "al.actor_id = ?")
args = append(args, actorID)
argN++
}
if rt := c.Query("resource_type"); rt != "" {
where += " AND al.resource_type = $" + strconv.Itoa(argN)
clauses = append(clauses, "al.resource_type = ?")
args = append(args, rt)
argN++
}
where := "WHERE " + strings.Join(clauses, " AND ")
// For Postgres, convert ? to $N
if database.IsPostgres() {
where = convertPlaceholders(where)
}
// Count
@@ -679,16 +695,21 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
}
// Query with actor name join
limitOffset := fmt.Sprintf("LIMIT %d OFFSET %d", perPage, offset)
query := `
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
al.action, al.resource_type, al.resource_id,
COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at
COALESCE(al.metadata, '{}'), al.ip_address, al.created_at
FROM audit_log al
LEFT JOIN users u ON al.actor_id = u.id
` + where + `
ORDER BY al.created_at DESC
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
args = append(args, perPage, offset)
` + limitOffset
if database.IsPostgres() {
// Re-convert placeholders for the full query
query = convertPlaceholders(query)
}
rows, err := database.DB.Query(query, args...)
if err != nil {
@@ -731,18 +752,15 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
})
}
// ListTeamAuditActions returns distinct action names for audit entries within
// the team scope, for filter dropdowns.
// GET /api/v1/teams/:teamId/audit/actions
func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
teamID := c.Param("teamId")
rows, err := database.DB.Query(`
rows, err := database.DB.Query(database.Q(`
SELECT DISTINCT al.action
FROM audit_log al
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)
ORDER BY al.action ASC
`, teamID)
`), teamID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"actions": []string{}})
return
@@ -758,3 +776,18 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"actions": actions})
}
// convertPlaceholders converts ? placeholders to $1, $2, etc. for Postgres.
func convertPlaceholders(q string) string {
n := 1
var result strings.Builder
for _, ch := range q {
if ch == '?' {
result.WriteString(fmt.Sprintf("$%d", n))
n++
} else {
result.WriteRune(ch)
}
}
return result.String()
}