Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/database"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
@@ -218,6 +223,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
channels = append(channels, listItemToResponse(item))
|
||||
}
|
||||
|
||||
// Resolve DM titles: show the other participant's name, not the creator's label
|
||||
resolveDMTitles(c.Request.Context(), channels, userID)
|
||||
|
||||
SafeJSON(c, http.StatusOK, paginatedResponse{
|
||||
Data: channels,
|
||||
Page: page,
|
||||
@@ -346,15 +354,29 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
// Verify ownership (participants can only move to folder)
|
||||
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if !owns {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return
|
||||
// Participants may move channels to their own folders
|
||||
folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil &&
|
||||
req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil &&
|
||||
req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil &&
|
||||
req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil &&
|
||||
req.Topic == nil && req.Settings == nil
|
||||
if !folderOnly {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return
|
||||
}
|
||||
// Verify they are a participant
|
||||
canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID)
|
||||
if !canAccess {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build fields map for store.Update
|
||||
@@ -455,18 +477,51 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
n, err := h.stores.Channels.DeleteByOwner(c.Request.Context(), channelID, userID)
|
||||
// Check ownership first (without deleting)
|
||||
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
if !owns {
|
||||
// Not the owner — if participant, leave the channel instead of deleting.
|
||||
res, _ := database.DB.ExecContext(ctx, database.Q(`
|
||||
DELETE FROM channel_participants
|
||||
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
|
||||
`), channelID, userID)
|
||||
if rows, _ := res.RowsAffected(); rows > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "left channel"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up storage files (CASCADE already removed PG file rows)
|
||||
// Check if retention policy applies (global/team provider + TTL > 0)
|
||||
if h.shouldRetain(ctx, channelID) {
|
||||
ttl := h.retentionTTL(ctx)
|
||||
purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour)
|
||||
if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil {
|
||||
log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "channel archived for retention",
|
||||
"purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Exempt — hard delete
|
||||
n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID)
|
||||
if err != nil || n == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
|
||||
return
|
||||
}
|
||||
|
||||
if channelDeleteHook != nil {
|
||||
go channelDeleteHook(channelID)
|
||||
}
|
||||
@@ -474,6 +529,45 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
||||
}
|
||||
|
||||
// shouldRetain returns true if a channel uses a global or team provider
|
||||
// and the retention TTL is configured (> 0).
|
||||
func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool {
|
||||
ttl := h.retentionTTL(ctx)
|
||||
if ttl <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only BYOK (personal) provider channels are exempt.
|
||||
// All others — global, team, or no explicit provider — follow retention.
|
||||
var providerConfigID *string
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT provider_config_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&providerConfigID)
|
||||
|
||||
if providerConfigID == nil || *providerConfigID == "" {
|
||||
return true // no explicit provider → defaults to global → retain
|
||||
}
|
||||
|
||||
cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID)
|
||||
if err != nil {
|
||||
return true // provider deleted (FK SET NULL already handled above) → retain
|
||||
}
|
||||
|
||||
return cfg.Scope != models.ScopePersonal
|
||||
}
|
||||
|
||||
// retentionTTL reads the configured retention TTL in days from global settings.
|
||||
func (h *ChannelHandler) retentionTTL(ctx context.Context) int {
|
||||
cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := cfg["value"].(float64); ok {
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ── Mark Read (v0.23.2) ───────────────────────
|
||||
|
||||
func (h *ChannelHandler) MarkRead(c *gin.Context) {
|
||||
@@ -487,3 +581,60 @@ func (h *ChannelHandler) MarkRead(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ── DM Title Resolution ──────────────────────
|
||||
|
||||
// resolveDMTitles replaces DM channel titles with the other participant's
|
||||
// display name so each user sees "DM <other_person>" instead of the
|
||||
// creator's original label.
|
||||
func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) {
|
||||
// Collect DM channel IDs
|
||||
var dmIDs []string
|
||||
dmIdx := map[string]int{} // channel_id → index in channels slice
|
||||
for i, ch := range channels {
|
||||
if ch.Type == "dm" {
|
||||
dmIDs = append(dmIDs, ch.ID)
|
||||
dmIdx[ch.ID] = i
|
||||
}
|
||||
}
|
||||
if len(dmIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Query: for each DM, get the OTHER participant's display name
|
||||
// Uses channel_participants to find the peer, then joins users for name
|
||||
placeholders := make([]string, len(dmIDs))
|
||||
args := make([]interface{}, 0, len(dmIDs)+1)
|
||||
for i, id := range dmIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, viewerID)
|
||||
viewerPlaceholder := fmt.Sprintf("$%d", len(args))
|
||||
|
||||
query := database.Q(fmt.Sprintf(`
|
||||
SELECT cp.channel_id,
|
||||
COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown')
|
||||
FROM channel_participants cp
|
||||
JOIN users u ON u.id = cp.participant_id
|
||||
WHERE cp.channel_id IN (%s)
|
||||
AND cp.participant_type = 'user'
|
||||
AND cp.participant_id != %s
|
||||
LIMIT %d
|
||||
`, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs)))
|
||||
|
||||
rows, err := database.DB.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return // non-fatal: keep original titles
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var chID, peerName string
|
||||
if rows.Scan(&chID, &peerName) == nil {
|
||||
if idx, ok := dmIdx[chID]; ok {
|
||||
channels[idx].Title = "DM " + peerName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user