Changeset 0.23.0 (#153)
This commit is contained in:
340
server/handlers/participants.go
Normal file
340
server/handlers/participants.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user