361 lines
11 KiB
Go
361 lines
11 KiB
Go
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|