Changeset 0.20.0 (#85)
This commit is contained in:
17
server/database/migrations/007_v0200_notifications.sql
Normal file
17
server/database/migrations/007_v0200_notifications.sql
Normal 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);
|
||||
18
server/database/migrations/008_v0200_notification_prefs.sql
Normal file
18
server/database/migrations/008_v0200_notification_prefs.sql
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -256,6 +256,7 @@ func TruncateAll(t *testing.T) {
|
||||
"resource_grants",
|
||||
"group_members",
|
||||
"groups",
|
||||
"notifications",
|
||||
"usage_log",
|
||||
"model_pricing",
|
||||
"notes",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
78
server/handlers/admin_email.go
Normal file
78
server/handlers/admin_email.go
Normal 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})
|
||||
}
|
||||
237
server/handlers/channel_models.go
Normal file
237
server/handlers/channel_models.go
Normal 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
281
server/handlers/notifications.go
Normal file
281
server/handlers/notifications.go
Normal 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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
155
server/mentions/parser.go
Normal 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, " ")
|
||||
}
|
||||
213
server/mentions/parser_test.go
Normal file
213
server/mentions/parser_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
server/models/models_notification.go
Normal file
40
server/models/models_notification.go
Normal 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"
|
||||
)
|
||||
20
server/models/models_notification_pref.go
Normal file
20
server/models/models_notification_pref.go
Normal 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}
|
||||
198
server/notifications/email.go
Normal file
198
server/notifications/email.go
Normal 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
|
||||
}
|
||||
203
server/notifications/service.go
Normal file
203
server/notifications/service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
137
server/notifications/service_test.go
Normal file
137
server/notifications/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
146
server/notifications/sources.go
Normal file
146
server/notifications/sources.go
Normal 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)
|
||||
}
|
||||
}
|
||||
108
server/notifications/templates.go
Normal file
108
server/notifications/templates.go
Normal 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
|
||||
}
|
||||
}
|
||||
69
server/notifications/templates_test.go
Normal file
69
server/notifications/templates_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// =========================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
133
server/store/postgres/notification.go
Normal file
133
server/store/postgres/notification.go
Normal 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}
|
||||
}
|
||||
69
server/store/postgres/notification_prefs.go
Normal file
69
server/store/postgres/notification_prefs.go
Normal 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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
146
server/store/sqlite/notification.go
Normal file
146
server/store/sqlite/notification.go
Normal 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}
|
||||
}
|
||||
70
server/store/sqlite/notification_prefs.go
Normal file
70
server/store/sqlite/notification_prefs.go
Normal 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
|
||||
}
|
||||
@@ -33,5 +33,7 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
Memories: NewMemoryStore(),
|
||||
Projects: NewProjectStore(),
|
||||
Notifications: NewNotificationStore(),
|
||||
NotifPrefs: NewNotificationPreferenceStore(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user