Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -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
}
}
}
}