This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/participants.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

361 lines
11 KiB
Go

package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/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{"data": 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{"data": 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{"data": 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{"data": 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
}