package handlers import ( "database/sql" "net/http" "github.com/gin-gonic/gin" "chat-switchboard/models" "chat-switchboard/store" ) // ── Channel Participants Handler ────────────── // ICD §3.7: Manages polymorphic participants per channel. // Routes: // GET /channels/:id/participants — list // POST /channels/:id/participants — add // PATCH /channels/:id/participants/:participantId — update role // DELETE /channels/:id/participants/:participantId — remove // GET /channels/:id/presence — who's online type ParticipantHandler struct { stores store.Stores } func NewParticipantHandler(stores store.Stores) *ParticipantHandler { return &ParticipantHandler{stores: stores} } // ── List ───────────────────────────────────── func (h *ParticipantHandler) List(c *gin.Context) { channelID := c.Param("id") userID := getUserID(c) // Caller must be a participant (any role) if !h.requireParticipant(c, channelID, userID) { return } participants, err := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list participants"}) return } if participants == nil { participants = []models.ChannelParticipant{} } c.JSON(http.StatusOK, gin.H{"participants": participants}) } // ── Add ────────────────────────────────────── type addParticipantRequest struct { ParticipantType string `json:"participant_type" binding:"required"` ParticipantID string `json:"participant_id" binding:"required"` Role string `json:"role"` } func (h *ParticipantHandler) Add(c *gin.Context) { channelID := c.Param("id") userID := getUserID(c) // Only owners can add participants if !h.requireOwner(c, channelID, userID) { return } var req addParticipantRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate participant_type switch req.ParticipantType { case "user", "persona", "session": // valid default: c.JSON(http.StatusBadRequest, gin.H{"error": "participant_type must be user, persona, or session"}) return } // Default role role := req.Role if role == "" { role = "member" } switch role { case "owner", "member", "observer": // valid default: c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"}) return } // Build participant p := &models.ChannelParticipant{ ChannelID: channelID, ParticipantType: req.ParticipantType, ParticipantID: req.ParticipantID, Role: role, } // If adding a persona, resolve display_name and avatar from persona record, // and auto-add persona's model to channel_models roster. if req.ParticipantType == "persona" { persona, err := h.stores.Personas.GetByID(c.Request.Context(), req.ParticipantID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up persona"}) return } dn := persona.Name p.DisplayName = &dn if persona.Avatar != "" { p.AvatarURL = &persona.Avatar } // Auto-add persona's model to channel_models roster cm := &models.ChannelModel{ ChannelID: channelID, ModelID: persona.BaseModelID, ProviderConfigID: derefStr(persona.ProviderConfigID), PersonaID: &req.ParticipantID, Handle: persona.Handle, DisplayName: persona.Name, SystemPrompt: persona.SystemPrompt, IsDefault: false, } _ = h.stores.Channels.SetModel(c.Request.Context(), cm) } else if req.ParticipantType == "user" { // Resolve user display name user, err := h.stores.Users.GetByID(c.Request.Context(), req.ParticipantID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up user"}) return } dn := user.DisplayName if dn == "" { dn = user.Username } p.DisplayName = &dn if user.AvatarURL != "" { p.AvatarURL = &user.AvatarURL } } if err := h.stores.Channels.AddParticipant(c.Request.Context(), p); err != nil { // Check for duplicate if isDuplicateErr(err) { c.JSON(http.StatusConflict, gin.H{"error": "participant already in channel"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"}) return } // Return updated participant list participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) c.JSON(http.StatusCreated, gin.H{"participants": participants}) } // ── Update Role ───────────────────────────── type updateParticipantRequest struct { Role string `json:"role" binding:"required"` } func (h *ParticipantHandler) Update(c *gin.Context) { channelID := c.Param("id") participantRecordID := c.Param("participantId") userID := getUserID(c) if !h.requireOwner(c, channelID, userID) { return } var req updateParticipantRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } switch req.Role { case "owner", "member", "observer": // valid default: c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"}) return } // Verify participant belongs to this channel p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"}) return } if p.ChannelID != channelID { c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"}) return } if err := h.stores.Channels.UpdateParticipantRole(c.Request.Context(), participantRecordID, req.Role); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update participant"}) return } participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) c.JSON(http.StatusOK, gin.H{"participants": participants}) } // ── Remove ────────────────────────────────── func (h *ParticipantHandler) Remove(c *gin.Context) { channelID := c.Param("id") participantRecordID := c.Param("participantId") userID := getUserID(c) if !h.requireOwner(c, channelID, userID) { return } // Verify participant belongs to this channel p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"}) return } if p.ChannelID != channelID { c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"}) return } // Cannot remove the last owner if p.Role == "owner" { owners, _ := h.stores.Channels.CountParticipantsByRole(c.Request.Context(), channelID, "owner") if owners <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last owner"}) return } } // v0.23.2: DM guard — cannot drop below 2 human participants channelType, _, _ := h.stores.Channels.GetTypeAndTeamID(c.Request.Context(), channelID) if channelType == "dm" && p.ParticipantType == "user" { userCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "user") if userCount <= 2 { c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"}) return } } // v0.23.2: Group guard — cannot remove the last persona if channelType == "group" && p.ParticipantType == "persona" { personaCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "persona") if personaCount <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"}) return } } // If removing a persona, also remove its auto-created model roster entry if p.ParticipantType == "persona" { _ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID) } if err := h.stores.Channels.RemoveParticipant(c.Request.Context(), participantRecordID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"}) return } participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) c.JSON(http.StatusOK, gin.H{"participants": participants}) } // ── Helpers ────────────────────────────────── // requireParticipant checks the user is any participant in the channel. func (h *ParticipantHandler) requireParticipant(c *gin.Context, channelID, userID string) bool { ok, err := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check participation"}) return false } if !ok { // Fall back to legacy channels.user_id ownership for direct channels if userOwnsChannel(c, channelID, userID) { return true } return false } return true } // requireOwner checks the user is an owner participant in the channel. func (h *ParticipantHandler) requireOwner(c *gin.Context, channelID, userID string) bool { role, err := h.stores.Channels.GetParticipantRole(c.Request.Context(), channelID, "user", userID) if err != nil { // Fall back to legacy channels.user_id ownership for direct channels if userOwnsChannel(c, channelID, userID) { return true } return false } if role != "owner" { c.JSON(http.StatusForbidden, gin.H{"error": "owner role required"}) return false } return true } func derefStr(s *string) string { if s == nil { return "" } return *s } // isDuplicateErr detects unique constraint violations across both Postgres and SQLite. func isDuplicateErr(err error) bool { if err == nil { return false } msg := err.Error() // Postgres: "duplicate key value violates unique constraint" // SQLite: "UNIQUE constraint failed" return contains(msg, "duplicate key") || contains(msg, "UNIQUE constraint") } func contains(s, substr string) bool { return len(s) >= len(substr) && searchStr(s, substr) } func searchStr(s, sub string) bool { for i := 0; i <= len(s)-len(sub); i++ { if s[i:i+len(sub)] == sub { return true } } return false }