Changeset 0.23.2 (#155)
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user