package handlers import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "armature/crypto" "armature/database" "armature/events" "armature/models" "armature/store" ) // ── Request types ─────────────────────────── type createTeamRequest struct { Name string `json:"name" binding:"required,min=1,max=200"` Description string `json:"description,omitempty"` } type updateTeamRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` IsActive *bool `json:"is_active,omitempty"` Settings *string `json:"settings,omitempty"` // JSON string } type addMemberRequest struct { UserID string `json:"user_id" binding:"required"` Role string `json:"role" binding:"required,min=1,max=50"` } type updateMemberRequest struct { Role string `json:"role" binding:"required,min=1,max=50"` } // ── Handler ───────────────────────────────── type TeamHandler struct{ stores store.Stores vault *crypto.KeyResolver hub *events.Hub } func NewTeamHandler(s store.Stores, vault *crypto.KeyResolver) *TeamHandler { return &TeamHandler{stores: s, vault: vault} } // SetHub attaches the event hub for auth change notifications. func (h *TeamHandler) SetHub(hub *events.Hub) { h.hub = hub } // notifyAuthChanged sends an auth.changed event to a specific user. func (h *TeamHandler) notifyAuthChanged(userID, reason string) { if h.hub == nil { return } h.hub.PublishToUser(userID, events.Event{ Label: "auth.changed", Payload: events.MustJSON(map[string]string{"reason": reason}), Ts: time.Now().UnixMilli(), }) } // ── Admin: List All Teams ─────────────────── func (h *TeamHandler) ListTeams(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50")) if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 50 } all, err := h.stores.Teams.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } if all == nil { all = []models.Team{} } total := len(all) // In-memory pagination (teams are low-cardinality) offset := (page - 1) * perPage end := offset + perPage if offset > total { offset = total } if end > total { end = total } c.JSON(http.StatusOK, gin.H{ "data": all[offset:end], "total": total, "page": page, "per_page": perPage, }) } // ── Admin: Create Team ────────────────────── func (h *TeamHandler) CreateTeam(c *gin.Context) { adminID := getUserID(c) var req createTeamRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } team := &models.Team{ Name: req.Name, Description: req.Description, CreatedBy: adminID, IsActive: true, } if err := h.stores.Teams.Create(c.Request.Context(), team); err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team"}) return } c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name}) AuditLog(h.stores.Audit, c, "team.create", "team", team.ID, map[string]interface{}{"name": team.Name}) } // ── Admin: Get Team ───────────────────────── func (h *TeamHandler) GetTeam(c *gin.Context) { teamID := c.Param("id") team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } c.JSON(http.StatusOK, team) } // ── Admin: Update Team ────────────────────── func (h *TeamHandler) UpdateTeam(c *gin.Context) { teamID := c.Param("id") var req updateTeamRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Build fields map for store.Update (simple scalar fields) fields := map[string]interface{}{} if req.Name != nil { fields["name"] = *req.Name } if req.Description != nil { fields["description"] = *req.Description } if req.IsActive != nil { fields["is_active"] = *req.IsActive } hasFields := len(fields) > 0 hasSettings := req.Settings != nil if !hasFields && !hasSettings { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } ctx := c.Request.Context() if hasFields { if err := h.stores.Teams.Update(ctx, teamID, fields); err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return } } if hasSettings { if err := h.stores.Teams.MergeSettings(ctx, teamID, *req.Settings); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "settings update failed"}) return } } c.JSON(http.StatusOK, gin.H{"ok": true}) AuditLog(h.stores.Audit, c, "team.update", "team", teamID, nil) } // ── Admin: Delete Team ────────────────────── func (h *TeamHandler) DeleteTeam(c *gin.Context) { teamID := c.Param("id") ctx := c.Request.Context() // Verify existence 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"}) return } if err := h.stores.Teams.Delete(ctx, teamID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) AuditLog(h.stores.Audit, c, "team.delete", "team", teamID, nil) } // ── Members: List ─────────────────────────── func (h *TeamHandler) ListMembers(c *gin.Context) { teamID := getTeamID(c) members, err := h.stores.Teams.ListMembers(c.Request.Context(), teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } if members == nil { members = []models.TeamMember{} } c.JSON(http.StatusOK, gin.H{"data": members}) } // ── Members: Add ──────────────────────────── func (h *TeamHandler) AddMember(c *gin.Context) { teamID := getTeamID(c) ctx := c.Request.Context() var req addMemberRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify team exists exists, _ := h.stores.Teams.Exists(ctx, teamID) if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) return } // Verify user exists userExists, _ := h.stores.Users.Exists(ctx, req.UserID) if !userExists { c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) return } // Validate role against team's configured roles if err := validateTeamRole(ctx, h.stores, teamID, req.Role); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role) if err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "user is already a member"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"}) return } c.JSON(http.StatusCreated, gin.H{"id": id}) h.notifyAuthChanged(req.UserID, "team_member_added") AuditLog(h.stores.Audit, c, "team.add_member", "team", getTeamID(c), map[string]interface{}{ "user_id": req.UserID, "role": req.Role, }) } // ── Members: Update Role ──────────────────── func (h *TeamHandler) UpdateMember(c *gin.Context) { teamID := getTeamID(c) memberID := c.Param("memberId") var req updateMemberRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate role against team's configured roles if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return } if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) if uid := h.memberUserID(c.Request.Context(), teamID, memberID); uid != "" { h.notifyAuthChanged(uid, "team_role") } AuditLog(h.stores.Audit, c, "team.update_member", "team", getTeamID(c), map[string]interface{}{ "member_id": memberID, "role": req.Role, }) } // ── Members: Remove ───────────────────────── func (h *TeamHandler) RemoveMember(c *gin.Context) { teamID := getTeamID(c) memberID := c.Param("memberId") // Look up user_id before delete for auth notification affectedUID := h.memberUserID(c.Request.Context(), teamID, memberID) n, err := h.stores.Teams.DeleteMemberByID(c.Request.Context(), memberID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"}) return } if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) if affectedUID != "" { h.notifyAuthChanged(affectedUID, "team_member_removed") } AuditLog(h.stores.Audit, c, "team.remove_member", "team", getTeamID(c), map[string]interface{}{ "member_id": memberID, }) } // ── User: My Teams ────────────────────────── func (h *TeamHandler) MyTeams(c *gin.Context) { userID := getUserID(c) teams, err := h.stores.Teams.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } if teams == nil { teams = []models.Team{} } c.JSON(http.StatusOK, gin.H{"data": teams}) } // ── Helpers ───────────────────────────────── // getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes). func getTeamID(c *gin.Context) string { if id := c.Param("teamId"); id != "" { return id } return c.Param("id") } // ── Team Audit Log (scoped to team members) ─ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { teamID := c.Param("teamId") page, perPage, offset := parsePagination(c) opts := store.AuditListOptions{ ListOptions: store.ListOptions{ Limit: perPage, Offset: offset, }, TeamID: teamID, } if action := c.Query("action"); action != "" { opts.Action = action } if actorID := c.Query("actor_id"); actorID != "" { opts.ActorID = actorID } if rt := c.Query("resource_type"); rt != "" { opts.ResourceType = rt } entries, total, err := h.stores.Audit.List(c.Request.Context(), opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } if entries == nil { entries = []models.AuditEntry{} } c.JSON(http.StatusOK, gin.H{ "data": entries, "total": total, "page": page, "per_page": perPage, }) } func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) { teamID := c.Param("teamId") actions, err := h.stores.Teams.ListTeamAuditActions(c.Request.Context(), teamID) if err != nil { c.JSON(http.StatusOK, gin.H{"actions": []string{}}) return } c.JSON(http.StatusOK, gin.H{"actions": actions}) } // ── Team Roles API ───────────────── // builtinRoles are always present in every team's role list. var builtinRoles = []string{"admin", "member"} // ListRoles returns the team's configured roles (builtins + custom). // GET /api/v1/teams/:teamId/roles func (h *TeamHandler) ListRoles(c *gin.Context) { teamID := getTeamID(c) roles := getTeamRoles(c.Request.Context(), h.stores, teamID) c.JSON(http.StatusOK, gin.H{"data": roles}) } // UpdateRoles replaces the team's roles array. Builtins (admin, member) are always included. // PUT /api/v1/teams/:teamId/roles func (h *TeamHandler) UpdateRoles(c *gin.Context) { teamID := getTeamID(c) var body struct { Roles []string `json:"roles" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Ensure builtins are present roleSet := map[string]bool{} for _, r := range builtinRoles { roleSet[r] = true } for _, r := range body.Roles { if r != "" && len(r) <= 50 { roleSet[r] = true } } roles := make([]string, 0, len(roleSet)) for r := range roleSet { roles = append(roles, r) } rolesJSON, _ := json.Marshal(map[string]any{"roles": roles}) if err := h.stores.Teams.MergeSettings(c.Request.Context(), teamID, string(rolesJSON)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update roles"}) return } c.JSON(http.StatusOK, gin.H{"data": roles}) // Notify all team members — role definitions changed h.notifyTeamMembers(c.Request.Context(), teamID, "team_roles_updated") AuditLog(h.stores.Audit, c, "team.update_roles", "team", teamID, map[string]interface{}{"roles": roles}) } // memberUserID looks up the user_id for a team member by row ID. func (h *TeamHandler) memberUserID(ctx context.Context, teamID, memberID string) string { members, err := h.stores.Teams.ListMembers(ctx, teamID) if err != nil { return "" } for _, m := range members { if m.ID == memberID { return m.UserID } } return "" } // notifyTeamMembers sends an auth.changed event to all members of a team. func (h *TeamHandler) notifyTeamMembers(ctx context.Context, teamID, reason string) { if h.hub == nil { return } members, err := h.stores.Teams.ListMembers(ctx, teamID) if err != nil { return } for _, m := range members { h.notifyAuthChanged(m.UserID, reason) } } // getTeamRoles reads the roles array from team settings, falling back to builtins. func getTeamRoles(ctx context.Context, stores store.Stores, teamID string) []string { team, err := stores.Teams.GetByID(ctx, teamID) if err != nil || team.Settings == nil { return builtinRoles } rolesRaw, ok := team.Settings["roles"] if !ok { return builtinRoles } // rolesRaw is []interface{} from JSONMap arr, ok := rolesRaw.([]interface{}) if !ok { return builtinRoles } roles := make([]string, 0, len(arr)) for _, v := range arr { if s, ok := v.(string); ok { roles = append(roles, s) } } if len(roles) == 0 { return builtinRoles } return roles } // validateTeamRole checks that a role string is valid for the given team. func validateTeamRole(ctx context.Context, stores store.Stores, teamID, role string) error { roles := getTeamRoles(ctx, stores, teamID) for _, r := range roles { if r == role { return nil } } // Also allow builtins unconditionally (in case settings are empty) for _, r := range builtinRoles { if r == role { return nil } } return fmt.Errorf("role %q is not configured for this team", role) }