Changeset 0.23.2 (#155)
This commit is contained in:
10
server/database/migrations/017_unread.sql
Normal file
10
server/database/migrations/017_unread.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 017 Unread Counts
|
||||
-- ==========================================
|
||||
-- Adds last_read_message_id to channel_participants for
|
||||
-- per-user unread count computation.
|
||||
-- ICD §3 (Channels & Conversations) — v0.23.2
|
||||
-- ==========================================
|
||||
|
||||
ALTER TABLE channel_participants
|
||||
ADD COLUMN IF NOT EXISTS last_read_message_id UUID;
|
||||
3
server/database/migrations/sqlite/017_unread.sql
Normal file
3
server/database/migrations/sqlite/017_unread.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Chat Switchboard — 017 Unread Counts (SQLite)
|
||||
|
||||
ALTER TABLE channel_participants ADD COLUMN last_read_message_id TEXT;
|
||||
@@ -45,6 +45,9 @@ var routeTable = map[string]Direction{
|
||||
// User/presence
|
||||
"user.presence": DirToClient,
|
||||
"user.status": DirToClient,
|
||||
"user.mentioned": DirToClient, // v0.23.2: targeted @mention notification
|
||||
"typing.user": DirToClient, // v0.23.2: human typing in DM/channel
|
||||
"message.created": DirToClient, // v0.23.2: chained/DM message delivery
|
||||
|
||||
// System
|
||||
"system.notify": DirToClient,
|
||||
|
||||
@@ -333,6 +333,14 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
hasAdminPrompt = true
|
||||
}
|
||||
|
||||
// Channel retention mode (v0.23.2 — flexible or retain)
|
||||
retentionMode := "flexible"
|
||||
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
|
||||
if mode, ok := retCfg["mode"].(string); ok && mode != "" {
|
||||
retentionMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"banner": banner,
|
||||
"branding": branding,
|
||||
@@ -340,9 +348,10 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
"storage_configured": storageConfigured,
|
||||
"paste_to_file_chars": pasteChars,
|
||||
"policies": gin.H{
|
||||
"allow_registration": policies["allow_registration"],
|
||||
"allow_user_byok": policies["allow_user_byok"],
|
||||
"allow_user_personas": policies["allow_user_personas"],
|
||||
"allow_registration": policies["allow_registration"],
|
||||
"allow_user_byok": policies["allow_user_byok"],
|
||||
"allow_user_personas": policies["allow_user_personas"],
|
||||
"channel_retention_mode": retentionMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -724,3 +733,88 @@ func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID
|
||||
log.Printf("audit log error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Archived Channels (v0.23.2) ──────────────
|
||||
|
||||
func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT c.id, c.title, c.type, c.user_id,
|
||||
COALESCE(u.username, '') AS owner_name,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count
|
||||
FROM channels c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.is_archived = true
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 100
|
||||
`))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type archivedChannel struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MessageCount int `json:"message_count"`
|
||||
}
|
||||
|
||||
channels := []archivedChannel{}
|
||||
for rows.Next() {
|
||||
var ch archivedChannel
|
||||
var userID string
|
||||
if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &userID, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil {
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Retention config
|
||||
retentionMode := "flexible"
|
||||
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
|
||||
if mode, ok := retCfg["mode"].(string); ok {
|
||||
retentionMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channels": channels,
|
||||
"retention_mode": retentionMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PurgeChannel(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Verify the channel is actually archived
|
||||
var isArchived bool
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT is_archived FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&isArchived)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if !isArchived {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "channel must be archived before purging"})
|
||||
return
|
||||
}
|
||||
|
||||
// Hard delete (CASCADE removes messages, participants, models)
|
||||
_, err = database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
DELETE FROM channels WHERE id = $1
|
||||
`), channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log
|
||||
uid := getUserID(c)
|
||||
h.auditLog(c, uid, "channel.purged", "channel", channelID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -19,13 +19,16 @@ import (
|
||||
|
||||
type createChannelRequest struct {
|
||||
Title string `json:"title" binding:"required,max=500"`
|
||||
Type string `json:"type,omitempty"` // direct (default), group, workflow
|
||||
Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow
|
||||
Description string `json:"description,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// v0.23.2: DM creation
|
||||
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
|
||||
AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off
|
||||
}
|
||||
|
||||
type updateChannelRequest struct {
|
||||
@@ -40,6 +43,8 @@ type updateChannelRequest struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
||||
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
|
||||
AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off
|
||||
Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic
|
||||
}
|
||||
|
||||
type channelResponse struct {
|
||||
@@ -47,6 +52,8 @@ type channelResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
AiMode string `json:"ai_mode,omitempty"`
|
||||
Topic *string `json:"topic,omitempty"`
|
||||
Description *string `json:"description"`
|
||||
Model *string `json:"model"`
|
||||
ProviderConfigID *string `json:"provider_config_id"`
|
||||
@@ -59,6 +66,7 @@ type channelResponse struct {
|
||||
Tags []string `json:"tags"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
MessageCount int `json:"message_count"`
|
||||
UnreadCount int `json:"unread_count,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -140,16 +148,24 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
archived := c.DefaultQuery("archived", "false")
|
||||
folder := c.Query("folder")
|
||||
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||
channelTypes := c.QueryArray("types") // v0.23.1: multi-value, e.g. ?types=dm&types=channel
|
||||
// Comma-joined convenience: ?types=dm,channel
|
||||
if len(channelTypes) == 0 && c.Query("types") != "" {
|
||||
channelTypes = strings.Split(c.Query("types"), ",")
|
||||
// v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel
|
||||
// and comma-joined ?types=dm,channel
|
||||
var channelTypes []string
|
||||
if raw := c.Query("types"); raw != "" {
|
||||
for _, t := range strings.Split(raw, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
channelTypes = append(channelTypes, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
projectFilter := c.Query("project_id") // "uuid" or "none"
|
||||
|
||||
// Count total
|
||||
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
|
||||
// Count total — include channels owned by user OR where user is a participant
|
||||
countQuery := `SELECT COUNT(*) FROM channels c WHERE (c.user_id = $1 OR c.id IN (
|
||||
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
|
||||
)) AND c.is_archived = $2`
|
||||
countArgs := []interface{}{userID, archived == "true"}
|
||||
argN := 3
|
||||
|
||||
@@ -160,26 +176,26 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
countArgs = append(countArgs, strings.TrimSpace(t))
|
||||
argN++
|
||||
}
|
||||
countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")"
|
||||
countQuery += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
|
||||
} else if channelType != "" {
|
||||
countQuery += ` AND type = $` + strconv.Itoa(argN)
|
||||
countQuery += ` AND c.type = $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, channelType)
|
||||
argN++
|
||||
}
|
||||
if folder != "" {
|
||||
countQuery += ` AND folder = $` + strconv.Itoa(argN)
|
||||
countQuery += ` AND c.folder = $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, folder)
|
||||
argN++
|
||||
}
|
||||
if search != "" {
|
||||
countQuery += ` AND title ILIKE $` + strconv.Itoa(argN)
|
||||
countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, "%"+search+"%")
|
||||
argN++
|
||||
}
|
||||
if projectFilter == "none" {
|
||||
countQuery += ` AND project_id IS NULL`
|
||||
countQuery += ` AND c.project_id IS NULL`
|
||||
} else if projectFilter != "" {
|
||||
countQuery += ` AND project_id = $` + strconv.Itoa(argN)
|
||||
countQuery += ` AND c.project_id = $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, projectFilter)
|
||||
argN++
|
||||
}
|
||||
@@ -191,8 +207,10 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Fetch channels with message count
|
||||
// Include channels owned by user OR where user is a participant (multi-user)
|
||||
query := `
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
|
||||
c.description, c.model, c.provider_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
|
||||
c.tags, c.settings,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
@@ -201,7 +219,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
LEFT JOIN (
|
||||
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||
) mc ON mc.channel_id = c.id
|
||||
WHERE c.user_id = $1 AND c.is_archived = $2`
|
||||
WHERE (c.user_id = $1 OR c.id IN (
|
||||
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
|
||||
)) AND c.is_archived = $2`
|
||||
|
||||
args := []interface{}{userID, archived == "true"}
|
||||
argN = 3
|
||||
@@ -252,7 +272,8 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := rows.Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
||||
&ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
@@ -267,6 +288,18 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
ch.Tags = tags
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// v0.23.2: Compute unread counts per channel (safe post-processing)
|
||||
for i := range channels {
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN channel_participants cp ON cp.channel_id = m.channel_id
|
||||
WHERE cp.channel_id = $1
|
||||
AND cp.participant_type = 'user' AND cp.participant_id = $2
|
||||
AND m.created_at > cp.last_read_at
|
||||
`), channels[i].ID, userID).Scan(&channels[i].UnreadCount)
|
||||
}
|
||||
|
||||
SafeJSON(c, http.StatusOK, paginatedResponse{
|
||||
Data: channels,
|
||||
@@ -298,6 +331,62 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
channelType = "direct"
|
||||
}
|
||||
|
||||
// Default ai_mode: mention_only for DMs, auto for everything else
|
||||
aiMode := req.AiMode
|
||||
if aiMode == "" {
|
||||
if channelType == "dm" {
|
||||
aiMode = "mention_only"
|
||||
} else {
|
||||
aiMode = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
// ── DM dedup: check for existing DM between these two users ──
|
||||
if channelType == "dm" && len(req.Participants) == 1 {
|
||||
otherUserID := req.Participants[0]
|
||||
if otherUserID == userID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look for an existing DM channel where both users are participants
|
||||
var existingID string
|
||||
_ = database.DB.QueryRow(database.Q(`
|
||||
SELECT cp1.channel_id FROM channel_participants cp1
|
||||
JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id
|
||||
JOIN channels c ON c.id = cp1.channel_id
|
||||
WHERE c.type = 'dm'
|
||||
AND cp1.participant_type = 'user' AND cp1.participant_id = $1
|
||||
AND cp2.participant_type = 'user' AND cp2.participant_id = $2
|
||||
LIMIT 1
|
||||
`), userID, otherUserID).Scan(&existingID)
|
||||
if existingID != "" {
|
||||
// Return existing channel instead of creating duplicate
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT id, user_id, title, type, description, model, provider_config_id,
|
||||
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
|
||||
tags, settings,
|
||||
created_at, updated_at
|
||||
FROM channels WHERE id = $1
|
||||
`), existingID).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
ch.Tags = tags
|
||||
SafeJSON(c, http.StatusOK, ch) // 200, not 201 — existing resource
|
||||
return
|
||||
}
|
||||
// If read fails, fall through and create new (shouldn't happen)
|
||||
}
|
||||
}
|
||||
|
||||
// INSERT and retrieve the new row
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
@@ -306,10 +395,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
id := store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO channels (id, user_id, title, type, description, model,
|
||||
system_prompt, provider_config_id, folder, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
system_prompt, provider_config_id, folder, tags, ai_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, userID, req.Title, channelType, req.Description, req.Model,
|
||||
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags),
|
||||
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), aiMode,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
@@ -332,12 +421,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
req.Folder, pq.Array(req.Tags), aiMode,
|
||||
).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
@@ -370,6 +459,28 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
`, ch.ID, userID)
|
||||
}
|
||||
|
||||
// v0.23.2: Add DM partner as member participant
|
||||
if channelType == "dm" && len(req.Participants) > 0 {
|
||||
for _, pid := range req.Participants {
|
||||
if pid == userID {
|
||||
continue // skip self (already added as owner)
|
||||
}
|
||||
if database.IsSQLite() {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
||||
VALUES (?, ?, 'user', ?, 'member')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, store.NewID(), ch.ID, pid)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
|
||||
VALUES ($1, 'user', $2, 'member')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-create channel_model if model specified
|
||||
if req.Model != "" {
|
||||
if database.IsSQLite() {
|
||||
@@ -519,6 +630,17 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
addClause("workspace_id", *req.WorkspaceID)
|
||||
}
|
||||
}
|
||||
if req.AiMode != nil {
|
||||
mode := *req.AiMode
|
||||
if mode != "auto" && mode != "mention_only" && mode != "off" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"})
|
||||
return
|
||||
}
|
||||
addClause("ai_mode", mode)
|
||||
}
|
||||
if req.Topic != nil {
|
||||
addClause("topic", *req.Topic)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
@@ -571,3 +693,38 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
||||
}
|
||||
|
||||
// ── Mark Read (v0.23.2) ───────────────────────
|
||||
|
||||
func (h *ChannelHandler) MarkRead(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Update the read cursor timestamp (last_read_at exists since migration 005)
|
||||
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
UPDATE channel_participants
|
||||
SET last_read_at = NOW()
|
||||
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
|
||||
`), channelID, userID)
|
||||
if err != nil {
|
||||
// Participant row may not exist for legacy direct chats — that's OK
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort: also set last_read_message_id if the column exists (migration 017)
|
||||
var latestMsgID *string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`), channelID).Scan(&latestMsgID)
|
||||
if latestMsgID != nil {
|
||||
// This may fail if 017 hasn't been applied — that's fine, last_read_at is primary
|
||||
_, _ = database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
UPDATE channel_participants
|
||||
SET last_read_message_id = $1
|
||||
WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3
|
||||
`), *latestMsgID, channelID, userID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -224,8 +224,43 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
|
||||
// Deliver message without triggering completion
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true})
|
||||
// Persist the user message before returning
|
||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
|
||||
if err != nil {
|
||||
log.Printf("[mention_only] Failed to persist user message: %v", err)
|
||||
}
|
||||
// Broadcast via WS to ALL user participants (not just sender)
|
||||
if h.hub != nil && msgID != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"id": msgID,
|
||||
"channel_id": channelID,
|
||||
"role": "user",
|
||||
"content": req.Content,
|
||||
"user_id": userID,
|
||||
})
|
||||
evt := events.Event{
|
||||
Label: "message.created",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
// Send to sender
|
||||
h.hub.SendToUser(userID, evt)
|
||||
// Send to other user participants in the channel
|
||||
pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
|
||||
`), channelID, userID)
|
||||
if pErr == nil {
|
||||
defer pRows.Close()
|
||||
for pRows.Next() {
|
||||
var pid string
|
||||
if pRows.Scan(&pid) == nil {
|
||||
h.hub.SendToUser(pid, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -234,6 +269,40 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
var personaSystemPrompt string
|
||||
var personaID string // tracks active persona for KB scoping
|
||||
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||
|
||||
// v0.23.2: Group leader default — when type=group and no @mention, use the leader persona
|
||||
if req.PersonaID == "" && extractFirstMention(req.Content) == "" {
|
||||
var channelType string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&channelType)
|
||||
if channelType == "group" {
|
||||
// Find the leader persona via channel_participants → persona_group_members
|
||||
var leaderPersonaID string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT cp.participant_id
|
||||
FROM channel_participants cp
|
||||
JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id
|
||||
WHERE cp.channel_id = $1
|
||||
AND cp.participant_type = 'persona'
|
||||
AND pgm.is_leader = true
|
||||
LIMIT 1
|
||||
`), channelID).Scan(&leaderPersonaID)
|
||||
// Fallback: first persona participant if no leader flag set
|
||||
if leaderPersonaID == "" {
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||
ORDER BY created_at LIMIT 1
|
||||
`), channelID).Scan(&leaderPersonaID)
|
||||
}
|
||||
if leaderPersonaID != "" {
|
||||
req.PersonaID = leaderPersonaID
|
||||
log.Printf("[group] channel %s: leader persona %s", channelID[:min(8, len(channelID))], leaderPersonaID[:min(8, len(leaderPersonaID))])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.PersonaID != "" {
|
||||
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
||||
if persona == nil {
|
||||
@@ -413,14 +482,60 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// - AI mention → override model/persona for this turn (v0.23.0)
|
||||
mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content)
|
||||
|
||||
if mentionedUserID != "" {
|
||||
// Human @mention — message already persisted; notify, skip AI.
|
||||
if hub, ok := c.Get("events_hub"); ok {
|
||||
if h2, ok2 := hub.(interface {
|
||||
NotifyUserMention(toUser, channel, fromUser string)
|
||||
}); ok2 {
|
||||
h2.NotifyUserMention(mentionedUserID, channelID, userID)
|
||||
// v0.23.2: @all fan-out — route to every persona participant (depth-1 only)
|
||||
if strings.Contains(strings.ToLower(req.Content), "@all") {
|
||||
// Get all persona participants for this channel
|
||||
allRows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||
ORDER BY created_at
|
||||
`), channelID)
|
||||
if err == nil {
|
||||
defer allRows.Close()
|
||||
var allPersonaIDs []string
|
||||
for allRows.Next() {
|
||||
var pid string
|
||||
if allRows.Scan(&pid) == nil {
|
||||
allPersonaIDs = append(allPersonaIDs, pid)
|
||||
}
|
||||
}
|
||||
if len(allPersonaIDs) > 0 {
|
||||
// Use the first persona for the streamed response
|
||||
first := ResolvePersona(h.stores, allPersonaIDs[0], userID)
|
||||
if first != nil {
|
||||
mentionModel = first.BaseModelID
|
||||
if first.ProviderConfigID != nil {
|
||||
mentionConfig = *first.ProviderConfigID
|
||||
}
|
||||
mentionPersona = first
|
||||
}
|
||||
// Chain remaining personas via goroutine after the stream completes
|
||||
if len(allPersonaIDs) > 1 && h.hub != nil {
|
||||
remaining := allPersonaIDs[1:]
|
||||
go func() {
|
||||
for _, pid := range remaining {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
h.chainToPersona(channelID, userID, pid, req.Content, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mentionedUserID != "" {
|
||||
// Human @mention — message already persisted; notify recipient, skip AI.
|
||||
if h.hub != nil {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"channel_id": channelID,
|
||||
"from_user": userID,
|
||||
"content": truncateContent(req.Content, 120),
|
||||
})
|
||||
h.hub.SendToUser(mentionedUserID, events.Event{
|
||||
Label: "user.mentioned",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
|
||||
return
|
||||
@@ -1230,7 +1345,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
var mentionedUserID string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) = $1 AND id != $2 AND is_approved = true
|
||||
WHERE LOWER(username) = $1 AND id != $2 AND is_active = true
|
||||
LIMIT 1
|
||||
`), normalized, userID).Scan(&mentionedUserID)
|
||||
if err == nil && mentionedUserID != "" {
|
||||
@@ -1242,12 +1357,12 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
||||
var userCount int
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COUNT(*) FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||
`), normalized+"%", userID).Scan(&userCount)
|
||||
if userCount == 1 {
|
||||
database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||
LIMIT 1
|
||||
`), normalized+"%", userID).Scan(&mentionedUserID)
|
||||
if mentionedUserID != "" {
|
||||
@@ -1820,3 +1935,12 @@ func (h *CompletionHandler) recordHealth(configID string, start time.Time, err e
|
||||
h.health.RecordSuccess(configID, latencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateContent returns content truncated to maxLen runes with ellipsis.
|
||||
func truncateContent(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxLen]) + "…"
|
||||
}
|
||||
|
||||
@@ -203,6 +203,141 @@ func (h *CompletionHandler) chainIfMentioned(
|
||||
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
|
||||
}
|
||||
|
||||
// chainToPersona triggers a completion for a specific persona by ID.
|
||||
// Used by @all fan-out. Does NOT recurse (depth-1 only).
|
||||
func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, triggerContent string, depth int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain-all] PANIC: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
persona := ResolvePersona(h.stores, personaID, userID)
|
||||
if persona == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if h.hub != nil {
|
||||
h.emitTyping(userID, channelID, persona.ID, persona.Name, true)
|
||||
defer h.emitTyping(userID, channelID, persona.ID, persona.Name, false)
|
||||
}
|
||||
|
||||
configID := ""
|
||||
if persona.ProviderConfigID != nil {
|
||||
configID = *persona.ProviderConfigID
|
||||
}
|
||||
chainReq := completionRequest{
|
||||
ChannelID: channelID,
|
||||
Model: persona.BaseModelID,
|
||||
ProviderConfigID: configID,
|
||||
}
|
||||
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
|
||||
if err != nil {
|
||||
log.Printf("[chain-all] Config failed for %s: %v", persona.Name, err)
|
||||
return
|
||||
}
|
||||
configID = resolvedConfigID
|
||||
|
||||
provider, err := providers.Get(providerID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
messages, err := h.loadConversation(channelID, userID, persona.SystemPrompt, persona.ID, model)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
boundary := fmt.Sprintf(
|
||||
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style. Respond only as yourself per your system prompt.]",
|
||||
persona.Name,
|
||||
)
|
||||
messages = append(messages, providers.Message{Role: "system", Content: boundary})
|
||||
|
||||
caps := capspkg.ResolveIntrinsic(model, nil, nil)
|
||||
provReq := providers.CompletionRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
|
||||
}
|
||||
if persona.Temperature != nil {
|
||||
provReq.Temperature = persona.Temperature
|
||||
}
|
||||
if persona.ThinkingBudget != nil && *persona.ThinkingBudget > 0 {
|
||||
if providerCfg.Settings == nil {
|
||||
providerCfg.Settings = make(map[string]interface{})
|
||||
}
|
||||
providerCfg.Settings["extended_thinking"] = true
|
||||
providerCfg.Settings["thinking_budget"] = *persona.ThinkingBudget
|
||||
}
|
||||
|
||||
if hooks := providers.GetHooks(providerID); hooks != nil {
|
||||
hooks.PreRequest(providerCfg, &provReq)
|
||||
}
|
||||
|
||||
callStart := time.Now()
|
||||
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
|
||||
latencyMs := int(time.Since(callStart).Milliseconds())
|
||||
if err != nil {
|
||||
if h.health != nil {
|
||||
h.health.RecordError(configID, latencyMs, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if h.health != nil {
|
||||
h.health.RecordSuccess(configID, latencyMs)
|
||||
}
|
||||
if resp.Content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
|
||||
resp.InputTokens, resp.OutputTokens, nil, nil, configID, persona.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if h.hub != nil {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": channelID,
|
||||
"role": "assistant",
|
||||
"content": resp.Content,
|
||||
"model": model,
|
||||
"participant_type": "persona",
|
||||
"participant_id": persona.ID,
|
||||
"display_name": persona.Name,
|
||||
"avatar": persona.Avatar,
|
||||
"tokens_used": resp.InputTokens + resp.OutputTokens,
|
||||
"chain_depth": depth,
|
||||
})
|
||||
h.hub.SendToUser(userID, events.Event{
|
||||
Label: "message.created",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
if h.stores.Usage != nil {
|
||||
entry := &models.UsageEntry{
|
||||
ChannelID: &channelID,
|
||||
UserID: userID,
|
||||
ProviderConfigID: &configID,
|
||||
ProviderScope: "global",
|
||||
ModelID: model,
|
||||
PromptTokens: resp.InputTokens,
|
||||
CompletionTokens: resp.OutputTokens,
|
||||
}
|
||||
_ = h.stores.Usage.Log(ctx, entry)
|
||||
}
|
||||
|
||||
log.Printf("[chain-all] ✅ %s responded (%d tokens) in channel %s",
|
||||
persona.Name, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
|
||||
}
|
||||
|
||||
// emitTyping sends a typing indicator via WebSocket.
|
||||
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
|
||||
eventType := "typing.start"
|
||||
|
||||
@@ -29,16 +29,20 @@ type createMessageRequest struct {
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Model *string `json:"model"`
|
||||
TokensUsed *int `json:"tokens_used"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
SiblingCount int `json:"sibling_count"`
|
||||
SiblingIndex int `json:"sibling_index"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Model *string `json:"model"`
|
||||
TokensUsed *int `json:"tokens_used"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
SiblingCount int `json:"sibling_count"`
|
||||
SiblingIndex int `json:"sibling_index"`
|
||||
ParticipantType *string `json:"participant_type,omitempty"`
|
||||
ParticipantID *string `json:"participant_id,omitempty"`
|
||||
SenderName *string `json:"sender_name,omitempty"`
|
||||
SenderAvatar *string `json:"sender_avatar,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type editRequest struct {
|
||||
@@ -98,11 +102,20 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
FROM messages
|
||||
WHERE channel_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
|
||||
m.sibling_index, m.participant_type, m.participant_id,
|
||||
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
|
||||
WHEN m.participant_type = 'persona' THEN p.name
|
||||
ELSE NULL END AS sender_name,
|
||||
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
|
||||
WHEN m.participant_type = 'persona' THEN p.avatar
|
||||
ELSE NULL END AS sender_avatar,
|
||||
m.created_at
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
|
||||
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
|
||||
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`), channelID, perPage, offset)
|
||||
if err != nil {
|
||||
@@ -117,7 +130,9 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
err := rows.Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
&msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID,
|
||||
&msg.SenderName, &msg.SenderAvatar,
|
||||
&msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
|
||||
@@ -693,9 +708,20 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
|
||||
return false
|
||||
}
|
||||
if ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return false
|
||||
if ownerID == userID {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
||||
// v0.23.2: Also allow if user is a participant (multi-user DMs, channels)
|
||||
var exists bool
|
||||
_ = database.DB.QueryRow(
|
||||
database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`),
|
||||
channelID, userID,
|
||||
).Scan(&exists)
|
||||
if exists {
|
||||
return true
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
@@ -258,6 +259,37 @@ func (h *ParticipantHandler) Remove(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.23.2: DM guard — cannot drop below 2 human participants
|
||||
var channelType string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&channelType)
|
||||
|
||||
if channelType == "dm" && p.ParticipantType == "user" {
|
||||
var userCount int
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COUNT(*) FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'user'
|
||||
`), channelID).Scan(&userCount)
|
||||
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" {
|
||||
var personaCount int
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COUNT(*) FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||
`), channelID).Scan(&personaCount)
|
||||
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)
|
||||
|
||||
311
server/handlers/persona_groups.go
Normal file
311
server/handlers/persona_groups.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package handlers
|
||||
|
||||
// persona_groups.go — Persona group (roster template) CRUD (v0.23.2)
|
||||
//
|
||||
// Persona groups are saved collections of personas used as templates
|
||||
// for creating group chats. Each member has an is_leader flag.
|
||||
//
|
||||
// Routes:
|
||||
// GET /api/v1/persona-groups
|
||||
// POST /api/v1/persona-groups
|
||||
// GET /api/v1/persona-groups/:id
|
||||
// PUT /api/v1/persona-groups/:id
|
||||
// DELETE /api/v1/persona-groups/:id
|
||||
// POST /api/v1/persona-groups/:id/members
|
||||
// DELETE /api/v1/persona-groups/:id/members/:memberId
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
type PersonaGroupHandler struct{}
|
||||
|
||||
func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} }
|
||||
|
||||
// ── List ────────────────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) List(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||
FROM persona_groups
|
||||
WHERE owner_id = $1
|
||||
ORDER BY name
|
||||
`), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := []models.PersonaGroup{}
|
||||
for rows.Next() {
|
||||
var g models.PersonaGroup
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
// Load members for each group
|
||||
for i := range groups {
|
||||
groups[i].Members = h.loadMembers(c, groups[i].ID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
|
||||
// ── Get ─────────────────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) Get(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var g models.PersonaGroup
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||
FROM persona_groups WHERE id = $1 AND owner_id = $2
|
||||
`), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"})
|
||||
return
|
||||
}
|
||||
|
||||
g.Members = h.loadMembers(c, g.ID)
|
||||
c.JSON(http.StatusOK, g)
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) Create(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var g models.PersonaGroup
|
||||
if database.IsSQLite() {
|
||||
id := store.NewID()
|
||||
_, err := database.DB.ExecContext(c.Request.Context(), `
|
||||
INSERT INTO persona_groups (id, name, description, owner_id, scope)
|
||||
VALUES (?, ?, ?, ?, 'personal')
|
||||
`, id, strings.TrimSpace(req.Name), req.Description, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
||||
return
|
||||
}
|
||||
database.DB.QueryRowContext(c.Request.Context(), `
|
||||
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||
FROM persona_groups WHERE id = ?
|
||||
`, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
||||
} else {
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), `
|
||||
INSERT INTO persona_groups (name, description, owner_id, scope)
|
||||
VALUES ($1, $2, $3, 'personal')
|
||||
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||
`, strings.TrimSpace(req.Name), req.Description, userID).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
g.Members = []models.PersonaGroupMember{}
|
||||
c.JSON(http.StatusCreated, g)
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) Update(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
var ownerID string
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT owner_id FROM persona_groups WHERE id = $1
|
||||
`), id).Scan(&ownerID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
UPDATE persona_groups SET name = $1, updated_at = NOW() WHERE id = $2
|
||||
`), strings.TrimSpace(*req.Name), id)
|
||||
}
|
||||
if req.Description != nil {
|
||||
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
UPDATE persona_groups SET description = $1, updated_at = NOW() WHERE id = $2
|
||||
`), *req.Description, id)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) Delete(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
result, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2
|
||||
`), id, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── Add Member ──────────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) AddMember(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
groupID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
PersonaID string `json:"persona_id" binding:"required"`
|
||||
IsLeader bool `json:"is_leader"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
var ownerID string
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT owner_id FROM persona_groups WHERE id = $1
|
||||
`), groupID).Scan(&ownerID)
|
||||
if err != nil || ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
|
||||
return
|
||||
}
|
||||
|
||||
// If setting as leader, clear existing leader
|
||||
if req.IsLeader {
|
||||
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
UPDATE persona_group_members SET is_leader = false WHERE group_id = $1
|
||||
`), groupID)
|
||||
}
|
||||
|
||||
if database.IsSQLite() {
|
||||
id := store.NewID()
|
||||
_, err = database.DB.ExecContext(c.Request.Context(), `
|
||||
INSERT INTO persona_group_members (id, group_id, persona_id, is_leader)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader
|
||||
`, id, groupID, req.PersonaID, req.IsLeader)
|
||||
} else {
|
||||
_, err = database.DB.ExecContext(c.Request.Context(), `
|
||||
INSERT INTO persona_group_members (group_id, persona_id, is_leader)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader
|
||||
`, groupID, req.PersonaID, req.IsLeader)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── Remove Member ───────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
groupID := c.Param("id")
|
||||
memberID := c.Param("memberId")
|
||||
|
||||
// Verify ownership
|
||||
var ownerID string
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT owner_id FROM persona_groups WHERE id = $1
|
||||
`), groupID).Scan(&ownerID)
|
||||
if err != nil || ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2
|
||||
`), memberID, groupID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────
|
||||
|
||||
func (h *PersonaGroupHandler) loadMembers(c *gin.Context, groupID string) []models.PersonaGroupMember {
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order,
|
||||
COALESCE(p.name, '') AS persona_name,
|
||||
COALESCE(p.handle, '') AS persona_handle,
|
||||
COALESCE(p.avatar, '') AS persona_avatar
|
||||
FROM persona_group_members pgm
|
||||
LEFT JOIN personas p ON p.id = pgm.persona_id
|
||||
WHERE pgm.group_id = $1
|
||||
ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id
|
||||
`), groupID)
|
||||
if err != nil {
|
||||
return []models.PersonaGroupMember{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []models.PersonaGroupMember{}
|
||||
for rows.Next() {
|
||||
var m models.PersonaGroupMember
|
||||
if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder,
|
||||
&m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil {
|
||||
continue
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
return members
|
||||
}
|
||||
@@ -67,3 +67,50 @@ func PresenceQuery(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"presence": result})
|
||||
}
|
||||
|
||||
// SearchUsers returns a lightweight list of approved users matching a query.
|
||||
// GET /api/v1/users/search?q=alice — matches against username and display_name.
|
||||
// Returns at most 20 results. Excludes the calling user.
|
||||
func SearchUsers(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
|
||||
query := database.Q(`
|
||||
SELECT id, username, COALESCE(display_name, '') AS display_name
|
||||
FROM users
|
||||
WHERE is_active = true AND id != $1
|
||||
`)
|
||||
args := []interface{}{userID}
|
||||
|
||||
if q != "" {
|
||||
query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3)`)
|
||||
pattern := "%" + strings.ToLower(q) + "%"
|
||||
args = append(args, pattern, pattern)
|
||||
}
|
||||
|
||||
query += ` ORDER BY username LIMIT 20`
|
||||
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type userResult struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
results := []userResult{}
|
||||
for rows.Next() {
|
||||
var u userResult
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, u)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"users": results})
|
||||
}
|
||||
|
||||
@@ -376,6 +376,46 @@ func main() {
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
protected.POST("/channels/:id/mark-read", channels.MarkRead)
|
||||
|
||||
// Typing indicator broadcast (v0.23.2)
|
||||
protected.POST("/channels/:id/typing", func(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
channelID := c.Param("id")
|
||||
// Resolve display name
|
||||
var displayName string
|
||||
_ = database.DB.QueryRow(database.Q(`
|
||||
SELECT COALESCE(display_name, username) FROM users WHERE id = $1
|
||||
`), userID).Scan(&displayName)
|
||||
if displayName == "" {
|
||||
displayName = userID[:8]
|
||||
}
|
||||
// Broadcast to other user participants
|
||||
pRows, err := database.DB.Query(database.Q(`
|
||||
SELECT participant_id FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
|
||||
`), channelID, userID)
|
||||
if err == nil {
|
||||
defer pRows.Close()
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"channel_id": channelID,
|
||||
"user_id": userID,
|
||||
"display_name": displayName,
|
||||
})
|
||||
evt := events.Event{
|
||||
Label: "typing.user",
|
||||
Payload: payload,
|
||||
Ts: time.Now().UnixMilli(),
|
||||
}
|
||||
for pRows.Next() {
|
||||
var pid string
|
||||
if pRows.Scan(&pid) == nil {
|
||||
hub.SendToUser(pid, evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
// Chat Folders (v0.23.1)
|
||||
folders := handlers.NewFolderHandler()
|
||||
@@ -388,6 +428,19 @@ func main() {
|
||||
protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat)
|
||||
protected.GET("/presence", handlers.PresenceQuery)
|
||||
|
||||
// User search (v0.23.2 — DM user picker)
|
||||
protected.GET("/users/search", handlers.SearchUsers)
|
||||
|
||||
// Persona groups (v0.23.2 — roster templates for group chats)
|
||||
pgH := handlers.NewPersonaGroupHandler()
|
||||
protected.GET("/persona-groups", pgH.List)
|
||||
protected.POST("/persona-groups", pgH.Create)
|
||||
protected.GET("/persona-groups/:id", pgH.Get)
|
||||
protected.PUT("/persona-groups/:id", pgH.Update)
|
||||
protected.DELETE("/persona-groups/:id", pgH.Delete)
|
||||
protected.POST("/persona-groups/:id/members", pgH.AddMember)
|
||||
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
|
||||
|
||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||
chModelH := handlers.NewChannelModelHandler(stores)
|
||||
protected.GET("/channels/:id/models", chModelH.List)
|
||||
@@ -802,6 +855,10 @@ func main() {
|
||||
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
|
||||
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
|
||||
|
||||
// Archived channels (admin — v0.23.2)
|
||||
admin.GET("/channels/archived", adm.ListArchivedChannels)
|
||||
admin.DELETE("/channels/:id/purge", adm.PurgeChannel)
|
||||
|
||||
// Extensions (admin)
|
||||
extAdm := handlers.NewExtensionHandler(stores)
|
||||
admin.GET("/extensions", extAdm.AdminListExtensions)
|
||||
|
||||
@@ -160,7 +160,10 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-section-body" id="sbBodyChats">
|
||||
<div class="sb-section-body" id="sbBodyChats"
|
||||
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||||
ondragleave="this.classList.remove('drag-over')"
|
||||
ondrop="onChatSectionDrop(event)">
|
||||
{{/* Populated by UI.renderChatList() */}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,6 +267,9 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* v0.23.2: Channel context banner — shown for DMs and named channels */}}
|
||||
<div id="channelContextBanner" class="channel-context-banner" style="display:none;"></div>
|
||||
|
||||
{{/* Messages */}}
|
||||
<div id="chatMessages" class="msg-container">
|
||||
{{/* Populated by UI.renderMessages() */}}
|
||||
|
||||
@@ -140,6 +140,10 @@
|
||||
</div>
|
||||
<div id="userAddPersonaForm" style="display:none;"></div>
|
||||
<div id="userPersonaList"><div class="settings-placeholder">Loading personas…</div></div>
|
||||
{{else if eq .Section "models"}}
|
||||
<div id="userModelList"><div class="settings-placeholder">Loading models…</div></div>
|
||||
{{else if eq .Section "teams"}}
|
||||
<div id="settingsDynamic"><div class="settings-placeholder">Loading teams…</div></div>
|
||||
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ type PathMessage struct {
|
||||
SiblingIndex int `json:"sibling_index"`
|
||||
ParticipantType string `json:"participant_type,omitempty"`
|
||||
ParticipantID string `json:"participant_id,omitempty"`
|
||||
SenderName *string `json:"sender_name,omitempty"`
|
||||
SenderAvatar *string `json:"sender_avatar,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -159,5 +161,79 @@ func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||
path[i].SiblingCount = GetSiblingCount(channelID, path[i].ParentID)
|
||||
}
|
||||
|
||||
// v0.23.2: Resolve sender names and avatars
|
||||
resolveSenderInfo(path)
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// resolveSenderInfo batch-resolves sender_name and sender_avatar for path messages.
|
||||
func resolveSenderInfo(path []PathMessage) {
|
||||
// Collect unique participant IDs by type
|
||||
userIDs := map[string]bool{}
|
||||
personaIDs := map[string]bool{}
|
||||
for _, m := range path {
|
||||
if m.ParticipantID == "" {
|
||||
continue
|
||||
}
|
||||
switch m.ParticipantType {
|
||||
case "user":
|
||||
userIDs[m.ParticipantID] = true
|
||||
case "persona":
|
||||
personaIDs[m.ParticipantID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve users
|
||||
userNames := map[string]string{}
|
||||
userAvatars := map[string]string{}
|
||||
for uid := range userIDs {
|
||||
var name, avatar sql.NullString
|
||||
_ = database.DB.QueryRow(database.Q(`
|
||||
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
|
||||
`), uid).Scan(&name, &avatar)
|
||||
if name.Valid {
|
||||
userNames[uid] = name.String
|
||||
}
|
||||
if avatar.Valid {
|
||||
userAvatars[uid] = avatar.String
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve personas
|
||||
personaNames := map[string]string{}
|
||||
personaAvatars := map[string]string{}
|
||||
for pid := range personaIDs {
|
||||
var name, avatar sql.NullString
|
||||
_ = database.DB.QueryRow(database.Q(`
|
||||
SELECT name, avatar FROM personas WHERE id = $1
|
||||
`), pid).Scan(&name, &avatar)
|
||||
if name.Valid {
|
||||
personaNames[pid] = name.String
|
||||
}
|
||||
if avatar.Valid {
|
||||
personaAvatars[pid] = avatar.String
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to path
|
||||
for i := range path {
|
||||
pid := path[i].ParticipantID
|
||||
switch path[i].ParticipantType {
|
||||
case "user":
|
||||
if n, ok := userNames[pid]; ok {
|
||||
path[i].SenderName = &n
|
||||
}
|
||||
if a, ok := userAvatars[pid]; ok && a != "" {
|
||||
path[i].SenderAvatar = &a
|
||||
}
|
||||
case "persona":
|
||||
if n, ok := personaNames[pid]; ok {
|
||||
path[i].SenderName = &n
|
||||
}
|
||||
if a, ok := personaAvatars[pid]; ok && a != "" {
|
||||
path[i].SenderAvatar = &a
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user