Changeset 0.20.0 (#85)

This commit is contained in:
2026-03-01 12:40:15 +00:00
parent eb74180611
commit 817062e5fc
57 changed files with 5435 additions and 72 deletions

View File

@@ -0,0 +1,17 @@
-- 007_v0200_notifications.sql
-- Notification infrastructure (v0.20.0 Phase 1)
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT DEFAULT '',
resource_type VARCHAR(50),
resource_id UUID,
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC);

View File

@@ -0,0 +1,18 @@
-- 008_v0200_notification_prefs.sql
-- Notification preferences per user per type.
-- Resolution: specific type → user '*' default → system default (in_app=true, email=false).
BEGIN;
CREATE TABLE IF NOT EXISTS notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- notification type or '*' for default
in_app BOOLEAN NOT NULL DEFAULT true,
email BOOLEAN NOT NULL DEFAULT false,
UNIQUE(user_id, type)
);
CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id);
COMMIT;

View File

@@ -0,0 +1,17 @@
-- 006_v0200_notifications.sql
-- Notification infrastructure (v0.20.0 Phase 1)
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT DEFAULT '',
resource_type TEXT,
resource_id TEXT,
is_read INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC);

View File

@@ -0,0 +1,13 @@
-- 007_v0200_notification_prefs.sql
-- Notification preferences per user per type (SQLite dialect).
CREATE TABLE IF NOT EXISTS notification_preferences (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
in_app INTEGER NOT NULL DEFAULT 1,
email INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id, type)
);
CREATE INDEX IF NOT EXISTS idx_notification_prefs_user ON notification_preferences(user_id);

View File

@@ -256,6 +256,7 @@ func TruncateAll(t *testing.T) {
"resource_grants",
"group_members",
"groups",
"notifications",
"usage_log",
"model_pricing",
"notes",

View File

@@ -56,6 +56,10 @@ var routeTable = map[string]Direction{
// Role alerts (v0.17.0)
"role.fallback": DirToClient,
// Notifications (v0.20.0)
"notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based
"notification.read": DirToClient, // badge sync across tabs
// Plugin hooks — never cross the wire
"plugin.hook.": DirLocal,
"internal.": DirLocal,
@@ -103,3 +107,12 @@ func ShouldAcceptFromClient(label string) bool {
d := RouteFor(label)
return d == DirFromClient || d == DirBoth
}
// MustJSON marshals v to json.RawMessage, returning {} on error.
func MustJSON(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
return json.RawMessage(`{}`)
}
return data
}

View File

@@ -0,0 +1,78 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// AdminEmailHandler handles admin SMTP configuration and test endpoints.
type AdminEmailHandler struct {
stores store.Stores
}
// NewAdminEmailHandler creates a new admin email handler.
func NewAdminEmailHandler(s store.Stores) *AdminEmailHandler {
return &AdminEmailHandler{stores: s}
}
// TestEmail sends a test email to the requesting admin's email address.
// POST /api/v1/admin/notifications/test-email
func (h *AdminEmailHandler) TestEmail(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if user.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no email address configured for your account"})
return
}
// Load current SMTP config
cfg, err := notifications.LoadSMTPConfig(h.stores.GlobalConfig, nil)
if err != nil || cfg == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP not configured: " + err.Error()})
return
}
transport := notifications.NewEmailTransport(*cfg)
if transport == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP configuration is incomplete"})
return
}
// Get instance name
instanceName := "Chat Switchboard"
if branding, err := h.stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
if name, ok := branding["instance_name"].(string); ok && name != "" {
instanceName = name
}
}
subject := "[" + instanceName + "] Test Email"
htmlBody := `<!DOCTYPE html>
<html><body style="font-family: sans-serif; padding: 20px;">
<h2 style="color: #4a8af4;">` + instanceName + `</h2>
<p>This is a test email from your Chat Switchboard instance.</p>
<p>If you received this, your SMTP configuration is working correctly.</p>
</body></html>`
textBody := instanceName + " — Test Email\n\nThis is a test email. Your SMTP configuration is working correctly."
if err := transport.Send(c.Request.Context(), user.Email, subject, htmlBody, textBody); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "send failed: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "sent_to": user.Email})
}

View File

@@ -0,0 +1,237 @@
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 Models Handler ──────────────────
// Manages multi-model assignments per channel (v0.20.0 Phase 2).
// Routes:
// GET /channels/:id/models — list
// POST /channels/:id/models — add
// PATCH /channels/:id/models/:modelId — update (display_name, is_default, system_prompt)
// DELETE /channels/:id/models/:modelId — remove
const maxModelsPerChannel = 5
type ChannelModelHandler struct {
stores store.Stores
}
func NewChannelModelHandler(stores store.Stores) *ChannelModelHandler {
return &ChannelModelHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ChannelModelHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
roster, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
if roster == nil {
roster = []models.ChannelModel{}
}
c.JSON(http.StatusOK, roster)
}
// ── Add ──────────────────────────────────────
type addChannelModelRequest struct {
ModelID string `json:"model_id" binding:"required"`
ProviderConfigID string `json:"provider_config_id"`
DisplayName string `json:"display_name" binding:"required"`
SystemPrompt string `json:"system_prompt"`
IsDefault bool `json:"is_default"`
}
func (h *ChannelModelHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req addChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check max models limit
existing, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check existing models"})
return
}
if len(existing) >= maxModelsPerChannel {
c.JSON(http.StatusConflict, gin.H{"error": "maximum models per channel reached"})
return
}
// If this is the first model or marked as default, ensure only one default
isDefault := req.IsDefault || len(existing) == 0
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: req.ModelID,
ProviderConfigID: req.ProviderConfigID,
DisplayName: req.DisplayName,
SystemPrompt: req.SystemPrompt,
IsDefault: isDefault,
}
// SetModel handles INSERT ON CONFLICT (upsert by channel_id + model_id)
if err := h.stores.Channels.SetModel(c.Request.Context(), cm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add model"})
return
}
// If this is the new default, clear default on others
if isDefault {
h.clearOtherDefaults(c, channelID, req.ModelID)
}
// Return the updated roster
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"models": roster})
}
// ── Update ───────────────────────────────────
type updateChannelModelRequest struct {
DisplayName *string `json:"display_name"`
SystemPrompt *string `json:"system_prompt"`
IsDefault *bool `json:"is_default"`
}
func (h *ChannelModelHandler) Update(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req updateChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
fields := make(map[string]interface{})
if req.DisplayName != nil {
fields["display_name"] = *req.DisplayName
}
if req.SystemPrompt != nil {
fields["system_prompt"] = *req.SystemPrompt
}
if req.IsDefault != nil {
fields["is_default"] = *req.IsDefault
if *req.IsDefault {
h.clearOtherDefaults(c, channelID, cm.ModelID)
}
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Channels.UpdateModel(c.Request.Context(), modelRecordID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
return
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"models": roster})
}
// ── Delete ───────────────────────────────────
func (h *ChannelModelHandler) Delete(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
if err := h.stores.Channels.DeleteModel(c.Request.Context(), modelRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
// If deleted model was default, promote the first remaining model
if cm.IsDefault {
remaining, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(remaining) > 0 {
h.stores.Channels.UpdateModel(c.Request.Context(), remaining[0].ID,
map[string]interface{}{"is_default": true})
}
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"models": roster})
}
// ── Helpers ──────────────────────────────────
// clearOtherDefaults sets is_default=false on all models in the channel
// except the one with the given modelID.
func (h *ChannelModelHandler) clearOtherDefaults(c *gin.Context, channelID, keepModelID string) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
for _, m := range roster {
if m.ModelID != keepModelID && m.IsDefault {
h.stores.Channels.UpdateModel(c.Request.Context(), m.ID,
map[string]interface{}{"is_default": false})
}
}
}

View File

@@ -18,6 +18,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/mentions"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage"
@@ -218,6 +219,19 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── Multi-model @mention routing (v0.20.0) ──────────
// Check if the message @mentions specific channel models.
// If multiple models are targeted, fan out sequentially.
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(roster) > 1 {
parsed := mentions.Parse(req.Content, roster)
targets := mentions.ResolvedModels(parsed)
if len(targets) > 1 {
h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, req)
return
}
}
// Build provider request
provReq := providers.CompletionRequest{
Model: model,
@@ -257,6 +271,122 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── Multi-Model Sequential Stream (v0.20.0) ──
// multiModelStream handles the case where a message @mentions multiple
// channel models. It streams each model's response sequentially within
// a single SSE response, sending model_start/model_end delimiters.
func (h *CompletionHandler) multiModelStream(
c *gin.Context,
targets []models.ChannelModel,
messages []providers.Message,
channelID, userID, personaID, presetSystemPrompt string,
req completionRequest,
) {
// Set SSE headers once for the entire multi-model stream
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, _ := c.Writer.(http.Flusher)
flush := func() {
if flusher != nil {
flusher.Flush()
}
}
sendSSE := func(data string) {
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
flush()
}
sendEvent := func(event, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
flush()
}
for _, target := range targets {
// Notify client which model is about to respond
startJSON, _ := json.Marshal(map[string]string{
"model_id": target.ModelID,
"display_name": target.DisplayName,
})
sendEvent("model_start", string(startJSON))
// Resolve config for this specific model
targetReq := req
targetReq.Model = target.ModelID
if target.ProviderConfigID != "" {
targetReq.APIConfigID = target.ProviderConfigID
}
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"failed to resolve config for %s: %s"}`, target.DisplayName, escapeJSON(err.Error())))
sendEvent("model_end", string(startJSON))
continue
}
provider, err := providers.Get(providerID)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"provider unavailable for %s"}`, target.DisplayName))
sendEvent("model_end", string(startJSON))
continue
}
// Build per-model messages (inject model-specific system prompt if set)
modelMessages := make([]providers.Message, len(messages))
copy(modelMessages, messages)
if target.SystemPrompt != "" {
// Prepend model-specific system prompt
modelMessages = append([]providers.Message{{
Role: "system",
Content: target.SystemPrompt,
}}, modelMessages...)
}
caps := h.getModelCapabilities(c, model, configID)
provReq := providers.CompletionRequest{
Model: model,
Messages: modelMessages,
}
if targetReq.MaxTokens > 0 {
provReq.MaxTokens = targetReq.MaxTokens
} else {
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if targetReq.Temperature != nil {
provReq.Temperature = targetReq.Temperature
}
if targetReq.TopP != nil {
provReq.TopP = targetReq.TopP
}
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools)
}
// Stream this model's response using the shared streaming core
result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, h.hub)
// Persist assistant message with model attribution
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
log.Printf("Failed to persist multi-model assistant message: %v", err)
}
}
// Log usage
h.logUsage(c, channelID, userID, configID, providerScope, model,
result.InputTokens, result.OutputTokens,
result.CacheCreationTokens, result.CacheReadTokens)
sendEvent("model_end", string(startJSON))
}
sendSSE("[DONE]")
}
// buildToolDefs converts registered tools to provider-format tool definitions.
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
// Tools whose names appear in disabledTools are excluded.

View File

@@ -8,6 +8,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -202,16 +203,26 @@ func (h *GroupHandler) AddMember(c *gin.Context) {
}
// Verify group exists
if _, err := h.stores.Groups.GetByID(c.Request.Context(), groupID); err == sql.ErrNoRows {
group, err := h.stores.Groups.GetByID(c.Request.Context(), groupID)
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 look up group"})
return
}
if err := h.stores.Groups.AddMember(c.Request.Context(), groupID, req.UserID, actorID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
// Notify the added user (v0.20.0)
if svc := notifications.Default(); svc != nil {
notifications.NotifyGroupMemberAdded(svc, req.UserID, groupID, group.Name)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.member.add", "group", groupID, map[string]interface{}{
"user_id": req.UserID,
@@ -224,6 +235,9 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
groupID := c.Param("id")
userID := c.Param("userId")
// Fetch group name for notification before removal
group, _ := h.stores.Groups.GetByID(c.Request.Context(), groupID)
err := h.stores.Groups.RemoveMember(c.Request.Context(), groupID, userID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "member not found"})
@@ -234,6 +248,11 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
return
}
// Notify the removed user (v0.20.0)
if svc := notifications.Default(); svc != nil && group != nil {
notifications.NotifyGroupMemberRemoved(svc, userID, groupID, group.Name)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "group.member.remove", "group", groupID, map[string]interface{}{
"user_id": userID,

View File

@@ -0,0 +1,281 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Handler ─────────────────────────────────
// NotificationHandler handles notification CRUD endpoints.
type NotificationHandler struct {
stores store.Stores
hub *events.Hub
}
// NewNotificationHandler creates a new notification handler.
func NewNotificationHandler(s store.Stores, hub *events.Hub) *NotificationHandler {
return &NotificationHandler{stores: s, hub: hub}
}
// ── GET /api/v1/notifications ───────────────
// Returns paginated notifications for the authenticated user.
// Query params: ?limit=20&offset=0&unread_only=false
func (h *NotificationHandler) List(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
unreadOnly := c.Query("unread_only") == "true"
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
items, total, err := h.stores.Notifications.ListByUser(
c.Request.Context(), userID, limit, offset, unreadOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// ── GET /api/v1/notifications/unread-count ──
func (h *NotificationHandler) UnreadCount(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
count, err := h.stores.Notifications.UnreadCount(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, gin.H{"count": count})
}
// ── PATCH /api/v1/notifications/:id/read ────
func (h *NotificationHandler) MarkRead(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"})
return
}
if err := h.stores.Notifications.MarkRead(c.Request.Context(), id, userID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
// Sync badge across tabs
if h.hub != nil {
payload, _ := json.Marshal(map[string]string{"id": id})
h.hub.SendToUser(userID, events.Event{
Label: "notification.read",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── POST /api/v1/notifications/mark-all-read ─
func (h *NotificationHandler) MarkAllRead(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if err := h.stores.Notifications.MarkAllRead(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
// Sync badge across tabs
if h.hub != nil {
payload, _ := json.Marshal(map[string]string{"action": "mark_all_read"})
h.hub.SendToUser(userID, events.Event{
Label: "notification.read",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── DELETE /api/v1/notifications/:id ────────
func (h *NotificationHandler) Delete(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "notification id required"})
return
}
if err := h.stores.Notifications.Delete(c.Request.Context(), id, userID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "notification not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Notification Preferences (v0.20.0 Phase 3) ──
// GET /api/v1/notifications/preferences
func (h *NotificationHandler) ListPreferences(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if h.stores.NotifPrefs == nil {
c.JSON(http.StatusOK, []models.NotificationPreference{})
return
}
prefs, err := h.stores.NotifPrefs.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if prefs == nil {
prefs = []models.NotificationPreference{}
}
c.JSON(http.StatusOK, prefs)
}
// PUT /api/v1/notifications/preferences/:type
func (h *NotificationHandler) SetPreference(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
notifType := c.Param("type")
if notifType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"})
return
}
var req struct {
InApp *bool `json:"in_app"`
Email *bool `json:"email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.stores.NotifPrefs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"})
return
}
// Load existing or create new
existing, _ := h.stores.NotifPrefs.Get(c.Request.Context(), userID, notifType)
pref := &models.NotificationPreference{
UserID: userID,
Type: notifType,
InApp: true, // default
Email: false, // default
}
if existing != nil {
pref = existing
}
if req.InApp != nil {
pref.InApp = *req.InApp
}
if req.Email != nil {
pref.Email = *req.Email
}
if err := h.stores.NotifPrefs.Upsert(c.Request.Context(), pref); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
c.JSON(http.StatusOK, pref)
}
// DELETE /api/v1/notifications/preferences/:type
func (h *NotificationHandler) DeletePreference(c *gin.Context) {
userID := getUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
notifType := c.Param("type")
if notifType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "type is required"})
return
}
if h.stores.NotifPrefs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "preferences not available"})
return
}
if err := h.stores.NotifPrefs.Delete(c.Request.Context(), userID, notifType); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -240,6 +240,181 @@ func streamWithToolLoop(
const browserToolTimeout = 30 * time.Second
// streamModelResponse is a variant of streamWithToolLoop used by multi-model
// fan-out (v0.20.0). It streams a single model's response within an already-
// established SSE connection — it does NOT set SSE headers or send [DONE].
//
// SSE deltas include a "model_display" field so the frontend can attribute
// each response to the correct model.
func streamModelResponse(
c *gin.Context,
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, displayName, userID, channelID, personaID string,
hub *events.Hub,
) streamResult {
flusher, _ := c.Writer.(http.Flusher)
flush := func() {
if flusher != nil {
flusher.Flush()
}
}
sendSSE := func(data string) {
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
flush()
}
sendEvent := func(event, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
flush()
}
var result streamResult
for iteration := 0; iteration < maxToolIterations; iteration++ {
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
return result
}
var iterContent string
var iterReasoning string
var toolCalls []providers.ToolCall
for event := range ch {
if event.Error != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
return result
}
if event.Reasoning != "" {
iterReasoning += event.Reasoning
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
event.Reasoning, model, displayName))
}
if event.Delta != "" {
iterContent += event.Delta
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
event.Delta, model, displayName))
}
if event.Done {
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
} else {
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`,
finishReason, model, displayName))
// Do NOT send [DONE] — caller manages that for multi-model
return result
}
break
}
}
// Tool execution (same logic as streamWithToolLoop)
if len(toolCalls) == 0 {
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
return result
}
toolCallsJSON, _ := json.Marshal(toolCalls)
sendEvent("tool_use", string(toolCallsJSON))
assistantMsg := providers.Message{
Role: "assistant",
Content: iterContent,
}
for _, tc := range toolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
req.Messages = append(req.Messages, assistantMsg)
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
}
for _, tc := range toolCalls {
call := tools.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
}
var toolResult tools.ToolResult
if tools.Get(call.Name) != nil {
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
} else if hub != nil && hub.IsConnected(userID) {
toolResult = executeBrowserTool(hub, userID, call)
} else {
toolResult = tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"unknown tool or browser not connected"}`,
IsError: true,
}
}
resultJSON, _ := json.Marshal(map[string]interface{}{
"tool_call_id": toolResult.ToolCallID,
"name": toolResult.Name,
"content": toolResult.Content,
"is_error": toolResult.IsError,
})
sendEvent("tool_result", string(resultJSON))
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
"id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
"result": toolResult.Content,
"is_error": toolResult.IsError,
})
req.Messages = append(req.Messages, providers.Message{
Role: "tool",
Content: toolResult.Content,
ToolCallID: toolResult.ToolCallID,
Name: toolResult.Name,
})
}
result.Content += iterContent
}
// Hit max iterations
log.Printf("⚠️ Multi-model tool loop hit max iterations (%d) for model %s", maxToolIterations, displayName)
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`,
escapeJSON("[Tool execution limit reached]"), model, displayName))
return result
}
// executeBrowserTool sends a tool call to the user's browser via WebSocket
// and blocks until a result is returned or the timeout elapses.
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {

View File

@@ -10,6 +10,7 @@ import (
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -85,6 +86,10 @@ func (ing *Ingester) IngestDocument(kb *models.KnowledgeBase, doc *models.KBDocu
log.Printf("❌ ingest doc %s (%s): %v", doc.ID, doc.Filename, err)
ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "error", &errMsg)
ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"})
// Notify document owner of ingestion failure (v0.20.0)
if svc := notifications.Default(); svc != nil {
notifications.NotifyKBError(svc, userID, kb.ID, kb.Name, errMsg)
}
}
}()
}
@@ -205,6 +210,11 @@ func (ing *Ingester) ingest(ctx context.Context, kb *models.KnowledgeBase, doc *
log.Printf(" ✓ ingest complete: %s (%d chunks, %s)",
doc.Filename, len(chunks), time.Since(start).Round(time.Millisecond))
// Notify document owner of successful ingestion (v0.20.0)
if svc := notifications.Default(); svc != nil {
notifications.NotifyKBReady(svc, userID, kb.ID, kb.Name, len(chunks))
}
return nil
}

View File

@@ -21,6 +21,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/memory"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/storage"
@@ -185,6 +186,35 @@ func main() {
// ── WebSocket Hub ─────────────────────────
hub := events.NewHub(bus)
// ── Notification Service (v0.20.0) ───────
var notifSvc *notifications.Service
if stores.Notifications != nil {
notifSvc = notifications.NewService(stores.Notifications, hub).
WithPrefs(stores.NotifPrefs).
WithUsers(stores.Users)
// Load SMTP config from platform settings for email transport (Phase 3)
if stores.GlobalConfig != nil {
if smtpCfg, err := notifications.LoadSMTPConfig(stores.GlobalConfig, keyResolver); err == nil && smtpCfg != nil {
transport := notifications.NewEmailTransport(*smtpCfg)
instanceName := "Chat Switchboard"
if brandCfg, err := stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
if name, ok := brandCfg["instance_name"].(string); ok && name != "" {
instanceName = name
}
}
notifSvc.WithEmail(transport, instanceName)
log.Println("[notifications] email transport enabled")
}
}
notifSvc.StartCleanup()
notifications.SetDefault(notifSvc)
// Subscribe to role.fallback events → generate notifications for admins
bus.Subscribe("role.fallback", notifications.RoleFallbackHandler(notifSvc, stores))
defer notifSvc.StopCleanup()
}
// Health check (k8s probes hit this directly)
base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
@@ -246,6 +276,13 @@ func main() {
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List)
protected.POST("/channels/:id/models", chModelH.Add)
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
// Messages
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
protected.GET("/channels/:id/messages", msgs.ListMessages)
@@ -350,6 +387,19 @@ func main() {
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
protected.GET("/projects/:id/notes", projectH.ListNotes)
// Notifications (v0.20.0)
notifH := handlers.NewNotificationHandler(stores, hub)
protected.GET("/notifications", notifH.List)
protected.GET("/notifications/unread-count", notifH.UnreadCount)
protected.PATCH("/notifications/:id/read", notifH.MarkRead)
protected.POST("/notifications/mark-all-read", notifH.MarkAllRead)
protected.DELETE("/notifications/:id", notifH.Delete)
// Notification preferences (v0.20.0 Phase 3)
protected.GET("/notifications/preferences", notifH.ListPreferences)
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
// Attachments (file upload/download)
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
protected.POST("/channels/:id/attachments", attachH.Upload)
@@ -563,6 +613,10 @@ func main() {
// Vault
admin.GET("/vault/status", adm.VaultStatus)
// Email / SMTP test (v0.20.0 Phase 3)
emailAdm := handlers.NewAdminEmailHandler(stores)
admin.POST("/notifications/test-email", emailAdm.TestEmail)
// Storage management (orphan cleanup)
attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue)
admin.GET("/storage/orphans", attachAdm.OrphanCount)

155
server/mentions/parser.go Normal file
View File

@@ -0,0 +1,155 @@
package mentions
import (
"sort"
"strings"
"unicode"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// Mention represents a parsed @mention token in message content.
type Mention struct {
Raw string // "@claude-3-opus" as written (includes @)
Name string // "claude-3-opus" (normalized, no @)
Start int // byte offset in message content
End int // byte offset end (exclusive)
Resolved *models.ChannelModel // nil if unresolved
}
// Parse extracts @mentions from message content and resolves them
// against the channel's model roster.
//
// Rules:
// - @ followed by one or more non-whitespace characters
// - Greedy: @claude-3-opus-20240229 matches the full token
// - Resolution: case-insensitive match against display_name with
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus")
// - Longest-match-first when display names overlap
// - Unresolved mentions are included with Resolved = nil
// - @ must be at start of content or preceded by whitespace
func Parse(content string, roster []models.ChannelModel) []Mention {
if len(roster) == 0 || !strings.Contains(content, "@") {
return nil
}
// Build lookup: normalized display_name → *ChannelModel
// Sort roster by display_name length descending for longest-match-first
type entry struct {
normalized string
model models.ChannelModel
}
entries := make([]entry, 0, len(roster))
for _, cm := range roster {
if cm.DisplayName == "" {
continue
}
entries = append(entries, entry{
normalized: normalize(cm.DisplayName),
model: cm,
})
}
sort.Slice(entries, func(i, j int) bool {
return len(entries[i].normalized) > len(entries[j].normalized)
})
// Extract @tokens
var mentions []Mention
i := 0
for i < len(content) {
if content[i] != '@' {
i++
continue
}
// @ must be at start or preceded by whitespace
if i > 0 && !unicode.IsSpace(rune(content[i-1])) {
i++
continue
}
// Extract the token after @
start := i
i++ // skip @
tokenStart := i
for i < len(content) && !unicode.IsSpace(rune(content[i])) {
i++
}
if i == tokenStart {
continue // bare @ with no token
}
// Strip trailing punctuation (commas, periods, etc.)
tokenEnd := i
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
tokenEnd--
}
if tokenEnd == tokenStart {
continue // all punctuation
}
raw := content[start:tokenEnd]
name := content[tokenStart:tokenEnd]
normalizedName := normalize(name)
// Try to resolve against roster (longest match first)
var resolved *models.ChannelModel
for idx := range entries {
if normalizedName == entries[idx].normalized {
cm := entries[idx].model
resolved = &cm
break
}
}
mentions = append(mentions, Mention{
Raw: raw,
Name: name,
Start: start,
End: tokenEnd,
Resolved: resolved,
})
}
return mentions
}
// ResolvedModels returns deduplicated resolved models from parsed mentions.
// Returns nil if no mentions resolved.
func ResolvedModels(mentions []Mention) []models.ChannelModel {
if len(mentions) == 0 {
return nil
}
seen := make(map[string]bool)
var result []models.ChannelModel
for _, m := range mentions {
if m.Resolved != nil && !seen[m.Resolved.ID] {
seen[m.Resolved.ID] = true
result = append(result, *m.Resolved)
}
}
return result
}
// isMentionTrailingPunct returns true for punctuation that commonly
// follows an @mention but isn't part of the name.
func isMentionTrailingPunct(b byte) bool {
switch b {
case ',', '.', '!', '?', ':', ';', ')', ']', '}':
return true
}
return false
}
// normalize converts a display name to a canonical form for matching:
// lowercase, hyphens → spaces collapsed, trimmed.
func normalize(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")
// Collapse multiple spaces
fields := strings.Fields(s)
return strings.Join(fields, " ")
}

View File

@@ -0,0 +1,213 @@
package mentions
import (
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
func roster() []models.ChannelModel {
return []models.ChannelModel{
{ID: "1", ChannelID: "ch1", ModelID: "anthropic/claude-3-opus", DisplayName: "Claude-3-Opus", IsDefault: true},
{ID: "2", ChannelID: "ch1", ModelID: "openai/gpt-4", DisplayName: "GPT-4"},
{ID: "3", ChannelID: "ch1", ModelID: "anthropic/claude-3-haiku", DisplayName: "Claude-3-Haiku"},
}
}
func TestParse_NoMentions(t *testing.T) {
m := Parse("Hello, how are you?", roster())
if len(m) != 0 {
t.Fatalf("expected 0 mentions, got %d", len(m))
}
}
func TestParse_EmptyRoster(t *testing.T) {
m := Parse("@gpt-4 hello", nil)
if len(m) != 0 {
t.Fatalf("expected 0 mentions with empty roster, got %d", len(m))
}
}
func TestParse_SingleMention(t *testing.T) {
m := Parse("@GPT-4 what do you think?", roster())
if len(m) != 1 {
t.Fatalf("expected 1 mention, got %d", len(m))
}
if m[0].Name != "GPT-4" {
t.Errorf("expected Name='GPT-4', got %q", m[0].Name)
}
if m[0].Resolved == nil {
t.Fatal("expected resolved, got nil")
}
if m[0].Resolved.ID != "2" {
t.Errorf("expected resolved to GPT-4 (ID=2), got ID=%s", m[0].Resolved.ID)
}
}
func TestParse_CaseInsensitive(t *testing.T) {
m := Parse("@claude-3-opus please help", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected 1 resolved mention")
}
if m[0].Resolved.ID != "1" {
t.Errorf("expected Claude-3-Opus (ID=1), got ID=%s", m[0].Resolved.ID)
}
}
func TestParse_MultipleMentions(t *testing.T) {
m := Parse("Hey @Claude-3-Opus and @GPT-4, compare your approaches.", roster())
if len(m) != 2 {
t.Fatalf("expected 2 mentions, got %d", len(m))
}
if m[0].Resolved == nil || m[0].Resolved.ID != "1" {
t.Errorf("first mention: expected Claude-3-Opus")
}
if m[1].Resolved == nil || m[1].Resolved.ID != "2" {
t.Errorf("second mention: expected GPT-4")
}
}
func TestParse_UnresolvedMention(t *testing.T) {
m := Parse("@nonexistent-model hello", roster())
if len(m) != 1 {
t.Fatalf("expected 1 mention, got %d", len(m))
}
if m[0].Resolved != nil {
t.Errorf("expected unresolved mention, got resolved to %s", m[0].Resolved.DisplayName)
}
}
func TestParse_MixedResolvedUnresolved(t *testing.T) {
m := Parse("@GPT-4 and @fake-model what?", roster())
if len(m) != 2 {
t.Fatalf("expected 2 mentions, got %d", len(m))
}
if m[0].Resolved == nil {
t.Error("first mention should resolve")
}
if m[1].Resolved != nil {
t.Error("second mention should not resolve")
}
}
func TestParse_MentionAtStart(t *testing.T) {
m := Parse("@GPT-4", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected 1 resolved mention at start")
}
}
func TestParse_MentionInMiddle(t *testing.T) {
m := Parse("Ask @GPT-4 about this", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected 1 resolved mention in middle")
}
}
func TestParse_NoSpaceBefore(t *testing.T) {
// email@GPT-4 should NOT be parsed as a mention
m := Parse("email@GPT-4 test", roster())
if len(m) != 0 {
t.Fatalf("expected 0 mentions (no space before @), got %d", len(m))
}
}
func TestParse_BareAt(t *testing.T) {
m := Parse("@ nothing", roster())
if len(m) != 0 {
t.Fatalf("expected 0 mentions for bare @, got %d", len(m))
}
}
func TestParse_HyphenSpaceNormalization(t *testing.T) {
// Display name "Claude-3-Opus" should match "@claude_3_opus" (underscore normalization)
m := Parse("@claude_3_opus help", roster())
if len(m) != 1 || m[0].Resolved == nil {
t.Fatal("expected underscore-normalized match")
}
if m[0].Resolved.ID != "1" {
t.Errorf("expected Claude-3-Opus, got %s", m[0].Resolved.DisplayName)
}
}
func TestParse_ByteOffsets(t *testing.T) {
content := "Hey @GPT-4 ok"
m := Parse(content, roster())
if len(m) != 1 {
t.Fatal("expected 1 mention")
}
if m[0].Start != 4 || m[0].End != 10 {
t.Errorf("expected offsets [4,10), got [%d,%d)", m[0].Start, m[0].End)
}
if content[m[0].Start:m[0].End] != "@GPT-4" {
t.Errorf("offsets don't match: %q", content[m[0].Start:m[0].End])
}
}
func TestParse_TrailingPunctuation(t *testing.T) {
// Comma, period, exclamation should be stripped
tests := []struct {
input string
want int // expected resolved count
}{
{"@GPT-4, what?", 1},
{"@GPT-4.", 1},
{"@GPT-4!", 1},
{"@GPT-4? help", 1},
{"(@GPT-4)", 0}, // ( before @ is not whitespace — not a mention
{"( @GPT-4)", 1}, // space before @ — valid mention, ) stripped
}
for _, tt := range tests {
m := Parse(tt.input, roster())
resolved := 0
for _, mm := range m {
if mm.Resolved != nil {
resolved++
}
}
if resolved != tt.want {
t.Errorf("Parse(%q): expected %d resolved, got %d", tt.input, tt.want, resolved)
}
}
}
func TestResolvedModels_Dedup(t *testing.T) {
m := Parse("@GPT-4 do this @GPT-4 and that", roster())
resolved := ResolvedModels(m)
if len(resolved) != 1 {
t.Fatalf("expected 1 deduplicated model, got %d", len(resolved))
}
}
func TestResolvedModels_NilOnNoResolve(t *testing.T) {
m := Parse("@unknown-model test", roster())
resolved := ResolvedModels(m)
if resolved != nil {
t.Fatalf("expected nil for unresolved only, got %d", len(resolved))
}
}
func TestResolvedModels_MultipleDistinct(t *testing.T) {
m := Parse("@GPT-4 and @Claude-3-Opus compare", roster())
resolved := ResolvedModels(m)
if len(resolved) != 2 {
t.Fatalf("expected 2 distinct models, got %d", len(resolved))
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
in, want string
}{
{"Claude-3-Opus", "claude 3 opus"},
{"GPT-4", "gpt 4"},
{"gpt_4", "gpt 4"},
{" mixed--Case__Name ", "mixed case name"},
}
for _, tt := range tests {
got := normalize(tt.in)
if got != tt.want {
t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

View File

@@ -0,0 +1,40 @@
package models
import "time"
// =========================================
// NOTIFICATIONS (v0.20.0)
// =========================================
// Notification represents an in-app notification for a user.
// Notifications are immutable once created — only is_read can be toggled.
type Notification struct {
ID string `json:"id" db:"id"`
UserID string `json:"user_id" db:"user_id"`
Type string `json:"type" db:"type"` // domain.action, e.g. "role.fallback", "kb.ready"
Title string `json:"title" db:"title"` // short summary shown in bell dropdown
Body string `json:"body,omitempty" db:"body"` // optional detail text
ResourceType string `json:"resource_type,omitempty" db:"resource_type"` // "channel", "knowledge_base", "project", etc.
ResourceID string `json:"resource_id,omitempty" db:"resource_id"` // navigable entity ID (no FK — resource may be deleted)
IsRead bool `json:"is_read" db:"is_read"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// Notification type constants. Convention: domain.action.
// Free-form strings — new types don't require migration.
const (
NotifTypeRoleFallback = "role.fallback"
NotifTypeKBReady = "kb.ready"
NotifTypeKBError = "kb.error"
NotifTypeGrantChanged = "grant.changed"
NotifTypeProjectInvite = "project.invite"
)
// Resource type constants for click-to-navigate.
// Note: ResourceTypeKnowledgeBase is already declared in models.go (resource_grants).
const (
ResourceTypeChannel = "channel"
ResourceTypeProject = "project"
ResourceTypeTeam = "team"
ResourceTypeGroup = "group"
)

View File

@@ -0,0 +1,20 @@
package models
// NotificationPreference stores a user's per-type notification delivery preference.
// Type "*" acts as a catch-all default for types without an explicit row.
type NotificationPreference struct {
ID string `json:"id" db:"id"`
UserID string `json:"user_id" db:"user_id"`
Type string `json:"type" db:"type"` // notification type or "*"
InApp bool `json:"in_app" db:"in_app"` // show in-app bell/panel
Email bool `json:"email" db:"email"` // send email
}
// ResolvedPreference is the effective preference after resolution chain.
type ResolvedPreference struct {
InApp bool
Email bool
}
// SystemDefaultPreference is the fallback when no user preference exists.
var SystemDefaultPreference = ResolvedPreference{InApp: true, Email: false}

View File

@@ -0,0 +1,198 @@
package notifications
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// SMTPConfig holds SMTP connection settings, loaded from platform_settings.
type SMTPConfig struct {
Host string `json:"smtp_host"`
Port int `json:"smtp_port"`
User string `json:"smtp_user"`
Password string `json:"smtp_password"` // decrypted at load time
From string `json:"smtp_from"`
TLS bool `json:"smtp_tls"`
}
// EmailTransport sends notification emails via SMTP.
type EmailTransport struct {
config SMTPConfig
}
// NewEmailTransport creates an email transport. Returns nil if config is invalid.
func NewEmailTransport(cfg SMTPConfig) *EmailTransport {
if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" {
return nil
}
return &EmailTransport{config: cfg}
}
// Send delivers an email with both HTML and plaintext bodies.
// Uses multipart/alternative MIME for email client compatibility.
func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
if to == "" {
return fmt.Errorf("recipient email is empty")
}
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
boundary := fmt.Sprintf("==boundary_%d==", time.Now().UnixNano())
// Build MIME message
var msg strings.Builder
msg.WriteString(fmt.Sprintf("From: %s\r\n", t.config.From))
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
msg.WriteString("\r\n")
// Plaintext part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
msg.WriteString(textBody)
msg.WriteString("\r\n")
// HTML part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
msg.WriteString(htmlBody)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
// Connect with timeout
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
var client *smtp.Client
if t.config.TLS {
// Implicit TLS (port 465)
tlsConn := tls.Client(conn, &tls.Config{ServerName: t.config.Host})
client, err = smtp.NewClient(tlsConn, t.config.Host)
} else {
client, err = smtp.NewClient(conn, t.config.Host)
}
if err != nil {
conn.Close()
return fmt.Errorf("smtp client: %w", err)
}
defer client.Close()
// STARTTLS for non-implicit TLS on port 587
if !t.config.TLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: t.config.Host}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
}
}
// Authenticate if credentials provided
if t.config.User != "" && t.config.Password != "" {
auth := smtp.PlainAuth("", t.config.User, t.config.Password, t.config.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
// Send
if err := client.Mail(t.config.From); err != nil {
return fmt.Errorf("smtp MAIL: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp RCPT: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp DATA: %w", err)
}
if _, err := w.Write([]byte(msg.String())); err != nil {
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return client.Quit()
}
// SendAsync wraps Send in a goroutine so notification delivery isn't blocked.
func (t *EmailTransport) SendAsync(to, subject, htmlBody, textBody string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := t.Send(ctx, to, subject, htmlBody, textBody); err != nil {
log.Printf("[email] failed to send to %s: %v", to, err)
}
}()
}
// LoadSMTPConfig reads SMTP settings from global config and returns an SMTPConfig.
// Returns nil if email is not enabled or config is missing.
// The vault parameter is optional; if provided, it decrypts the SMTP password.
func LoadSMTPConfig(gc store.GlobalConfigStore, vault interface{}) (*SMTPConfig, error) {
raw, err := gc.Get(context.Background(), "notifications")
if err != nil {
return nil, fmt.Errorf("no notification settings: %w", err)
}
// Check if email is enabled
enabled, _ := raw["email_enabled"].(bool)
if !enabled {
return nil, nil
}
cfg := SMTPConfig{
Host: stringVal(raw, "smtp_host"),
Port: intVal(raw, "smtp_port", 587),
User: stringVal(raw, "smtp_user"),
From: stringVal(raw, "smtp_from"),
TLS: boolVal(raw, "smtp_tls"),
}
// Password: try decrypted value first, then raw
cfg.Password = stringVal(raw, "smtp_password")
if cfg.Host == "" {
return nil, fmt.Errorf("smtp_host is required")
}
if cfg.From == "" {
return nil, fmt.Errorf("smtp_from is required")
}
return &cfg, nil
}
func stringVal(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func intVal(m map[string]interface{}, key string, def int) int {
switch v := m[key].(type) {
case float64:
return int(v)
case int:
return v
}
return def
}
func boolVal(m map[string]interface{}, key string) bool {
v, _ := m[key].(bool)
return v
}

View File

@@ -0,0 +1,203 @@
package notifications
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Package-level singleton ─────────────────
// Allows notification sources (KB ingester, group handler, etc.)
// to access the service without threading it through constructors.
var defaultService *Service
// SetDefault registers the global notification service instance.
// Called once at startup from main.go.
func SetDefault(svc *Service) {
defaultService = svc
}
// Default returns the global notification service instance, or nil if
// not configured (e.g. in unmanaged mode without a database).
func Default() *Service {
return defaultService
}
// Service handles notification creation, persistence, and real-time delivery.
// All notification sources should go through this service — never insert directly.
type Service struct {
store store.NotificationStore
prefStore store.NotificationPreferenceStore
userStore store.UserStore
hub *events.Hub
// Email transport (nil = email disabled)
email *EmailTransport
instanceName string
// Retention cleanup
retentionDays int
stopCleanup chan struct{}
cleanupWg sync.WaitGroup
}
// NewService creates a notification service bound to the given store and WebSocket hub.
func NewService(s store.NotificationStore, hub *events.Hub) *Service {
return &Service{
store: s,
hub: hub,
instanceName: "Chat Switchboard",
retentionDays: 90,
stopCleanup: make(chan struct{}),
}
}
// WithPrefs attaches the preference store for per-user delivery control.
func (s *Service) WithPrefs(ps store.NotificationPreferenceStore) *Service {
s.prefStore = ps
return s
}
// WithUsers attaches the user store for email address lookup.
func (s *Service) WithUsers(us store.UserStore) *Service {
s.userStore = us
return s
}
// WithEmail enables email transport.
func (s *Service) WithEmail(transport *EmailTransport, instanceName string) *Service {
s.email = transport
if instanceName != "" {
s.instanceName = instanceName
}
return s
}
// WithRetention sets the retention period in days. Notifications older than
// this are pruned by the background cleanup goroutine. Default: 90 days.
func (s *Service) WithRetention(days int) *Service {
if days > 0 {
s.retentionDays = days
}
return s
}
// Notify routes a notification through the user's delivery preferences:
// in-app (persist + WebSocket) and/or email. Falls back to system defaults
// if no user preferences are set.
func (s *Service) Notify(ctx context.Context, n *models.Notification) error {
prefs := s.resolvePrefs(ctx, n.UserID, n.Type)
// In-app: persist + WebSocket push
if prefs.InApp {
if err := s.store.Create(ctx, n); err != nil {
return err
}
if s.hub != nil {
payload, _ := json.Marshal(n)
s.hub.SendToUser(n.UserID, events.Event{
Label: "notification.new",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
}
// Email: async send (don't block the caller)
if prefs.Email && s.email != nil && s.userStore != nil {
user, err := s.userStore.GetByID(ctx, n.UserID)
if err == nil && user != nil && user.Email != "" {
subject := SubjectForNotification(n, s.instanceName)
htmlBody := RenderHTML(n, s.instanceName)
textBody := RenderText(n, s.instanceName)
s.email.SendAsync(user.Email, subject, htmlBody, textBody)
}
}
return nil
}
// NotifyMany fans out a notification template to multiple users.
// Each user gets their own copy. Errors are logged but don't fail the batch.
func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) {
for _, uid := range userIDs {
n := template
n.ID = "" // let store generate
n.UserID = uid
if err := s.Notify(ctx, &n); err != nil {
log.Printf("[notifications] failed to notify user %s: %v", uid, err)
}
}
}
// resolvePrefs returns the effective delivery preferences for a user + type.
// Resolution chain: specific type → user '*' default → system default.
func (s *Service) resolvePrefs(ctx context.Context, userID, notifType string) models.ResolvedPreference {
if s.prefStore == nil {
return models.SystemDefaultPreference
}
// 1. Specific type preference
pref, err := s.prefStore.Get(ctx, userID, notifType)
if err == nil && pref != nil {
return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email}
}
// 2. User's wildcard default
pref, err = s.prefStore.Get(ctx, userID, "*")
if err == nil && pref != nil {
return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email}
}
// 3. System default
return models.SystemDefaultPreference
}
// StartCleanup launches a background goroutine that prunes old notifications daily.
func (s *Service) StartCleanup() {
s.cleanupWg.Add(1)
go func() {
defer s.cleanupWg.Done()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
// Run once on startup (after a short delay to avoid contention)
time.Sleep(5 * time.Minute)
s.runCleanup()
for {
select {
case <-ticker.C:
s.runCleanup()
case <-s.stopCleanup:
return
}
}
}()
}
// StopCleanup signals the cleanup goroutine to stop and waits for it.
func (s *Service) StopCleanup() {
close(s.stopCleanup)
s.cleanupWg.Wait()
}
func (s *Service) runCleanup() {
cutoff := time.Now().AddDate(0, 0, -s.retentionDays)
deleted, err := s.store.DeleteOlderThan(context.Background(), cutoff)
if err != nil {
log.Printf("[notifications] cleanup error: %v", err)
return
}
if deleted > 0 {
log.Printf("[notifications] cleaned up %d notifications older than %d days", deleted, s.retentionDays)
}
}

View File

@@ -0,0 +1,137 @@
package notifications
import (
"context"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// mockPrefStore implements store.NotificationPreferenceStore for testing.
type mockPrefStore struct {
prefs map[string]map[string]*models.NotificationPreference // userID → type → pref
}
func newMockPrefStore() *mockPrefStore {
return &mockPrefStore{prefs: make(map[string]map[string]*models.NotificationPreference)}
}
func (m *mockPrefStore) Get(_ context.Context, userID, notifType string) (*models.NotificationPreference, error) {
if userPrefs, ok := m.prefs[userID]; ok {
if p, ok := userPrefs[notifType]; ok {
return p, nil
}
}
return nil, nil
}
func (m *mockPrefStore) ListForUser(_ context.Context, userID string) ([]models.NotificationPreference, error) {
var result []models.NotificationPreference
if userPrefs, ok := m.prefs[userID]; ok {
for _, p := range userPrefs {
result = append(result, *p)
}
}
return result, nil
}
func (m *mockPrefStore) Upsert(_ context.Context, pref *models.NotificationPreference) error {
if m.prefs[pref.UserID] == nil {
m.prefs[pref.UserID] = make(map[string]*models.NotificationPreference)
}
m.prefs[pref.UserID][pref.Type] = pref
return nil
}
func (m *mockPrefStore) Delete(_ context.Context, userID, notifType string) error {
if userPrefs, ok := m.prefs[userID]; ok {
delete(userPrefs, notifType)
}
return nil
}
func (m *mockPrefStore) set(userID, notifType string, inApp, email bool) {
if m.prefs[userID] == nil {
m.prefs[userID] = make(map[string]*models.NotificationPreference)
}
m.prefs[userID][notifType] = &models.NotificationPreference{
UserID: userID,
Type: notifType,
InApp: inApp,
Email: email,
}
}
func TestResolvePrefs_SystemDefault(t *testing.T) {
svc := &Service{prefStore: newMockPrefStore()}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("expected system default (in_app=true, email=false), got %+v", p)
}
}
func TestResolvePrefs_NoPrefStore(t *testing.T) {
svc := &Service{prefStore: nil}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("expected system default when no pref store, got %+v", p)
}
}
func TestResolvePrefs_SpecificType(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "kb.ready", true, true)
svc := &Service{prefStore: ps}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || !p.Email {
t.Errorf("expected specific pref (in_app=true, email=true), got %+v", p)
}
}
func TestResolvePrefs_WildcardFallback(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "*", false, true) // user turned off in-app, turned on email globally
svc := &Service{prefStore: ps}
// No specific "kb.ready" pref → falls to wildcard
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if p.InApp || !p.Email {
t.Errorf("expected wildcard pref (in_app=false, email=true), got %+v", p)
}
}
func TestResolvePrefs_SpecificOverridesWildcard(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "*", false, true) // wildcard: no in-app, yes email
ps.set("user1", "kb.ready", true, false) // specific: yes in-app, no email
svc := &Service{prefStore: ps}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("specific should override wildcard, got %+v", p)
}
}
func TestResolvePrefs_DifferentUsersIsolated(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "kb.ready", false, true)
svc := &Service{prefStore: ps}
// user2 has no prefs → system default
p := svc.resolvePrefs(context.Background(), "user2", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("user2 should get system default, got %+v", p)
}
}
func TestResolvePrefs_DisableBoth(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "kb.ready", false, false)
svc := &Service{prefStore: ps}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if p.InApp || p.Email {
t.Errorf("expected both disabled, got %+v", p)
}
}

View File

@@ -0,0 +1,146 @@
package notifications
import (
"context"
"encoding/json"
"fmt"
"log"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Role Fallback ───────────────────────────
// Subscribes to the existing "role.fallback" EventBus event (emitted by
// capabilities/resolver.go since v0.17.0) and creates notifications for
// all admin users.
// roleFallbackPayload matches the payload emitted by the role resolver.
type roleFallbackPayload struct {
Role string `json:"role"`
Primary string `json:"primary_model"`
Fallback string `json:"fallback_model"`
Error string `json:"error"`
}
// RoleFallbackHandler returns an events.Handler that creates admin
// notifications when a model role falls back.
func RoleFallbackHandler(svc *Service, stores store.Stores) events.Handler {
return func(e events.Event) {
var p roleFallbackPayload
if err := json.Unmarshal(e.Payload, &p); err != nil {
log.Printf("[notifications] failed to parse role.fallback payload: %v", err)
return
}
title := fmt.Sprintf("Model fallback: %s → %s", p.Primary, p.Fallback)
if p.Primary == "" {
title = fmt.Sprintf("Role '%s' fell back to %s", p.Role, p.Fallback)
}
body := ""
if p.Error != "" {
body = fmt.Sprintf("Primary model error: %s", p.Error)
}
// Notify all admin users
ctx := context.Background()
admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500})
if err != nil {
log.Printf("[notifications] failed to list users for role.fallback: %v", err)
return
}
for _, u := range admins {
if u.Role != "admin" {
continue
}
n := models.Notification{
UserID: u.ID,
Type: models.NotifTypeRoleFallback,
Title: title,
Body: body,
}
if err := svc.Notify(ctx, &n); err != nil {
log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.ID, err)
}
}
}
}
// ── Knowledge Base ──────────────────────────
// Called directly from the KB handler after indexing completes or fails.
// Not a bus subscriber — the KB handler calls these functions.
// NotifyKBReady creates a notification when a knowledge base finishes indexing.
func NotifyKBReady(svc *Service, userID, kbID, kbName string, chunkCount int) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeKBReady,
Title: fmt.Sprintf("Knowledge base \"%s\" ready (%d chunks)", kbName, chunkCount),
ResourceType: models.ResourceTypeKnowledgeBase,
ResourceID: kbID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] kb.ready failed for user %s: %v", userID, err)
}
}
// NotifyKBError creates a notification when KB indexing fails.
func NotifyKBError(svc *Service, userID, kbID, kbName, errMsg string) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeKBError,
Title: fmt.Sprintf("Knowledge base \"%s\" indexing failed", kbName),
Body: errMsg,
ResourceType: models.ResourceTypeKnowledgeBase,
ResourceID: kbID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] kb.error failed for user %s: %v", userID, err)
}
}
// ── Group Membership ────────────────────────
// Called directly from the group handler on add/remove.
// NotifyGroupMemberAdded creates a notification when a user is added to a group.
func NotifyGroupMemberAdded(svc *Service, userID, groupID, groupName string) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeGrantChanged,
Title: fmt.Sprintf("You were added to group \"%s\"", groupName),
ResourceType: models.ResourceTypeGroup,
ResourceID: groupID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] grant.changed (add) failed for user %s: %v", userID, err)
}
}
// NotifyGroupMemberRemoved creates a notification when a user is removed from a group.
func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeGrantChanged,
Title: fmt.Sprintf("You were removed from group \"%s\"", groupName),
ResourceType: models.ResourceTypeGroup,
ResourceID: groupID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] grant.changed (remove) failed for user %s: %v", userID, err)
}
}

View File

@@ -0,0 +1,108 @@
package notifications
import (
"bytes"
"html/template"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Template Data ───────────────────────────
type emailData struct {
Title string
Body string
Type string
ResourceType string
InstanceName string
}
// ── HTML Template ───────────────────────────
var htmlTmpl = template.Must(template.New("email").Parse(`<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<div style="border-bottom: 2px solid #4a8af4; padding-bottom: 12px; margin-bottom: 20px;">
<h2 style="margin: 0; color: #4a8af4;">{{.InstanceName}}</h2>
</div>
<div style="background: #f8f9fa; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<h3 style="margin: 0 0 8px;">{{.Title}}</h3>
{{if .Body}}<p style="margin: 0; color: #555;">{{.Body}}</p>{{end}}
</div>
<p style="font-size: 0.85em; color: #999;">
This notification was sent by {{.InstanceName}}. You can adjust your notification preferences in Settings.
</p>
</body>
</html>`))
// ── Plaintext Template ──────────────────────
var textTmpl = template.Must(template.New("email_text").Parse(`{{.InstanceName}} — Notification
{{.Title}}
{{if .Body}}
{{.Body}}
{{end}}
---
Adjust your notification preferences in Settings.
`))
// ── Render Functions ────────────────────────
// RenderHTML renders an HTML email body for a notification.
func RenderHTML(n *models.Notification, instanceName string) string {
if instanceName == "" {
instanceName = "Chat Switchboard"
}
data := emailData{
Title: n.Title,
Body: n.Body,
Type: n.Type,
ResourceType: n.ResourceType,
InstanceName: instanceName,
}
var buf bytes.Buffer
if err := htmlTmpl.Execute(&buf, data); err != nil {
return "<p>" + template.HTMLEscapeString(n.Title) + "</p>"
}
return buf.String()
}
// RenderText renders a plaintext email body for a notification.
func RenderText(n *models.Notification, instanceName string) string {
if instanceName == "" {
instanceName = "Chat Switchboard"
}
data := emailData{
Title: n.Title,
Body: n.Body,
Type: n.Type,
ResourceType: n.ResourceType,
InstanceName: instanceName,
}
var buf bytes.Buffer
if err := textTmpl.Execute(&buf, data); err != nil {
return n.Title
}
return buf.String()
}
// SubjectForNotification returns an email subject line.
func SubjectForNotification(n *models.Notification, instanceName string) string {
if instanceName == "" {
instanceName = "Chat Switchboard"
}
prefix := "[" + instanceName + "] "
switch {
case strings.HasPrefix(n.Type, "kb."):
return prefix + "Knowledge Base: " + n.Title
case strings.HasPrefix(n.Type, "role."):
return prefix + "Model Alert: " + n.Title
case strings.HasPrefix(n.Type, "grant."):
return prefix + "Access Change: " + n.Title
default:
return prefix + n.Title
}
}

View File

@@ -0,0 +1,69 @@
package notifications
import (
"strings"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
func TestRenderHTML_Basic(t *testing.T) {
n := &models.Notification{
Title: "KB Ready",
Body: "Your knowledge base has been processed.",
Type: "kb.ready",
}
html := RenderHTML(n, "TestInstance")
if !strings.Contains(html, "KB Ready") {
t.Error("expected title in HTML output")
}
if !strings.Contains(html, "TestInstance") {
t.Error("expected instance name in HTML output")
}
if !strings.Contains(html, "knowledge base") {
t.Error("expected body in HTML output")
}
}
func TestRenderHTML_DefaultInstanceName(t *testing.T) {
n := &models.Notification{Title: "Test"}
html := RenderHTML(n, "")
if !strings.Contains(html, "Chat Switchboard") {
t.Error("expected default instance name")
}
}
func TestRenderText_Basic(t *testing.T) {
n := &models.Notification{
Title: "Model Fallback",
Body: "GPT-4 fell back to GPT-3.5.",
Type: "role.fallback",
}
text := RenderText(n, "MyInstance")
if !strings.Contains(text, "Model Fallback") {
t.Error("expected title in text output")
}
if !strings.Contains(text, "GPT-4 fell back") {
t.Error("expected body in text output")
}
}
func TestSubjectForNotification(t *testing.T) {
tests := []struct {
notifType string
title string
want string
}{
{"kb.ready", "KB Processed", "[Test] Knowledge Base: KB Processed"},
{"role.fallback", "Fallback", "[Test] Model Alert: Fallback"},
{"grant.changed", "Added to team", "[Test] Access Change: Added to team"},
{"custom.type", "Hello", "[Test] Hello"},
}
for _, tt := range tests {
n := &models.Notification{Type: tt.notifType, Title: tt.title}
got := SubjectForNotification(n, "Test")
if got != tt.want {
t.Errorf("SubjectForNotification(%q, %q) = %q, want %q", tt.notifType, tt.title, got, tt.want)
}
}
}

View File

@@ -40,6 +40,8 @@ type Stores struct {
ResourceGrants ResourceGrantStore
Memories MemoryStore
Projects ProjectStore
Notifications NotificationStore
NotifPrefs NotificationPreferenceStore
}
// =========================================
@@ -216,6 +218,9 @@ type ChannelStore interface {
// Channel models
SetModel(ctx context.Context, cm *models.ChannelModel) error
GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error)
GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error)
UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error
DeleteModel(ctx context.Context, id string) error
// Ownership check
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
@@ -476,6 +481,35 @@ type ResourceGrantStore interface {
UserHasGroupAccess(ctx context.Context, userID, resourceType, resourceID string) (bool, error)
}
// =========================================
// NOTIFICATION STORE (v0.20.0)
// =========================================
type NotificationStore interface {
Create(ctx context.Context, n *models.Notification) error
ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error)
MarkRead(ctx context.Context, id, userID string) error
MarkAllRead(ctx context.Context, userID string) error
Delete(ctx context.Context, id, userID string) error
UnreadCount(ctx context.Context, userID string) (int, error)
DeleteOlderThan(ctx context.Context, before time.Time) (int64, error)
}
// =========================================
// NOTIFICATION PREFERENCES STORE (v0.20.0)
// =========================================
type NotificationPreferenceStore interface {
// Get returns the preference for a specific user + type. Returns nil if not set.
Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error)
// ListForUser returns all preferences set by a user.
ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error)
// Upsert creates or updates a preference row.
Upsert(ctx context.Context, pref *models.NotificationPreference) error
// Delete removes a preference (falls back to default).
Delete(ctx context.Context, userID, notifType string) error
}
// =========================================
// SHARED TYPES
// =========================================

View File

@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
@@ -212,6 +214,51 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
return result, rows.Err()
}
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
var cm models.ChannelModel
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
FROM channel_models WHERE id = $1`, id).Scan(
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
if err != nil {
return nil, err
}
return &cm, nil
}
func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
// Build SET clause dynamically
sets := make([]string, 0, len(fields))
args := make([]interface{}, 0, len(fields)+1)
i := 1
for col, val := range fields {
sets = append(sets, col+" = $"+fmt.Sprintf("%d", i))
args = append(args, val)
i++
}
args = append(args, id)
query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = $" + fmt.Sprintf("%d", i)
_, err := DB.ExecContext(ctx, query, args...)
return err
}
func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,

View File

@@ -0,0 +1,133 @@
package postgres
import (
"context"
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type NotificationStore struct{}
func NewNotificationStore() *NotificationStore { return &NotificationStore{} }
func (s *NotificationStore) Create(ctx context.Context, n *models.Notification) error {
return DB.QueryRowContext(ctx, `
INSERT INTO notifications (user_id, type, title, body, resource_type, resource_id, is_read)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at`,
n.UserID, n.Type, n.Title, n.Body,
nullStr(n.ResourceType), nullStr(n.ResourceID), n.IsRead,
).Scan(&n.ID, &n.CreatedAt)
}
func (s *NotificationStore) ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) {
if limit <= 0 {
limit = 20
}
// Count
countQ := `SELECT COUNT(*) FROM notifications WHERE user_id = $1`
args := []interface{}{userID}
if unreadOnly {
countQ += ` AND is_read = false`
}
var total int
if err := DB.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
return nil, 0, err
}
// Fetch
q := `SELECT id, user_id, type, title, body, resource_type, resource_id, is_read, created_at
FROM notifications WHERE user_id = $1`
if unreadOnly {
q += ` AND is_read = false`
}
q += ` ORDER BY created_at DESC LIMIT $2 OFFSET $3`
rows, err := DB.QueryContext(ctx, q, userID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []models.Notification
for rows.Next() {
var n models.Notification
var resType, resID sql.NullString
if err := rows.Scan(
&n.ID, &n.UserID, &n.Type, &n.Title, &n.Body,
&resType, &resID, &n.IsRead, &n.CreatedAt,
); err != nil {
return nil, 0, err
}
n.ResourceType = NullableString(resType)
n.ResourceID = NullableString(resID)
items = append(items, n)
}
if items == nil {
items = []models.Notification{}
}
return items, total, rows.Err()
}
func (s *NotificationStore) MarkRead(ctx context.Context, id, userID string) error {
res, err := DB.ExecContext(ctx,
`UPDATE notifications SET is_read = true WHERE id = $1 AND user_id = $2`,
id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *NotificationStore) MarkAllRead(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
`UPDATE notifications SET is_read = true WHERE user_id = $1 AND is_read = false`,
userID)
return err
}
func (s *NotificationStore) Delete(ctx context.Context, id, userID string) error {
res, err := DB.ExecContext(ctx,
`DELETE FROM notifications WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *NotificationStore) UnreadCount(ctx context.Context, userID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND is_read = false`,
userID).Scan(&count)
return count, err
}
func (s *NotificationStore) DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM notifications WHERE created_at < $1`, before)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// nullStr returns sql.NullString for optional string fields.
func nullStr(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}

View File

@@ -0,0 +1,69 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type NotificationPreferenceStore struct{}
func NewNotificationPreferenceStore() *NotificationPreferenceStore {
return &NotificationPreferenceStore{}
}
func (s *NotificationPreferenceStore) Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error) {
var p models.NotificationPreference
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, type, in_app, email
FROM notification_preferences
WHERE user_id = $1 AND type = $2`, userID, notifType).Scan(
&p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
func (s *NotificationPreferenceStore) ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, type, in_app, email
FROM notification_preferences
WHERE user_id = $1
ORDER BY type`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.NotificationPreference
for rows.Next() {
var p models.NotificationPreference
if err := rows.Scan(&p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email); err != nil {
return nil, err
}
result = append(result, p)
}
return result, rows.Err()
}
func (s *NotificationPreferenceStore) Upsert(ctx context.Context, pref *models.NotificationPreference) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO notification_preferences (user_id, type, in_app, email)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, type) DO UPDATE SET
in_app = $3, email = $4`,
pref.UserID, pref.Type, pref.InApp, pref.Email)
return err
}
func (s *NotificationPreferenceStore) Delete(ctx context.Context, userID, notifType string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM notification_preferences
WHERE user_id = $1 AND type = $2`, userID, notifType)
return err
}

View File

@@ -11,27 +11,29 @@ import (
func NewStores(db *sql.DB) store.Stores {
SetDB(db)
return store.Stores{
Providers: NewProviderStore(),
Catalog: NewCatalogStore(),
Personas: NewPersonaStore(),
Policies: NewPolicyStore(),
UserSettings: NewUserModelSettingsStore(),
Users: NewUserStore(),
Teams: NewTeamStore(),
Channels: NewChannelStore(),
Messages: NewMessageStore(),
Audit: NewAuditStore(),
Notes: NewNoteStore(),
NoteLinks: NewNoteLinkStore(),
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Attachments: NewAttachmentStore(),
Providers: NewProviderStore(),
Catalog: NewCatalogStore(),
Personas: NewPersonaStore(),
Policies: NewPolicyStore(),
UserSettings: NewUserModelSettingsStore(),
Users: NewUserStore(),
Teams: NewTeamStore(),
Channels: NewChannelStore(),
Messages: NewMessageStore(),
Audit: NewAuditStore(),
Notes: NewNoteStore(),
NoteLinks: NewNoteLinkStore(),
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Attachments: NewAttachmentStore(),
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
ResourceGrants: NewResourceGrantStore(),
Memories: NewMemoryStore(),
Projects: NewProjectStore(),
Notifications: NewNotificationStore(),
NotifPrefs: NewNotificationPreferenceStore(),
}
}

View File

@@ -14,8 +14,8 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens,
sort_order, created_at, updated_at
SELECT id, user_id, model_id, COALESCE(hidden, false), preferred_temperature, preferred_max_tokens,
COALESCE(sort_order, 0), created_at, updated_at
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
if err != nil {
return nil, err
@@ -91,7 +91,7 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
VALUES ($1, $2, COALESCE($3, false), $4, $5, COALESCE($6, 0))
ON CONFLICT (user_id, model_id)
DO UPDATE SET
hidden = COALESCE($3, user_model_settings.hidden),

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -218,6 +219,48 @@ func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]model
return result, rows.Err()
}
func (s *ChannelStore) GetModelByID(ctx context.Context, id string) (*models.ChannelModel, error) {
var cm models.ChannelModel
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
FROM channel_models WHERE id = ?`, id).Scan(
&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault)
if err != nil {
return nil, err
}
return &cm, nil
}
func (s *ChannelStore) UpdateModel(ctx context.Context, id string, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
sets := make([]string, 0, len(fields))
args := make([]interface{}, 0, len(fields)+1)
for col, val := range fields {
sets = append(sets, col+" = ?")
args = append(args, val)
}
args = append(args, id)
query := "UPDATE channel_models SET " + strings.Join(sets, ", ") + " WHERE id = ?"
_, err := DB.ExecContext(ctx, query, args...)
return err
}
func (s *ChannelStore) DeleteModel(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, `DELETE FROM channel_models WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,

View File

@@ -0,0 +1,146 @@
package sqlite
import (
"context"
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type NotificationStore struct{}
func NewNotificationStore() *NotificationStore { return &NotificationStore{} }
func (s *NotificationStore) Create(ctx context.Context, n *models.Notification) error {
if n.ID == "" {
n.ID = store.NewID()
}
now := time.Now().UTC().Format(timeFmt)
_, err := DB.ExecContext(ctx, `
INSERT INTO notifications (id, user_id, type, title, body, resource_type, resource_id, is_read, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, n.Type, n.Title, n.Body,
nullStr(n.ResourceType), nullStr(n.ResourceID), boolToInt(n.IsRead), now,
)
if err != nil {
return err
}
n.CreatedAt, _ = time.Parse(timeFmt, now)
return nil
}
func (s *NotificationStore) ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) {
if limit <= 0 {
limit = 20
}
// Count
countQ := `SELECT COUNT(*) FROM notifications WHERE user_id = ?`
countArgs := []interface{}{userID}
if unreadOnly {
countQ += ` AND is_read = 0`
}
var total int
if err := DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total); err != nil {
return nil, 0, err
}
// Fetch
q := `SELECT id, user_id, type, title, body, resource_type, resource_id, is_read, created_at
FROM notifications WHERE user_id = ?`
if unreadOnly {
q += ` AND is_read = 0`
}
q += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
rows, err := DB.QueryContext(ctx, q, userID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []models.Notification
for rows.Next() {
var n models.Notification
var resType, resID sql.NullString
var isRead int
if err := rows.Scan(
&n.ID, &n.UserID, &n.Type, &n.Title, &n.Body,
&resType, &resID, &isRead, st(&n.CreatedAt),
); err != nil {
return nil, 0, err
}
n.ResourceType = NullableString(resType)
n.ResourceID = NullableString(resID)
n.IsRead = isRead != 0
items = append(items, n)
}
if items == nil {
items = []models.Notification{}
}
return items, total, rows.Err()
}
func (s *NotificationStore) MarkRead(ctx context.Context, id, userID string) error {
res, err := DB.ExecContext(ctx,
`UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?`,
id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *NotificationStore) MarkAllRead(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
`UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0`,
userID)
return err
}
func (s *NotificationStore) Delete(ctx context.Context, id, userID string) error {
res, err := DB.ExecContext(ctx,
`DELETE FROM notifications WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *NotificationStore) UnreadCount(ctx context.Context, userID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0`,
userID).Scan(&count)
return count, err
}
func (s *NotificationStore) DeleteOlderThan(ctx context.Context, before time.Time) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM notifications WHERE created_at < ?`,
before.UTC().Format(timeFmt))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// nullStr returns sql.NullString for optional string fields.
func nullStr(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}

View File

@@ -0,0 +1,70 @@
package sqlite
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type NotificationPreferenceStore struct{}
func NewNotificationPreferenceStore() *NotificationPreferenceStore {
return &NotificationPreferenceStore{}
}
func (s *NotificationPreferenceStore) Get(ctx context.Context, userID, notifType string) (*models.NotificationPreference, error) {
var p models.NotificationPreference
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, type, in_app, email
FROM notification_preferences
WHERE user_id = ? AND type = ?`, userID, notifType).Scan(
&p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
func (s *NotificationPreferenceStore) ListForUser(ctx context.Context, userID string) ([]models.NotificationPreference, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, type, in_app, email
FROM notification_preferences
WHERE user_id = ?
ORDER BY type`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.NotificationPreference
for rows.Next() {
var p models.NotificationPreference
if err := rows.Scan(&p.ID, &p.UserID, &p.Type, &p.InApp, &p.Email); err != nil {
return nil, err
}
result = append(result, p)
}
return result, rows.Err()
}
func (s *NotificationPreferenceStore) Upsert(ctx context.Context, pref *models.NotificationPreference) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO notification_preferences (id, user_id, type, in_app, email)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, type) DO UPDATE SET
in_app = excluded.in_app, email = excluded.email`,
store.NewID(), pref.UserID, pref.Type, pref.InApp, pref.Email)
return err
}
func (s *NotificationPreferenceStore) Delete(ctx context.Context, userID, notifType string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM notification_preferences
WHERE user_id = ? AND type = ?`, userID, notifType)
return err
}

View File

@@ -33,5 +33,7 @@ func NewStores(db *sql.DB) store.Stores {
ResourceGrants: NewResourceGrantStore(),
Memories: NewMemoryStore(),
Projects: NewProjectStore(),
Notifications: NewNotificationStore(),
NotifPrefs: NewNotificationPreferenceStore(),
}
}

View File

@@ -15,8 +15,8 @@ func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSett
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens,
sort_order, created_at, updated_at
SELECT id, user_id, model_id, COALESCE(hidden, 0), preferred_temperature, preferred_max_tokens,
COALESCE(sort_order, 0), created_at, updated_at
FROM user_model_settings WHERE user_id = ? ORDER BY sort_order, model_id`, userID)
if err != nil {
return nil, err
@@ -92,7 +92,7 @@ func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, COALESCE(?, 0), ?, ?, COALESCE(?, 0))
ON CONFLICT (user_id, model_id)
DO UPDATE SET
hidden = COALESCE(excluded.hidden, user_model_settings.hidden),