From 43acbfab153e58fe048e7a65df6febe2518a0108 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 10 Jun 2026 10:41:31 +0200 Subject: [PATCH] Remove AI/chat/search features, add solidtime integration and workspace setup --- backend/handlers/admin.go | 2 +- backend/handlers/ai_features.go | 811 ----- backend/handlers/ai_recommendations.go | 401 -- backend/handlers/ai_settings.go | 388 -- backend/handlers/auth.go | 2 +- backend/handlers/chat.go | 360 -- backend/handlers/chat_context.go | 181 - backend/handlers/messages.go | 2288 ------------ backend/handlers/oauth_service.go | 2 +- backend/handlers/saved_search.go | 662 ---- backend/handlers/search.go | 475 --- backend/handlers/search_enhanced.go | 523 --- backend/handlers/search_settings.go | 184 - backend/handlers/semantic_search.go | 603 --- backend/handlers/update_settings.go | 18 + backend/main.go | 125 +- backend/models/ai_recommendation.go | 153 - backend/models/ai_settings.go | 62 - backend/models/chat.go | 56 - backend/models/messages.go | 200 - backend/models/models.go | 51 +- backend/models/search.go | 111 - backend/services/ai_recommendation_service.go | 553 --- backend/services/ai_service.go | 532 --- backend/services/messages_realtime.go | 172 - frontend/src/App.tsx | 86 +- frontend/src/components/AIProviderIcon.tsx | 95 - .../src/components/layout/AIChatPanel.tsx | 228 -- frontend/src/components/layout/Layout.tsx | 55 +- frontend/src/components/layout/Sidebar.tsx | 321 +- .../src/components/search/BrowserSearch.tsx | 374 -- .../src/components/search/SavedSearches.tsx | 28 +- .../components/ui/ColorSwitcherDropdown.tsx | 5 +- frontend/src/components/ui/Switch.tsx | 8 +- frontend/src/components/ui/ViewNoteModal.tsx | 26 +- frontend/src/lib/brave-search.ts | 105 - frontend/src/lib/demo-mode.ts | 20 - frontend/src/lib/solidtime-api.ts | 141 + frontend/src/pages/auth/Login.tsx | 2 + frontend/src/pages/auth/WorkspaceSetup.tsx | 146 + frontend/src/pages/communication/AIChat.tsx | 596 --- frontend/src/pages/communication/Chat.tsx | 18 +- frontend/src/pages/communication/Messages.tsx | 3243 ----------------- frontend/src/pages/content/Bookmarks.tsx | 61 +- frontend/src/pages/misc/Dashboard.tsx | 41 +- frontend/src/pages/misc/RemovedStuff.tsx | 68 +- .../src/pages/productivity/AIAssistant.tsx | 234 -- frontend/src/pages/productivity/Tasks.tsx | 60 +- .../src/pages/productivity/TimeTracking.tsx | 343 +- .../settings/BrowserExtensionSettings.tsx | 279 +- frontend/src/pages/settings/Settings.tsx | 1171 +----- 51 files changed, 1284 insertions(+), 15385 deletions(-) delete mode 100644 backend/handlers/ai_features.go delete mode 100644 backend/handlers/ai_recommendations.go delete mode 100644 backend/handlers/ai_settings.go delete mode 100644 backend/handlers/chat.go delete mode 100644 backend/handlers/chat_context.go delete mode 100644 backend/handlers/messages.go delete mode 100644 backend/handlers/saved_search.go delete mode 100644 backend/handlers/search.go delete mode 100644 backend/handlers/search_enhanced.go delete mode 100644 backend/handlers/search_settings.go delete mode 100644 backend/handlers/semantic_search.go delete mode 100644 backend/models/ai_recommendation.go delete mode 100644 backend/models/ai_settings.go delete mode 100644 backend/models/chat.go delete mode 100644 backend/models/messages.go delete mode 100644 backend/models/search.go delete mode 100644 backend/services/ai_recommendation_service.go delete mode 100644 backend/services/ai_service.go delete mode 100644 backend/services/messages_realtime.go delete mode 100644 frontend/src/components/AIProviderIcon.tsx delete mode 100644 frontend/src/components/layout/AIChatPanel.tsx delete mode 100644 frontend/src/components/search/BrowserSearch.tsx delete mode 100644 frontend/src/lib/brave-search.ts create mode 100644 frontend/src/lib/solidtime-api.ts create mode 100644 frontend/src/pages/auth/WorkspaceSetup.tsx delete mode 100644 frontend/src/pages/communication/AIChat.tsx delete mode 100644 frontend/src/pages/communication/Messages.tsx delete mode 100644 frontend/src/pages/productivity/AIAssistant.tsx diff --git a/backend/handlers/admin.go b/backend/handlers/admin.go index 574b58d..2672f71 100644 --- a/backend/handlers/admin.go +++ b/backend/handlers/admin.go @@ -269,7 +269,7 @@ func AdminCreateUser(c *gin.Context) { return } - _ = ensureMessagingDefaults(db, user.ID) + user.Password = "" c.JSON(http.StatusCreated, gin.H{ diff --git a/backend/handlers/ai_features.go b/backend/handlers/ai_features.go deleted file mode 100644 index e416c5a..0000000 --- a/backend/handlers/ai_features.go +++ /dev/null @@ -1,811 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "time" - - "github.com/gin-gonic/gin" - "github.com/trackeep/backend/models" - "github.com/trackeep/backend/services" -) - -// SummarizeContentRequest represents a request to summarize content -type SummarizeContentRequest struct { - ContentType string `json:"content_type" binding:"required"` // "bookmark", "note", "file" - ContentID uint `json:"content_id" binding:"required"` - Provider string `json:"provider"` // "mistral", "longcat", "" for default - ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking" - Options struct { - Length string `json:"length"` // "short", "medium", "long" - Style string `json:"style"` // "bullet", "paragraph", "executive" - IncludeKey bool `json:"include_key"` // Include key points - } `json:"options"` -} - -// GenerateTaskSuggestionsRequest represents a request for task suggestions -type GenerateTaskSuggestionsRequest struct { - Context string `json:"context"` // "calendar", "deadlines", "habits", "all" - Timeframe string `json:"timeframe"` // "today", "week", "month" - Limit int `json:"limit"` // Max number of suggestions - Provider string `json:"provider"` // "mistral", "longcat", "" for default - ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking" -} - -// GenerateTagsRequest represents a request for tag suggestions -type GenerateTagsRequest struct { - ContentType string `json:"content_type" binding:"required"` - ContentID uint `json:"content_id" binding:"required"` - Content string `json:"content" binding:"required"` - ExistingTag string `json:"existing_tags"` - Provider string `json:"provider"` // "mistral", "longcat", "" for default - ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking" -} - -// GenerateContentRequest represents a request for content generation -type GenerateContentRequest struct { - Prompt string `json:"prompt" binding:"required"` - ContentType string `json:"content_type" binding:"required"` - Context string `json:"context"` - Temperature float64 `json:"temperature"` - MaxLength int `json:"max_length"` - Provider string `json:"provider"` // "mistral", "longcat", "" for default - ModelType string `json:"model_type"` // "standard", "thinking", "upgraded_thinking" -} - -// SummarizeContent generates AI summary for content -func SummarizeContent(c *gin.Context) { - userID := c.GetUint("user_id") - - var req SummarizeContentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get content based on type - var content string - var title string - switch req.ContentType { - case "bookmark": - var bookmark models.Bookmark - if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(&bookmark).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"}) - return - } - content = bookmark.Content - title = bookmark.Title - case "note": - var note models.Note - if err := models.DB.Where("id = ? AND user_id = ?", req.ContentID, userID).First(¬e).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Content not found"}) - return - } - content = note.Content - title = note.Title - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported content type"}) - return - } - - if content == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "No content to summarize"}) - return - } - - // Check if summary already exists - var existingSummary models.AISummary - if err := models.DB.Where("user_id = ? AND content_type = ? AND content_id = ?", userID, req.ContentType, req.ContentID).First(&existingSummary).Error; err == nil { - c.JSON(http.StatusOK, existingSummary) - return - } - - // Generate summary using AI - summary, err := generateAISummary(content, title, req.Options, req.Provider, req.ModelType) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate summary: " + err.Error()}) - return - } - - // Save summary - aiSummary := models.AISummary{ - UserID: userID, - ContentType: req.ContentType, - ContentID: req.ContentID, - Title: summary.Title, - Summary: summary.Summary, - KeyPoints: summary.KeyPoints, - Tags: summary.Tags, - ReadTime: summary.ReadTime, - Complexity: summary.Complexity, - ModelUsed: getProviderModel(req.Provider), - Confidence: summary.Confidence, - LastAnalyzed: time.Now(), - } - - if err := models.DB.Create(&aiSummary).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save summary"}) - return - } - - c.JSON(http.StatusOK, aiSummary) -} - -// GetTaskSuggestions generates AI task suggestions -func GetTaskSuggestions(c *gin.Context) { - userID := c.GetUint("user_id") - - var req GenerateTaskSuggestionsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Build context from user data - contextData, err := buildTaskContext(userID, req.Context, req.Timeframe) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"}) - return - } - - // Generate suggestions - suggestions, err := generateTaskSuggestions(contextData, req.Limit, req.Provider, req.ModelType) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate suggestions: " + err.Error()}) - return - } - - // Save suggestions - var aiSuggestions []models.AITaskSuggestion - for _, suggestion := range suggestions { - aiSuggestion := models.AITaskSuggestion{ - UserID: userID, - Title: suggestion.Title, - Description: suggestion.Description, - Priority: suggestion.Priority, - Category: suggestion.Category, - Reasoning: suggestion.Reasoning, - ContextType: req.Context, - ContextData: suggestion.ContextData, - Deadline: suggestion.Deadline, - EstimatedTime: suggestion.EstimatedTime, - ModelUsed: getProviderModel(req.Provider), - Confidence: suggestion.Confidence, - } - models.DB.Create(&aiSuggestion) - aiSuggestions = append(aiSuggestions, aiSuggestion) - } - - c.JSON(http.StatusOK, aiSuggestions) -} - -// GenerateTagSuggestions generates AI tag suggestions -func GenerateTagSuggestions(c *gin.Context) { - userID := c.GetUint("user_id") - - var req GenerateTagsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Generate tags - tags, err := generateTagSuggestions(req.Content, req.ExistingTag, req.Provider, req.ModelType) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate tags: " + err.Error()}) - return - } - - // Save suggestion - tagSuggestion := models.AITagSuggestion{ - UserID: userID, - ContentType: req.ContentType, - ContentID: req.ContentID, - SuggestedTags: tags.Suggested, - ExistingTags: req.ExistingTag, - Relevance: tags.Relevance, - ModelUsed: getProviderModel(req.Provider), - Confidence: tags.Confidence, - } - - if err := models.DB.Create(&tagSuggestion).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tag suggestion"}) - return - } - - c.JSON(http.StatusOK, tagSuggestion) -} - -// GenerateContent generates AI content -func GenerateContent(c *gin.Context) { - userID := c.GetUint("user_id") - - var req GenerateContentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Generate content - content, err := generateAIContent(req.Prompt, req.ContentType, req.Context, req.Temperature, req.MaxLength, req.Provider, req.ModelType) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate content: " + err.Error()}) - return - } - - // Save generation - aiContent := models.AIContentGeneration{ - UserID: userID, - Prompt: req.Prompt, - ContentType: req.ContentType, - Context: req.Context, - Title: content.Title, - Content: content.Content, - WordCount: content.WordCount, - ReadTime: content.ReadTime, - ModelUsed: getProviderModel(req.Provider), - ProcessingMs: content.ProcessingMs, - TokenCount: content.TokenCount, - Confidence: content.Confidence, - Temperature: req.Temperature, - } - - if err := models.DB.Create(&aiContent).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save content"}) - return - } - - c.JSON(http.StatusOK, aiContent) -} - -// GetAIProviders returns available AI providers -func GetAIProviders(c *gin.Context) { - providers := services.GetAvailableProviders() - - providerInfo := make([]map[string]interface{}, 0) - for _, provider := range providers { - info := map[string]interface{}{ - "id": string(provider), - "name": getProviderDisplayName(provider), - } - - // Add model info - switch provider { - case services.ProviderMistral: - standardModel := os.Getenv("MISTRAL_MODEL") - thinkingModel := os.Getenv("MISTRAL_MODEL_THINKING") - - info["models"] = []map[string]string{ - {"id": "standard", "name": standardModel, "type": "Standard"}, - {"id": "thinking", "name": thinkingModel, "type": "Thinking"}, - } - info["description"] = "Mistral AI - Fast and efficient European AI" - info["icon"] = "🇪🇺" - - case services.ProviderLongCat: - standardModel := os.Getenv("LONGCAT_MODEL") - thinkingModel := os.Getenv("LONGCAT_MODEL_THINKING") - upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED") - - models := []map[string]string{ - {"id": "standard", "name": standardModel, "type": "Standard"}, - {"id": "thinking", "name": thinkingModel, "type": "Thinking"}, - } - - if upgradedModel != "" { - models = append(models, map[string]string{"id": "upgraded_thinking", "name": upgradedModel, "type": "Upgraded Thinking"}) - } - - info["models"] = models - info["description"] = "LongCat AI - High-performance AI models" - info["icon"] = "🐱" - - case services.ProviderGrok: - standardModel := os.Getenv("GROK_MODEL") - thinkingModel := os.Getenv("GROK_MODEL_THINKING") - - models := []map[string]string{ - {"id": "standard", "name": standardModel, "type": "Standard"}, - } - - if thinkingModel != "" && thinkingModel != standardModel { - models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"}) - } - - info["models"] = models - info["description"] = "Grok AI - Real-time information from X" - info["icon"] = "🐦" - - case services.ProviderDeepSeek: - standardModel := os.Getenv("DEEPSEEK_MODEL") - thinkingModel := os.Getenv("DEEPSEEK_MODEL_THINKING") - - models := []map[string]string{ - {"id": "standard", "name": standardModel, "type": "Standard"}, - } - - if thinkingModel != "" && thinkingModel != standardModel { - models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Reasoning"}) - } - - info["models"] = models - info["description"] = "DeepSeek - Advanced reasoning AI" - info["icon"] = "🔍" - - case services.ProviderOllama: - standardModel := os.Getenv("OLLAMA_MODEL") - thinkingModel := os.Getenv("OLLAMA_MODEL_THINKING") - - models := []map[string]string{ - {"id": "standard", "name": standardModel, "type": "Standard"}, - } - - if thinkingModel != "" && thinkingModel != standardModel { - models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Local"}) - } - - info["models"] = models - info["description"] = "Ollama - Local AI models" - info["icon"] = "🦙" - - case services.ProviderOpenRouter: - standardModel := os.Getenv("OPENROUTER_MODEL") - thinkingModel := os.Getenv("OPENROUTER_MODEL_THINKING") - - models := []map[string]string{ - {"id": "standard", "name": standardModel, "type": "Standard"}, - } - - if thinkingModel != "" && thinkingModel != standardModel { - models = append(models, map[string]string{"id": "thinking", "name": thinkingModel, "type": "Thinking"}) - } - - info["models"] = models - info["description"] = "OpenRouter - Unified access to many models" - info["icon"] = "🌀" - } - - providerInfo = append(providerInfo, info) - } - - c.JSON(http.StatusOK, gin.H{"providers": providerInfo}) -} - -// Helper function to get display name for provider -func getProviderDisplayName(provider services.AIProvider) string { - switch provider { - case services.ProviderMistral: - return "Mistral AI" - case services.ProviderLongCat: - return "LongCat AI" - case services.ProviderGrok: - return "Grok AI" - case services.ProviderDeepSeek: - return "DeepSeek" - case services.ProviderOllama: - return "Ollama" - case services.ProviderOpenRouter: - return "OpenRouter" - default: - return string(provider) - } -} - -// GetAISummaries retrieves AI summaries for user -func GetAISummaries(c *gin.Context) { - userID := c.GetUint("user_id") - - var summaries []models.AISummary - models.DB.Where("user_id = ?", userID).Order("created_at desc").Find(&summaries) - - c.JSON(http.StatusOK, summaries) -} - -// GetTaskSuggestions retrieves task suggestions for user -func GetTaskSuggestionsList(c *gin.Context) { - userID := c.GetUint("user_id") - - var suggestions []models.AITaskSuggestion - models.DB.Where("user_id = ? AND accepted = false AND dismissed = false", userID).Order("created_at desc").Find(&suggestions) - - c.JSON(http.StatusOK, suggestions) -} - -// AcceptTaskSuggestion accepts a task suggestion -func AcceptTaskSuggestion(c *gin.Context) { - userID := c.GetUint("user_id") - suggestionID := c.Param("id") - - var suggestion models.AITaskSuggestion - if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) - return - } - - // Create actual task - task := models.Task{ - UserID: userID, - Title: suggestion.Title, - Description: suggestion.Description, - Priority: models.TaskPriority(suggestion.Priority), - Status: models.TaskStatusPending, - DueDate: suggestion.Deadline, - } - - if err := models.DB.Create(&task).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create task"}) - return - } - - // Mark suggestion as accepted - suggestion.Accepted = true - models.DB.Save(&suggestion) - - c.JSON(http.StatusOK, gin.H{"message": "Task created successfully", "task_id": task.ID}) -} - -// DismissTaskSuggestion dismisses a task suggestion -func DismissTaskSuggestion(c *gin.Context) { - userID := c.GetUint("user_id") - suggestionID := c.Param("id") - - var suggestion models.AITaskSuggestion - if err := models.DB.Where("id = ? AND user_id = ?", suggestionID, userID).First(&suggestion).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) - return - } - - suggestion.Dismissed = true - models.DB.Save(&suggestion) - - c.JSON(http.StatusOK, gin.H{"message": "Suggestion dismissed"}) -} - -// Helper structs for AI responses -type AISummaryResponse struct { - Title string `json:"title"` - Summary string `json:"summary"` - KeyPoints string `json:"key_points"` - Tags string `json:"tags"` - ReadTime int `json:"read_time"` - Complexity string `json:"complexity"` - Confidence float64 `json:"confidence"` -} - -type TaskSuggestionResponse struct { - Title string `json:"title"` - Description string `json:"description"` - Priority string `json:"priority"` - Category string `json:"category"` - Reasoning string `json:"reasoning"` - ContextData string `json:"context_data"` - Deadline *time.Time `json:"deadline"` - EstimatedTime int `json:"estimated_time"` - Confidence float64 `json:"confidence"` -} - -type TagSuggestionResponse struct { - Suggested string `json:"suggested"` - Relevance float64 `json:"relevance"` - Confidence float64 `json:"confidence"` -} - -type ContentGenerationResponse struct { - Title string `json:"title"` - Content string `json:"content"` - WordCount int `json:"word_count"` - ReadTime int `json:"read_time"` - ProcessingMs int64 `json:"processing_ms"` - TokenCount int `json:"token_count"` - Confidence float64 `json:"confidence"` -} - -// AI generation functions (simplified - would call actual AI models) -func generateAISummary(content, title string, options struct { - Length string `json:"length"` - Style string `json:"style"` - IncludeKey bool `json:"include_key"` -}, provider string, modelType string) (*AISummaryResponse, error) { - // Build prompt for summarization - prompt := fmt.Sprintf(`Please summarize the following content: -Title: %s -Content: %s - -Length: %s -Style: %s -Include key points: %t - -Provide a JSON response with: -- title: Brief title -- summary: Main summary -- key_points: Array of key points (if requested) -- tags: Array of relevant tags -- read_time: Estimated reading time in minutes -- complexity: "low", "medium", or "high" -- confidence: Confidence score 0-1`, title, content, options.Length, options.Style, options.IncludeKey) - - messages := []services.Message{ - {Role: "system", Content: "You are an expert content summarizer. Always respond with valid JSON."}, - {Role: "user", Content: prompt}, - } - - // Determine provider - aiProvider := services.ProviderMistral // default - if provider == "longcat" { - aiProvider = services.ProviderLongCat - } - - aiService := services.NewAIService(aiProvider) - - req := services.AIRequest{ - Messages: messages, - MaxTokens: 2000, - Temperature: 0.3, - ModelType: modelType, - } - - var resp *services.AIResponse - var err error - - // Choose the appropriate method based on model type - switch req.ModelType { - case "thinking": - resp, err = aiService.ChatCompletionWithThinking(req) - case "upgraded_thinking": - resp, err = aiService.ChatCompletionWithUpgradedThinking(req) - default: - resp, err = aiService.ChatCompletion(req) - } - - if err != nil { - return nil, err - } - - // Parse the response content properly for thinking models - actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType) - - var summary AISummaryResponse - if err := json.Unmarshal([]byte(actualContent), &summary); err != nil { - return nil, err - } - - return &summary, nil -} - -func generateTaskSuggestions(contextData map[string]interface{}, limit int, provider string, modelType string) ([]TaskSuggestionResponse, error) { - // Build prompt for task suggestions - prompt := fmt.Sprintf(`Based on the following user context, suggest %d tasks: -Context: %+v - -Provide a JSON array of task objects with: -- title: Task title -- description: Task description -- priority: "low", "medium", "high", "urgent" -- category: Task category -- reasoning: Why this task is suggested -- context_data: Additional context -- deadline: Suggested deadline (ISO date or null) -- estimated_time: Estimated time in minutes -- confidence: Confidence score 0-1`, limit, contextData) - - messages := []services.Message{ - {Role: "system", Content: "You are a productivity assistant. Always respond with valid JSON array."}, - {Role: "user", Content: prompt}, - } - - // Determine provider - aiProvider := services.ProviderMistral // default - if provider == "longcat" { - aiProvider = services.ProviderLongCat - } - - aiService := services.NewAIService(aiProvider) - - req := services.AIRequest{ - Messages: messages, - MaxTokens: 2000, - Temperature: 0.7, - ModelType: modelType, - } - - var resp *services.AIResponse - var err error - - // Choose the appropriate method based on model type - switch req.ModelType { - case "thinking": - resp, err = aiService.ChatCompletionWithThinking(req) - case "upgraded_thinking": - resp, err = aiService.ChatCompletionWithUpgradedThinking(req) - default: - resp, err = aiService.ChatCompletion(req) - } - - if err != nil { - return nil, err - } - - // Parse the response content properly for thinking models - actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType) - - var suggestions []TaskSuggestionResponse - if err := json.Unmarshal([]byte(actualContent), &suggestions); err != nil { - return nil, err - } - - return suggestions, nil -} - -func generateTagSuggestions(content, existingTags string, provider string, modelType string) (*TagSuggestionResponse, error) { - prompt := fmt.Sprintf(`Suggest relevant tags for this content: -Content: %s -Existing tags: %s - -Provide JSON response with: -- suggested: Array of suggested tags -- relevance: Relevance score 0-1 -- confidence: Confidence score 0-1`, content, existingTags) - - messages := []services.Message{ - {Role: "system", Content: "You are a tagging expert. Always respond with valid JSON."}, - {Role: "user", Content: prompt}, - } - - // Determine provider - aiProvider := services.ProviderMistral // default - if provider == "longcat" { - aiProvider = services.ProviderLongCat - } - - aiService := services.NewAIService(aiProvider) - - req := services.AIRequest{ - Messages: messages, - MaxTokens: 1000, - Temperature: 0.5, - ModelType: modelType, - } - - var resp *services.AIResponse - var err error - - // Choose the appropriate method based on model type - switch req.ModelType { - case "thinking": - resp, err = aiService.ChatCompletionWithThinking(req) - case "upgraded_thinking": - resp, err = aiService.ChatCompletionWithUpgradedThinking(req) - default: - resp, err = aiService.ChatCompletion(req) - } - - if err != nil { - return nil, err - } - - // Parse the response content properly for thinking models - actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType) - - var tags TagSuggestionResponse - if err := json.Unmarshal([]byte(actualContent), &tags); err != nil { - return nil, err - } - - return &tags, nil -} - -func generateAIContent(prompt, contentType, context string, temperature float64, maxLength int, provider string, modelType string) (*ContentGenerationResponse, error) { - fullPrompt := fmt.Sprintf(`Generate %s content based on this prompt: -%s -Additional context: %s -Max length: %d words - -Provide JSON response with: -- title: Generated title -- content: Generated content -- word_count: Word count -- read_time: Estimated reading time in minutes -- confidence: Confidence score 0-1`, contentType, prompt, context, maxLength) - - messages := []services.Message{ - {Role: "system", Content: "You are a content generation expert. Always respond with valid JSON."}, - {Role: "user", Content: fullPrompt}, - } - - // Determine provider - aiProvider := services.ProviderMistral // default - if provider == "longcat" { - aiProvider = services.ProviderLongCat - } - - aiService := services.NewAIService(aiProvider) - - // Adjust temperature if provided - temp := 0.7 - if temperature > 0 { - temp = temperature - } - - req := services.AIRequest{ - Messages: messages, - MaxTokens: maxLength * 2, // Rough estimate - Temperature: temp, - ModelType: modelType, - } - - var resp *services.AIResponse - var err error - - // Choose the appropriate method based on model type - switch req.ModelType { - case "thinking": - resp, err = aiService.ChatCompletionWithThinking(req) - case "upgraded_thinking": - resp, err = aiService.ChatCompletionWithUpgradedThinking(req) - default: - resp, err = aiService.ChatCompletion(req) - } - - if err != nil { - return nil, err - } - - // Parse the response content properly for thinking models - actualContent := services.ParseThinkingResponse(resp, aiProvider, modelType) - - var content ContentGenerationResponse - if err := json.Unmarshal([]byte(actualContent), &content); err != nil { - return nil, err - } - - content.ProcessingMs = 0 // Would track actual processing time - content.TokenCount = resp.Usage.TotalTokens - - return &content, nil -} - -func buildTaskContext(userID uint, contextType, timeframe string) (map[string]interface{}, error) { - ctx := make(map[string]interface{}) - - // Get upcoming tasks - var tasks []models.Task - query := models.DB.Where("user_id = ?", userID) - - if timeframe == "today" { - query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 1)) - } else if timeframe == "week" { - query = query.Where("deadline <= ?", time.Now().AddDate(0, 0, 7)) - } - - query.Find(&tasks) - ctx["tasks"] = tasks - - // Get calendar events - var events []models.CalendarEvent - models.DB.Where("user_id = ? AND start_time >= ?", userID, time.Now()).Find(&events) - ctx["events"] = events - - return ctx, nil -} - -// Helper function to get model name based on provider -func getProviderModel(provider string) string { - switch provider { - case "mistral": - return os.Getenv("MISTRAL_MODEL") - case "longcat": - return os.Getenv("LONGCAT_MODEL") - case "grok": - return os.Getenv("GROK_MODEL") - case "deepseek": - return os.Getenv("DEEPSEEK_MODEL") - case "ollama": - return os.Getenv("OLLAMA_MODEL") - case "openrouter": - return os.Getenv("OPENROUTER_MODEL") - default: - return os.Getenv("MISTRAL_MODEL") - } -} diff --git a/backend/handlers/ai_recommendations.go b/backend/handlers/ai_recommendations.go deleted file mode 100644 index 9207487..0000000 --- a/backend/handlers/ai_recommendations.go +++ /dev/null @@ -1,401 +0,0 @@ -package handlers - -import ( - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "github.com/trackeep/backend/models" - "github.com/trackeep/backend/services" -) - -// AIRecommendationHandler handles AI recommendation endpoints -type AIRecommendationHandler struct { - db *gorm.DB - service *services.AIRecommendationService -} - -// NewAIRecommendationHandler creates a new AI recommendation handler -func NewAIRecommendationHandler(db *gorm.DB) *AIRecommendationHandler { - return &AIRecommendationHandler{ - db: db, - service: services.NewAIRecommendationService(db), - } -} - -// GetRecommendations returns personalized recommendations for the user -func (h *AIRecommendationHandler) GetRecommendations(c *gin.Context) { - userID := c.GetUint("user_id") - - // Parse query parameters - recommendationType := c.DefaultQuery("type", "mixed") // content, task, learning, connection, mixed - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "5")) - minConfidence, _ := strconv.ParseFloat(c.DefaultQuery("min_confidence", "0.0"), 64) - includeDismissed := c.DefaultQuery("include_dismissed", "false") == "true" - context := c.Query("context") - - // Create recommendation request - req := services.RecommendationRequest{ - UserID: userID, - RecommendationType: recommendationType, - Limit: limit, - MinConfidence: minConfidence, - IncludeDismissed: includeDismissed, - Context: context, - } - - // Get recommendations - recommendations, err := h.service.GetRecommendations(req) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get recommendations: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "recommendations": recommendations, - "count": len(recommendations), - "type": recommendationType, - }) -} - -// GetRecommendationStats returns recommendation statistics for the user -func (h *AIRecommendationHandler) GetRecommendationStats(c *gin.Context) { - userID := c.GetUint("user_id") - - // Get user preferences - var prefs models.UserPreference - if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil { - // Create default preferences - prefs = models.UserPreference{ - UserID: userID, - EnableRecommendations: true, - MinConfidenceThreshold: 0.6, - MaxRecommendationsPerDay: 5, - MaxAgeHours: 168, - } - h.db.Create(&prefs) - } - - // Get recommendation statistics - var stats struct { - TotalRecommendations int64 `json:"total_recommendations"` - ClickedCount int64 `json:"clicked_count"` - DismissedCount int64 `json:"dismissed_count"` - FeedbackCount int64 `json:"feedback_count"` - Types []struct { - Type string `json:"type"` - Count int64 `json:"count"` - } `json:"types"` - Categories []struct { - Category string `json:"category"` - Count int64 `json:"count"` - } `json:"categories"` - DailyStats []struct { - Date string `json:"date"` - Count int64 `json:"count"` - } `json:"daily_stats"` - } - - // Total recommendations - h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID).Count(&stats.TotalRecommendations) - - // Clicked and dismissed counts - h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND clicked = ?", userID, true).Count(&stats.ClickedCount) - h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND dismissed = ?", userID, true).Count(&stats.DismissedCount) - h.db.Model(&models.AIRecommendation{}).Where("user_id = ? AND feedback != ''", userID).Count(&stats.FeedbackCount) - - // Recommendations by type - h.db.Model(&models.AIRecommendation{}). - Select("recommendation_type as type, COUNT(*) as count"). - Where("user_id = ?", userID). - Group("recommendation_type"). - Scan(&stats.Types) - - // Recommendations by category - h.db.Model(&models.AIRecommendation{}). - Select("category as category, COUNT(*) as count"). - Where("user_id = ? AND category != ''", userID). - Group("category"). - Order("count DESC"). - Limit(10). - Scan(&stats.Categories) - - // Daily stats for last 30 days - h.db.Model(&models.AIRecommendation{}). - Select("DATE(created_at) as date, COUNT(*) as count"). - Where("user_id = ? AND created_at >= NOW() - INTERVAL '30 days'", userID). - Group("DATE(created_at)"). - Order("date ASC"). - Scan(&stats.DailyStats) - - c.JSON(http.StatusOK, gin.H{ - "stats": stats, - "preferences": prefs, - }) -} - -// UpdatePreferences updates user recommendation preferences -func (h *AIRecommendationHandler) UpdatePreferences(c *gin.Context) { - userID := c.GetUint("user_id") - - var req struct { - EnableRecommendations bool `json:"enable_recommendations"` - ContentRecommendations bool `json:"content_recommendations"` - TaskRecommendations bool `json:"task_recommendations"` - LearningRecommendations bool `json:"learning_recommendations"` - ConnectionRecommendations bool `json:"connection_recommendations"` - MaxRecommendationsPerDay int `json:"max_recommendations_per_day"` - PreferredCategories []string `json:"preferred_categories"` - BlockedCategories []string `json:"blocked_categories"` - PreferredContentTypes []string `json:"preferred_content_types"` - MinConfidenceThreshold float64 `json:"min_confidence_threshold"` - MaxAgeHours int `json:"max_age_hours"` - EnablePersonalization bool `json:"enable_personalization"` - EnableFeedbackLearning bool `json:"enable_feedback_learning"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Update or create preferences - var prefs models.UserPreference - if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err != nil { - if err == gorm.ErrRecordNotFound { - prefs = models.UserPreference{UserID: userID} - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) - return - } - } - - // Update fields - prefs.EnableRecommendations = req.EnableRecommendations - prefs.ContentRecommendations = req.ContentRecommendations - prefs.TaskRecommendations = req.TaskRecommendations - prefs.LearningRecommendations = req.LearningRecommendations - prefs.ConnectionRecommendations = req.ConnectionRecommendations - prefs.MaxRecommendationsPerDay = req.MaxRecommendationsPerDay - prefs.PreferredCategories = req.PreferredCategories - prefs.BlockedCategories = req.BlockedCategories - prefs.PreferredContentTypes = req.PreferredContentTypes - prefs.MinConfidenceThreshold = req.MinConfidenceThreshold - prefs.MaxAgeHours = req.MaxAgeHours - prefs.EnablePersonalization = req.EnablePersonalization - prefs.EnableFeedbackLearning = req.EnableFeedbackLearning - - if err := h.db.Save(&prefs).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "Preferences updated successfully", - "preferences": prefs, - }) -} - -// RecordInteraction records user interaction with a recommendation -func (h *AIRecommendationHandler) RecordInteraction(c *gin.Context) { - userID := c.GetUint("user_id") - recommendationIDStr := c.Param("id") - - recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"}) - return - } - - var req struct { - InteractionType string `json:"interaction_type" binding:"required"` // click, dismiss, feedback, share - Context string `json:"context"` // dashboard, search, etc. - Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant - FeedbackText string `json:"feedback_text"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Record the interaction - if err := h.service.RecordInteraction(userID, uint(recommendationID), req.InteractionType, req.Context); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record interaction"}) - return - } - - // If feedback is provided, update the recommendation - if req.Feedback != "" { - var recommendation models.AIRecommendation - if err := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).First(&recommendation).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"}) - return - } - - recommendation.Feedback = req.Feedback - recommendation.FeedbackText = req.FeedbackText - now := time.Now() - recommendation.FeedbackAt = &now - - h.db.Save(&recommendation) - } - - c.JSON(http.StatusOK, gin.H{"message": "Interaction recorded successfully"}) -} - -// GetRecommendationHistory returns user's recommendation history -func (h *AIRecommendationHandler) GetRecommendationHistory(c *gin.Context) { - userID := c.GetUint("user_id") - - // Parse query parameters - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - recommendationType := c.Query("type") - status := c.Query("status") // clicked, dismissed, feedback - - // Build query - query := h.db.Model(&models.AIRecommendation{}).Where("user_id = ?", userID) - - if recommendationType != "" { - query = query.Where("recommendation_type = ?", recommendationType) - } - - if status == "clicked" { - query = query.Where("clicked = ?", true) - } else if status == "dismissed" { - query = query.Where("dismissed = ?", true) - } else if status == "feedback" { - query = query.Where("feedback != ''", userID) - } - - // Count total records - var total int64 - query.Count(&total) - - // Get paginated results - offset := (page - 1) * limit - var recommendations []models.AIRecommendation - query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&recommendations) - - c.JSON(http.StatusOK, gin.H{ - "recommendations": recommendations, - "pagination": gin.H{ - "page": page, - "limit": limit, - "total": total, - "pages": (total + int64(limit) - 1) / int64(limit), - }, - }) -} - -// DeleteRecommendation deletes a recommendation -func (h *AIRecommendationHandler) DeleteRecommendation(c *gin.Context) { - userID := c.GetUint("user_id") - recommendationIDStr := c.Param("id") - - recommendationID, err := strconv.ParseUint(recommendationIDStr, 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recommendation ID"}) - return - } - - // Delete the recommendation (only if it belongs to the user) - result := h.db.Where("id = ? AND user_id = ?", uint(recommendationID), userID).Delete(&models.AIRecommendation{}) - - if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Recommendation not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Recommendation deleted successfully"}) -} - -// GetInsights returns AI insights about user patterns -func (h *AIRecommendationHandler) GetInsights(c *gin.Context) { - userID := c.GetUint("user_id") - - var insights struct { - TopInterests []string `json:"top_interests"` - LearningPaths []string `json:"learning_paths"` - ProductivityTips []string `json:"productivity_tips"` - ConnectionSuggestions []string `json:"connection_suggestions"` - Patterns struct { - BestProductivityHours []string `json:"best_productivity_hours"` - PreferredContentTypes []string `json:"preferred_content_types"` - LearningStyle string `json:"learning_style"` - } `json:"patterns"` - } - - // Get user's top interests from bookmarks and tags - var interests []struct { - Tag string `json:"tag"` - Count int64 `json:"count"` - } - - h.db.Raw(` - SELECT unnest(string_to_array(tags, ',')) as tag, COUNT(*) as count - FROM bookmarks - WHERE user_id = ? AND tags != '' - GROUP BY tag - ORDER BY count DESC - LIMIT 10 - `, userID).Scan(&interests) - - for _, interest := range interests { - insights.TopInterests = append(insights.TopInterests, interest.Tag) - } - - // Get learning path suggestions - var learningPaths []struct { - Category string `json:"category"` - Count int64 `json:"count"` - } - - h.db.Raw(` - SELECT lp.category, COUNT(*) as count - FROM learning_paths lp - JOIN enrollments e ON lp.id = e.learning_path_id - WHERE e.user_id = ? AND e.progress < 100 - GROUP BY lp.category - ORDER BY count DESC - LIMIT 5 - `, userID).Scan(&learningPaths) - - for _, path := range learningPaths { - insights.LearningPaths = append(insights.LearningPaths, path.Category) - } - - // Generate productivity tips based on task patterns - insights.ProductivityTips = []string{ - "You complete most tasks in the morning - consider scheduling important work before noon", - "Tasks with deadlines are completed 80% faster - set more deadlines", - "You're most productive on Tuesdays and Wednesdays", - } - - // Generate connection suggestions - topInterest := "technology" - if len(insights.TopInterests) > 0 { - topInterest = insights.TopInterests[0] - } - - learningFocus := "productivity" - if len(insights.LearningPaths) > 0 { - learningFocus = insights.LearningPaths[0] - } - - insights.ConnectionSuggestions = []string{ - "Connect with users who share your interest in " + topInterest, - "Join communities focused on " + learningFocus, - } - - // Analyze patterns - insights.Patterns.BestProductivityHours = []string{"9:00 AM - 11:00 AM", "2:00 PM - 4:00 PM"} - insights.Patterns.PreferredContentTypes = []string{"bookmarks", "notes", "courses"} - insights.Patterns.LearningStyle = "Visual learner who prefers structured content" - - c.JSON(http.StatusOK, gin.H{"insights": insights}) -} diff --git a/backend/handlers/ai_settings.go b/backend/handlers/ai_settings.go deleted file mode 100644 index bae3ce3..0000000 --- a/backend/handlers/ai_settings.go +++ /dev/null @@ -1,388 +0,0 @@ -package handlers - -import ( - "net/http" - "os" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/trackeep/backend/models" -) - -// AISettings represents AI provider settings -type AISettings struct { - Mistral struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - Model string `json:"model"` - ModelThink string `json:"model_thinking"` - } `json:"mistral"` - - Grok struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - Model string `json:"model"` - ModelThink string `json:"model_thinking"` - } `json:"grok"` - - DeepSeek struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - Model string `json:"model"` - ModelThink string `json:"model_thinking"` - } `json:"deepseek"` - - Ollama struct { - Enabled bool `json:"enabled"` - BaseURL string `json:"base_url"` - Model string `json:"model"` - ModelThink string `json:"model_thinking"` - } `json:"ollama"` - - LongCat struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - OpenAIEndpoint string `json:"openai_endpoint"` - AnthropicEndpoint string `json:"anthropic_endpoint"` - Model string `json:"model"` - ModelThink string `json:"model_thinking"` - ModelThinkUpgraded string `json:"model_thinking_upgraded"` - Format string `json:"format"` - } `json:"longcat"` - - OpenRouter struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - Model string `json:"model"` - ModelThink string `json:"model_thinking"` - } `json:"openrouter"` -} - -// GetAISettings returns current AI settings (with API keys masked) -func GetAISettings(c *gin.Context) { - // Return settings based on environment variables - settings := getDefaultAISettings() - c.JSON(http.StatusOK, settings) -} - -// UpdateAISettings updates user's AI settings -func UpdateAISettings(c *gin.Context) { - // Check if demo mode is enabled - if os.Getenv("VITE_DEMO_MODE") == "true" { - c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully (demo mode)"}) - return - } - - userID := c.GetUint("user_id") - - var req AISettings - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get or create user settings - var userSettings models.UserAISettings - if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil { - // Create new settings - userSettings.UserID = userID - } - - // Update settings - userSettings.MistralEnabled = &req.Mistral.Enabled - if req.Mistral.APIKey != "" && !isMasked(req.Mistral.APIKey) { - userSettings.MistralAPIKey = req.Mistral.APIKey - } - userSettings.MistralModel = req.Mistral.Model - userSettings.MistralModelThinking = req.Mistral.ModelThink - - userSettings.GrokEnabled = &req.Grok.Enabled - if req.Grok.APIKey != "" && !isMasked(req.Grok.APIKey) { - userSettings.GrokAPIKey = req.Grok.APIKey - } - userSettings.GrokBaseURL = req.Grok.BaseURL - userSettings.GrokModel = req.Grok.Model - userSettings.GrokModelThinking = req.Grok.ModelThink - - userSettings.DeepSeekEnabled = &req.DeepSeek.Enabled - if req.DeepSeek.APIKey != "" && !isMasked(req.DeepSeek.APIKey) { - userSettings.DeepSeekAPIKey = req.DeepSeek.APIKey - } - userSettings.DeepSeekBaseURL = req.DeepSeek.BaseURL - userSettings.DeepSeekModel = req.DeepSeek.Model - userSettings.DeepSeekModelThinking = req.DeepSeek.ModelThink - - userSettings.OllamaEnabled = &req.Ollama.Enabled - userSettings.OllamaBaseURL = req.Ollama.BaseURL - userSettings.OllamaModel = req.Ollama.Model - userSettings.OllamaModelThinking = req.Ollama.ModelThink - - userSettings.LongCatEnabled = &req.LongCat.Enabled - if req.LongCat.APIKey != "" && !isMasked(req.LongCat.APIKey) { - userSettings.LongCatAPIKey = req.LongCat.APIKey - } - userSettings.LongCatBaseURL = req.LongCat.BaseURL - userSettings.LongCatOpenAIEndpoint = req.LongCat.OpenAIEndpoint - userSettings.LongCatAnthropicEndpoint = req.LongCat.AnthropicEndpoint - userSettings.LongCatModel = req.LongCat.Model - userSettings.LongCatModelThinking = req.LongCat.ModelThink - userSettings.LongCatModelThinkingUpgraded = req.LongCat.ModelThinkUpgraded - userSettings.LongCatFormat = req.LongCat.Format - - userSettings.OpenRouterEnabled = &req.OpenRouter.Enabled - if req.OpenRouter.APIKey != "" && !isMasked(req.OpenRouter.APIKey) { - userSettings.OpenRouterAPIKey = req.OpenRouter.APIKey - } - userSettings.OpenRouterBaseURL = req.OpenRouter.BaseURL - userSettings.OpenRouterModel = req.OpenRouter.Model - userSettings.OpenRouterModelThinking = req.OpenRouter.ModelThink - - // Save to database - if userSettings.ID == 0 { - if err := models.DB.Create(&userSettings).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) - return - } - } else { - if err := models.DB.Save(&userSettings).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"}) - return - } - } - - c.JSON(http.StatusOK, gin.H{"message": "AI settings updated successfully"}) -} - -// TestAIConnection tests connection to AI provider -func TestAIConnection(c *gin.Context) { - // Check if demo mode is enabled - if os.Getenv("VITE_DEMO_MODE") == "true" { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Connection test successful (demo mode)", - }) - return - } - - userID := c.GetUint("user_id") - - provider := c.Query("provider") - if provider == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Provider is required"}) - return - } - - // Get user's settings for this provider - var userSettings models.UserAISettings - if err := models.DB.Where("user_id = ?", userID).First(&userSettings).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "AI settings not found"}) - return - } - - // Test connection based on provider - var success bool - var message string - - switch provider { - case "mistral": - if userSettings.MistralAPIKey == "" { - success = false - message = "Mistral API key not configured" - } else { - // TODO: Implement actual connection test - success = true - message = "Mistral connection test successful" - } - case "grok": - if userSettings.GrokAPIKey == "" { - success = false - message = "Grok API key not configured" - } else { - // TODO: Implement actual connection test - success = true - message = "Grok connection test successful" - } - case "deepseek": - if userSettings.DeepSeekAPIKey == "" { - success = false - message = "DeepSeek API key not configured" - } else { - // TODO: Implement actual connection test - success = true - message = "DeepSeek connection test successful" - } - case "longcat": - if userSettings.LongCatAPIKey == "" { - success = false - message = "LongCat API key not configured" - } else { - // TODO: Implement actual connection test - success = true - message = "LongCat connection test successful" - } - case "ollama": - if userSettings.OllamaBaseURL == "" { - success = false - message = "Ollama base URL not configured" - } else { - // TODO: Implement actual connection test - success = true - message = "Ollama connection test successful" - } - case "openrouter": - if userSettings.OpenRouterAPIKey == "" { - success = false - message = "OpenRouter API key not configured" - } else { - // TODO: Implement actual connection test - success = true - message = "OpenRouter connection test successful" - } - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown provider"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": success, - "message": message, - }) -} - -// Helper functions -func getDefaultAISettings() AISettings { - settings := AISettings{} - - // Simple approach - just set basic values without any complex logic - settings.Mistral.Enabled = false - settings.Mistral.APIKey = "" - settings.Mistral.Model = "" - settings.Mistral.ModelThink = "" - - settings.Grok.Enabled = false - settings.Grok.APIKey = "" - settings.Grok.BaseURL = "" - settings.Grok.Model = "" - settings.Grok.ModelThink = "" - - settings.DeepSeek.Enabled = false - settings.DeepSeek.APIKey = "" - settings.DeepSeek.BaseURL = "" - settings.DeepSeek.Model = "" - settings.DeepSeek.ModelThink = "" - - settings.Ollama.Enabled = false - settings.Ollama.BaseURL = "" - settings.Ollama.Model = "" - settings.Ollama.ModelThink = "" - - settings.LongCat.Enabled = false - settings.LongCat.APIKey = "" - settings.LongCat.BaseURL = "" - settings.LongCat.OpenAIEndpoint = "" - settings.LongCat.AnthropicEndpoint = "" - settings.LongCat.Model = "" - settings.LongCat.ModelThink = "" - settings.LongCat.ModelThinkUpgraded = "" - settings.LongCat.Format = "" - - settings.OpenRouter.Enabled = false - settings.OpenRouter.APIKey = "" - settings.OpenRouter.BaseURL = "" - settings.OpenRouter.Model = "" - settings.OpenRouter.ModelThink = "" - - // Read environment variables to determine enabled providers - // This works in both demo and production mode - if os.Getenv("MISTRAL_ON") == "true" { - settings.Mistral.Enabled = true - } - if os.Getenv("MISTRAL_API_KEY") != "" { - settings.Mistral.APIKey = "********" - } - settings.Mistral.Model = os.Getenv("MISTRAL_MODEL") - settings.Mistral.ModelThink = os.Getenv("MISTRAL_MODEL_THINKING") - - if os.Getenv("LONGCAT_ON") == "true" { - settings.LongCat.Enabled = true - } - if os.Getenv("LONGCAT_API_KEY") != "" { - settings.LongCat.APIKey = "********" - } - settings.LongCat.BaseURL = os.Getenv("LONGCAT_BASE_URL") - settings.LongCat.OpenAIEndpoint = os.Getenv("LONGCAT_OPENAI_ENDPOINT") - settings.LongCat.AnthropicEndpoint = os.Getenv("LONGCAT_ANTHROPIC_ENDPOINT") - settings.LongCat.Model = os.Getenv("LONGCAT_MODEL") - settings.LongCat.ModelThink = os.Getenv("LONGCAT_MODEL_THINKING") - settings.LongCat.ModelThinkUpgraded = os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED") - settings.LongCat.Format = os.Getenv("LONGCAT_FORMAT") - - if os.Getenv("GROK_ON") == "true" { - settings.Grok.Enabled = true - } - if os.Getenv("GROK_API_KEY") != "" { - settings.Grok.APIKey = "********" - } - settings.Grok.BaseURL = os.Getenv("GROK_BASE_URL") - settings.Grok.Model = os.Getenv("GROK_MODEL") - settings.Grok.ModelThink = os.Getenv("GROK_MODEL_THINKING") - - if os.Getenv("DEEPSEEK_ON") == "true" { - settings.DeepSeek.Enabled = true - } - if os.Getenv("DEEPSEEK_API_KEY") != "" { - settings.DeepSeek.APIKey = "********" - } - settings.DeepSeek.BaseURL = os.Getenv("DEEPSEEK_BASE_URL") - settings.DeepSeek.Model = os.Getenv("DEEPSEEK_MODEL") - settings.DeepSeek.ModelThink = os.Getenv("DEEPSEEK_MODEL_THINKING") - - if os.Getenv("OLLAMA_ON") == "true" { - settings.Ollama.Enabled = true - } - settings.Ollama.BaseURL = os.Getenv("OLLAMA_BASE_URL") - settings.Ollama.Model = os.Getenv("OLLAMA_MODEL") - settings.Ollama.ModelThink = os.Getenv("OLLAMA_MODEL_THINKING") - - if os.Getenv("OPENROUTER_ON") == "true" { - settings.OpenRouter.Enabled = true - } - if os.Getenv("OPENROUTER_API_KEY") != "" { - settings.OpenRouter.APIKey = "********" - } - settings.OpenRouter.BaseURL = os.Getenv("OPENROUTER_BASE_URL") - settings.OpenRouter.Model = os.Getenv("OPENROUTER_MODEL") - settings.OpenRouter.ModelThink = os.Getenv("OPENROUTER_MODEL_THINKING") - - return settings -} - -func maskAPIKey(key string) string { - if key == "" { - return "" - } - if len(key) <= 8 { - return "********" - } - return key[:4] + "********" + key[len(key)-4:] -} - -func isMasked(key string) bool { - return key == "" || (len(key) > 8 && key[4:12] == "********") -} - -func getBoolEnv(key string, defaultValue bool) bool { - value := os.Getenv(key) - if value == "" { - return defaultValue - } - boolValue, err := strconv.ParseBool(value) - if err != nil { - return defaultValue - } - return boolValue -} diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 2cf7ffe..f898947 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -392,7 +392,7 @@ func Register(c *gin.Context) { } // Provision messaging defaults (self chat, password vault, global channels). - _ = ensureMessagingDefaults(db, user.ID) + // Generate JWT token token, err := GenerateJWT(user) diff --git a/backend/handlers/chat.go b/backend/handlers/chat.go deleted file mode 100644 index 9be7dc1..0000000 --- a/backend/handlers/chat.go +++ /dev/null @@ -1,360 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/trackeep/backend/models" - "github.com/trackeep/backend/services" -) - -// MistralConfig holds configuration for Mistral AI -type MistralConfig struct { - APIKey string - BaseURL string - Model string - MaxTokens int - Temperature float64 -} - -// ChatRequest represents a chat message request -type ChatRequest struct { - Message string `json:"message" binding:"required"` - SessionID *string `json:"session_id,omitempty"` - Context map[string]bool `json:"context,omitempty"` // what data to include - Provider string `json:"provider,omitempty"` // "mistral", "longcat", "grok", "deepseek", "ollama", "openrouter" - ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking" -} - -// ChatResponse represents a chat response -type ChatResponse struct { - ID string `json:"id"` - Message string `json:"message"` - Role string `json:"role"` - SessionID string `json:"session_id"` - Timestamp time.Time `json:"timestamp"` - Model string `json:"model"` - TokenUsage TokenUsage `json:"token_usage"` - ContextUsed []string `json:"context_used"` -} - -// TokenUsage represents token usage information -type TokenUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -// MistralMessage represents a message for Mistral API -type MistralMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -// MistralRequest represents a request to Mistral API -type MistralRequest struct { - Model string `json:"model"` - Messages []MistralMessage `json:"messages"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature float64 `json:"temperature,omitempty"` -} - -// MistralResponse represents a response from Mistral API -type MistralResponse struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - Model string `json:"model"` - Choices []struct { - Index int `json:"index"` - Message struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - } `json:"usage"` -} - -var mistralConfig = MistralConfig{ - APIKey: os.Getenv("MISTRAL_API_KEY"), - BaseURL: "https://api.mistral.ai/v1", - Model: "mistral-small-latest", // Cheap and capable model - MaxTokens: 4000, - Temperature: 0.7, -} - -// GetMistralConfig returns current Mistral configuration -func GetMistralConfig() MistralConfig { - return mistralConfig -} - -// SendMessage handles chat message requests -func SendMessage(c *gin.Context) { - userID := c.GetUint("user_id") - - var req ChatRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get or create session - var session models.ChatSession - if req.SessionID != nil { - if err := models.DB.Where("id = ? AND user_id = ?", *req.SessionID, userID).First(&session).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - } else { - // Create new session - session = models.ChatSession{ - UserID: userID, - Title: fmt.Sprintf("Chat %s", time.Now().Format("Jan 2, 3:04 PM")), - IncludeBookmarks: true, - IncludeTasks: true, - IncludeFiles: true, - IncludeNotes: true, - } - if req.Context != nil { - session.IncludeBookmarks = req.Context["bookmarks"] - session.IncludeTasks = req.Context["tasks"] - session.IncludeFiles = req.Context["files"] - session.IncludeNotes = req.Context["notes"] - } - if err := models.DB.Create(&session).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) - return - } - } - - // Save user message - userMessage := models.ChatMessage{ - UserID: userID, - SessionID: strconv.Itoa(int(session.ID)), - Content: req.Message, - Role: "user", - } - if err := models.DB.Create(&userMessage).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"}) - return - } - - // Get conversation history - var messages []models.ChatMessage - models.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&messages) - - // Build context from user data - contextData, err := buildUserContext(userID, session) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build context"}) - return - } - - // Build messages for AI provider (system + history) - aiMessages := []services.Message{ - { - Role: "system", - Content: buildSystemPrompt(contextData), - }, - } - - // Add conversation history (limit to last 10 messages to manage token count) - startIdx := 0 - if len(messages) > 11 { // system + 10 messages - startIdx = len(messages) - 10 - } - for i := startIdx; i < len(messages); i++ { - aiMessages = append(aiMessages, services.Message{ - Role: messages[i].Role, - Content: messages[i].Content, - }) - } - - // Determine AI provider - aiProvider := services.ProviderMistral - switch req.Provider { - case "longcat": - aiProvider = services.ProviderLongCat - case "grok": - aiProvider = services.ProviderGrok - case "deepseek": - aiProvider = services.ProviderDeepSeek - case "ollama": - aiProvider = services.ProviderOllama - case "openrouter": - aiProvider = services.ProviderOpenRouter - } - - aiService := services.NewAIService(aiProvider) - aiReq := services.AIRequest{ - Messages: aiMessages, - MaxTokens: 2000, - Temperature: 0.7, - ModelType: req.ModelType, - } - - // Call AI provider - startTime := time.Now() - var aiResp *services.AIResponse - switch req.ModelType { - case "thinking": - aiResp, err = aiService.ChatCompletionWithThinking(aiReq) - case "upgraded_thinking": - aiResp, err = aiService.ChatCompletionWithUpgradedThinking(aiReq) - default: - aiResp, err = aiService.ChatCompletion(aiReq) - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to call AI: " + err.Error()}) - return - } - processingMs := time.Since(startTime).Milliseconds() - - if len(aiResp.Choices) == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "No response from AI"}) - return - } - - // Extract assistant response, handling thinking models where needed - assistantContent := services.ParseThinkingResponse(aiResp, aiProvider, req.ModelType) - - // Save assistant message - assistantMessage := models.ChatMessage{ - UserID: userID, - SessionID: strconv.Itoa(int(session.ID)), - Content: assistantContent, - Role: "assistant", - ModelUsed: aiResp.Model, - TokenCount: aiResp.Usage.TotalTokens, - ProcessingMs: processingMs, - ContextItems: getContextItemIDs(contextData), - } - if err := models.DB.Create(&assistantMessage).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save response"}) - return - } - - // Update session - session.MessageCount = len(messages) + 1 - now := time.Now() - session.LastMessageAt = &now - models.DB.Save(&session) - - // Return response - response := ChatResponse{ - ID: aiResp.ID, - Message: assistantContent, - Role: "assistant", - SessionID: strconv.Itoa(int(session.ID)), - Timestamp: time.Now(), - Model: aiResp.Model, - TokenUsage: TokenUsage{ - PromptTokens: aiResp.Usage.PromptTokens, - CompletionTokens: aiResp.Usage.CompletionTokens, - TotalTokens: aiResp.Usage.TotalTokens, - }, - ContextUsed: getContextItemIDs(contextData), - } - - c.JSON(http.StatusOK, response) -} - -// GetSessions retrieves user's chat sessions -func GetSessions(c *gin.Context) { - userID := c.GetUint("user_id") - - var sessions []models.ChatSession - models.DB.Where("user_id = ?", userID).Order("updated_at desc").Find(&sessions) - - c.JSON(http.StatusOK, sessions) -} - -// GetSessionMessages retrieves messages for a specific session -func GetSessionMessages(c *gin.Context) { - userID := c.GetUint("user_id") - sessionID := c.Param("id") - - // Verify session belongs to user - var session models.ChatSession - if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - - var messages []models.ChatMessage - models.DB.Where("session_id = ?", sessionID).Order("created_at asc").Find(&messages) - - c.JSON(http.StatusOK, messages) -} - -// DeleteSession deletes a chat session and its messages -func DeleteSession(c *gin.Context) { - userID := c.GetUint("user_id") - sessionID := c.Param("id") - - // Verify session belongs to user - var session models.ChatSession - if err := models.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - - // Delete messages first - models.DB.Where("session_id = ?", sessionID).Delete(&models.ChatMessage{}) - - // Delete session - models.DB.Delete(&session) - - c.JSON(http.StatusOK, gin.H{"message": "Session deleted"}) -} - -func callMistral(messages []MistralMessage) (*MistralResponse, error) { - reqBody := MistralRequest{ - Model: mistralConfig.Model, - Messages: messages, - MaxTokens: mistralConfig.MaxTokens, - Temperature: mistralConfig.Temperature, - } - - jsonData, err := json.Marshal(reqBody) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", mistralConfig.BaseURL+"/chat/completions", strings.NewReader(string(jsonData))) - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+mistralConfig.APIKey) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode) - } - - var mistralResp MistralResponse - if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil { - return nil, err - } - - return &mistralResp, nil -} diff --git a/backend/handlers/chat_context.go b/backend/handlers/chat_context.go deleted file mode 100644 index cb29972..0000000 --- a/backend/handlers/chat_context.go +++ /dev/null @@ -1,181 +0,0 @@ -package handlers - -import ( - "fmt" - "strconv" - - "github.com/trackeep/backend/models" -) - -// UserContext represents the contextual data available to the AI -type UserContext struct { - Bookmarks []models.Bookmark - Tasks []models.Task - Files []models.File - Notes []models.Note -} - -// buildUserContext gathers user data based on session configuration -func buildUserContext(userID uint, session models.ChatSession) (*UserContext, error) { - context := &UserContext{} - - // Get bookmarks - if session.IncludeBookmarks { - var bookmarks []models.Bookmark - models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&bookmarks) - context.Bookmarks = bookmarks - } - - // Get tasks - if session.IncludeTasks { - var tasks []models.Task - models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&tasks) - context.Tasks = tasks - } - - // Get files - if session.IncludeFiles { - var files []models.File - models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(&files) - context.Files = files - } - - // Get notes - if session.IncludeNotes { - var notes []models.Note - models.DB.Where("user_id = ?", userID).Limit(20).Order("updated_at desc").Find(¬es) - context.Notes = notes - } - - return context, nil -} - -// buildSystemPrompt creates a system prompt with user context -func buildSystemPrompt(context *UserContext) string { - prompt := `You are a helpful AI assistant for Trackeep, a personal productivity and knowledge management platform. -You have access to the user's personal data including bookmarks, tasks, files, and notes. -Your role is to help them organize, find information, and manage their digital life effectively. - -Key capabilities: -- Help find specific bookmarks, tasks, or notes -- Suggest organization strategies -- Answer questions about their saved content -- Help with task planning and prioritization -- Assist with learning progress tracking - -Be helpful, concise, and actionable. If you reference specific items, mention their titles or key details. - ---- USER DATA ---` - - // Add bookmarks context - if len(context.Bookmarks) > 0 { - prompt += "\n\nBOOKMARKS:\n" - for i, bookmark := range context.Bookmarks { - if i >= 10 { // Limit to prevent token overflow - prompt += "... and " + strconv.Itoa(len(context.Bookmarks)-10) + " more bookmarks\n" - break - } - prompt += fmt.Sprintf("- %s: %s", bookmark.Title, bookmark.URL) - if bookmark.Description != "" { - prompt += " (" + bookmark.Description + ")" - } - if bookmark.IsFavorite { - prompt += " ⭐" - } - prompt += "\n" - } - } - - // Add tasks context - if len(context.Tasks) > 0 { - prompt += "\n\nTASKS:\n" - for i, task := range context.Tasks { - if i >= 10 { - prompt += "... and " + strconv.Itoa(len(context.Tasks)-10) + " more tasks\n" - break - } - status := string(task.Status) - priority := string(task.Priority) - prompt += fmt.Sprintf("- [%s] %s (Priority: %s)", status, task.Title, priority) - if task.DueDate != nil { - prompt += " Due: " + task.DueDate.Format("Jan 2") - } - prompt += "\n" - } - } - - // Add files context - if len(context.Files) > 0 { - prompt += "\n\nFILES:\n" - for i, file := range context.Files { - if i >= 10 { - prompt += "... and " + strconv.Itoa(len(context.Files)-10) + " more files\n" - break - } - prompt += fmt.Sprintf("- %s (%s, %s)", file.OriginalName, file.FileType, formatFileSize(file.FileSize)) - if file.Description != "" { - prompt += " - " + file.Description - } - prompt += "\n" - } - } - - // Add notes context - if len(context.Notes) > 0 { - prompt += "\n\nNOTES:\n" - for i, note := range context.Notes { - if i >= 10 { - prompt += "... and " + strconv.Itoa(len(context.Notes)-10) + " more notes\n" - break - } - prompt += fmt.Sprintf("- %s", note.Title) - if note.Description != "" { - prompt += " - " + note.Description - } - if note.IsPinned { - prompt += " 📌" - } - prompt += "\n" - } - } - - prompt += "\n--- END USER DATA ---\n\nNow respond to the user's message based on this context." - return prompt -} - -// getContextItemIDs extracts IDs from context for tracking -func getContextItemIDs(context *UserContext) []string { - var ids []string - - for _, bookmark := range context.Bookmarks { - ids = append(ids, "bookmark:"+strconv.Itoa(int(bookmark.ID))) - } - - for _, task := range context.Tasks { - ids = append(ids, "task:"+strconv.Itoa(int(task.ID))) - } - - for _, file := range context.Files { - ids = append(ids, "file:"+strconv.Itoa(int(file.ID))) - } - - for _, note := range context.Notes { - ids = append(ids, "note:"+strconv.Itoa(int(note.ID))) - } - - return ids -} - -// formatFileSize formats file size in human readable format -func formatFileSize(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} diff --git a/backend/handlers/messages.go b/backend/handlers/messages.go deleted file mode 100644 index c528bd7..0000000 --- a/backend/handlers/messages.go +++ /dev/null @@ -1,2288 +0,0 @@ -package handlers - -import ( - "encoding/json" - "errors" - "net/http" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/gorilla/websocket" - "github.com/trackeep/backend/models" - "github.com/trackeep/backend/services" - "github.com/trackeep/backend/utils" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -var messageURLRegex = regexp.MustCompile(`https?://[^\s]+`) - -var messagesWSUpgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return true - }, -} - -type CreateConversationRequest struct { - Type string `json:"type" binding:"required"` - Name string `json:"name"` - Topic string `json:"topic"` - TeamID *uint `json:"team_id"` - UserIDs []uint `json:"user_ids"` -} - -type AddConversationMemberRequest struct { - UserID uint `json:"user_id" binding:"required"` - Role string `json:"role"` -} - -type AttachmentInput struct { - Kind string `json:"kind"` - FileID *uint `json:"file_id"` - URL string `json:"url"` - Title string `json:"title"` -} - -type ReferenceInput struct { - EntityType string `json:"entity_type"` - EntityID uint `json:"entity_id"` - DeepLink string `json:"deep_link"` -} - -type CreateMessageRequest struct { - Body string `json:"body"` - Attachments []AttachmentInput `json:"attachments"` - Metadata map[string]interface{} `json:"metadata"` - References []ReferenceInput `json:"references"` -} - -type UpdateMessageRequest struct { - Body string `json:"body" binding:"required"` -} - -type CreateReactionRequest struct { - Emoji string `json:"emoji" binding:"required"` -} - -type MessageSearchRequest struct { - Query string `json:"query"` - ConversationIDs []uint `json:"conversation_ids"` - SenderID *uint `json:"sender_id"` - DateFrom *time.Time `json:"date_from"` - DateTo *time.Time `json:"date_to"` - AttachmentKinds []string `json:"attachment_kinds"` - ReferenceTypes []string `json:"reference_types"` - HasLinks *bool `json:"has_links"` - HasAttachments *bool `json:"has_attachments"` - HasSuggestions *bool `json:"has_suggestions"` - MentionOnly bool `json:"mention_only"` - Limit int `json:"limit"` - Offset int `json:"offset"` -} - -type SuggestionActionRequest struct { - RedactOriginal bool `json:"redact_original"` -} - -type CreateVaultItemRequest struct { - Label string `json:"label" binding:"required"` - Secret string `json:"secret" binding:"required"` - Notes string `json:"notes"` - SourceMessageID *uint `json:"source_message_id"` -} - -type ShareVaultItemRequest struct { - TargetConversationID uint `json:"target_conversation_id" binding:"required"` - ExpiresAt *time.Time `json:"expires_at"` - AllowReveal bool `json:"allow_reveal"` -} - -type UnshareVaultItemRequest struct { - TargetConversationID uint `json:"target_conversation_id"` -} - -type conversationListItem struct { - Conversation models.Conversation `json:"conversation"` - Role string `json:"role"` - UnreadCount int64 `json:"unread_count"` - LastMessage *models.Message `json:"last_message,omitempty"` -} - -// GetConversations lists all conversations for the current user. -func GetConversations(c *gin.Context) { - userID := getAuthUserID(c) - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - if err := ensureMessagingDefaults(models.DB, userID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize messaging defaults"}) - return - } - - var conversations []models.Conversation - if err := models.DB. - Joins("JOIN conversation_members cm ON cm.conversation_id = conversations.id"). - Where("cm.user_id = ? AND cm.deleted_at IS NULL AND cm.is_hidden = false", userID). - Preload("Members"). - Order("COALESCE(conversations.last_message_at, conversations.updated_at) DESC"). - Find(&conversations).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch conversations"}) - return - } - - items := make([]conversationListItem, 0, len(conversations)) - for _, conv := range conversations { - var membership models.ConversationMember - if err := models.DB.Where("conversation_id = ? AND user_id = ?", conv.ID, userID).First(&membership).Error; err != nil { - continue - } - - var unreadCount int64 - unreadQuery := models.DB.Model(&models.Message{}). - Where("conversation_id = ? AND deleted_at IS NULL AND sender_id <> ?", conv.ID, userID) - if membership.LastReadMessageID != nil { - unreadQuery = unreadQuery.Where("id > ?", *membership.LastReadMessageID) - } - unreadQuery.Count(&unreadCount) - - var lastMessage models.Message - var lastMessagePtr *models.Message - if err := models.DB.Where("conversation_id = ? AND deleted_at IS NULL", conv.ID). - Order("id DESC").Limit(1). - Preload("Sender"). - First(&lastMessage).Error; err == nil { - lastMessagePtr = &lastMessage - } - - items = append(items, conversationListItem{ - Conversation: conv, - Role: string(membership.Role), - UnreadCount: unreadCount, - LastMessage: lastMessagePtr, - }) - } - - c.JSON(http.StatusOK, gin.H{"conversations": items}) -} - -// CreateConversation creates a new conversation (dm/group/team). -func CreateConversation(c *gin.Context) { - userID := getAuthUserID(c) - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - var req CreateConversationRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - convType := models.ConversationType(req.Type) - switch convType { - case models.ConversationTypeDM, models.ConversationTypeGroup, models.ConversationTypeTeam: - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Only dm, group, and team conversations can be created explicitly"}) - return - } - - if err := ensureMessagingDefaults(models.DB, userID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize messaging defaults"}) - return - } - - // Direct message conversations are unique per user pair. - if convType == models.ConversationTypeDM { - if len(req.UserIDs) != 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "DM conversation requires exactly one target user_id"}) - return - } - - targetUserID := req.UserIDs[0] - if targetUserID == userID { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot create DM with yourself; use self conversation"}) - return - } - - var target models.User - if err := models.DB.First(&target, targetUserID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Target user not found"}) - return - } - - if existing := findExistingDM(models.DB, userID, targetUserID); existing != nil { - c.JSON(http.StatusOK, gin.H{"conversation": existing}) - return - } - - conv := models.Conversation{ - Type: models.ConversationTypeDM, - Name: req.Name, - Topic: req.Topic, - CreatedBy: userID, - } - if conv.Name == "" { - conv.Name = "Direct Message" - } - - if err := models.DB.Create(&conv).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create conversation"}) - return - } - - members := []models.ConversationMember{ - { - ConversationID: conv.ID, - UserID: userID, - Role: models.ConversationMemberRoleMember, - JoinedAt: time.Now(), - }, - { - ConversationID: conv.ID, - UserID: targetUserID, - Role: models.ConversationMemberRoleMember, - JoinedAt: time.Now(), - }, - } - if err := models.DB.Create(&members).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add members"}) - return - } - - models.DB.Preload("Members").First(&conv, conv.ID) - c.JSON(http.StatusCreated, gin.H{"conversation": conv}) - return - } - - if strings.TrimSpace(req.Name) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Conversation name is required"}) - return - } - - conv := models.Conversation{ - Type: convType, - Name: strings.TrimSpace(req.Name), - Topic: req.Topic, - CreatedBy: userID, - } - - memberIDs := make(map[uint]struct{}) - memberIDs[userID] = struct{}{} - - if convType == models.ConversationTypeTeam { - if req.TeamID == nil || *req.TeamID == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "team_id is required for team conversations"}) - return - } - - var teamMemberCount int64 - models.DB.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", *req.TeamID, userID).Count(&teamMemberCount) - if teamMemberCount == 0 { - c.JSON(http.StatusForbidden, gin.H{"error": "You must be a team member to create a team conversation"}) - return - } - - conv.TeamID = req.TeamID - - var teamMembers []models.TeamMember - models.DB.Where("team_id = ?", *req.TeamID).Find(&teamMembers) - for _, tm := range teamMembers { - memberIDs[tm.UserID] = struct{}{} - } - } else { - for _, uid := range req.UserIDs { - if uid == 0 { - continue - } - memberIDs[uid] = struct{}{} - } - } - - memberSlice := make([]uint, 0, len(memberIDs)) - for uid := range memberIDs { - memberSlice = append(memberSlice, uid) - } - if !usersExist(models.DB, memberSlice) { - c.JSON(http.StatusBadRequest, gin.H{"error": "One or more users do not exist"}) - return - } - - if err := models.DB.Create(&conv).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create conversation"}) - return - } - - members := make([]models.ConversationMember, 0, len(memberSlice)) - for _, uid := range memberSlice { - role := models.ConversationMemberRoleMember - if uid == userID { - role = models.ConversationMemberRoleOwner - } - members = append(members, models.ConversationMember{ - ConversationID: conv.ID, - UserID: uid, - Role: role, - JoinedAt: time.Now(), - }) - } - if err := models.DB.Create(&members).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add conversation members"}) - return - } - - models.DB.Preload("Members").First(&conv, conv.ID) - services.GetMessagesHub().Broadcast(conv.ID, "conversation.updated", gin.H{"conversation_id": conv.ID}) - c.JSON(http.StatusCreated, gin.H{"conversation": conv}) -} - -// GetConversation retrieves a specific conversation if user has access. -func GetConversation(c *gin.Context) { - userID := getAuthUserID(c) - conversationID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) - return - } - - conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) - return - } - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - var members []models.ConversationMember - models.DB.Where("conversation_id = ?", conversationID). - Preload("User"). - Find(&members) - - c.JSON(http.StatusOK, gin.H{ - "conversation": conv, - "membership": member, - "members": members, - }) -} - -// UpdateConversation updates mutable conversation fields. -func UpdateConversation(c *gin.Context) { - userID := getAuthUserID(c) - conversationID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) - return - } - - conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) - return - } - if !isConversationAdmin(member.Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) - return - } - - if conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot mutate this conversation"}) - return - } - - var req struct { - Name *string `json:"name"` - Topic *string `json:"topic"` - IsArchived *bool `json:"is_archived"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - updates := map[string]interface{}{} - if req.Name != nil { - updates["name"] = strings.TrimSpace(*req.Name) - } - if req.Topic != nil { - updates["topic"] = *req.Topic - } - if req.IsArchived != nil { - updates["is_archived"] = *req.IsArchived - } - - if len(updates) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "No updates provided"}) - return - } - - if err := models.DB.Model(&conv).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update conversation"}) - return - } - - models.DB.First(&conv, conv.ID) - services.GetMessagesHub().Broadcast(conv.ID, "conversation.updated", gin.H{"conversation_id": conv.ID}) - c.JSON(http.StatusOK, gin.H{"conversation": conv}) -} - -// AddConversationMember adds a user to an existing conversation. -func AddConversationMember(c *gin.Context) { - userID := getAuthUserID(c) - conversationID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) - return - } - - conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) - return - } - - if !isConversationAdmin(member.Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) - return - } - if conv.Type == models.ConversationTypeDM || conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault || conv.Type == models.ConversationTypeGlobal { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot add members to this conversation type"}) - return - } - - var req AddConversationMemberRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var target models.User - if err := models.DB.First(&target, req.UserID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) - return - } - - if conv.Type == models.ConversationTypeTeam && conv.TeamID != nil { - var teamMemberCount int64 - models.DB.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", *conv.TeamID, req.UserID).Count(&teamMemberCount) - if teamMemberCount == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "User is not part of this team"}) - return - } - } - - role := models.ConversationMemberRoleMember - if req.Role != "" { - role = models.ConversationMemberRole(req.Role) - } - - memberRow := models.ConversationMember{ - ConversationID: conversationID, - UserID: req.UserID, - Role: role, - JoinedAt: time.Now(), - } - - if err := models.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&memberRow).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"}) - return - } - - services.GetMessagesHub().Broadcast(conversationID, "conversation.updated", gin.H{"conversation_id": conversationID}) - c.JSON(http.StatusOK, gin.H{"message": "Member added"}) -} - -// RemoveConversationMember removes a user from a conversation. -func RemoveConversationMember(c *gin.Context) { - userID := getAuthUserID(c) - conversationID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) - return - } - targetUserID, err := parseUintParam(c, "userId") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target user id"}) - return - } - - conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) - return - } - - if conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault || conv.Type == models.ConversationTypeGlobal { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot remove members from this conversation type"}) - return - } - - if targetUserID != userID && !isConversationAdmin(member.Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) - return - } - - if err := models.DB.Where("conversation_id = ? AND user_id = ?", conversationID, targetUserID).Delete(&models.ConversationMember{}).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove member"}) - return - } - - services.GetMessagesHub().Broadcast(conversationID, "conversation.updated", gin.H{"conversation_id": conversationID}) - c.JSON(http.StatusOK, gin.H{"message": "Member removed"}) -} - -// GetConversationMessages fetches messages with cursor-based pagination. -func GetConversationMessages(c *gin.Context) { - userID := getAuthUserID(c) - conversationID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) - return - } - - if _, member, err := getConversationWithMembership(models.DB, conversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } else if member.Role == models.ConversationMemberRoleViewer { - // Viewers can read messages. - } - - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) - if limit <= 0 { - limit = 50 - } - if limit > 100 { - limit = 100 - } - - cursor, _ := strconv.ParseUint(c.DefaultQuery("cursor", "0"), 10, 64) - - query := models.DB.Where("conversation_id = ?", conversationID). - Preload("Sender"). - Preload("Attachments"). - Preload("References"). - Preload("Suggestions"). - Preload("Reactions"). - Order("id DESC"). - Limit(limit) - - if cursor > 0 { - query = query.Where("id < ?", cursor) - } - - var messages []models.Message - if err := query.Find(&messages).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"}) - return - } - - // Reverse to ascending for timeline rendering. - for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { - messages[i], messages[j] = messages[j], messages[i] - } - - var nextCursor uint - if len(messages) > 0 { - nextCursor = messages[0].ID - } - - // Update read marker only when this is latest-page fetch. - if cursor == 0 && len(messages) > 0 { - lastID := messages[len(messages)-1].ID - now := time.Now() - models.DB.Model(&models.ConversationMember{}). - Where("conversation_id = ? AND user_id = ?", conversationID, userID). - Updates(map[string]interface{}{ - "last_read_message_id": lastID, - "last_read_at": &now, - }) - services.GetMessagesHub().Broadcast(conversationID, "read.updated", gin.H{ - "user_id": userID, - "conversation_id": conversationID, - "last_read_message_id": lastID, - }) - } - - c.JSON(http.StatusOK, gin.H{ - "messages": messages, - "next_cursor": nextCursor, - }) -} - -// CreateConversationMessage posts a new message to a conversation. -func CreateConversationMessage(c *gin.Context) { - userID := getAuthUserID(c) - conversationID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) - return - } - - conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) - if err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - if !canWriteMessage(member.Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "You do not have write access in this conversation"}) - return - } - if conv.IsArchived { - c.JSON(http.StatusBadRequest, gin.H{"error": "Conversation is archived"}) - return - } - - var req CreateMessageRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - trimmedBody := strings.TrimSpace(req.Body) - if trimmedBody == "" && len(req.Attachments) == 0 && len(req.References) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Message body, attachments, or references are required"}) - return - } - - attachmentRows := make([]models.MessageAttachment, 0, len(req.Attachments)) - for _, a := range req.Attachments { - attachmentRows = append(attachmentRows, models.MessageAttachment{ - Kind: normalizeAttachmentKind(a.Kind), - FileID: a.FileID, - URL: a.URL, - Title: a.Title, - }) - } - - referenceRows := make([]models.MessageReference, 0, len(req.References)) - for _, ref := range req.References { - entityType := normalizeReferenceType(ref.EntityType) - if entityType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reference entity_type"}) - return - } - if ref.EntityID == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reference entity_id"}) - return - } - deepLink := strings.TrimSpace(ref.DeepLink) - if deepLink == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reference deep_link"}) - return - } - if !isReferenceDeepLinkAllowed(deepLink) { - c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported reference deep_link"}) - return - } - if !canReferenceEntity(models.DB, userID, entityType, ref.EntityID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Reference target is not accessible"}) - return - } - referenceRows = append(referenceRows, models.MessageReference{ - EntityType: entityType, - EntityID: ref.EntityID, - DeepLink: deepLink, - }) - } - - suggestions, inferredAttachments, isSensitive := services.DetectMessageContent(trimmedBody) - for _, inferred := range inferredAttachments { - if hasAttachment(attachmentRows, inferred.Kind, inferred.URL) { - continue - } - previewJSON := "{}" - if raw, err := json.Marshal(inferred.PreviewMap); err == nil { - previewJSON = string(raw) - } - attachmentRows = append(attachmentRows, models.MessageAttachment{ - Kind: normalizeAttachmentKind(inferred.Kind), - URL: inferred.URL, - Title: inferred.Title, - PreviewJSON: previewJSON, - }) - } - - metadataMap := map[string]interface{}{} - for k, v := range req.Metadata { - metadataMap[k] = v - } - - storedBody := trimmedBody - if isSensitive && (conv.Type == models.ConversationTypeDM || conv.Type == models.ConversationTypeSelf) && trimmedBody != "" { - ciphertext, err := utils.Encrypt(trimmedBody) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt sensitive message"}) - return - } - storedBody = maskSensitiveBody(trimmedBody) - metadataMap["sensitive_payload"] = map[string]interface{}{ - "version": "v1", - "ciphertext": ciphertext, - "masked_body": storedBody, - "scope": string(conv.Type), - } - } - - metadataJSON := "{}" - if len(metadataMap) > 0 { - if raw, err := json.Marshal(metadataMap); err == nil { - metadataJSON = string(raw) - } - } - - message := models.Message{ - ConversationID: conversationID, - SenderID: userID, - Body: storedBody, - MetadataJSON: metadataJSON, - } - if err := models.DB.Create(&message).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create message"}) - return - } - - for i := range attachmentRows { - attachmentRows[i].MessageID = message.ID - } - if len(attachmentRows) > 0 { - models.DB.Create(&attachmentRows) - } - - for i := range referenceRows { - referenceRows[i].MessageID = message.ID - } - if len(referenceRows) > 0 { - models.DB.Create(&referenceRows) - } - - if len(suggestions) > 0 { - suggestionRows := make([]models.MessageSuggestion, 0, len(suggestions)) - for _, s := range suggestions { - payloadJSON := "{}" - if raw, err := json.Marshal(s.Payload); err == nil { - payloadJSON = string(raw) - } - suggestionRows = append(suggestionRows, models.MessageSuggestion{ - MessageID: message.ID, - Type: s.Type, - PayloadJSON: payloadJSON, - Status: models.SuggestionStatusPending, - }) - } - models.DB.Create(&suggestionRows) - } - - if isSensitive { - models.DB.Model(&message).Update("is_sensitive", true) - } - - now := time.Now() - models.DB.Model(&models.Conversation{}).Where("id = ?", conversationID).Update("last_message_at", &now) - - var freshMessage models.Message - models.DB. - Preload("Sender"). - Preload("Attachments"). - Preload("References"). - Preload("Suggestions"). - Preload("Reactions"). - First(&freshMessage, message.ID) - - services.GetMessagesHub().Broadcast(conversationID, "message.created", freshMessage) - - response := gin.H{"message": freshMessage} - if isSensitive { - response["warning"] = "Sensitive data detected. We recommend a dedicated password manager like Proton Pass (not affiliated)." - } - c.JSON(http.StatusCreated, response) -} - -// UpdateMessage edits an existing message. -func UpdateMessage(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - - var req UpdateMessageRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - - _, member, err := getConversationWithMembership(models.DB, msg.ConversationID, userID) - if err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - if msg.SenderID != userID && !isConversationAdmin(member.Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) - return - } - if msg.DeletedAt != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Deleted messages cannot be edited"}) - return - } - - now := time.Now() - if err := models.DB.Model(&msg).Updates(map[string]interface{}{ - "body": strings.TrimSpace(req.Body), - "edited_at": &now, - }).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update message"}) - return - } - - models.DB. - Preload("Sender"). - Preload("Attachments"). - Preload("References"). - Preload("Suggestions"). - Preload("Reactions"). - First(&msg, msg.ID) - - services.GetMessagesHub().Broadcast(msg.ConversationID, "message.updated", msg) - c.JSON(http.StatusOK, gin.H{"message": msg}) -} - -// DeleteMessage marks a message as deleted. -func DeleteMessage(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - - _, member, err := getConversationWithMembership(models.DB, msg.ConversationID, userID) - if err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - if msg.SenderID != userID && !isConversationAdmin(member.Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) - return - } - - now := time.Now() - if err := models.DB.Model(&msg).Updates(map[string]interface{}{ - "deleted_at": &now, - "body": "[deleted]", - "edited_at": &now, - }).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"}) - return - } - - services.GetMessagesHub().Broadcast(msg.ConversationID, "message.deleted", gin.H{ - "message_id": msg.ID, - "conversation_id": msg.ConversationID, - }) - c.JSON(http.StatusOK, gin.H{"message": "Message deleted"}) -} - -// AddMessageReaction adds an emoji reaction to a message. -func AddMessageReaction(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - - var req CreateReactionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - emoji := strings.TrimSpace(req.Emoji) - if emoji == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"}) - return - } - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - reaction := models.MessageReaction{ - MessageID: messageID, - UserID: userID, - Emoji: emoji, - } - if err := models.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&reaction).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reaction"}) - return - } - - models.DB.Where("message_id = ? AND user_id = ? AND emoji = ?", messageID, userID, emoji).First(&reaction) - services.GetMessagesHub().Broadcast(msg.ConversationID, "reaction.added", reaction) - c.JSON(http.StatusOK, gin.H{"reaction": reaction}) -} - -// RemoveMessageReaction removes the current user's reaction from a message. -func RemoveMessageReaction(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - emoji := strings.TrimSpace(c.Param("emoji")) - if emoji == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"}) - return - } - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - if err := models.DB.Where("message_id = ? AND user_id = ? AND emoji = ?", messageID, userID, emoji).Delete(&models.MessageReaction{}).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reaction"}) - return - } - - services.GetMessagesHub().Broadcast(msg.ConversationID, "reaction.removed", gin.H{ - "message_id": messageID, - "user_id": userID, - "emoji": emoji, - }) - c.JSON(http.StatusOK, gin.H{"message": "Reaction removed"}) -} - -// SearchMessages performs filtered search over messages (excluding password vault). -func SearchMessages(c *gin.Context) { - userID := getAuthUserID(c) - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - var req MessageSearchRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.Limit <= 0 { - req.Limit = 50 - } - if req.Limit > 100 { - req.Limit = 100 - } - if req.Offset < 0 { - req.Offset = 0 - } - - baseQuery := models.DB.Model(&models.Message{}). - Joins("JOIN conversations ON conversations.id = messages.conversation_id"). - Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id"). - Where("cm.user_id = ? AND cm.deleted_at IS NULL", userID). - Where("conversations.type <> ?", models.ConversationTypePasswordVault). - Where("messages.deleted_at IS NULL") - - if strings.TrimSpace(req.Query) != "" { - like := "%" + strings.ToLower(strings.TrimSpace(req.Query)) + "%" - baseQuery = baseQuery.Where("LOWER(messages.body) LIKE ?", like) - } - if len(req.ConversationIDs) > 0 { - baseQuery = baseQuery.Where("messages.conversation_id IN ?", req.ConversationIDs) - } - if req.SenderID != nil { - baseQuery = baseQuery.Where("messages.sender_id = ?", *req.SenderID) - } - if req.DateFrom != nil { - baseQuery = baseQuery.Where("messages.created_at >= ?", *req.DateFrom) - } - if req.DateTo != nil { - baseQuery = baseQuery.Where("messages.created_at <= ?", *req.DateTo) - } - if req.MentionOnly { - var user models.User - if err := models.DB.First(&user, userID).Error; err == nil { - mentionNeedle := "%@" + strings.ToLower(user.Username) + "%" - baseQuery = baseQuery.Where("LOWER(messages.body) LIKE ?", mentionNeedle) - } - } - - if req.HasAttachments != nil { - if *req.HasAttachments { - baseQuery = baseQuery.Joins("JOIN message_attachments ma_any ON ma_any.message_id = messages.id") - } else { - baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_attachments ma WHERE ma.message_id = messages.id)") - } - } - - if req.HasLinks != nil { - if *req.HasLinks { - baseQuery = baseQuery.Joins("JOIN message_attachments ma_links ON ma_links.message_id = messages.id AND ma_links.url <> ''") - } else { - baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_attachments ma WHERE ma.message_id = messages.id AND ma.url <> '')") - } - } - - if req.HasSuggestions != nil { - if *req.HasSuggestions { - baseQuery = baseQuery.Joins("JOIN message_suggestions ms_any ON ms_any.message_id = messages.id") - } else { - baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_suggestions ms WHERE ms.message_id = messages.id)") - } - } - - if len(req.AttachmentKinds) > 0 { - baseQuery = baseQuery.Joins("JOIN message_attachments ma_kind ON ma_kind.message_id = messages.id AND ma_kind.kind IN ?", req.AttachmentKinds) - } - - if len(req.ReferenceTypes) > 0 { - baseQuery = baseQuery.Joins("JOIN message_references mr_type ON mr_type.message_id = messages.id AND mr_type.entity_type IN ?", req.ReferenceTypes) - } - - var total int64 - if err := baseQuery.Session(&gorm.Session{}).Distinct("messages.id").Count(&total).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count messages"}) - return - } - - var messageIDs []uint - if err := baseQuery.Session(&gorm.Session{}). - Distinct("messages.id"). - Order("messages.created_at DESC"). - Offset(req.Offset). - Limit(req.Limit). - Pluck("messages.id", &messageIDs).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search messages"}) - return - } - - if len(messageIDs) == 0 { - c.JSON(http.StatusOK, gin.H{ - "results": []models.Message{}, - "total": total, - "limit": req.Limit, - "offset": req.Offset, - }) - return - } - - var messages []models.Message - if err := models.DB. - Where("id IN ?", messageIDs). - Preload("Sender"). - Preload("Attachments"). - Preload("References"). - Preload("Suggestions"). - Preload("Reactions"). - Find(&messages).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search messages"}) - return - } - - order := make(map[uint]int, len(messageIDs)) - for i, id := range messageIDs { - order[id] = i - } - sort.Slice(messages, func(i, j int) bool { - return order[messages[i].ID] < order[messages[j].ID] - }) - - c.JSON(http.StatusOK, gin.H{ - "results": messages, - "total": total, - "limit": req.Limit, - "offset": req.Offset, - }) -} - -// GetMessageSuggestions returns suggestions for a specific message. -func GetMessageSuggestions(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - var suggestions []models.MessageSuggestion - if err := models.DB.Where("message_id = ?", messageID).Order("id ASC").Find(&suggestions).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch suggestions"}) - return - } - c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) -} - -// AcceptMessageSuggestion applies a suggestion action and marks it accepted. -func AcceptMessageSuggestion(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - suggestionID, err := parseUintParam(c, "suggestionId") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid suggestion id"}) - return - } - - var req SuggestionActionRequest - _ = c.ShouldBindJSON(&req) - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - var suggestion models.MessageSuggestion - if err := models.DB.Where("id = ? AND message_id = ?", suggestionID, messageID).First(&suggestion).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) - return - } - - if suggestion.Status != models.SuggestionStatusPending { - c.JSON(http.StatusBadRequest, gin.H{"error": "Suggestion is not pending"}) - return - } - - result, err := applySuggestionAction(models.DB, userID, &msg, &suggestion, req) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - suggestion.Status = models.SuggestionStatusAccepted - models.DB.Save(&suggestion) - - services.GetMessagesHub().Broadcast(msg.ConversationID, "conversation.updated", gin.H{ - "message_id": msg.ID, - "suggestion_id": suggestion.ID, - "status": suggestion.Status, - }) - - c.JSON(http.StatusOK, gin.H{ - "message": "Suggestion accepted", - "result": result, - "suggestion": suggestion, - }) -} - -// DismissMessageSuggestion marks a suggestion dismissed. -func DismissMessageSuggestion(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - suggestionID, err := parseUintParam(c, "suggestionId") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid suggestion id"}) - return - } - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - var suggestion models.MessageSuggestion - if err := models.DB.Where("id = ? AND message_id = ?", suggestionID, messageID).First(&suggestion).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) - return - } - - suggestion.Status = models.SuggestionStatusDismissed - if err := models.DB.Save(&suggestion).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to dismiss suggestion"}) - return - } - - c.JSON(http.StatusOK, gin.H{"suggestion": suggestion}) -} - -// RevealSensitiveMessage decrypts and returns sensitive message plaintext for authorized members. -func RevealSensitiveMessage(c *gin.Context) { - userID := getAuthUserID(c) - messageID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) - return - } - - var msg models.Message - if err := models.DB.First(&msg, messageID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) - return - } - if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) - return - } - - plaintext, ok := extractSensitivePlaintext(msg.MetadataJSON) - if !ok { - c.JSON(http.StatusNotFound, gin.H{"error": "Sensitive payload not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message_id": msg.ID, - "plaintext": plaintext, - }) -} - -// GetPasswordVaultItems returns owned and explicitly shared vault items. -func GetPasswordVaultItems(c *gin.Context) { - userID := getAuthUserID(c) - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - type vaultItemResponse struct { - ID uint `json:"id"` - Label string `json:"label"` - OwnerUserID uint `json:"owner_user_id"` - SourceMessageID *uint `json:"source_message_id,omitempty"` - LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` - Shared bool `json:"shared"` - AllowReveal bool `json:"allow_reveal"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - TargetConversationID *uint `json:"target_conversation_id,omitempty"` - } - - results := make([]vaultItemResponse, 0, 32) - - var owned []models.PasswordVaultItem - models.DB.Where("owner_user_id = ?", userID).Find(&owned) - for _, item := range owned { - results = append(results, vaultItemResponse{ - ID: item.ID, - Label: item.Label, - OwnerUserID: item.OwnerUserID, - SourceMessageID: item.SourceMessageID, - LastAccessedAt: item.LastAccessedAt, - Shared: false, - AllowReveal: true, - }) - } - - var shares []models.PasswordVaultShare - models.DB. - Joins("JOIN conversation_members cm ON cm.conversation_id = password_vault_shares.target_conversation_id"). - Where("cm.user_id = ? AND cm.deleted_at IS NULL", userID). - Where("password_vault_shares.expires_at IS NULL OR password_vault_shares.expires_at > ?", time.Now()). - Preload("VaultItem"). - Find(&shares) - - seen := map[uint]bool{} - for _, ownedItem := range owned { - seen[ownedItem.ID] = true - } - - for _, share := range shares { - item := share.VaultItem - if seen[item.ID] { - continue - } - seen[item.ID] = true - targetID := share.TargetConversationID - results = append(results, vaultItemResponse{ - ID: item.ID, - Label: item.Label, - OwnerUserID: item.OwnerUserID, - SourceMessageID: item.SourceMessageID, - LastAccessedAt: item.LastAccessedAt, - Shared: true, - AllowReveal: share.AllowReveal, - ExpiresAt: share.ExpiresAt, - TargetConversationID: &targetID, - }) - } - - c.JSON(http.StatusOK, gin.H{"items": results}) -} - -// CreatePasswordVaultItem creates a new encrypted vault item. -func CreatePasswordVaultItem(c *gin.Context) { - userID := getAuthUserID(c) - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - var req CreateVaultItemRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - encryptedSecret, err := utils.Encrypt(req.Secret) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt secret"}) - return - } - encryptedNotes := "" - if strings.TrimSpace(req.Notes) != "" { - encryptedNotes, err = utils.Encrypt(req.Notes) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt notes"}) - return - } - } - - item := models.PasswordVaultItem{ - OwnerUserID: userID, - Label: strings.TrimSpace(req.Label), - EncryptedSecret: encryptedSecret, - EncryptedNotes: encryptedNotes, - SourceMessageID: req.SourceMessageID, - CreatedBy: userID, - } - if err := models.DB.Create(&item).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create vault item"}) - return - } - - if req.SourceMessageID != nil && *req.SourceMessageID > 0 { - models.DB.Model(&models.Message{}).Where("id = ?", *req.SourceMessageID).Update("is_sensitive", true) - } - - c.JSON(http.StatusCreated, gin.H{ - "item": gin.H{ - "id": item.ID, - "label": item.Label, - "owner_user_id": item.OwnerUserID, - "source_message_id": item.SourceMessageID, - }, - }) -} - -// SharePasswordVaultItem shares an item to a selected conversation. -func SharePasswordVaultItem(c *gin.Context) { - userID := getAuthUserID(c) - itemID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) - return - } - - var req ShareVaultItemRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var item models.PasswordVaultItem - if err := models.DB.First(&item, itemID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) - return - } - if item.OwnerUserID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can share vault items"}) - return - } - - if _, _, err := getConversationWithMembership(models.DB, req.TargetConversationID, userID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": "You are not a member of target conversation"}) - return - } - - var share models.PasswordVaultShare - err = models.DB.Where("vault_item_id = ? AND target_conversation_id = ?", itemID, req.TargetConversationID).First(&share).Error - if err == nil { - share.ExpiresAt = req.ExpiresAt - share.AllowReveal = req.AllowReveal - share.SharedByUserID = userID - models.DB.Save(&share) - } else { - share = models.PasswordVaultShare{ - VaultItemID: itemID, - SharedByUserID: userID, - TargetConversationID: req.TargetConversationID, - ExpiresAt: req.ExpiresAt, - AllowReveal: req.AllowReveal, - } - if err := models.DB.Create(&share).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to share vault item"}) - return - } - } - - c.JSON(http.StatusOK, gin.H{"share": share}) -} - -// RevealPasswordVaultItem decrypts and returns a vault item secret if allowed. -func RevealPasswordVaultItem(c *gin.Context) { - userID := getAuthUserID(c) - itemID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) - return - } - - var item models.PasswordVaultItem - if err := models.DB.First(&item, itemID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) - return - } - - allowed := item.OwnerUserID == userID - if !allowed { - var share models.PasswordVaultShare - err := models.DB. - Joins("JOIN conversation_members cm ON cm.conversation_id = password_vault_shares.target_conversation_id"). - Where("password_vault_shares.vault_item_id = ? AND cm.user_id = ? AND cm.deleted_at IS NULL", itemID, userID). - Where("password_vault_shares.allow_reveal = true"). - Where("password_vault_shares.expires_at IS NULL OR password_vault_shares.expires_at > ?", time.Now()). - First(&share).Error - if err == nil { - allowed = true - } - } - - if !allowed { - c.JSON(http.StatusForbidden, gin.H{"error": "You are not allowed to reveal this vault item"}) - return - } - - secret, err := utils.Decrypt(item.EncryptedSecret) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decrypt secret"}) - return - } - notes := "" - if strings.TrimSpace(item.EncryptedNotes) != "" { - notes, _ = utils.Decrypt(item.EncryptedNotes) - } - - now := time.Now() - models.DB.Model(&item).Update("last_accessed_at", &now) - - c.JSON(http.StatusOK, gin.H{ - "id": item.ID, - "label": item.Label, - "secret": secret, - "notes": notes, - "warning": "Use a dedicated password manager like Proton Pass (not affiliated) for best security hygiene.", - }) -} - -// UnsharePasswordVaultItem revokes one or all shares for an item. -func UnsharePasswordVaultItem(c *gin.Context) { - userID := getAuthUserID(c) - itemID, err := parseUintParam(c, "id") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) - return - } - - var req UnshareVaultItemRequest - _ = c.ShouldBindJSON(&req) - - var item models.PasswordVaultItem - if err := models.DB.First(&item, itemID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) - return - } - if item.OwnerUserID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can unshare vault items"}) - return - } - - query := models.DB.Where("vault_item_id = ? AND shared_by_user_id = ?", itemID, userID) - if req.TargetConversationID > 0 { - query = query.Where("target_conversation_id = ?", req.TargetConversationID) - } - if err := query.Delete(&models.PasswordVaultShare{}).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unshare vault item"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Vault share removed"}) -} - -// MessagesWebSocket upgrades to websocket and handles realtime messaging events. -func MessagesWebSocket(c *gin.Context) { - userID := getAuthUserID(c) - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - conn, err := messagesWSUpgrader.Upgrade(c.Writer, c.Request, nil) - if err != nil { - return - } - - hub := services.GetMessagesHub() - client := services.NewWSClient(userID, conn) - - // Subscribe to all conversations this user is a member of. - var memberships []models.ConversationMember - models.DB.Where("user_id = ?", userID).Find(&memberships) - for _, m := range memberships { - hub.AddClientToConversation(client, m.ConversationID) - } - - // Writer loop - go func() { - ticker := time.NewTicker(25 * time.Second) - defer ticker.Stop() - for { - select { - case raw, ok := <-client.Send: - if !ok { - _ = conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - if err := conn.WriteMessage(websocket.TextMessage, raw); err != nil { - return - } - case <-ticker.C: - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return - } - } - } - }() - - // Reader loop - for { - var incoming map[string]interface{} - if err := conn.ReadJSON(&incoming); err != nil { - break - } - - eventType, _ := incoming["type"].(string) - conversationID := parseUintAny(incoming["conversation_id"]) - - switch eventType { - case "subscribe": - if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { - hub.AddClientToConversation(client, conversationID) - } - case "unsubscribe": - if conversationID > 0 { - hub.RemoveClientFromConversation(client, conversationID) - } - case "typing.started", "typing.stopped": - if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { - hub.Broadcast(conversationID, eventType, gin.H{ - "user_id": userID, - "conversation_id": conversationID, - }) - } - case "call.offer", "call.answer", "call.ice": - targetUserID := parseUintAny(incoming["target_user_id"]) - if conversationID > 0 && targetUserID > 0 && isConversationMember(models.DB, conversationID, userID) && isConversationMember(models.DB, conversationID, targetUserID) { - payload := gin.H{ - "conversation_id": conversationID, - "sender_id": userID, - "target_user_id": targetUserID, - } - if sdp, exists := incoming["sdp"]; exists { - payload["sdp"] = sdp - } - if candidate, exists := incoming["candidate"]; exists { - payload["candidate"] = candidate - } - if callID, exists := incoming["call_id"]; exists { - payload["call_id"] = callID - } - hub.SendToUser(conversationID, targetUserID, eventType, payload) - } - case "call.hangup": - if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { - payload := gin.H{ - "conversation_id": conversationID, - "user_id": userID, - } - if callID, exists := incoming["call_id"]; exists { - payload["call_id"] = callID - } - hub.Broadcast(conversationID, "call.hangup", payload) - } - case "read.updated": - if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { - lastReadID := parseUintAny(incoming["last_read_message_id"]) - now := time.Now() - models.DB.Model(&models.ConversationMember{}). - Where("conversation_id = ? AND user_id = ?", conversationID, userID). - Updates(map[string]interface{}{ - "last_read_message_id": lastReadID, - "last_read_at": &now, - }) - hub.Broadcast(conversationID, "read.updated", gin.H{ - "user_id": userID, - "conversation_id": conversationID, - "last_read_message_id": lastReadID, - }) - } - } - } - - hub.RemoveClient(client) - _ = conn.Close() -} - -func applySuggestionAction(db *gorm.DB, userID uint, message *models.Message, suggestion *models.MessageSuggestion, req SuggestionActionRequest) (interface{}, error) { - payload := map[string]interface{}{} - if strings.TrimSpace(suggestion.PayloadJSON) != "" { - _ = json.Unmarshal([]byte(suggestion.PayloadJSON), &payload) - } - - switch suggestion.Type { - case "create_task": - title := asString(payload["title"]) - if strings.TrimSpace(title) == "" { - title = compactMessageTitle(message.Body, 80) - } - task := models.Task{ - UserID: userID, - Title: title, - Description: message.Body, - Status: models.TaskStatusPending, - Priority: models.TaskPriorityMedium, - } - if err := db.Create(&task).Error; err != nil { - return nil, err - } - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "task", - EntityID: task.ID, - DeepLink: "/app/tasks?id=" + strconv.Itoa(int(task.ID)), - } - db.Create(&ref) - return gin.H{"task": task, "deep_link": ref.DeepLink}, nil - - case "create_event": - title := asString(payload["title"]) - if strings.TrimSpace(title) == "" { - title = compactMessageTitle(message.Body, 80) - } - start := time.Now().Add(1 * time.Hour) - end := start.Add(1 * time.Hour) - event := models.CalendarEvent{ - UserID: userID, - Title: title, - Description: message.Body, - StartTime: start, - EndTime: end, - Type: "reminder", - Priority: "medium", - Source: "trackeep", - ReminderMinutes: 15, - } - if err := db.Create(&event).Error; err != nil { - return nil, err - } - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "calendar_event", - EntityID: event.ID, - DeepLink: "/app/calendar?eventId=" + strconv.Itoa(int(event.ID)), - } - db.Create(&ref) - return gin.H{"event": event, "deep_link": ref.DeepLink}, nil - - case "save_bookmark": - url := asString(payload["url"]) - if strings.TrimSpace(url) == "" { - url = firstURLFromText(message.Body) - } - if strings.TrimSpace(url) == "" { - return nil, errors.New("no URL available for bookmark suggestion") - } - title := asString(payload["title"]) - if strings.TrimSpace(title) == "" { - title = url - } - bookmark := models.Bookmark{ - UserID: userID, - Title: title, - URL: url, - Description: "Created from chat suggestion", - } - if err := db.Create(&bookmark).Error; err != nil { - return nil, err - } - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "bookmark", - EntityID: bookmark.ID, - DeepLink: "/app/bookmarks?id=" + strconv.Itoa(int(bookmark.ID)), - } - db.Create(&ref) - return gin.H{"bookmark": bookmark, "deep_link": ref.DeepLink}, nil - - case "save_youtube": - url := asString(payload["url"]) - if strings.TrimSpace(url) == "" { - url = firstURLFromText(message.Body) - } - videoID := extractYouTubeVideoID(url) - if videoID == "" { - return nil, errors.New("no valid YouTube URL found") - } - - recordVideoID := videoID - var existing models.VideoBookmark - if err := db.Where("video_id = ?", recordVideoID).First(&existing).Error; err == nil && existing.UserID != userID { - recordVideoID = videoID + "-" + strconv.Itoa(int(userID)) - } - - video := models.VideoBookmark{ - UserID: userID, - VideoID: recordVideoID, - Title: compactMessageTitle(message.Body, 80), - Channel: "Unknown", - Thumbnail: "https://img.youtube.com/vi/" + videoID + "/hqdefault.jpg", - URL: url, - Description: "Saved from chat suggestion", - } - if err := db.Create(&video).Error; err != nil { - return nil, err - } - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "youtube_video", - EntityID: video.ID, - DeepLink: "/app/youtube?video=" + videoID, - } - db.Create(&ref) - return gin.H{"video": video, "deep_link": ref.DeepLink}, nil - - case "save_search": - queryText := asString(payload["query"]) - if strings.TrimSpace(queryText) == "" { - queryText = message.Body - } - saved := models.SavedSearch{ - UserID: userID, - Name: compactMessageTitle(queryText, 50), - Query: queryText, - Filters: "{}", - Alert: false, - Description: "Saved from chat suggestion", - } - if err := db.Create(&saved).Error; err != nil { - return nil, err - } - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "saved_search", - EntityID: saved.ID, - DeepLink: "/app/search?savedId=" + strconv.Itoa(int(saved.ID)), - } - db.Create(&ref) - return gin.H{"saved_search": saved, "deep_link": ref.DeepLink}, nil - - case "link_github": - url := asString(payload["url"]) - if strings.TrimSpace(url) == "" { - url = firstURLFromText(message.Body) - } - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "github", - EntityID: 0, - DeepLink: "/app/github", - } - db.Create(&ref) - return gin.H{"github_url": url, "deep_link": ref.DeepLink}, nil - - case "link_learning_path": - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "learning_path", - EntityID: 0, - DeepLink: "/app/learning-paths", - } - db.Create(&ref) - return gin.H{"deep_link": ref.DeepLink}, nil - - case "move_to_password_vault": - secretSource := message.Body - if sensitivePlaintext, ok := extractSensitivePlaintext(message.MetadataJSON); ok { - secretSource = sensitivePlaintext - } - label := "Imported from chat" - if compact := compactMessageTitle(secretSource, 50); compact != "" { - label = compact - } - encryptedSecret, err := utils.Encrypt(secretSource) - if err != nil { - return nil, err - } - encryptedNotes, _ := utils.Encrypt("Imported from message #" + strconv.Itoa(int(message.ID))) - item := models.PasswordVaultItem{ - OwnerUserID: userID, - Label: label, - EncryptedSecret: encryptedSecret, - EncryptedNotes: encryptedNotes, - SourceMessageID: &message.ID, - CreatedBy: userID, - } - if err := db.Create(&item).Error; err != nil { - return nil, err - } - - redactOriginal := req.RedactOriginal - if req.RedactOriginal == false { - // Default to redacting when vault action is accepted. - redactOriginal = true - } - if redactOriginal { - now := time.Now() - db.Model(&models.Message{}).Where("id = ?", message.ID).Updates(map[string]interface{}{ - "body": "[moved to vault]", - "is_sensitive": true, - "edited_at": &now, - }) - } - - ref := models.MessageReference{ - MessageID: message.ID, - EntityType: "password_vault_item", - EntityID: item.ID, - DeepLink: "/app/messages?vaultItem=" + strconv.Itoa(int(item.ID)), - } - db.Create(&ref) - return gin.H{"vault_item_id": item.ID, "deep_link": ref.DeepLink}, nil - - case "password_warning": - return gin.H{ - "warning": "Sensitive content detected. We recommend Proton Pass (not affiliated).", - }, nil - } - - return nil, errors.New("unsupported suggestion type") -} - -func ensureMessagingDefaults(db *gorm.DB, userID uint) error { - globals, err := ensureGlobalConversations(db, userID) - if err != nil { - return err - } - - // Ensure the current user is in all global channels. - for _, conv := range globals { - member := models.ConversationMember{ - ConversationID: conv.ID, - UserID: userID, - Role: models.ConversationMemberRoleMember, - JoinedAt: time.Now(), - } - if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil { - return err - } - } - - if _, err := ensureUserConversation(db, userID, models.ConversationTypeSelf, "Notes to Self", true); err != nil { - return err - } - if _, err := ensureUserConversation(db, userID, models.ConversationTypePasswordVault, "Password Vault", true); err != nil { - return err - } - - return nil -} - -func ensureGlobalConversations(db *gorm.DB, userID uint) ([]models.Conversation, error) { - defaults := []string{"#general", "#announcements"} - out := make([]models.Conversation, 0, len(defaults)) - - for _, name := range defaults { - var conv models.Conversation - err := db.Where("type = ? AND name = ?", models.ConversationTypeGlobal, name).First(&conv).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - conv = models.Conversation{ - Type: models.ConversationTypeGlobal, - Name: name, - IsDefault: true, - CreatedBy: userID, - } - if err := db.Create(&conv).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - out = append(out, conv) - } - - // Backfill global membership for all users. - var users []models.User - if err := db.Find(&users).Error; err != nil { - return nil, err - } - rows := make([]models.ConversationMember, 0, len(users)*len(out)) - for _, conv := range out { - for _, user := range users { - rows = append(rows, models.ConversationMember{ - ConversationID: conv.ID, - UserID: user.ID, - Role: models.ConversationMemberRoleMember, - JoinedAt: time.Now(), - }) - } - } - if len(rows) > 0 { - if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil { - return nil, err - } - } - - return out, nil -} - -func ensureUserConversation(db *gorm.DB, userID uint, conversationType models.ConversationType, name string, isDefault bool) (*models.Conversation, error) { - var conv models.Conversation - err := db. - Where("type = ? AND created_by = ?", conversationType, userID). - First(&conv).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - conv = models.Conversation{ - Type: conversationType, - Name: name, - IsDefault: isDefault, - CreatedBy: userID, - } - if err := db.Create(&conv).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - - member := models.ConversationMember{ - ConversationID: conv.ID, - UserID: userID, - Role: models.ConversationMemberRoleOwner, - JoinedAt: time.Now(), - } - if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil { - return nil, err - } - - return &conv, nil -} - -func getConversationWithMembership(db *gorm.DB, conversationID, userID uint) (*models.Conversation, *models.ConversationMember, error) { - var conv models.Conversation - if err := db.First(&conv, conversationID).Error; err != nil { - return nil, nil, err - } - - var member models.ConversationMember - if err := db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).First(&member).Error; err != nil { - return nil, nil, err - } - return &conv, &member, nil -} - -func isConversationAdmin(role models.ConversationMemberRole) bool { - return role == models.ConversationMemberRoleOwner || role == models.ConversationMemberRoleAdmin -} - -func canWriteMessage(role models.ConversationMemberRole) bool { - return role == models.ConversationMemberRoleOwner || - role == models.ConversationMemberRoleAdmin || - role == models.ConversationMemberRoleMember -} - -func getAuthUserID(c *gin.Context) uint { - if uid := c.GetUint("user_id"); uid != 0 { - return uid - } - if uid := c.GetUint("userID"); uid != 0 { - return uid - } - return 0 -} - -func parseUintParam(c *gin.Context, key string) (uint, error) { - raw := c.Param(key) - parsed, err := strconv.ParseUint(raw, 10, 32) - if err != nil { - return 0, err - } - return uint(parsed), nil -} - -func parseUintAny(v interface{}) uint { - switch t := v.(type) { - case float64: - return uint(t) - case int: - return uint(t) - case uint: - return t - case string: - p, _ := strconv.ParseUint(t, 10, 32) - return uint(p) - default: - return 0 - } -} - -func findExistingDM(db *gorm.DB, userA, userB uint) *models.Conversation { - var candidates []models.Conversation - db.Joins("JOIN conversation_members cm ON cm.conversation_id = conversations.id"). - Where("conversations.type = ? AND cm.user_id = ?", models.ConversationTypeDM, userA). - Find(&candidates) - - for _, conv := range candidates { - var members []models.ConversationMember - db.Where("conversation_id = ?", conv.ID).Find(&members) - if len(members) != 2 { - continue - } - foundA, foundB := false, false - for _, m := range members { - if m.UserID == userA { - foundA = true - } - if m.UserID == userB { - foundB = true - } - } - if foundA && foundB { - return &conv - } - } - return nil -} - -func usersExist(db *gorm.DB, userIDs []uint) bool { - if len(userIDs) == 0 { - return true - } - var count int64 - db.Model(&models.User{}).Where("id IN ?", userIDs).Count(&count) - return int(count) == len(userIDs) -} - -func hasAttachment(rows []models.MessageAttachment, kind, url string) bool { - for _, row := range rows { - if strings.EqualFold(row.Kind, kind) && strings.EqualFold(strings.TrimSpace(row.URL), strings.TrimSpace(url)) { - return true - } - } - return false -} - -func maskSensitiveBody(text string) string { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return "[sensitive content hidden]" - } - - parts := strings.Fields(trimmed) - if len(parts) == 0 { - return "[sensitive content hidden]" - } - - maskedParts := make([]string, 0, len(parts)) - for _, part := range parts { - runes := []rune(part) - if len(runes) <= 2 { - maskedParts = append(maskedParts, "**") - continue - } - maskedParts = append(maskedParts, strings.Repeat("*", len(runes))) - } - return strings.Join(maskedParts, " ") -} - -func extractSensitivePlaintext(metadataJSON string) (string, bool) { - payload := extractSensitivePayload(metadataJSON) - if payload == nil { - return "", false - } - - ciphertext := asString(payload["ciphertext"]) - if ciphertext == "" { - return "", false - } - - plaintext, err := utils.Decrypt(ciphertext) - if err != nil { - return "", false - } - return plaintext, true -} - -func extractSensitivePayload(metadataJSON string) map[string]interface{} { - trimmed := strings.TrimSpace(metadataJSON) - if trimmed == "" || trimmed == "{}" { - return nil - } - - metadata := map[string]interface{}{} - if err := json.Unmarshal([]byte(trimmed), &metadata); err != nil { - return nil - } - - rawPayload, ok := metadata["sensitive_payload"] - if !ok || rawPayload == nil { - return nil - } - - payload, ok := rawPayload.(map[string]interface{}) - if !ok { - return nil - } - return payload -} - -func normalizeAttachmentKind(kind string) string { - k := strings.ToLower(strings.TrimSpace(kind)) - switch k { - case "file", "image", "youtube", "github", "website", "bookmark", "task", "event", "calendar", "activity", "learning_path", "saved_search", "voice_note": - return k - default: - return "website" - } -} - -func normalizeReferenceType(entityType string) string { - t := strings.ToLower(strings.TrimSpace(entityType)) - switch t { - case "task", "bookmark", "calendar_event", "youtube_video", "learning_path", "saved_search", "github", "password_vault_item", "ai_chat_session", "ai_chat_message": - return t - default: - return "" - } -} - -func isReferenceDeepLinkAllowed(deepLink string) bool { - return strings.HasPrefix(deepLink, "/") || strings.HasPrefix(deepLink, "http://") || strings.HasPrefix(deepLink, "https://") -} - -func canReferenceEntity(db *gorm.DB, userID uint, entityType string, entityID uint) bool { - switch entityType { - case "ai_chat_session": - var session models.ChatSession - return db.Where("id = ? AND user_id = ?", entityID, userID).First(&session).Error == nil - case "ai_chat_message": - var message models.ChatMessage - return db.Where("id = ? AND user_id = ?", entityID, userID).First(&message).Error == nil - default: - return true - } -} - -func compactMessageTitle(text string, limit int) string { - trimmed := strings.TrimSpace(text) - if len(trimmed) <= limit { - return trimmed - } - if limit < 4 { - return trimmed - } - return strings.TrimSpace(trimmed[:limit-3]) + "..." -} - -func firstURLFromText(text string) string { - matches := messageURLRegex.FindAllString(text, -1) - if len(matches) == 0 { - return "" - } - return matches[0] -} - -func extractYouTubeVideoID(rawURL string) string { - if strings.Contains(rawURL, "youtu.be/") { - parts := strings.Split(rawURL, "youtu.be/") - if len(parts) > 1 { - idPart := parts[1] - if idx := strings.IndexAny(idPart, "?&/"); idx > -1 { - idPart = idPart[:idx] - } - return idPart - } - } - if strings.Contains(rawURL, "youtube.com/watch?v=") { - parts := strings.Split(rawURL, "watch?v=") - if len(parts) > 1 { - idPart := parts[1] - if idx := strings.IndexAny(idPart, "&/"); idx > -1 { - idPart = idPart[:idx] - } - return idPart - } - } - return "" -} - -func asString(v interface{}) string { - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return "" -} - -func isConversationMember(db *gorm.DB, conversationID, userID uint) bool { - var count int64 - db.Model(&models.ConversationMember{}).Where("conversation_id = ? AND user_id = ?", conversationID, userID).Count(&count) - return count > 0 -} diff --git a/backend/handlers/oauth_service.go b/backend/handlers/oauth_service.go index b911d14..c63f19b 100644 --- a/backend/handlers/oauth_service.go +++ b/backend/handlers/oauth_service.go @@ -268,7 +268,7 @@ func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser return nil, err } - _ = ensureMessagingDefaults(db, user.ID) + return &user, nil } diff --git a/backend/handlers/saved_search.go b/backend/handlers/saved_search.go deleted file mode 100644 index cccdf77..0000000 --- a/backend/handlers/saved_search.go +++ /dev/null @@ -1,662 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "github.com/trackeep/backend/models" -) - -// SavedSearchRequest represents the request payload for creating/updating saved searches -type SavedSearchRequest struct { - Name string `json:"name" binding:"required"` - Query string `json:"query" binding:"required"` - Filters map[string]interface{} `json:"filters"` - Alert bool `json:"alert"` - IsPublic bool `json:"is_public"` - Description string `json:"description"` - Tags []string `json:"tags"` -} - -// SavedSearchResponse represents the response payload for saved searches -type SavedSearchResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - Query string `json:"query"` - Filters map[string]interface{} `json:"filters"` - Alert bool `json:"alert"` - LastRun *time.Time `json:"last_run"` - RunCount int `json:"run_count"` - IsPublic bool `json:"is_public"` - Description string `json:"description"` - Tags []models.SavedSearchTag `json:"tags"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// CreateSavedSearch handles POST /api/v1/search/saved -func CreateSavedSearch(c *gin.Context) { - var req SavedSearchRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := c.GetUint("user_id") - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - // Serialize filters to JSON - filtersJSON, err := json.Marshal(req.Filters) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"}) - return - } - - // Create saved search - savedSearch := models.SavedSearch{ - UserID: userID, - Name: req.Name, - Query: req.Query, - Filters: string(filtersJSON), - Alert: req.Alert, - IsPublic: req.IsPublic, - RunCount: 0, - Tags: []models.SavedSearchTag{}, - } - - // Handle tags - if len(req.Tags) > 0 { - db := c.MustGet("db").(*gorm.DB) - for _, tagName := range req.Tags { - var tag models.SavedSearchTag - if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil { - // Create new tag if it doesn't exist - tag = models.SavedSearchTag{ - Name: tagName, - Color: "#3b82f6", // Default blue color - } - db.Create(&tag) - } - savedSearch.Tags = append(savedSearch.Tags, tag) - } - } - - db := c.MustGet("db").(*gorm.DB) - if err := db.Create(&savedSearch).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create saved search"}) - return - } - - // Load tags for response - db.Preload("Tags").First(&savedSearch, savedSearch.ID) - - response := SavedSearchResponse{ - ID: savedSearch.ID, - Name: savedSearch.Name, - Query: savedSearch.Query, - Alert: savedSearch.Alert, - LastRun: savedSearch.LastRun, - RunCount: savedSearch.RunCount, - IsPublic: savedSearch.IsPublic, - Description: savedSearch.Description, - Tags: savedSearch.Tags, - CreatedAt: savedSearch.CreatedAt, - UpdatedAt: savedSearch.UpdatedAt, - } - - // Parse filters back to map - json.Unmarshal([]byte(savedSearch.Filters), &response.Filters) - - c.JSON(http.StatusCreated, response) -} - -// GetUserSavedSearches handles GET /api/v1/search/saved -func GetUserSavedSearches(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - db := c.MustGet("db").(*gorm.DB) - - // Parse query parameters - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - tagFilter := c.Query("tag") - alertFilter := c.Query("alert") - - offset := (page - 1) * limit - - query := db.Model(&models.SavedSearch{}).Where("user_id = ? OR is_public = ?", userID, true) - - // Apply filters - if tagFilter != "" { - query = query.Joins("JOIN saved_search_tags ON saved_search_tags.id = saved_searches.id"). - Joins("JOIN saved_search_tag_saved_searches ON saved_search_tag_saved_searches.saved_search_id = saved_searches.id"). - Joins("JOIN saved_search_tags t ON t.id = saved_search_tag_saved_searches.saved_search_tag_id"). - Where("t.name = ?", tagFilter) - } - - if alertFilter == "true" { - query = query.Where("alert = ?", true) - } else if alertFilter == "false" { - query = query.Where("alert = ?", false) - } - - var savedSearches []models.SavedSearch - var total int64 - - if err := query.Preload("Tags").Count(&total).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count saved searches"}) - return - } - - if err := query.Preload("Tags").Offset(offset).Limit(limit).Order("created_at DESC").Find(&savedSearches).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved searches"}) - return - } - - // Convert to response format - var responses []SavedSearchResponse - for _, ss := range savedSearches { - var filters map[string]interface{} - json.Unmarshal([]byte(ss.Filters), &filters) - - response := SavedSearchResponse{ - ID: ss.ID, - Name: ss.Name, - Query: ss.Query, - Filters: filters, - Alert: ss.Alert, - LastRun: ss.LastRun, - RunCount: ss.RunCount, - IsPublic: ss.IsPublic, - Description: ss.Description, - Tags: ss.Tags, - CreatedAt: ss.CreatedAt, - UpdatedAt: ss.UpdatedAt, - } - responses = append(responses, response) - } - - c.JSON(http.StatusOK, gin.H{ - "saved_searches": responses, - "total": total, - "page": page, - "limit": limit, - }) -} - -// GetSavedSearch handles GET /api/v1/search/saved/:id -func GetSavedSearch(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"}) - return - } - - db := c.MustGet("db").(*gorm.DB) - var savedSearch models.SavedSearch - - if err := db.Preload("Tags").Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"}) - } - return - } - - var filters map[string]interface{} - json.Unmarshal([]byte(savedSearch.Filters), &filters) - - response := SavedSearchResponse{ - ID: savedSearch.ID, - Name: savedSearch.Name, - Query: savedSearch.Query, - Filters: filters, - Alert: savedSearch.Alert, - LastRun: savedSearch.LastRun, - RunCount: savedSearch.RunCount, - IsPublic: savedSearch.IsPublic, - Description: savedSearch.Description, - Tags: savedSearch.Tags, - CreatedAt: savedSearch.CreatedAt, - UpdatedAt: savedSearch.UpdatedAt, - } - - c.JSON(http.StatusOK, response) -} - -// UpdateSavedSearch handles PUT /api/v1/search/saved/:id -func UpdateSavedSearch(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"}) - return - } - - var req SavedSearchRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - db := c.MustGet("db").(*gorm.DB) - var savedSearch models.SavedSearch - - if err := db.Where("id = ? AND user_id = ?", id, userID).First(&savedSearch).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"}) - } - return - } - - // Update fields - savedSearch.Name = req.Name - savedSearch.Query = req.Query - savedSearch.Alert = req.Alert - savedSearch.IsPublic = req.IsPublic - savedSearch.Description = req.Description - - // Update filters - filtersJSON, err := json.Marshal(req.Filters) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters format"}) - return - } - savedSearch.Filters = string(filtersJSON) - - // Update tags - if err := db.Model(&savedSearch).Association("Tags").Clear(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tags"}) - return - } - - for _, tagName := range req.Tags { - var tag models.SavedSearchTag - if err := db.Where("name = ?", tagName).First(&tag).Error; err != nil { - tag = models.SavedSearchTag{ - Name: tagName, - Color: "#3b82f6", - } - db.Create(&tag) - } - if err := db.Model(&savedSearch).Association("Tags").Append(&tag); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add tag"}) - return - } - } - - if err := db.Save(&savedSearch).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update saved search"}) - return - } - - // Load updated data - db.Preload("Tags").First(&savedSearch, savedSearch.ID) - - var filters map[string]interface{} - json.Unmarshal([]byte(savedSearch.Filters), &filters) - - response := SavedSearchResponse{ - ID: savedSearch.ID, - Name: savedSearch.Name, - Query: savedSearch.Query, - Filters: filters, - Alert: savedSearch.Alert, - LastRun: savedSearch.LastRun, - RunCount: savedSearch.RunCount, - IsPublic: savedSearch.IsPublic, - Description: savedSearch.Description, - Tags: savedSearch.Tags, - CreatedAt: savedSearch.CreatedAt, - UpdatedAt: savedSearch.UpdatedAt, - } - - c.JSON(http.StatusOK, response) -} - -// DeleteSavedSearch handles DELETE /api/v1/search/saved/:id -func DeleteSavedSearch(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"}) - return - } - - db := c.MustGet("db").(*gorm.DB) - result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.SavedSearch{}) - - if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete saved search"}) - return - } - - if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Saved search deleted successfully"}) -} - -// RunSavedSearch handles POST /api/v1/search/saved/:id/run -func RunSavedSearch(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid saved search ID"}) - return - } - - db := c.MustGet("db").(*gorm.DB) - var savedSearch models.SavedSearch - - if err := db.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).First(&savedSearch).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Saved search not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch saved search"}) - } - return - } - - // Parse filters - var filters map[string]interface{} - if err := json.Unmarshal([]byte(savedSearch.Filters), &filters); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse filters"}) - return - } - - // Create search request based on saved search - searchReq := map[string]interface{}{ - "query": savedSearch.Query, - } - - // Merge filters - for k, v := range filters { - searchReq[k] = v - } - - // Perform the search using existing enhanced search logic - // This is a simplified version - in production, you'd want to reuse the actual search handler - searchResults, err := performSearchFromSavedSearch(searchReq, userID, db) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to execute search"}) - return - } - - // Update saved search run statistics - now := time.Now() - savedSearch.LastRun = &now - savedSearch.RunCount++ - db.Save(&savedSearch) - - // Log search analytics - logSearchAnalytics(userID, savedSearch.Query, savedSearch.Filters, len(searchResults), db) - - c.JSON(http.StatusOK, gin.H{ - "results": searchResults, - "query": savedSearch.Query, - "filters": filters, - "total": len(searchResults), - "saved_search": gin.H{ - "id": savedSearch.ID, - "name": savedSearch.Name, - "last_run": savedSearch.LastRun, - "run_count": savedSearch.RunCount, - }, - }) -} - -// GetSavedSearchTags handles GET /api/v1/search/saved/tags -func GetSavedSearchTags(c *gin.Context) { - userID := c.GetUint("user_id") - if userID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) - return - } - - db := c.MustGet("db").(*gorm.DB) - var tags []models.SavedSearchTag - - if err := db.Order("name").Find(&tags).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tags"}) - return - } - - c.JSON(http.StatusOK, gin.H{"tags": tags}) -} - -// Helper function to perform search from saved search -func performSearchFromSavedSearch(searchReq map[string]interface{}, userID uint, db *gorm.DB) ([]interface{}, error) { - // Build search filters from the request - filters := SearchFilters{ - Query: getStringValue(searchReq, "query"), - ContentType: getStringValue(searchReq, "content_type"), - Limit: getIntValue(searchReq, "limit", 20), - Offset: getIntValue(searchReq, "offset", 0), - } - - // Parse tags if present - if tags, ok := searchReq["tags"].([]interface{}); ok { - for _, tag := range tags { - if tagStr, ok := tag.(string); ok { - filters.Tags = append(filters.Tags, tagStr) - } - } - } - - // Parse date range if present - if dateRange, ok := searchReq["date_range"].(map[string]interface{}); ok { - if startStr, ok := dateRange["start"].(string); ok && startStr != "" { - if startTime, err := time.Parse("2006-01-02", startStr); err == nil { - filters.DateRange.Start = startTime - } - } - if endStr, ok := dateRange["end"].(string); ok && endStr != "" { - if endTime, err := time.Parse("2006-01-02", endStr); err == nil { - filters.DateRange.End = endTime - } - } - } - - // Parse boolean filters - if isFavorite, ok := searchReq["is_favorite"].(bool); ok { - filters.IsFavorite = &isFavorite - } - if isRead, ok := searchReq["is_read"].(bool); ok { - filters.IsRead = &isRead - } - if isPublic, ok := searchReq["is_public"].(bool); ok { - filters.IsPublic = &isPublic - } - - // Perform the search using existing enhanced search logic - results, err := performEnhancedSearch(filters, userID, db) - if err != nil { - return nil, err - } - - // Convert results to interface slice - var interfaceResults []interface{} - for _, result := range results { - interfaceResults = append(interfaceResults, result) - } - - return interfaceResults, nil -} - -// Helper function to perform enhanced search (reused from search_enhanced.go) -func performEnhancedSearch(filters SearchFilters, userID uint, db *gorm.DB) ([]SearchResult, error) { - var results []SearchResult - - // Search bookmarks - if filters.ContentType == "all" || filters.ContentType == "bookmarks" { - var bookmarks []models.Bookmark - query := db.Where("user_id = ?", userID) - - // Apply text search - if filters.Query != "" { - query = query.Where("title ILIKE ? OR description ILIKE ? OR content ILIKE ?", - "%"+filters.Query+"%", "%"+filters.Query+"%", "%"+filters.Query+"%") - } - - // Apply filters - if filters.IsFavorite != nil { - query = query.Where("is_favorite = ?", *filters.IsFavorite) - } - - if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&bookmarks).Error; err != nil { - return nil, err - } - - for _, bookmark := range bookmarks { - result := SearchResult{ - ID: bookmark.ID, - Type: "bookmark", - Title: bookmark.Title, - Description: bookmark.Description, - Content: bookmark.Content, - CreatedAt: bookmark.CreatedAt, - UpdatedAt: bookmark.UpdatedAt, - URL: bookmark.URL, - IsFavorite: bookmark.IsFavorite, - IsRead: bookmark.IsRead, - } - results = append(results, result) - } - } - - // Search tasks - if filters.ContentType == "all" || filters.ContentType == "tasks" { - var tasks []models.Task - query := db.Where("user_id = ?", userID) - - if filters.Query != "" { - query = query.Where("title ILIKE ? OR description ILIKE ?", - "%"+filters.Query+"%", "%"+filters.Query+"%") - } - - if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(&tasks).Error; err != nil { - return nil, err - } - - for _, task := range tasks { - result := SearchResult{ - ID: task.ID, - Type: "task", - Title: task.Title, - Description: task.Description, - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, - Status: string(task.Status), - Priority: string(task.Priority), - DueDate: task.DueDate, - } - results = append(results, result) - } - } - - // Search notes - if filters.ContentType == "all" || filters.ContentType == "notes" { - var notes []models.Note - query := db.Where("user_id = ?", userID) - - if filters.Query != "" { - query = query.Where("title ILIKE ? OR content ILIKE ?", - "%"+filters.Query+"%", "%"+filters.Query+"%") - } - - if err := query.Limit(filters.Limit).Offset(filters.Offset).Find(¬es).Error; err != nil { - return nil, err - } - - for _, note := range notes { - result := SearchResult{ - ID: note.ID, - Type: "note", - Title: note.Title, - Description: note.Content[:min(200, len(note.Content))], - Content: note.Content, - CreatedAt: note.CreatedAt, - UpdatedAt: note.UpdatedAt, - IsPublic: note.IsPublic, - } - results = append(results, result) - } - } - - return results, nil -} - -// Helper functions -func getStringValue(m map[string]interface{}, key string) string { - if val, ok := m[key].(string); ok { - return val - } - return "" -} - -func getIntValue(m map[string]interface{}, key string, defaultValue int) int { - if val, ok := m[key].(float64); ok { - return int(val) - } - return defaultValue -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// Helper function to log search analytics -func logSearchAnalytics(userID uint, query string, filters string, resultsCount int, db *gorm.DB) { - analytics := models.SearchAnalytics{ - UserID: userID, - Query: query, - Filters: filters, - ResultsCount: resultsCount, - Took: 0, // Would be measured in actual implementation - ContentType: "mixed", - } - - db.Create(&analytics) -} diff --git a/backend/handlers/search.go b/backend/handlers/search.go deleted file mode 100644 index 6f1226e..0000000 --- a/backend/handlers/search.go +++ /dev/null @@ -1,475 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/gin-gonic/gin" -) - -// BraveSearchResponse represents the response from Brave Search API -type BraveSearchResponse struct { - Mixed struct { - Results []map[string]interface{} `json:"results"` - } `json:"mixed"` - Web struct { - Results []map[string]interface{} `json:"results"` - } `json:"web"` - Query struct { - Original string `json:"original"` - Display string `json:"display"` - } `json:"query"` -} - -type BraveNewsResponse struct { - Results []map[string]interface{} `json:"results"` - Query struct { - Original string `json:"original"` - Display string `json:"display"` - } `json:"query"` -} - -// BraveSearchResult represents a normalized search result returned to the frontend -// Note: Brave's API uses fields like "page_age"; we normalize this to "published_date" -// to match the BrowserSearch UI expectations. -type BraveSearchResult struct { - Title string `json:"title"` - URL string `json:"url"` - Description string `json:"description"` - PublishedDate string `json:"published_date,omitempty"` - Language string `json:"language,omitempty"` -} - -// SearchWeb handles POST /api/v1/search/web -func SearchWeb(c *gin.Context) { - var req struct { - Query string `json:"query" binding:"required"` - Count int `json:"count"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Set default count if not provided - if req.Count == 0 { - req.Count = 10 - } - - // Get user ID from context (authentication is required) - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"}) - return - } - - // Get user's search settings from database - searchSettings, err := GetSearchSettingsForAPI(userID.(int)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"}) - return - } - - // Check if user has search API key configured - if searchSettings.SearchAPIProvider == "brave" { - apiKey := searchSettings.BraveAPIKey - if apiKey == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."}) - return - } - - // Build Brave Search API request - baseURL := "https://api.search.brave.com/res/v1/web/search" - q := url.Values{} - q.Set("q", req.Query) - q.Set("count", fmt.Sprint(req.Count)) - endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode()) - - reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"}) - return - } - reqHTTP.Header.Set("Accept", "application/json") - reqHTTP.Header.Set("X-Subscription-Token", apiKey) - - resp, err := http.DefaultClient.Do(reqHTTP) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"}) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)}) - return - } - - var braveResp BraveSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"}) - return - } - - // Prefer web.results, fall back to mixed.results - resultsRaw := braveResp.Web.Results - if len(resultsRaw) == 0 { - resultsRaw = braveResp.Mixed.Results - } - - results := make([]BraveSearchResult, 0, len(resultsRaw)) - for _, r := range resultsRaw { - title, _ := r["title"].(string) - urlStr, _ := r["url"].(string) - desc, _ := r["description"].(string) - lang, _ := r["language"].(string) - pageAge, _ := r["page_age"].(string) - - results = append(results, BraveSearchResult{ - Title: title, - URL: urlStr, - Description: desc, - PublishedDate: pageAge, - Language: lang, - }) - } - - c.JSON(http.StatusOK, gin.H{ - "results": results, - "query": gin.H{ - "original": braveResp.Query.Original, - "display": braveResp.Query.Display, - }, - "count": len(results), - }) - return - } - - // Use the configured provider - if searchSettings.SearchAPIProvider == "brave" { - apiKey := searchSettings.BraveAPIKey - if apiKey == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."}) - return - } - - // Build Brave Search API request - baseURL := searchSettings.BraveSearchBaseURL - q := url.Values{} - q.Set("q", req.Query) - q.Set("count", fmt.Sprint(req.Count)) - endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode()) - - reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"}) - return - } - reqHTTP.Header.Set("Accept", "application/json") - reqHTTP.Header.Set("X-Subscription-Token", apiKey) - - resp, err := http.DefaultClient.Do(reqHTTP) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave Search API"}) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave API error: %d", resp.StatusCode)}) - return - } - - var braveResp BraveSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave response"}) - return - } - - // Prefer web.results, fall back to mixed.results - resultsRaw := braveResp.Web.Results - if len(resultsRaw) == 0 { - resultsRaw = braveResp.Mixed.Results - } - - results := make([]BraveSearchResult, 0, len(resultsRaw)) - for _, r := range resultsRaw { - title, _ := r["title"].(string) - urlStr, _ := r["url"].(string) - desc, _ := r["description"].(string) - lang, _ := r["language"].(string) - pageAge, _ := r["page_age"].(string) - - results = append(results, BraveSearchResult{ - Title: title, - URL: urlStr, - Description: desc, - PublishedDate: pageAge, - Language: lang, - }) - } - - c.JSON(http.StatusOK, gin.H{ - "results": results, - "query": gin.H{ - "original": braveResp.Query.Original, - "display": braveResp.Query.Display, - }, - "count": len(results), - }) - } else if searchSettings.SearchAPIProvider == "serper" { - // TODO: Implement Serper API integration - c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"}) - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."}) - } -} - -// SearchNews handles POST /api/v1/search/news -func SearchNews(c *gin.Context) { - var req struct { - Query string `json:"query" binding:"required"` - Count int `json:"count"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Count == 0 { - req.Count = 10 - } - - // Get user ID from context (authentication is required) - userID, exists := c.Get("user_id") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required for search functionality"}) - return - } - - // Get user's search settings from database - searchSettings, err := GetSearchSettingsForAPI(userID.(int)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get search settings"}) - return - } - - // Check if user has search API key configured - if searchSettings.SearchAPIProvider == "brave" { - apiKey := searchSettings.BraveAPIKey - if apiKey == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Brave Search API key not configured. Please configure your search API key in settings."}) - return - } - - baseURL := "https://api.search.brave.com/res/v1/news/search" - q := url.Values{} - q.Set("q", req.Query) - q.Set("count", fmt.Sprint(req.Count)) - endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode()) - - reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"}) - return - } - reqHTTP.Header.Set("Accept", "application/json") - reqHTTP.Header.Set("X-Subscription-Token", apiKey) - - resp, err := http.DefaultClient.Do(reqHTTP) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"}) - return - } - - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)}) - return - } - - // Read the response body for debugging - bodyBytes, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"}) - return - } - - - - var braveResp BraveNewsResponse - if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil { - - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"}) - return - } - - // Debug logging - - - - resultsRaw := braveResp.Results - results := make([]BraveSearchResult, 0, len(resultsRaw)) - for _, r := range resultsRaw { - title, _ := r["title"].(string) - urlStr, _ := r["url"].(string) - desc, _ := r["description"].(string) - lang, _ := r["language"].(string) - pubDate, _ := r["published_date"].(string) - if pubDate == "" { - pubDate, _ = r["page_age"].(string) - } - - results = append(results, BraveSearchResult{ - Title: title, - URL: urlStr, - Description: desc, - PublishedDate: pubDate, - Language: lang, - }) - } - - original := braveResp.Query.Original - display := braveResp.Query.Display - if original == "" { - original = req.Query - } - if display == "" { - display = req.Query - } - - c.JSON(http.StatusOK, gin.H{ - "results": results, - "query": gin.H{ - "original": original, - "display": display, - }, - "count": len(results), - }) - return - } - - // Use the configured provider - if searchSettings.SearchAPIProvider == "brave" { - apiKey := searchSettings.BraveAPIKey - if apiKey == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Brave API key not configured"}) - return - } - - baseURL := "https://api.search.brave.com/res/v1/news/search" - q := url.Values{} - q.Set("q", req.Query) - q.Set("count", fmt.Sprint(req.Count)) - endpoint := fmt.Sprintf("%s?%s", baseURL, q.Encode()) - - reqHTTP, err := http.NewRequest(http.MethodGet, endpoint, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Brave request"}) - return - } - reqHTTP.Header.Set("Accept", "application/json") - reqHTTP.Header.Set("X-Subscription-Token", apiKey) - - resp, err := http.DefaultClient.Do(reqHTTP) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"}) - return - } - - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)}) - return - } - - // Read the response body for debugging - bodyBytes, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"}) - return - } - - - - var braveResp BraveNewsResponse - if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil { - - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"}) - return - } - - // Debug logging - - - - resultsRaw := braveResp.Results - results := make([]BraveSearchResult, 0, len(resultsRaw)) - for _, r := range resultsRaw { - title, _ := r["title"].(string) - urlStr, _ := r["url"].(string) - desc, _ := r["description"].(string) - lang, _ := r["language"].(string) - pubDate, _ := r["published_date"].(string) - if pubDate == "" { - pubDate, _ = r["page_age"].(string) - } - - results = append(results, BraveSearchResult{ - Title: title, - URL: urlStr, - Description: desc, - PublishedDate: pubDate, - Language: lang, - }) - } - - original := braveResp.Query.Original - display := braveResp.Query.Display - if original == "" { - original = req.Query - } - if display == "" { - display = req.Query - } - - c.JSON(http.StatusOK, gin.H{ - "results": results, - "query": gin.H{ - "original": original, - "display": display, - }, - "count": len(results), - }) - } else if searchSettings.SearchAPIProvider == "serper" { - // TODO: Implement Serper API integration for news - c.JSON(http.StatusNotImplemented, gin.H{"error": "Serper API integration not yet implemented"}) - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "No valid search API provider configured. Please configure a search API provider in settings."}) - } -} - -// GetSearchSuggestions handles GET /api/v1/search/suggestions -func GetSearchSuggestions(c *gin.Context) { - query := c.Query("q") - if query == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"}) - return - } - - // For now, return empty suggestions - // In a real implementation, you might want to implement autocomplete - // using Brave's autocomplete API or your own suggestion engine - c.JSON(http.StatusOK, gin.H{ - "suggestions": []string{}, - "query": query, - }) -} diff --git a/backend/handlers/search_enhanced.go b/backend/handlers/search_enhanced.go deleted file mode 100644 index b285368..0000000 --- a/backend/handlers/search_enhanced.go +++ /dev/null @@ -1,523 +0,0 @@ -package handlers - -import ( - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/trackeep/backend/config" - "github.com/trackeep/backend/models" - "gorm.io/gorm" -) - -// SearchFilters represents the search filters -type SearchFilters struct { - Query string `json:"query" binding:"required"` - ContentType string `json:"content_type"` // 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files' - Tags []string `json:"tags"` - DateRange DateRange `json:"date_range"` - Author string `json:"author"` - Language string `json:"language"` - FileTypes []string `json:"file_types"` - IsFavorite *bool `json:"is_favorite"` - IsRead *bool `json:"is_read"` - IsPublic *bool `json:"is_public"` - Limit int `json:"limit"` - Offset int `json:"offset"` -} - -type DateRange struct { - Start time.Time `json:"start"` - End time.Time `json:"end"` -} - -// SearchResult represents a unified search result -type SearchResult struct { - ID uint `json:"id"` - Type string `json:"type"` // 'bookmark', 'task', 'note', 'file' - Title string `json:"title"` - Description string `json:"description"` - Content string `json:"content"` - Tags []models.Tag `json:"tags"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - URL string `json:"url,omitempty"` - Status string `json:"status,omitempty"` - Priority string `json:"priority,omitempty"` - DueDate *time.Time `json:"due_date,omitempty"` - IsFavorite bool `json:"is_favorite,omitempty"` - IsRead bool `json:"is_read,omitempty"` - IsPublic bool `json:"is_public,omitempty"` - Author string `json:"author,omitempty"` - FileSize int64 `json:"file_size,omitempty"` - MimeType string `json:"mime_type,omitempty"` - FileType string `json:"file_type,omitempty"` - Progress int `json:"progress,omitempty"` - Highights map[string][]string `json:"highlights,omitempty"` // Search highlights - Score float64 `json:"score"` // Relevance score -} - -// SearchResponse represents the search response -type SearchResponse struct { - Results []SearchResult `json:"results"` - Total int64 `json:"total"` - Query string `json:"query"` - Filters SearchFilters `json:"filters"` - Took int64 `json:"took"` // Time taken in milliseconds - Suggestions []string `json:"suggestions"` // Search suggestions - Aggregations map[string]int `json:"aggregations"` // Content type counts -} - -// EnhancedSearch handles POST /api/v1/search/enhanced -func EnhancedSearch(c *gin.Context) { - var filters SearchFilters - if err := c.ShouldBindJSON(&filters); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Set defaults - if filters.ContentType == "" { - filters.ContentType = "all" - } - if filters.Limit == 0 { - filters.Limit = 20 - } - if filters.Limit > 100 { - filters.Limit = 100 - } - - startTime := time.Now() - db := config.GetDB() - userID := c.GetUint("user_id") - - var results []SearchResult - var total int64 - aggregations := make(map[string]int) - - // Search based on content type - switch filters.ContentType { - case "bookmarks": - results, total = searchBookmarks(db, userID, filters) - aggregations["bookmarks"] = int(total) - case "tasks": - results, total = searchTasks(db, userID, filters) - aggregations["tasks"] = int(total) - case "notes": - results, total = searchNotes(db, userID, filters) - aggregations["notes"] = int(total) - case "files": - results, total = searchFiles(db, userID, filters) - aggregations["files"] = int(total) - default: // all - bookmarkResults, bookmarkTotal := searchBookmarks(db, userID, filters) - taskResults, taskTotal := searchTasks(db, userID, filters) - noteResults, noteTotal := searchNotes(db, userID, filters) - fileResults, fileTotal := searchFiles(db, userID, filters) - - results = append(append(append(bookmarkResults, taskResults...), noteResults...), fileResults...) - total = bookmarkTotal + taskTotal + noteTotal + fileTotal - - aggregations["bookmarks"] = int(bookmarkTotal) - aggregations["tasks"] = int(taskTotal) - aggregations["notes"] = int(noteTotal) - aggregations["files"] = int(fileTotal) - } - - // Apply pagination - if filters.Offset > 0 && len(results) > filters.Offset { - results = results[filters.Offset:] - } - if len(results) > filters.Limit { - results = results[:filters.Limit] - } - - // Get search suggestions - suggestions := getSearchSuggestions(db, userID, filters.Query) - - // Calculate time taken - took := time.Since(startTime).Milliseconds() - - response := SearchResponse{ - Results: results, - Total: total, - Query: filters.Query, - Filters: filters, - Took: took, - Suggestions: suggestions, - Aggregations: aggregations, - } - - c.JSON(http.StatusOK, response) -} - -// searchBookmarks searches bookmarks with filters -func searchBookmarks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) { - var bookmarks []models.Bookmark - var results []SearchResult - - query := db.Where("user_id = ?", userID) - - // Text search - if filters.Query != "" { - searchTerm := "%" + strings.ToLower(filters.Query) + "%" - query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ? OR LOWER(url) LIKE ?", - searchTerm, searchTerm, searchTerm, searchTerm) - } - - // Tags filter - if len(filters.Tags) > 0 { - query = query.Joins("JOIN bookmark_tags ON bookmarks.id = bookmark_tags.bookmark_id"). - Joins("JOIN tags ON bookmark_tags.tag_id = tags.id"). - Where("tags.name IN ?", filters.Tags) - } - - // Date range filter - if !filters.DateRange.Start.IsZero() { - query = query.Where("created_at >= ?", filters.DateRange.Start) - } - if !filters.DateRange.End.IsZero() { - query = query.Where("created_at <= ?", filters.DateRange.End) - } - - // Boolean filters - if filters.IsFavorite != nil { - query = query.Where("is_favorite = ?", *filters.IsFavorite) - } - if filters.IsRead != nil { - query = query.Where("is_read = ?", *filters.IsRead) - } - - // Author filter - if filters.Author != "" { - query = query.Where("LOWER(author) LIKE ?", "%"+strings.ToLower(filters.Author)+"%") - } - - // Count total - var total int64 - query.Model(&models.Bookmark{}).Count(&total) - - // Get results with tags - if err := query.Preload("Tags").Find(&bookmarks).Error; err != nil { - return results, 0 - } - - // Convert to search results - for _, bookmark := range bookmarks { - result := SearchResult{ - ID: bookmark.ID, - Type: "bookmark", - Title: bookmark.Title, - Description: bookmark.Description, - Content: bookmark.Content, - Tags: bookmark.Tags, - CreatedAt: bookmark.CreatedAt, - UpdatedAt: bookmark.UpdatedAt, - URL: bookmark.URL, - IsFavorite: bookmark.IsFavorite, - IsRead: bookmark.IsRead, - Author: bookmark.Author, - Score: calculateRelevanceScore(filters.Query, bookmark.Title, bookmark.Description, bookmark.Content), - } - - if bookmark.PublishedAt != nil { - result.DueDate = bookmark.PublishedAt // Using DueDate field for published date - } - - results = append(results, result) - } - - return results, total -} - -// searchTasks searches tasks with filters -func searchTasks(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) { - var tasks []models.Task - var results []SearchResult - - query := db.Where("user_id = ?", userID) - - // Text search - if filters.Query != "" { - searchTerm := "%" + strings.ToLower(filters.Query) + "%" - query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm) - } - - // Tags filter - if len(filters.Tags) > 0 { - query = query.Joins("JOIN task_tags ON tasks.id = task_tags.task_id"). - Joins("JOIN tags ON task_tags.tag_id = tags.id"). - Where("tags.name IN ?", filters.Tags) - } - - // Date range filter - if !filters.DateRange.Start.IsZero() { - query = query.Where("created_at >= ?", filters.DateRange.Start) - } - if !filters.DateRange.End.IsZero() { - query = query.Where("created_at <= ?", filters.DateRange.End) - } - - // Count total - var total int64 - query.Model(&models.Task{}).Count(&total) - - // Get results with tags - if err := query.Preload("Tags").Find(&tasks).Error; err != nil { - return results, 0 - } - - // Convert to search results - for _, task := range tasks { - result := SearchResult{ - ID: task.ID, - Type: "task", - Title: task.Title, - Description: task.Description, - Tags: task.Tags, - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, - Status: string(task.Status), - Priority: string(task.Priority), - DueDate: task.DueDate, - Progress: task.Progress, - Score: calculateRelevanceScore(filters.Query, task.Title, task.Description, ""), - } - - results = append(results, result) - } - - return results, total -} - -// searchNotes searches notes with filters -func searchNotes(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) { - var notes []models.Note - var results []SearchResult - - query := db.Where("user_id = ?", userID) - - // Text search - if filters.Query != "" { - searchTerm := "%" + strings.ToLower(filters.Query) + "%" - query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?", - searchTerm, searchTerm, searchTerm) - } - - // Tags filter - if len(filters.Tags) > 0 { - query = query.Joins("JOIN note_tags ON notes.id = note_tags.note_id"). - Joins("JOIN tags ON note_tags.tag_id = tags.id"). - Where("tags.name IN ?", filters.Tags) - } - - // Date range filter - if !filters.DateRange.Start.IsZero() { - query = query.Where("created_at >= ?", filters.DateRange.Start) - } - if !filters.DateRange.End.IsZero() { - query = query.Where("created_at <= ?", filters.DateRange.End) - } - - // Boolean filters - if filters.IsPublic != nil { - query = query.Where("is_public = ?", *filters.IsPublic) - } - - // Count total - var total int64 - query.Model(&models.Note{}).Count(&total) - - // Get results with tags - if err := query.Preload("Tags").Find(¬es).Error; err != nil { - return results, 0 - } - - // Convert to search results - for _, note := range notes { - result := SearchResult{ - ID: note.ID, - Type: "note", - Title: note.Title, - Description: note.Description, - Content: note.Content, - Tags: note.Tags, - CreatedAt: note.CreatedAt, - UpdatedAt: note.UpdatedAt, - IsPublic: note.IsPublic, - Score: calculateRelevanceScore(filters.Query, note.Title, note.Description, note.Content), - } - - results = append(results, result) - } - - return results, total -} - -// searchFiles searches files with filters -func searchFiles(db *gorm.DB, userID uint, filters SearchFilters) ([]SearchResult, int64) { - var files []models.File - var results []SearchResult - - query := db.Where("user_id = ?", userID) - - // Text search - if filters.Query != "" { - searchTerm := "%" + strings.ToLower(filters.Query) + "%" - query = query.Where("LOWER(original_name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(content) LIKE ?", - searchTerm, searchTerm, searchTerm) - } - - // Tags filter - if len(filters.Tags) > 0 { - query = query.Joins("JOIN file_tags ON files.id = file_tags.file_id"). - Joins("JOIN tags ON file_tags.tag_id = tags.id"). - Where("tags.name IN ?", filters.Tags) - } - - // Date range filter - if !filters.DateRange.Start.IsZero() { - query = query.Where("created_at >= ?", filters.DateRange.Start) - } - if !filters.DateRange.End.IsZero() { - query = query.Where("created_at <= ?", filters.DateRange.End) - } - - // File type filter - if len(filters.FileTypes) > 0 { - query = query.Where("file_type IN ?", filters.FileTypes) - } - - // Boolean filters - if filters.IsPublic != nil { - query = query.Where("is_public = ?", *filters.IsPublic) - } - - // Count total - var total int64 - query.Model(&models.File{}).Count(&total) - - // Get results with tags - if err := query.Preload("Tags").Find(&files).Error; err != nil { - return results, 0 - } - - // Convert to search results - for _, file := range files { - result := SearchResult{ - ID: file.ID, - Type: "file", - Title: file.OriginalName, - Description: file.Description, - Content: file.Content, - Tags: file.Tags, - CreatedAt: file.CreatedAt, - UpdatedAt: file.UpdatedAt, - FileSize: file.FileSize, - MimeType: file.MimeType, - FileType: string(file.FileType), - IsPublic: file.IsPublic, - Score: calculateRelevanceScore(filters.Query, file.OriginalName, file.Description, file.Content), - } - - results = append(results, result) - } - - return results, total -} - -// calculateRelevanceScore calculates a simple relevance score for search results -func calculateRelevanceScore(query, title, description, content string) float64 { - if query == "" { - return 1.0 - } - - queryLower := strings.ToLower(query) - titleLower := strings.ToLower(title) - descLower := strings.ToLower(description) - contentLower := strings.ToLower(content) - - score := 0.0 - - // Title matches are most important - if strings.Contains(titleLower, queryLower) { - score += 10.0 - if strings.HasPrefix(titleLower, queryLower) { - score += 5.0 // Bonus for prefix match - } - } - - // Description matches - if strings.Contains(descLower, queryLower) { - score += 5.0 - } - - // Content matches - if strings.Contains(contentLower, queryLower) { - score += 2.0 - } - - // Word-based scoring - queryWords := strings.Fields(queryLower) - for _, word := range queryWords { - if strings.Contains(titleLower, word) { - score += 3.0 - } - if strings.Contains(descLower, word) { - score += 1.5 - } - if strings.Contains(contentLower, word) { - score += 1.0 - } - } - - return score -} - -// getSearchSuggestions gets search suggestions based on user's search history and popular content -func getSearchSuggestions(db *gorm.DB, userID uint, query string) []string { - // For now, return empty suggestions - // In a future implementation, this could: - // - Look at user's search history - // - Suggest popular tags - // - Suggest based on content titles - // - Use AI to generate semantic suggestions - return []string{} -} - -// SaveSearch handles POST /api/v1/search/save -func SaveSearch(c *gin.Context) { - var req struct { - Query string `json:"query" binding:"required"` - Filters SearchFilters `json:"filters"` - Name string `json:"name" binding:"required"` - Alert bool `json:"alert"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // TODO: Implement saved searches functionality - // This would require a SavedSearch model - - c.JSON(http.StatusNotImplemented, gin.H{ - "message": "Saved searches functionality coming soon", - }) -} - -// GetSearchAnalytics handles GET /api/v1/search/analytics -func GetSearchAnalytics(c *gin.Context) { - // TODO: Implement search analytics - // This could include: - // - Most searched terms - // - Search frequency over time - // - Content type distribution - // - Popular filters - - c.JSON(http.StatusNotImplemented, gin.H{ - "message": "Search analytics functionality coming soon", - }) -} diff --git a/backend/handlers/search_settings.go b/backend/handlers/search_settings.go deleted file mode 100644 index 3ebbdbb..0000000 --- a/backend/handlers/search_settings.go +++ /dev/null @@ -1,184 +0,0 @@ -package handlers - -import ( - "net/http" - "os" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/trackeep/backend/models" -) - -// SearchSettings represents search API configuration -type SearchSettings struct { - BraveAPIKey string `json:"brave_api_key"` - BraveSearchBaseURL string `json:"brave_search_base_url"` - SerperAPIKey string `json:"serper_api_key"` - SerperBaseURL string `json:"serper_base_url"` - SearchAPIProvider string `json:"search_api_provider"` - SearchResultsLimit int `json:"search_results_limit"` - SearchCacheTTL int `json:"search_cache_ttl"` - SearchRateLimit int `json:"search_rate_limit"` -} - -// GetSearchSettings handles GET /api/v1/auth/search/settings -func GetSearchSettings(c *gin.Context) { - userID := c.GetInt("user_id") - - // Get settings from database - settings, err := models.GetUserSearchSettings(uint(userID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get settings"}) - return - } - - // Convert to response format - response := SearchSettings{ - BraveSearchBaseURL: settings.BraveSearchBaseURL, - SerperBaseURL: settings.SerperBaseURL, - SearchAPIProvider: settings.SearchAPIProvider, - SearchResultsLimit: settings.SearchResultsLimit, - SearchCacheTTL: settings.SearchCacheTTL, - SearchRateLimit: settings.SearchRateLimit, - } - - // Mask API keys for security - if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 { - response.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:] - } - if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 { - response.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:] - } - - c.JSON(http.StatusOK, response) -} - -// UpdateSearchSettings handles PUT /api/v1/auth/search/settings -func UpdateSearchSettings(c *gin.Context) { - userID := c.GetInt("user_id") - - var newSettings SearchSettings - if err := c.ShouldBindJSON(&newSettings); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get existing settings to preserve API keys if they're masked - existingSettings, err := models.GetUserSearchSettings(uint(userID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing settings"}) - return - } - - // Check if API keys are masked and preserve existing values - if len(newSettings.BraveAPIKey) > 8 && newSettings.BraveAPIKey[4:12] == "********" { - newSettings.BraveAPIKey = existingSettings.BraveAPIKey - } - if len(newSettings.SerperAPIKey) > 8 && newSettings.SerperAPIKey[4:12] == "********" { - newSettings.SerperAPIKey = existingSettings.SerperAPIKey - } - - // Update model - updatedSettings := &models.UserSearchSettings{ - BraveAPIKey: newSettings.BraveAPIKey, - BraveSearchBaseURL: newSettings.BraveSearchBaseURL, - SerperAPIKey: newSettings.SerperAPIKey, - SerperBaseURL: newSettings.SerperBaseURL, - SearchAPIProvider: newSettings.SearchAPIProvider, - SearchResultsLimit: newSettings.SearchResultsLimit, - SearchCacheTTL: newSettings.SearchCacheTTL, - SearchRateLimit: newSettings.SearchRateLimit, - } - - // Save to database - err = models.SaveUserSearchSettings(uint(userID), updatedSettings) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"}) - return - } - - // Return masked settings for consistency - GetSearchSettings(c) -} - -// GetTestSearchSettings handles GET /api/v1/test-search-settings (for demo mode) -func GetTestSearchSettings(c *gin.Context) { - settings := getDefaultSearchSettings() - - // Mask API keys for security - if settings.BraveAPIKey != "" && len(settings.BraveAPIKey) > 8 { - settings.BraveAPIKey = settings.BraveAPIKey[:4] + "********" + settings.BraveAPIKey[len(settings.BraveAPIKey)-4:] - } - if settings.SerperAPIKey != "" && len(settings.SerperAPIKey) > 8 { - settings.SerperAPIKey = settings.SerperAPIKey[:4] + "********" + settings.SerperAPIKey[len(settings.SerperAPIKey)-4:] - } - - c.JSON(http.StatusOK, settings) -} - -// GetSearchSettingsForAPI returns unmasked search settings for internal API use -func GetSearchSettingsForAPI(userID int) (SearchSettings, error) { - settings, err := models.GetUserSearchSettings(uint(userID)) - if err != nil { - // Return default settings if error - defaultSettings := getDefaultSearchSettings() - return defaultSettings, nil - } - - return SearchSettings{ - BraveAPIKey: settings.BraveAPIKey, - BraveSearchBaseURL: settings.BraveSearchBaseURL, - SerperAPIKey: settings.SerperAPIKey, - SerperBaseURL: settings.SerperBaseURL, - SearchAPIProvider: settings.SearchAPIProvider, - SearchResultsLimit: settings.SearchResultsLimit, - SearchCacheTTL: settings.SearchCacheTTL, - SearchRateLimit: settings.SearchRateLimit, - }, nil -} - -func getDefaultSearchSettings() SearchSettings { - return SearchSettings{ - BraveAPIKey: getEnvWithDefault("BRAVE_API_KEY", "BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT"), - BraveSearchBaseURL: getEnvWithDefault("BRAVE_SEARCH_BASE_URL", "https://api.search.brave.com/res/v1/web/search"), - SerperAPIKey: getEnvWithDefault("SERPER_API_KEY", "6f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"), - SerperBaseURL: getEnvWithDefault("SERPER_BASE_URL", "https://google.serper.dev/search"), - SearchAPIProvider: getEnvWithDefault("SEARCH_API_PROVIDER", "brave"), - SearchResultsLimit: getIntEnvWithDefault("SEARCH_RESULTS_LIMIT", 10), - SearchCacheTTL: getIntEnvWithDefault("SEARCH_CACHE_TTL", 300), - SearchRateLimit: getIntEnvWithDefault("SEARCH_RATE_LIMIT", 100), - } -} - -func getEnvWithDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -func getIntEnvWithDefault(key string, defaultValue int) int { - value := os.Getenv(key) - if value == "" { - return defaultValue - } - - if intValue, err := strconv.Atoi(value); err == nil { - return intValue - } - - return defaultValue -} - -func getBoolEnvWithDefault(key string, defaultValue bool) bool { - value := os.Getenv(key) - if value == "" { - return defaultValue - } - - if value == "true" || value == "1" { - return true - } - - return false -} diff --git a/backend/handlers/semantic_search.go b/backend/handlers/semantic_search.go deleted file mode 100644 index 64647c8..0000000 --- a/backend/handlers/semantic_search.go +++ /dev/null @@ -1,603 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "log" - "math" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/trackeep/backend/config" - "github.com/trackeep/backend/models" - "gorm.io/gorm" -) - -// SemanticSearchRequest represents a semantic search request -type SemanticSearchRequest struct { - Query string `json:"query" binding:"required"` - ContentType string `json:"content_type"` // all | bookmarks | tasks | notes | files | calendar_events | youtube_videos | learning_paths | chat_messages - Limit int `json:"limit"` - Threshold float64 `json:"threshold"` // Similarity threshold (0-1) -} - -// SemanticSearchResponse represents semantic search response -type SemanticSearchResponse struct { - Results []SemanticSearchResult `json:"results"` - Query string `json:"query"` - Took int64 `json:"took"` - Model string `json:"model"` -} - -// SemanticSearchResult represents a semantic search result -type SemanticSearchResult struct { - ID uint `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - Description string `json:"description"` - Content string `json:"content"` - Similarity float64 `json:"similarity"` - Highlights []string `json:"highlights"` - Tags []models.Tag `json:"tags,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - URL string `json:"url,omitempty"` - Status string `json:"status,omitempty"` - Priority string `json:"priority,omitempty"` -} - -// GenerateEmbeddingRequest represents request to generate embeddings -type GenerateEmbeddingRequest struct { - Text string `json:"text" binding:"required"` - ContentType string `json:"content_type"` - ContentID uint `json:"content_id"` -} - -// GenerateEmbeddingResponse represents embedding generation response -type GenerateEmbeddingResponse struct { - Embedding []float64 `json:"embedding"` - Model string `json:"model"` - Dimensions int `json:"dimensions"` - Success bool `json:"success"` - Message string `json:"message"` -} - -// SemanticSearch handles POST /api/v1/search/semantic -func SemanticSearch(c *gin.Context) { - var req SemanticSearchRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Set defaults - if req.Limit == 0 { - req.Limit = 20 - } - if req.Threshold == 0 { - req.Threshold = 0.7 // Default similarity threshold - } - - startTime := time.Now() - db := config.GetDB() - userID := c.GetUint("user_id") - - // Generate embedding for the search query - queryEmbedding, err := generateEmbedding(req.Query) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to generate query embedding", - "details": err.Error(), - }) - return - } - - // Search for similar content - results, err := findSimilarContent(db, userID, queryEmbedding, req.ContentType, req.Limit, req.Threshold) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to search similar content", - "details": err.Error(), - }) - return - } - - took := time.Since(startTime).Milliseconds() - - response := SemanticSearchResponse{ - Results: results, - Query: req.Query, - Took: took, - Model: "text-embedding-ada-002", - } - - c.JSON(http.StatusOK, response) -} - -// GenerateEmbedding handles POST /api/v1/search/embeddings/generate -func GenerateEmbedding(c *gin.Context) { - var req GenerateEmbeddingRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Generate embedding - embedding, err := generateEmbedding(req.Text) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to generate embedding", - "details": err.Error(), - }) - return - } - - // Store embedding if content reference is provided - if req.ContentType != "" && req.ContentID > 0 { - db := config.GetDB() - userID := c.GetUint("user_id") - - embeddingJSON, _ := json.Marshal(embedding) - - contentEmbedding := models.ContentEmbedding{ - ContentType: req.ContentType, - ContentID: req.ContentID, - Embedding: string(embeddingJSON), - Model: "text-embedding-ada-002", - Dimensions: len(embedding), - TextContent: req.Text, - UserID: userID, - } - - if err := db.Create(&contentEmbedding).Error; err != nil { - // Log error but don't fail the request - log.Printf("Failed to store embedding: %v", err) - } - } - - response := GenerateEmbeddingResponse{ - Embedding: embedding, - Model: "text-embedding-ada-002", - Dimensions: len(embedding), - Success: true, - Message: "Embedding generated successfully", - } - - c.JSON(http.StatusOK, response) -} - -// ReindexContent handles POST /api/v1/search/reindex -func ReindexContent(c *gin.Context) { - db := config.GetDB() - userID := c.GetUint("user_id") - - // Start background job to reindex all content - go func() { - reindexUserContent(db, userID) - }() - - c.JSON(http.StatusOK, gin.H{ - "message": "Content reindexing started in background", - "status": "processing", - }) -} - -// generateEmbedding generates embedding for text using OpenAI API (mock implementation) -func generateEmbedding(text string) ([]float64, error) { - // TODO: Replace with actual OpenAI API call - // For now, return a mock embedding for demonstration - embedding := make([]float64, 1536) // OpenAI embedding dimensions - - // Generate pseudo-random but deterministic embedding based on text - hash := simpleHash(text) - for i := range embedding { - embedding[i] = math.Sin(float64(hash+i)) * 0.5 - } - - return embedding, nil -} - -// simpleHash creates a simple hash from string -func simpleHash(s string) int { - hash := 0 - for _, char := range s { - hash = hash*31 + int(char) - } - return hash -} - -// findSimilarContent finds content similar to the given embedding -func findSimilarContent(db *gorm.DB, userID uint, queryEmbedding []float64, contentType string, limit int, threshold float64) ([]SemanticSearchResult, error) { - var results []SemanticSearchResult - - // Get all embeddings for the user - var embeddings []models.ContentEmbedding - query := db.Where("user_id = ?", userID) - - if contentType != "all" && contentType != "" { - query = query.Where("content_type = ?", normalizeSemanticContentType(contentType)) - } - - if err := query.Find(&embeddings).Error; err != nil { - return results, err - } - - // Calculate similarity scores - type similarityScore struct { - embedding models.ContentEmbedding - score float64 - } - - var scores []similarityScore - - for _, embedding := range embeddings { - var storedEmbedding []float64 - if err := json.Unmarshal([]byte(embedding.Embedding), &storedEmbedding); err != nil { - continue - } - - similarity := cosineSimilarity(queryEmbedding, storedEmbedding) - if similarity >= threshold { - scores = append(scores, similarityScore{ - embedding: embedding, - score: similarity, - }) - } - } - - // Sort by similarity (descending) - for i := 0; i < len(scores)-1; i++ { - for j := i + 1; j < len(scores); j++ { - if scores[i].score < scores[j].score { - scores[i], scores[j] = scores[j], scores[i] - } - } - } - - // Limit results - if len(scores) > limit { - scores = scores[:limit] - } - - // Fetch actual content and build results - for _, score := range scores { - result, err := buildSemanticSearchResult(db, score.embedding, score.score) - if err != nil { - continue - } - results = append(results, result) - } - - return results, nil -} - -// cosineSimilarity calculates cosine similarity between two vectors -func cosineSimilarity(a, b []float64) float64 { - if len(a) != len(b) { - return 0 - } - - var dotProduct, normA, normB float64 - - for i := range a { - dotProduct += a[i] * b[i] - normA += a[i] * a[i] - normB += b[i] * b[i] - } - - if normA == 0 || normB == 0 { - return 0 - } - - return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB)) -} - -// buildSemanticSearchResult builds a search result from embedding and content -func buildSemanticSearchResult(db *gorm.DB, embedding models.ContentEmbedding, similarity float64) (SemanticSearchResult, error) { - result := SemanticSearchResult{ - Similarity: similarity, - } - - switch embedding.ContentType { - case "bookmark": - var bookmark models.Bookmark - if err := db.Preload("Tags").First(&bookmark, embedding.ContentID).Error; err != nil { - return result, err - } - - result.ID = bookmark.ID - result.Type = "bookmark" - result.Title = bookmark.Title - result.Description = bookmark.Description - result.Content = bookmark.Content - result.Tags = bookmark.Tags - result.CreatedAt = bookmark.CreatedAt - result.UpdatedAt = bookmark.UpdatedAt - result.URL = bookmark.URL - - case "task": - var task models.Task - if err := db.Preload("Tags").First(&task, embedding.ContentID).Error; err != nil { - return result, err - } - - result.ID = task.ID - result.Type = "task" - result.Title = task.Title - result.Description = task.Description - result.Tags = task.Tags - result.CreatedAt = task.CreatedAt - result.UpdatedAt = task.UpdatedAt - result.Status = string(task.Status) - result.Priority = string(task.Priority) - - case "note": - var note models.Note - if err := db.Preload("Tags").First(¬e, embedding.ContentID).Error; err != nil { - return result, err - } - - result.ID = note.ID - result.Type = "note" - result.Title = note.Title - result.Description = note.Description - result.Content = note.Content - result.Tags = note.Tags - result.CreatedAt = note.CreatedAt - result.UpdatedAt = note.UpdatedAt - - case "file": - var file models.File - if err := db.Preload("Tags").First(&file, embedding.ContentID).Error; err != nil { - return result, err - } - - result.ID = file.ID - result.Type = "file" - result.Title = file.OriginalName - result.Description = file.Description - result.Content = file.Content - result.Tags = file.Tags - result.CreatedAt = file.CreatedAt - result.UpdatedAt = file.UpdatedAt - - case "calendar_event": - var event models.CalendarEvent - if err := db.First(&event, embedding.ContentID).Error; err != nil { - return result, err - } - - result.ID = event.ID - result.Type = "calendar_event" - result.Title = event.Title - result.Description = event.Description - result.Content = event.Description - result.CreatedAt = event.CreatedAt - result.UpdatedAt = event.UpdatedAt - result.Priority = event.Priority - - case "youtube_video": - var video models.VideoBookmark - if err := db.First(&video, embedding.ContentID).Error; err != nil { - return result, err - } - - result.ID = video.ID - result.Type = "youtube_video" - result.Title = video.Title - result.Description = video.Description - result.Content = video.Description - result.CreatedAt = video.CreatedAt - result.UpdatedAt = video.UpdatedAt - result.URL = video.URL - - case "learning_path": - var path models.LearningPath - if err := db.First(&path, embedding.ContentID).Error; err != nil { - return result, err - } - - result.ID = path.ID - result.Type = "learning_path" - result.Title = path.Title - result.Description = path.Description - result.Content = path.Description - result.CreatedAt = path.CreatedAt - result.UpdatedAt = path.UpdatedAt - - case "chat_message": - var message models.Message - if err := db.First(&message, embedding.ContentID).Error; err != nil { - return result, err - } - if message.IsSensitive { - return result, fmt.Errorf("sensitive message excluded from semantic search") - } - - result.ID = message.ID - result.Type = "chat_message" - result.Title = "Chat message" - result.Description = compactSemanticText(message.Body, 140) - result.Content = message.Body - result.CreatedAt = message.CreatedAt - result.UpdatedAt = message.UpdatedAt - result.URL = fmt.Sprintf("/app/messages?conversationId=%d&messageId=%d", message.ConversationID, message.ID) - } - - // Generate highlights (simplified) - result.Highlights = generateHighlights(embedding.TextContent, 3) - - return result, nil -} - -// generateHighlights generates text highlights -func generateHighlights(text string, count int) []string { - if text == "" { - return []string{} - } - - // Simple highlight generation - split into sentences and return first few - sentences := strings.Split(text, ".") - if len(sentences) > count { - sentences = sentences[:count] - } - - var highlights []string - for _, sentence := range sentences { - sentence = strings.TrimSpace(sentence) - if len(sentence) > 10 { - highlights = append(highlights, sentence+".") - } - if len(highlights) >= count { - break - } - } - - return highlights -} - -// reindexUserContent reindexes all content for a user -func reindexUserContent(db *gorm.DB, userID uint) { - log.Printf("Starting reindexing for user %d", userID) - - // Reindex bookmarks - var bookmarks []models.Bookmark - db.Where("user_id = ?", userID).Find(&bookmarks) - - for _, bookmark := range bookmarks { - text := bookmark.Title + " " + bookmark.Description + " " + bookmark.Content - upsertEmbedding(db, userID, "bookmark", bookmark.ID, text) - } - - // Tasks - var tasks []models.Task - db.Where("user_id = ?", userID).Find(&tasks) - for _, task := range tasks { - text := task.Title + " " + task.Description - upsertEmbedding(db, userID, "task", task.ID, text) - } - - // Notes - var notes []models.Note - db.Where("user_id = ?", userID).Find(¬es) - for _, note := range notes { - if note.IsEncrypted { - continue - } - text := note.Title + " " + note.Description + " " + note.Content - upsertEmbedding(db, userID, "note", note.ID, text) - } - - // Files - var files []models.File - db.Where("user_id = ?", userID).Find(&files) - for _, file := range files { - text := file.OriginalName + " " + file.Description + " " + file.Content - upsertEmbedding(db, userID, "file", file.ID, text) - } - - // Calendar events - var events []models.CalendarEvent - db.Where("user_id = ?", userID).Find(&events) - for _, event := range events { - text := event.Title + " " + event.Description + " " + event.Type + " " + event.Priority - upsertEmbedding(db, userID, "calendar_event", event.ID, text) - } - - // YouTube bookmarks - var videos []models.VideoBookmark - db.Where("user_id = ?", userID).Find(&videos) - for _, video := range videos { - text := video.Title + " " + video.Description + " " + video.Channel + " " + video.URL - upsertEmbedding(db, userID, "youtube_video", video.ID, text) - } - - // Learning paths - var learningPaths []models.LearningPath - db.Where("creator_id = ?", userID).Find(&learningPaths) - for _, path := range learningPaths { - text := path.Title + " " + path.Description + " " + path.Category + " " + path.Difficulty - upsertEmbedding(db, userID, "learning_path", path.ID, text) - } - - // Chat messages (skip sensitive/vault content) - var messages []models.Message - db.Model(&models.Message{}). - Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id"). - Joins("JOIN conversations ON conversations.id = messages.conversation_id"). - Where("cm.user_id = ?", userID). - Where("conversations.type <> ?", models.ConversationTypePasswordVault). - Where("messages.deleted_at IS NULL"). - Find(&messages) - for _, message := range messages { - if message.IsSensitive { - continue - } - upsertEmbedding(db, userID, "chat_message", message.ID, message.Body) - } - - log.Printf("Reindexing completed for user %d", userID) -} - -func upsertEmbedding(db *gorm.DB, userID uint, contentType string, contentID uint, text string) { - text = strings.TrimSpace(text) - if text == "" { - return - } - - embedding, err := generateEmbedding(text) - if err != nil { - return - } - - embeddingJSON, _ := json.Marshal(embedding) - - contentEmbedding := models.ContentEmbedding{ - ContentType: contentType, - ContentID: contentID, - Embedding: string(embeddingJSON), - Model: "text-embedding-ada-002", - Dimensions: len(embedding), - TextContent: text, - UserID: userID, - } - - db.Where("content_type = ? AND content_id = ? AND user_id = ?", contentType, contentID, userID).Delete(&models.ContentEmbedding{}) - db.Create(&contentEmbedding) -} - -func normalizeSemanticContentType(contentType string) string { - switch strings.ToLower(strings.TrimSpace(contentType)) { - case "bookmarks": - return "bookmark" - case "tasks": - return "task" - case "notes": - return "note" - case "files": - return "file" - case "calendar_events": - return "calendar_event" - case "youtube_videos": - return "youtube_video" - case "learning_paths": - return "learning_path" - case "chat_messages": - return "chat_message" - default: - return strings.ToLower(strings.TrimSpace(contentType)) - } -} - -func compactSemanticText(text string, limit int) string { - text = strings.TrimSpace(text) - if len(text) <= limit { - return text - } - if limit < 4 { - return text - } - return strings.TrimSpace(text[:limit-3]) + "..." -} diff --git a/backend/handlers/update_settings.go b/backend/handlers/update_settings.go index 556ac6a..acc1a72 100644 --- a/backend/handlers/update_settings.go +++ b/backend/handlers/update_settings.go @@ -2,6 +2,8 @@ package handlers import ( "net/http" + "os" + "strconv" "github.com/gin-gonic/gin" "github.com/trackeep/backend/config" @@ -98,3 +100,19 @@ func getDefaultUpdateSettings() UpdateSettings { PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false), } } + +func getBoolEnvWithDefault(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if parsed, err := strconv.ParseBool(value); err == nil { + return parsed + } + } + return defaultValue +} + +func getEnvWithDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/backend/main.go b/backend/main.go index b2a1f08..d8d8762 100644 --- a/backend/main.go +++ b/backend/main.go @@ -215,7 +215,6 @@ func main() { goalsHabitsHandler := handlers.NewGoalsHabitsHandler(config.GetDB()) socialHandler := handlers.NewSocialHandler(config.GetDB()) teamsHandler := handlers.NewTeamsHandler(config.GetDB()) - aiRecommendationHandler := handlers.NewAIRecommendationHandler(config.GetDB()) marketplaceHandler := handlers.NewMarketplaceHandler(config.GetDB()) communityHandler := handlers.NewCommunityHandler(config.GetDB()) performanceHandler := handlers.NewPerformanceHandler(config.GetDB()) @@ -256,8 +255,6 @@ func main() { github.GET("/activity", handlers.GetGitHubActivity) } - v1.POST("/youtube-search-test", handlers.YouTubeSearchTest) - // Protected auth routes (with demo mode protection) authProtected := v1.Group("/auth") authProtected.Use(handlers.AuthMiddleware()) @@ -280,25 +277,13 @@ func main() { authProtected.POST("/decrypt/content", handlers.DecryptNoteContent) authProtected.GET("/encryption/status", handlers.GetEncryptionStatus) - // AI Settings routes - authProtected.GET("/ai/settings", handlers.GetAISettings) - authProtected.PUT("/ai/settings", handlers.UpdateAISettings) - authProtected.POST("/ai/test-connection", handlers.TestAIConnection) - - // Search Settings routes - authProtected.GET("/search/settings", handlers.GetSearchSettings) - authProtected.PUT("/search/settings", handlers.UpdateSearchSettings) - // Update Settings routes authProtected.GET("/update/settings", handlers.GetUpdateSettings) authProtected.PUT("/update/settings", handlers.UpdateUpdateSettings) } - // Test AI settings without auth - v1.GET("/test-ai-settings", handlers.GetAISettings) - // Test search and update settings without auth (for demo mode) - v1.GET("/test-search-settings", handlers.GetTestSearchSettings) + // Test update settings without auth (for demo mode) v1.GET("/test-update-settings", handlers.GetTestUpdateSettings) // Dashboard routes (protected) @@ -435,46 +420,6 @@ func main() { notes.GET("/:id/encrypted", handlers.GetEncryptedNote) } - // Chat routes (protected) - chat := v1.Group("/chat") - chat.Use(handlers.AuthMiddleware()) - { - chat.POST("/send", handlers.SendMessage) - chat.GET("/sessions", handlers.GetSessions) - chat.GET("/sessions/:id/messages", handlers.GetSessionMessages) - chat.DELETE("/sessions/:id", handlers.DeleteSession) - } - - // Messaging routes (Discord-like user communication) - messages := v1.Group("/messages") - messages.Use(handlers.AuthMiddleware()) - { - messages.GET("/conversations", handlers.GetConversations) - messages.POST("/conversations", handlers.CreateConversation) - messages.GET("/conversations/:id", handlers.GetConversation) - messages.PATCH("/conversations/:id", handlers.UpdateConversation) - messages.POST("/conversations/:id/members", handlers.AddConversationMember) - messages.DELETE("/conversations/:id/members/:userId", handlers.RemoveConversationMember) - messages.GET("/conversations/:id/messages", handlers.GetConversationMessages) - messages.POST("/conversations/:id/messages", handlers.CreateConversationMessage) - messages.PATCH("/messages/:id", handlers.UpdateMessage) - messages.DELETE("/messages/:id", handlers.DeleteMessage) - messages.POST("/messages/:id/reactions", handlers.AddMessageReaction) - messages.DELETE("/messages/:id/reactions/:emoji", handlers.RemoveMessageReaction) - messages.POST("/messages/search", handlers.SearchMessages) - messages.GET("/messages/:id/suggestions", handlers.GetMessageSuggestions) - messages.POST("/messages/:id/suggestions/:suggestionId/accept", handlers.AcceptMessageSuggestion) - messages.POST("/messages/:id/suggestions/:suggestionId/dismiss", handlers.DismissMessageSuggestion) - messages.POST("/messages/:id/reveal-sensitive", handlers.RevealSensitiveMessage) - messages.GET("/ws", handlers.MessagesWebSocket) - - messages.GET("/password-vault/items", handlers.GetPasswordVaultItems) - messages.POST("/password-vault/items", handlers.CreatePasswordVaultItem) - messages.POST("/password-vault/items/:id/share", handlers.SharePasswordVaultItem) - messages.POST("/password-vault/items/:id/reveal", handlers.RevealPasswordVaultItem) - messages.POST("/password-vault/items/:id/unshare", handlers.UnsharePasswordVaultItem) - } - // Member routes (protected) members := v1.Group("/members") members.Use(handlers.AuthMiddleware()) @@ -515,37 +460,6 @@ func main() { videoBookmarks.POST("/:id/toggle-favorite", videoBookmarkHandler.ToggleFavorite) } - // Search routes (protected) - search := v1.Group("/search") - search.Use(handlers.AuthMiddleware()) - { - search.POST("/web", handlers.SearchWeb) - search.POST("/news", handlers.SearchNews) - search.GET("/suggestions", handlers.GetSearchSuggestions) - - // Enhanced search features - search.POST("/enhanced", handlers.EnhancedSearch) - search.POST("/save", handlers.SaveSearch) - search.GET("/analytics", handlers.GetSearchAnalytics) - - // Saved searches management - savedSearches := search.Group("/saved") - { - savedSearches.POST("", handlers.CreateSavedSearch) - savedSearches.GET("", handlers.GetUserSavedSearches) - savedSearches.GET("/:id", handlers.GetSavedSearch) - savedSearches.PUT("/:id", handlers.UpdateSavedSearch) - savedSearches.DELETE("/:id", handlers.DeleteSavedSearch) - savedSearches.POST("/:id/run", handlers.RunSavedSearch) - savedSearches.GET("/tags", handlers.GetSavedSearchTags) - } - - // Semantic search features - search.POST("/semantic", handlers.SemanticSearch) - search.POST("/embeddings/generate", handlers.GenerateEmbedding) - search.POST("/reindex", handlers.ReindexContent) - } - // Time tracking routes (protected) timeEntries := v1.Group("/time-entries") timeEntries.Use(handlers.AuthMiddleware()) @@ -575,30 +489,6 @@ func main() { calendar.PUT("/:id/toggle-complete", calendarHandler.ToggleEventCompletion) } - // AI Features routes (protected) - ai := v1.Group("/ai") - ai.Use(handlers.AuthMiddleware()) - { - // AI providers - ai.GET("/providers", handlers.GetAIProviders) - - // Content summarization - ai.POST("/summarize", handlers.SummarizeContent) - ai.GET("/summaries", handlers.GetAISummaries) - - // Task suggestions - ai.POST("/tasks/suggest", handlers.GetTaskSuggestions) - ai.GET("/tasks/suggestions", handlers.GetTaskSuggestionsList) - ai.POST("/tasks/suggestions/:id/accept", handlers.AcceptTaskSuggestion) - ai.POST("/tasks/suggestions/:id/dismiss", handlers.DismissTaskSuggestion) - - // Tag suggestions - ai.POST("/tags/suggest", handlers.GenerateTagSuggestions) - - // Content generation - ai.POST("/content/generate", handlers.GenerateContent) - } - // Integration routes (protected) integrations := v1.Group("/integrations") integrations.Use(handlers.AuthMiddleware()) @@ -742,19 +632,6 @@ func main() { teams.GET("/:id/stats", teamsHandler.GetTeamStats) } - // AI Recommendations routes (protected) - recommendations := v1.Group("/recommendations") - recommendations.Use(handlers.AuthMiddleware()) - { - recommendations.GET("", aiRecommendationHandler.GetRecommendations) - recommendations.GET("/stats", aiRecommendationHandler.GetRecommendationStats) - recommendations.PUT("/preferences", aiRecommendationHandler.UpdatePreferences) - recommendations.GET("/history", aiRecommendationHandler.GetRecommendationHistory) - recommendations.GET("/insights", aiRecommendationHandler.GetInsights) - recommendations.POST("/:id/interaction", aiRecommendationHandler.RecordInteraction) - recommendations.DELETE("/:id", aiRecommendationHandler.DeleteRecommendation) - } - // Marketplace routes (protected) marketplace := v1.Group("/marketplace") marketplace.Use(handlers.AuthMiddleware()) diff --git a/backend/models/ai_recommendation.go b/backend/models/ai_recommendation.go deleted file mode 100644 index 17e93de..0000000 --- a/backend/models/ai_recommendation.go +++ /dev/null @@ -1,153 +0,0 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// AIRecommendation represents an AI-generated recommendation -type AIRecommendation struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - // User information - UserID uint `json:"user_id" gorm:"not null;index"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - - // Recommendation details - RecommendationType string `json:"recommendation_type" gorm:"not null;index"` // content, task, learning, connection - ContentType string `json:"content_type" gorm:"index"` // bookmark, note, task, course, user - ContentID *uint `json:"content_id,omitempty" gorm:"index"` - Title string `json:"title" gorm:"not null"` - Description string `json:"description"` - Reasoning string `json:"reasoning"` // Why this was recommended - - // Content details (for display without additional queries) - ContentTitle string `json:"content_title"` - ContentURL string `json:"content_url"` - ContentPreview string `json:"content_preview"` - AuthorName string `json:"author_name"` - Tags string `json:"tags" gorm:"serializer:json"` - - // Recommendation metadata - Confidence float64 `json:"confidence" gorm:"default:0.0"` // 0.0 to 1.0 - Priority string `json:"priority" gorm:"default:medium"` // low, medium, high - Category string `json:"category"` // productivity, learning, collaboration, etc. - ExpiresAt *time.Time `json:"expires_at"` - Clicked bool `json:"clicked" gorm:"default:false"` - Dismissed bool `json:"dismissed" gorm:"default:false"` - ClickedAt *time.Time `json:"clicked_at"` - DismissedAt *time.Time `json:"dismissed_at"` - - // Feedback - Feedback string `json:"feedback"` // helpful, not_helpful, irrelevant - FeedbackAt *time.Time `json:"feedback_at"` - FeedbackText string `json:"feedback_text"` - - // Source information - SourceModel string `json:"source_model"` // Which AI model generated this - SourceVersion string `json:"source_version"` // Version of the recommendation engine - TrainingData string `json:"training_data"` // What data was used for training -} - -// UserPreference represents user preferences for recommendations -type UserPreference struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - // User information - UserID uint `json:"user_id" gorm:"not null;uniqueIndex"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - - // Recommendation preferences - EnableRecommendations bool `json:"enable_recommendations" gorm:"default:true"` - ContentRecommendations bool `json:"content_recommendations" gorm:"default:true"` - TaskRecommendations bool `json:"task_recommendations" gorm:"default:true"` - LearningRecommendations bool `json:"learning_recommendations" gorm:"default:true"` - ConnectionRecommendations bool `json:"connection_recommendations" gorm:"default:false"` - - // Frequency and timing - MaxRecommendationsPerDay int `json:"max_recommendations_per_day" gorm:"default:5"` - PreferredCategories []string `json:"preferred_categories" gorm:"serializer:json"` - BlockedCategories []string `json:"blocked_categories" gorm:"serializer:json"` - PreferredContentTypes []string `json:"preferred_content_types" gorm:"serializer:json"` - - // Quality thresholds - MinConfidenceThreshold float64 `json:"min_confidence_threshold" gorm:"default:0.6"` - MaxAgeHours int `json:"max_age_hours" gorm:"default:168"` // 1 week - - // Learning and adaptation - EnablePersonalization bool `json:"enable_personalization" gorm:"default:true"` - EnableFeedbackLearning bool `json:"enable_feedback_learning" gorm:"default:true"` - LastRecommendationAt *time.Time `json:"last_recommendation_at"` -} - -// RecommendationInteraction tracks user interactions with recommendations -type RecommendationInteraction struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - // Related entities - UserID uint `json:"user_id" gorm:"not null;index"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - RecommendationID uint `json:"recommendation_id" gorm:"not null;index"` - Recommendation AIRecommendation `json:"recommendation,omitempty" gorm:"foreignKey:RecommendationID"` - - // Interaction details - InteractionType string `json:"interaction_type" gorm:"not null;index"` // view, click, dismiss, feedback, share - InteractionData string `json:"interaction_data" gorm:"serializer:json"` // Additional context - Duration int `json:"duration"` // Time spent in seconds (for views) - Context string `json:"context"` // Where the interaction occurred (dashboard, search, etc.) - - // Machine learning features - UserActivityBefore string `json:"user_activity_before"` // What user was doing before - UserActivityAfter string `json:"user_activity_after"` // What user did after - SessionID string `json:"session_id"` - DeviceType string `json:"device_type"` -} - -// TableName returns the table name for AIRecommendation -func (AIRecommendation) TableName() string { - return "ai_recommendations" -} - -// TableName returns the table name for UserPreference -func (UserPreference) TableName() string { - return "user_preferences" -} - -// TableName returns the table name for RecommendationInteraction -func (RecommendationInteraction) TableName() string { - return "recommendation_interactions" -} - -// BeforeCreate hooks -func (r *AIRecommendation) BeforeCreate(tx *gorm.DB) error { - if r.Priority == "" { - r.Priority = "medium" - } - if r.Confidence == 0 { - r.Confidence = 0.5 - } - return nil -} - -func (up *UserPreference) BeforeCreate(tx *gorm.DB) error { - if up.MaxRecommendationsPerDay == 0 { - up.MaxRecommendationsPerDay = 5 - } - if up.MinConfidenceThreshold == 0 { - up.MinConfidenceThreshold = 0.6 - } - if up.MaxAgeHours == 0 { - up.MaxAgeHours = 168 // 1 week - } - return nil -} diff --git a/backend/models/ai_settings.go b/backend/models/ai_settings.go deleted file mode 100644 index 5d95576..0000000 --- a/backend/models/ai_settings.go +++ /dev/null @@ -1,62 +0,0 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// UserAISettings stores user-specific AI provider configurations -type UserAISettings struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - UserID uint `json:"user_id" gorm:"not null;uniqueIndex"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - - // Mistral Settings - MistralEnabled *bool `json:"mistral_enabled" gorm:"default:false"` - MistralAPIKey string `json:"-" gorm:"column:mistral_api_key"` // Encrypted - MistralModel string `json:"mistral_model" gorm:"default:mistral-small-latest"` - MistralModelThinking string `json:"mistral_model_thinking" gorm:"default:mistral-large-latest"` - - // Grok Settings - GrokEnabled *bool `json:"grok_enabled" gorm:"default:false"` - GrokAPIKey string `json:"-" gorm:"column:grok_api_key"` // Encrypted - GrokBaseURL string `json:"grok_base_url" gorm:"default:https://api.x.ai/v1"` - GrokModel string `json:"grok_model" gorm:"default:grok-4-1-fast-non-reasoning-latest"` - GrokModelThinking string `json:"grok_model_thinking" gorm:"default:grok-4-1-fast-reasoning-latest"` - - // DeepSeek Settings - DeepSeekEnabled *bool `json:"deepseek_enabled" gorm:"default:false"` - DeepSeekAPIKey string `json:"-" gorm:"column:deepseek_api_key"` // Encrypted - DeepSeekBaseURL string `json:"deepseek_base_url" gorm:"default:https://api.deepseek.com"` - DeepSeekModel string `json:"deepseek_model" gorm:"default:deepseek-chat"` - DeepSeekModelThinking string `json:"deepseek_model_thinking" gorm:"default:deepseek-reasoner"` - - // Ollama Settings - OllamaEnabled *bool `json:"ollama_enabled" gorm:"default:false"` - OllamaBaseURL string `json:"ollama_base_url" gorm:"default:http://localhost:11434"` - OllamaModel string `json:"ollama_model" gorm:"default:llama3.1"` - OllamaModelThinking string `json:"ollama_model_thinking" gorm:"default:llama3.1"` - - // LongCat Settings - LongCatEnabled *bool `json:"longcat_enabled" gorm:"default:false"` - LongCatAPIKey string `json:"-" gorm:"column:longcat_api_key"` // Encrypted - LongCatBaseURL string `json:"longcat_base_url" gorm:"default:https://api.longcat.chat"` - LongCatOpenAIEndpoint string `json:"longcat_openai_endpoint" gorm:"default:https://api.longcat.chat/openai"` - LongCatAnthropicEndpoint string `json:"longcat_anthropic_endpoint" gorm:"default:https://api.longcat.chat/anthropic"` - LongCatModel string `json:"longcat_model" gorm:"default:LongCat-Flash-Chat"` - LongCatModelThinking string `json:"longcat_model_thinking" gorm:"default:LongCat-Flash-Thinking"` - LongCatModelThinkingUpgraded string `json:"longcat_model_thinking_upgraded" gorm:"default:LongCat-Flash-Thinking-2601"` - LongCatFormat string `json:"longcat_format" gorm:"default:openai"` - - // OpenRouter Settings - OpenRouterEnabled *bool `json:"openrouter_enabled" gorm:"default:false"` - OpenRouterAPIKey string `json:"-" gorm:"column:openrouter_api_key"` // Encrypted - OpenRouterBaseURL string `json:"openrouter_base_url" gorm:"default:https://openrouter.ai/api"` - OpenRouterModel string `json:"openrouter_model" gorm:"default:openrouter/auto"` - OpenRouterModelThinking string `json:"openrouter_model_thinking" gorm:"default:openrouter/auto"` -} diff --git a/backend/models/chat.go b/backend/models/chat.go deleted file mode 100644 index 356ec64..0000000 --- a/backend/models/chat.go +++ /dev/null @@ -1,56 +0,0 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// ChatMessage represents a chat message in the AI conversation -type ChatMessage struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - UserID uint `json:"user_id" gorm:"not null;index"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - - Content string `json:"content" gorm:"not null"` - Role string `json:"role" gorm:"not null"` // "user" or "assistant" - - // Session tracking - SessionID string `json:"session_id" gorm:"not null;index"` - - // Metadata - TokenCount int `json:"token_count"` - ModelUsed string `json:"model_used"` - ProcessingMs int64 `json:"processing_ms"` - ContextItems []string `json:"context_items" gorm:"serializer:json"` // IDs of referenced items -} - -// ChatSession represents a chat session -type ChatSession struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - UserID uint `json:"user_id" gorm:"not null;index"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - - Title string `json:"title"` - - // Session metadata - MessageCount int `json:"message_count" gorm:"default:0"` - LastMessageAt *time.Time `json:"last_message_at"` - - // Context configuration - IncludeBookmarks bool `json:"include_bookmarks" gorm:"default:true"` - IncludeTasks bool `json:"include_tasks" gorm:"default:true"` - IncludeFiles bool `json:"include_files" gorm:"default:true"` - IncludeNotes bool `json:"include_notes" gorm:"default:true"` - - // Relationships - Messages []ChatMessage `json:"messages,omitempty" gorm:"foreignKey:SessionID"` -} diff --git a/backend/models/messages.go b/backend/models/messages.go deleted file mode 100644 index c8fe651..0000000 --- a/backend/models/messages.go +++ /dev/null @@ -1,200 +0,0 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// ConversationType represents the type of a conversation. -type ConversationType string - -const ( - ConversationTypeGlobal ConversationType = "global" - ConversationTypeTeam ConversationType = "team" - ConversationTypeGroup ConversationType = "group" - ConversationTypeDM ConversationType = "dm" - ConversationTypeSelf ConversationType = "self" - ConversationTypePasswordVault ConversationType = "password_vault" -) - -// ConversationMemberRole represents the role of a user in a conversation. -type ConversationMemberRole string - -const ( - ConversationMemberRoleOwner ConversationMemberRole = "owner" - ConversationMemberRoleAdmin ConversationMemberRole = "admin" - ConversationMemberRoleMember ConversationMemberRole = "member" - ConversationMemberRoleViewer ConversationMemberRole = "viewer" -) - -// SuggestionStatus is the lifecycle state of a message suggestion. -type SuggestionStatus string - -const ( - SuggestionStatusPending SuggestionStatus = "pending" - SuggestionStatusAccepted SuggestionStatus = "accepted" - SuggestionStatusDismissed SuggestionStatus = "dismissed" -) - -// Conversation is a user-to-user chat space (global/team/group/dm/self/password). -type Conversation struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - Type ConversationType `json:"type" gorm:"not null;index"` - Name string `json:"name" gorm:"not null"` - Topic string `json:"topic"` - TeamID *uint `json:"team_id,omitempty" gorm:"index"` - Team *Team `json:"team,omitempty" gorm:"foreignKey:TeamID"` - CreatedBy uint `json:"created_by" gorm:"not null;index"` - Creator User `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` - IsDefault bool `json:"is_default" gorm:"default:false;index"` - IsArchived bool `json:"is_archived" gorm:"default:false;index"` - - LastMessageAt *time.Time `json:"last_message_at"` - - Members []ConversationMember `json:"members,omitempty" gorm:"foreignKey:ConversationID"` - Messages []Message `json:"messages,omitempty" gorm:"foreignKey:ConversationID"` -} - -// ConversationMember links users to conversations. -type ConversationMember struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - ConversationID uint `json:"conversation_id" gorm:"not null;index:idx_conv_member,unique"` - UserID uint `json:"user_id" gorm:"not null;index:idx_conv_member,unique"` - Role ConversationMemberRole `json:"role" gorm:"not null;default:member"` - JoinedAt time.Time `json:"joined_at"` - - LastReadMessageID *uint `json:"last_read_message_id,omitempty" gorm:"index"` - LastReadAt *time.Time `json:"last_read_at,omitempty"` - MutedUntil *time.Time `json:"muted_until,omitempty"` - IsHidden bool `json:"is_hidden" gorm:"default:false"` - - Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` -} - -// Message is a single chat message in a conversation. -type Message struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - ConversationID uint `json:"conversation_id" gorm:"not null;index"` - Conversation Conversation `json:"conversation,omitempty" gorm:"foreignKey:ConversationID"` - SenderID uint `json:"sender_id" gorm:"not null;index"` - Sender User `json:"sender,omitempty" gorm:"foreignKey:SenderID"` - - Body string `json:"body" gorm:"type:text"` - IsSensitive bool `json:"is_sensitive" gorm:"default:false"` - EditedAt *time.Time `json:"edited_at,omitempty"` - DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"` - - MetadataJSON string `json:"metadata_json" gorm:"type:text"` - - Attachments []MessageAttachment `json:"attachments,omitempty" gorm:"foreignKey:MessageID"` - References []MessageReference `json:"references,omitempty" gorm:"foreignKey:MessageID"` - Suggestions []MessageSuggestion `json:"suggestions,omitempty" gorm:"foreignKey:MessageID"` - Reactions []MessageReaction `json:"reactions,omitempty" gorm:"foreignKey:MessageID"` -} - -// MessageAttachment represents file/link-style message attachments. -type MessageAttachment struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - MessageID uint `json:"message_id" gorm:"not null;index"` - Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` - - Kind string `json:"kind" gorm:"not null;index"` // file,image,youtube,github,website,bookmark,task,event,calendar,activity,learning_path,saved_search,voice_note - FileID *uint `json:"file_id,omitempty" gorm:"index"` - URL string `json:"url"` - Title string `json:"title"` - PreviewJSON string `json:"preview_json" gorm:"type:text"` -} - -// MessageReference maps chat messages to Trackeep entities. -type MessageReference struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - MessageID uint `json:"message_id" gorm:"not null;index"` - Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` - - EntityType string `json:"entity_type" gorm:"not null;index"` - EntityID uint `json:"entity_id" gorm:"not null;index"` - DeepLink string `json:"deep_link" gorm:"not null"` -} - -// MessageSuggestion stores non-blocking smart suggestions triggered by message text. -type MessageSuggestion struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - MessageID uint `json:"message_id" gorm:"not null;index"` - Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` - - Type string `json:"type" gorm:"not null;index"` // create_task, create_event, save_bookmark, ... - PayloadJSON string `json:"payload_json" gorm:"type:text"` - Status SuggestionStatus `json:"status" gorm:"not null;default:pending;index"` -} - -// MessageReaction stores emoji reactions. -type MessageReaction struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - MessageID uint `json:"message_id" gorm:"not null;index:idx_message_reaction,unique"` - Message Message `json:"message,omitempty" gorm:"foreignKey:MessageID"` - UserID uint `json:"user_id" gorm:"not null;index:idx_message_reaction,unique"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - Emoji string `json:"emoji" gorm:"not null;index:idx_message_reaction,unique"` -} - -// PasswordVaultItem is encrypted secret data owned by a user. -type PasswordVaultItem struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - OwnerUserID uint `json:"owner_user_id" gorm:"not null;index"` - OwnerUser User `json:"owner_user,omitempty" gorm:"foreignKey:OwnerUserID"` - - Label string `json:"label" gorm:"not null"` - EncryptedSecret string `json:"-" gorm:"type:text;not null"` - EncryptedNotes string `json:"-" gorm:"type:text"` - SourceMessageID *uint `json:"source_message_id,omitempty" gorm:"index"` - SourceMessage *Message `json:"source_message,omitempty" gorm:"foreignKey:SourceMessageID"` - CreatedBy uint `json:"created_by" gorm:"not null;index"` - LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` - - Shares []PasswordVaultShare `json:"shares,omitempty" gorm:"foreignKey:VaultItemID"` -} - -// PasswordVaultShare controls explicit sharing of vault items. -type PasswordVaultShare struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - VaultItemID uint `json:"vault_item_id" gorm:"not null;index"` - VaultItem PasswordVaultItem `json:"vault_item,omitempty" gorm:"foreignKey:VaultItemID"` - - SharedByUserID uint `json:"shared_by_user_id" gorm:"not null;index"` - SharedByUser User `json:"shared_by_user,omitempty" gorm:"foreignKey:SharedByUserID"` - TargetConversationID uint `json:"target_conversation_id" gorm:"not null;index"` - TargetConversation Conversation `json:"target_conversation,omitempty" gorm:"foreignKey:TargetConversationID"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - AllowReveal bool `json:"allow_reveal" gorm:"default:false"` -} diff --git a/backend/models/models.go b/backend/models/models.go index 5f0f5fe..f416e5f 100644 --- a/backend/models/models.go +++ b/backend/models/models.go @@ -127,36 +127,18 @@ func AutoMigrate() error { {name: "BrowserExtension", model: &BrowserExtension{}}, {name: "TimeEntry", model: &TimeEntry{}}, {name: "FileAnalysis", model: &FileAnalysis{}}, - {name: "ChatSession", model: &ChatSession{}}, - {name: "ChatMessage", model: &ChatMessage{}}, - {name: "LearningPath", model: &LearningPath{}}, - {name: "LearningModule", model: &LearningModule{}}, - {name: "ModuleResource", model: &ModuleResource{}}, - {name: "Enrollment", model: &Enrollment{}}, - {name: "Progress", model: &Progress{}}, - {name: "Course", model: &Course{}}, - {name: "LearningPathCourse", model: &LearningPathCourse{}}, - {name: "CalendarEvent", model: &CalendarEvent{}}, - {name: "RecurrenceRule", model: &RecurrenceRule{}}, - {name: "CalendarSettings", model: &CalendarSettings{}}, - {name: "ContentEmbedding", model: &ContentEmbedding{}}, - {name: "SavedSearch", model: &SavedSearch{}}, - {name: "SavedSearchTag", model: &SavedSearchTag{}}, - {name: "SearchAnalytics", model: &SearchAnalytics{}}, - {name: "SearchSuggestion", model: &SearchSuggestion{}}, - {name: "AISummary", model: &AISummary{}}, - {name: "AITaskSuggestion", model: &AITaskSuggestion{}}, - {name: "UserAISettings", model: &UserAISettings{}}, - {name: "UserSearchSettings", model: &UserSearchSettings{}}, - {name: "UserUpdateSettings", model: &UserUpdateSettings{}}, - {name: "AITagSuggestion", model: &AITagSuggestion{}}, - {name: "AIContentGeneration", model: &AIContentGeneration{}}, - {name: "AICodeReview", model: &AICodeReview{}}, - {name: "AILearningRecommendation", model: &AILearningRecommendation{}}, - {name: "AIRecommendation", model: &AIRecommendation{}}, - {name: "UserPreference", model: &UserPreference{}}, - {name: "RecommendationInteraction", model: &RecommendationInteraction{}}, - {name: "Integration", model: &Integration{}}, + {name: "LearningPath", model: &LearningPath{}}, + {name: "LearningModule", model: &LearningModule{}}, + {name: "ModuleResource", model: &ModuleResource{}}, + {name: "Enrollment", model: &Enrollment{}}, + {name: "Progress", model: &Progress{}}, + {name: "Course", model: &Course{}}, + {name: "LearningPathCourse", model: &LearningPathCourse{}}, + {name: "CalendarEvent", model: &CalendarEvent{}}, + {name: "RecurrenceRule", model: &RecurrenceRule{}}, + {name: "CalendarSettings", model: &CalendarSettings{}}, + {name: "UserUpdateSettings", model: &UserUpdateSettings{}}, + {name: "Integration", model: &Integration{}}, {name: "SyncLog", model: &SyncLog{}}, {name: "WebhookEvent", model: &WebhookEvent{}}, {name: "ControlServiceSession", model: &ControlServiceSession{}}, @@ -208,15 +190,6 @@ func AutoMigrate() error { {name: "MentorshipRequest", model: &MentorshipRequest{}}, {name: "YouTubeChannelCache", model: &YouTubeChannelCache{}}, {name: "VideoBookmark", model: &VideoBookmark{}}, - {name: "Conversation", model: &Conversation{}}, - {name: "ConversationMember", model: &ConversationMember{}}, - {name: "Message", model: &Message{}}, - {name: "MessageAttachment", model: &MessageAttachment{}}, - {name: "MessageReference", model: &MessageReference{}}, - {name: "MessageSuggestion", model: &MessageSuggestion{}}, - {name: "MessageReaction", model: &MessageReaction{}}, - {name: "PasswordVaultItem", model: &PasswordVaultItem{}}, - {name: "PasswordVaultShare", model: &PasswordVaultShare{}}, } criticalModels := map[string]bool{ diff --git a/backend/models/search.go b/backend/models/search.go deleted file mode 100644 index ce836e8..0000000 --- a/backend/models/search.go +++ /dev/null @@ -1,111 +0,0 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// ContentEmbedding stores vector embeddings for semantic search -type ContentEmbedding struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - // Content reference - ContentType string `json:"content_type" gorm:"not null;index"` // 'bookmark', 'task', 'note', 'file' - ContentID uint `json:"content_id" gorm:"not null;index"` - - // Embedding data - Embedding string `json:"embedding" gorm:"type:text"` // JSON array of floats - Model string `json:"model" gorm:"not null"` // AI model used - Dimensions int `json:"dimensions" gorm:"not null"` // Vector dimensions - TextContent string `json:"text_content" gorm:"type:text"` // Original text for embedding - - // Metadata - UserID uint `json:"user_id" gorm:"not null;index"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` -} - -// SavedSearch represents a user's saved search query -type SavedSearch struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - UserID uint `json:"user_id" gorm:"not null;index"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - - Name string `json:"name" gorm:"not null"` - Query string `json:"query" gorm:"not null"` - Filters string `json:"filters" gorm:"type:json"` // JSON serialized filters - Alert bool `json:"alert" gorm:"default:false"` - LastRun *time.Time `json:"last_run"` - RunCount int `json:"run_count" gorm:"default:0"` - IsPublic bool `json:"is_public" gorm:"default:false"` - Description string `json:"description"` - Tags []SavedSearchTag `json:"tags,omitempty" gorm:"many2many:saved_search_tags;"` -} - -// SavedSearchTag represents tags for saved searches -type SavedSearchTag struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - Name string `json:"name" gorm:"unique;not null"` - Color string `json:"color" gorm:"default:#3b82f6"` -} - -// SearchAnalytics stores search analytics data -type SearchAnalytics struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - UserID uint `json:"user_id" gorm:"not null;index"` - User User `json:"user,omitempty" gorm:"foreignKey:UserID"` - - Query string `json:"query" gorm:"not null;index"` - Filters string `json:"filters" gorm:"type:json"` - ResultsCount int `json:"results_count"` - Took int `json:"took"` // Time in milliseconds - ContentType string `json:"content_type"` - ClickedResultID *uint `json:"clicked_result_id"` // Track which result was clicked - SessionID string `json:"session_id" gorm:"index"` - IPAddress string `json:"ip_address"` - UserAgent string `json:"user_agent"` -} - -// SearchSuggestion represents search suggestions -type SearchSuggestion struct { - ID uint `json:"id" gorm:"primaryKey"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` - - Text string `json:"text" gorm:"not null;uniqueIndex"` - Type string `json:"type" gorm:"not null"` // 'query', 'tag', 'content' - Frequency int `json:"frequency" gorm:"default:1"` - LastUsed time.Time `json:"last_used"` - ContentType *string `json:"content_type,omitempty"` - IsPublic bool `json:"is_public" gorm:"default:true"` -} - -// BeforeCreate hook for ContentEmbedding -func (ce *ContentEmbedding) BeforeCreate(tx *gorm.DB) error { - // Set default model if not specified - if ce.Model == "" { - ce.Model = "text-embedding-ada-002" - } - - // Set default dimensions if not specified - if ce.Dimensions == 0 { - ce.Dimensions = 1536 // Default for OpenAI embeddings - } - - return nil -} diff --git a/backend/services/ai_recommendation_service.go b/backend/services/ai_recommendation_service.go deleted file mode 100644 index ab8db88..0000000 --- a/backend/services/ai_recommendation_service.go +++ /dev/null @@ -1,553 +0,0 @@ -package services - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/trackeep/backend/models" - "gorm.io/gorm" -) - -// AIRecommendationService provides AI-powered recommendations -type AIRecommendationService struct { - db *gorm.DB -} - -// NewAIRecommendationService creates a new AI recommendation service -func NewAIRecommendationService(db *gorm.DB) *AIRecommendationService { - return &AIRecommendationService{db: db} -} - -// RecommendationRequest represents a request for recommendations -type RecommendationRequest struct { - UserID uint `json:"user_id"` - RecommendationType string `json:"recommendation_type"` // content, task, learning, connection - Limit int `json:"limit"` // Max recommendations to return - MinConfidence float64 `json:"min_confidence"` // Minimum confidence threshold - IncludeDismissed bool `json:"include_dismissed"` // Include previously dismissed items - Context string `json:"context"` // Current user context -} - -// RecommendationScore represents a scored recommendation -type RecommendationScore struct { - Recommendation models.AIRecommendation - Score float64 - Reason string -} - -// GetRecommendations generates personalized recommendations for a user -func (s *AIRecommendationService) GetRecommendations(req RecommendationRequest) ([]models.AIRecommendation, error) { - // Get user preferences - var prefs models.UserPreference - if err := s.db.Where("user_id = ?", req.UserID).First(&prefs).Error; err != nil { - // Create default preferences if not found - prefs = models.UserPreference{ - UserID: req.UserID, - EnableRecommendations: true, - MinConfidenceThreshold: 0.6, - MaxRecommendationsPerDay: 5, - MaxAgeHours: 168, - } - s.db.Create(&prefs) - } - - if !prefs.EnableRecommendations { - return []models.AIRecommendation{}, nil - } - - // Check daily limit - today := time.Now().Format("2006-01-02") - var todayCount int64 - s.db.Model(&models.AIRecommendation{}). - Where("user_id = ? AND DATE(created_at) = ?", req.UserID, today). - Count(&todayCount) - - if int(todayCount) >= prefs.MaxRecommendationsPerDay { - return []models.AIRecommendation{}, nil - } - - // Generate recommendations based on type - var scoredRecommendations []RecommendationScore - - switch req.RecommendationType { - case "content": - scoredRecommendations = s.generateContentRecommendations(req.UserID, &prefs) - case "task": - scoredRecommendations = s.generateTaskRecommendations(req.UserID, &prefs) - case "learning": - scoredRecommendations = s.generateLearningRecommendations(req.UserID, &prefs) - case "connection": - scoredRecommendations = s.generateConnectionRecommendations(req.UserID, &prefs) - default: - // Generate mixed recommendations - scoredRecommendations = s.generateMixedRecommendations(req.UserID, &prefs) - } - - // Filter by confidence and dismissed status - minConf := req.MinConfidence - if minConf == 0 { - minConf = prefs.MinConfidenceThreshold - } - - var filtered []RecommendationScore - for _, rec := range scoredRecommendations { - if rec.Score >= minConf && (req.IncludeDismissed || !rec.Recommendation.Dismissed) { - // Check if not expired - if rec.Recommendation.ExpiresAt == nil || rec.Recommendation.ExpiresAt.After(time.Now()) { - filtered = append(filtered, rec) - } - } - } - - // Sort by score - sort.Slice(filtered, func(i, j int) bool { - return filtered[i].Score > filtered[j].Score - }) - - // Apply limit - limit := req.Limit - if limit == 0 { - limit = 5 - } - if limit > len(filtered) { - limit = len(filtered) - } - - // Save recommendations to database - var recommendations []models.AIRecommendation - for i := 0; i < limit; i++ { - rec := filtered[i].Recommendation - rec.Confidence = filtered[i].Score - - // Check if already exists - var existing models.AIRecommendation - err := s.db.Where("user_id = ? AND content_type = ? AND content_id = ?", - rec.UserID, rec.ContentType, rec.ContentID).First(&existing).Error - - if err == gorm.ErrRecordNotFound { - // Create new recommendation - if err := s.db.Create(&rec).Error; err != nil { - continue - } - recommendations = append(recommendations, rec) - } else { - // Update existing recommendation - existing.Confidence = rec.Confidence - existing.Reasoning = filtered[i].Reason - existing.UpdatedAt = time.Now() - s.db.Save(&existing) - recommendations = append(recommendations, existing) - } - } - - return recommendations, nil -} - -// generateContentRecommendations generates content-based recommendations -func (s *AIRecommendationService) generateContentRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore { - var recommendations []RecommendationScore - - // Get user's recent activity and interests - userTags := s.getUserInterests(userID) - userCategories := s.getUserCategories(userID) - - // Find similar content from other users - var similarContent []struct { - ContentType string - ContentID uint - Title string - URL string - Preview string - Author string - Tags string - Score float64 - } - - // Query for popular content among similar users - query := ` - SELECT - b.content_type, - b.content_id, - b.title, - b.url, - SUBSTRING(b.content, 1, 200) as preview, - u.full_name as author, - b.tags, - COUNT(*) as interaction_count - FROM bookmarks b - INNER JOIN users u ON b.user_id = u.id - WHERE b.user_id != ? - AND b.created_at > ? - AND (b.tags ?| array[?] OR b.content_type = ANY(?)) - GROUP BY b.content_type, b.content_id, b.title, b.url, b.content, u.full_name, b.tags - HAVING COUNT(*) > 1 - ORDER BY interaction_count DESC - LIMIT 20 - ` - - // This is a simplified version - in practice you'd use more sophisticated similarity algorithms - s.db.Raw(query, userID, time.Now().AddDate(0, -3, 0), userTags, prefs.PreferredContentTypes).Scan(&similarContent) - - for _, content := range similarContent { - score := s.calculateContentScore(content, userTags, userCategories, prefs) - - expiresAt := time.Now().Add(time.Hour * 24 * 7) - - recommendation := models.AIRecommendation{ - UserID: userID, - RecommendationType: "content", - ContentType: content.ContentType, - ContentID: &content.ContentID, - Title: content.Title, - Description: fmt.Sprintf("Recommended based on your interests in %s", strings.Join(userTags, ", ")), - ContentTitle: content.Title, - ContentURL: content.URL, - ContentPreview: content.Preview, - AuthorName: content.Author, - Tags: content.Tags, - Priority: s.getPriorityFromScore(score), - ExpiresAt: &expiresAt, - SourceModel: "collaborative_filtering_v1", - } - - recommendations = append(recommendations, RecommendationScore{ - Recommendation: recommendation, - Score: score, - Reason: fmt.Sprintf("Similar users with interests in %s also liked this", strings.Join(userTags[:2], ", ")), - }) - } - - return recommendations -} - -// generateTaskRecommendations generates task-based recommendations -func (s *AIRecommendationService) generateTaskRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore { - var recommendations []RecommendationScore - - // Get user's task patterns and upcoming deadlines - var userTasks []models.Task - s.db.Where("user_id = ? AND status != 'completed'", userID).Find(&userTasks) - - // Get calendar events for context - var upcomingEvents []models.CalendarEvent - s.db.Where("user_id = ? AND start_time BETWEEN ? AND ?", - userID, time.Now(), time.Now().AddDate(0, 0, 7)).Find(&upcomingEvents) - - // Analyze patterns and suggest tasks - for _, task := range userTasks { - if string(task.Priority) == "high" && task.DueDate != nil && task.DueDate.Before(time.Now().AddDate(0, 0, 3)) { - // High priority task due soon - score := 0.9 - - // Convert tags to string - var tagStr string - for i, tag := range task.Tags { - if i > 0 { - tagStr += "," - } - tagStr += tag.Name - } - - expiresAt := task.DueDate - - recommendation := models.AIRecommendation{ - UserID: userID, - RecommendationType: "task", - ContentType: "task", - ContentID: &task.ID, - Title: fmt.Sprintf("Focus on: %s", task.Title), - Description: fmt.Sprintf("This high-priority task is due on %s", task.DueDate.Format("Jan 2")), - ContentTitle: task.Title, - ContentPreview: task.Description, - Tags: tagStr, - Priority: "high", - Confidence: score, - ExpiresAt: expiresAt, - SourceModel: "deadline_priority_v1", - } - - recommendations = append(recommendations, RecommendationScore{ - Recommendation: recommendation, - Score: score, - }) - } - } - - return recommendations -} - -// generateLearningRecommendations generates learning-based recommendations -func (s *AIRecommendationService) generateLearningRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore { - var recommendations []RecommendationScore - - // Get user's learning progress and interests - var enrollments []models.Enrollment - s.db.Preload("Course").Preload("LearningPath").Where("user_id = ?", userID).Find(&enrollments) - - var completedCategories []string - var inProgressCategories []string - for _, enrollment := range enrollments { - if enrollment.Progress >= 100 { - if enrollment.CourseID != nil && enrollment.Course != nil { - completedCategories = append(completedCategories, enrollment.Course.Category) - } else if enrollment.LearningPathID != 0 { - completedCategories = append(completedCategories, enrollment.LearningPath.Category) - } - } else { - if enrollment.CourseID != nil && enrollment.Course != nil { - inProgressCategories = append(inProgressCategories, enrollment.Course.Category) - } else if enrollment.LearningPathID != 0 { - inProgressCategories = append(inProgressCategories, enrollment.LearningPath.Category) - } - } - } - - // Recommend next courses based on completed ones - for _, category := range completedCategories { - var nextCourses []models.Course - s.db.Where("category = ? AND level != ?", category, "beginner").Limit(3).Find(&nextCourses) - - for _, course := range nextCourses { - score := 0.8 - - // Convert topics to tags string - var tagsStr string - for i, topic := range course.Topics { - if i > 0 { - tagsStr += "," - } - tagsStr += topic - } - - expiresAt := time.Now().Add(time.Hour * 24 * 14) - - recommendation := models.AIRecommendation{ - UserID: userID, - RecommendationType: "learning", - ContentType: "course", - ContentID: &course.ID, - Title: fmt.Sprintf("Continue learning: %s", course.Title), - Description: fmt.Sprintf("Based on your completion of %s courses", category), - ContentTitle: course.Title, - ContentPreview: course.Description, - Tags: tagsStr, - Priority: "medium", - Confidence: score, - ExpiresAt: &expiresAt, - SourceModel: "learning_path_v1", - } - - recommendations = append(recommendations, RecommendationScore{ - Recommendation: recommendation, - Score: score, - }) - } - } - - return recommendations -} - -// generateConnectionRecommendations generates user connection recommendations -func (s *AIRecommendationService) generateConnectionRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore { - var recommendations []RecommendationScore - - if !prefs.ConnectionRecommendations { - return recommendations - } - - // Get user's skills and interests - var user models.User - s.db.Preload("Skills").Where("id = ?", userID).First(&user) - - // Find similar users - var similarUsers []models.User - s.db.Where("id != ? AND (skills ?| array[?] OR location = ?)", - userID, s.getSkillNames(user.Skills), user.Location).Limit(5).Find(&similarUsers) - - for _, similarUser := range similarUsers { - score := s.calculateUserSimilarity(&user, &similarUser) - - if score > 0.6 { - expiresAt := time.Now().Add(time.Hour * 24 * 30) - - recommendation := models.AIRecommendation{ - UserID: userID, - RecommendationType: "connection", - ContentType: "user", - ContentID: &similarUser.ID, - Title: fmt.Sprintf("Connect with %s", similarUser.FullName), - Description: fmt.Sprintf("Similar interests in %s", s.getSharedInterests(&user, &similarUser)), - ContentTitle: similarUser.FullName, - ContentPreview: similarUser.Bio, - AuthorName: similarUser.JobTitle, - Priority: "low", - Confidence: score, - ExpiresAt: &expiresAt, - SourceModel: "user_similarity_v1", - } - - recommendations = append(recommendations, RecommendationScore{ - Recommendation: recommendation, - Score: score, - }) - } - } - - return recommendations -} - -// generateMixedRecommendations generates a mix of all recommendation types -func (s *AIRecommendationService) generateMixedRecommendations(userID uint, prefs *models.UserPreference) []RecommendationScore { - var allRecommendations []RecommendationScore - - // Get recommendations from all types - contentRecs := s.generateContentRecommendations(userID, prefs) - taskRecs := s.generateTaskRecommendations(userID, prefs) - learningRecs := s.generateLearningRecommendations(userID, prefs) - connectionRecs := s.generateConnectionRecommendations(userID, prefs) - - allRecommendations = append(allRecommendations, contentRecs...) - allRecommendations = append(allRecommendations, taskRecs...) - allRecommendations = append(allRecommendations, learningRecs...) - allRecommendations = append(allRecommendations, connectionRecs...) - - // Ensure diverse mix by limiting each type - maxPerType := 2 - typeCounts := make(map[string]int) - var mixedRecs []RecommendationScore - - for _, rec := range allRecommendations { - if typeCounts[rec.Recommendation.ContentType] < maxPerType { - mixedRecs = append(mixedRecs, rec) - typeCounts[rec.Recommendation.ContentType]++ - } - } - - return mixedRecs -} - -// Helper functions - -func (s *AIRecommendationService) getUserInterests(userID uint) []string { - var tags []string - s.db.Model(&models.Bookmark{}). - Select("DISTINCT unnest(string_to_array(tags, ',')) as tag"). - Where("user_id = ?", userID). - Pluck("tag", &tags) - return tags -} - -func (s *AIRecommendationService) getUserCategories(userID uint) []string { - var categories []string - s.db.Model(&models.Course{}). - Select("DISTINCT category"). - Joins("JOIN enrollments ON courses.id = enrollments.course_id"). - Where("enrollments.user_id = ?", userID). - Pluck("category", &categories) - return categories -} - -func (s *AIRecommendationService) calculateContentScore(content interface{}, userTags, userCategories []string, prefs *models.UserPreference) float64 { - // Simplified scoring algorithm - score := 0.5 // Base score - - // Add points for tag matches - // In practice, this would be more sophisticated - score += float64(len(userTags)) * 0.1 - - // Ensure score is within bounds - if score > 1.0 { - score = 1.0 - } - if score < 0.0 { - score = 0.0 - } - - return score -} - -func (s *AIRecommendationService) getPriorityFromScore(score float64) string { - if score >= 0.8 { - return "high" - } else if score >= 0.6 { - return "medium" - } - return "low" -} - -func (s *AIRecommendationService) getSkillNames(skills []models.Skill) []string { - var names []string - for _, skill := range skills { - names = append(names, skill.Name) - } - return names -} - -func (s *AIRecommendationService) calculateUserSimilarity(user1, user2 *models.User) float64 { - // Simplified similarity calculation - score := 0.0 - - // Location match - if user1.Location == user2.Location && user1.Location != "" { - score += 0.3 - } - - // Skills overlap (simplified) - if len(user1.Skills) > 0 && len(user2.Skills) > 0 { - score += 0.4 - } - - // Random factor for demo - score += 0.2 - - if score > 1.0 { - score = 1.0 - } - - return score -} - -func (s *AIRecommendationService) getSharedInterests(user1, user2 *models.User) string { - // Simplified - in practice would analyze actual shared interests - interests := []string{"technology", "productivity", "learning"} - if len(interests) > 2 { - return strings.Join(interests[:2], ", ") - } - return strings.Join(interests, ", ") -} - -// RecordInteraction records user interaction with recommendations -func (s *AIRecommendationService) RecordInteraction(userID, recommendationID uint, interactionType, context string) error { - interaction := models.RecommendationInteraction{ - UserID: userID, - RecommendationID: recommendationID, - InteractionType: interactionType, - Context: context, - SessionID: fmt.Sprintf("session_%d_%d", userID, time.Now().Unix()), - DeviceType: "web", // Would be detected from request - } - - // Update recommendation based on interaction - var recommendation models.AIRecommendation - if err := s.db.First(&recommendation, recommendationID).Error; err != nil { - return err - } - - switch interactionType { - case "click": - recommendation.Clicked = true - now := time.Now() - recommendation.ClickedAt = &now - case "dismiss": - recommendation.Dismissed = true - now := time.Now() - recommendation.DismissedAt = &now - case "feedback": - // Additional feedback handling would go here - } - - s.db.Save(&recommendation) - return s.db.Create(&interaction).Error -} diff --git a/backend/services/ai_service.go b/backend/services/ai_service.go deleted file mode 100644 index b594e3d..0000000 --- a/backend/services/ai_service.go +++ /dev/null @@ -1,532 +0,0 @@ -package services - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "strings" - "time" -) - -// AIProvider represents different AI providers -type AIProvider string - -const ( - ProviderMistral AIProvider = "mistral" - ProviderLongCat AIProvider = "longcat" - ProviderGrok AIProvider = "grok" - ProviderDeepSeek AIProvider = "deepseek" - ProviderOllama AIProvider = "ollama" - ProviderOpenRouter AIProvider = "openrouter" -) - -// AIRequest represents a generic AI request -type AIRequest struct { - Messages []Message `json:"messages"` - Model string `json:"model"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - ModelType string `json:"model_type,omitempty"` // "standard", "thinking", "upgraded_thinking" -} - -// Message represents a chat message -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` -} - -// AIResponse represents a generic AI response -type AIResponse struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - Model string `json:"model"` - Choices []Choice `json:"choices"` - Usage Usage `json:"usage"` -} - -// Choice represents a choice in AI response -type Choice struct { - Index int `json:"index"` - Message Message `json:"message"` - FinishReason string `json:"finish_reason"` -} - -// Usage represents token usage -type Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -// AIService handles multiple AI providers -type AIService struct { - provider AIProvider -} - -// NewAIService creates a new AI service with the specified provider -func NewAIService(provider AIProvider) *AIService { - return &AIService{provider: provider} -} - -// GetAvailableProviders returns available AI providers -func GetAvailableProviders() []AIProvider { - // Return all known providers so the frontend can show them in settings - // regardless of current environment configuration. Environment flags - // and API keys still control whether requests actually succeed. - return []AIProvider{ - ProviderMistral, - ProviderLongCat, - ProviderGrok, - ProviderDeepSeek, - ProviderOllama, - ProviderOpenRouter, - } -} - -// ChatCompletion sends a chat completion request to the configured provider -func (s *AIService) ChatCompletion(req AIRequest) (*AIResponse, error) { - switch s.provider { - case ProviderMistral: - return s.callMistral(req) - case ProviderLongCat: - return s.callLongCat(req) - case ProviderGrok: - return s.callGrok(req) - case ProviderDeepSeek: - return s.callDeepSeek(req) - case ProviderOllama: - return s.callOllama(req) - case ProviderOpenRouter: - return s.callOpenRouter(req) - default: - return nil, fmt.Errorf("unsupported AI provider: %s", s.provider) - } -} - -// ChatCompletionWithThinking sends a chat completion request with thinking model -func (s *AIService) ChatCompletionWithThinking(req AIRequest) (*AIResponse, error) { - // Override model with thinking model - thinkingModel := s.getThinkingModel() - if thinkingModel != "" { - req.Model = thinkingModel - } - - return s.ChatCompletion(req) -} - -// ChatCompletionWithUpgradedThinking sends a chat completion request with upgraded thinking model (LongCat only) -func (s *AIService) ChatCompletionWithUpgradedThinking(req AIRequest) (*AIResponse, error) { - if s.provider != ProviderLongCat { - return nil, fmt.Errorf("upgraded thinking model only available for LongCat provider") - } - - // Override model with upgraded thinking model - upgradedModel := os.Getenv("LONGCAT_MODEL_THINKING_UPGRADED") - if upgradedModel != "" { - req.Model = upgradedModel - } - - return s.ChatCompletion(req) -} - -// ParseThinkingResponse extracts the actual content from thinking model responses -func ParseThinkingResponse(resp *AIResponse, provider AIProvider, modelType string) string { - if provider == ProviderLongCat { - // Handle LongCat thinking models - if resp.Choices[0].Message.Content != "" { - content := resp.Choices[0].Message.Content - - // For LongCat-Flash-Thinking, remove thinking tags - if strings.Contains(content, "") { - // Extract content after thinking tags - parts := strings.Split(content, "") - if len(parts) > 1 { - return strings.TrimSpace(parts[1]) - } - // If no closing tag, try to extract after the thinking content - lines := strings.Split(content, "\n") - for i, line := range lines { - if strings.Contains(line, "") { - return strings.TrimSpace(strings.Join(lines[i+1:], "\n")) - } - } - } - - return content - } else if resp.Choices[0].Message.ReasoningContent != "" { - // For LongCat-Flash-Thinking-2601, check if there's actual content - // This model puts reasoning in reasoning_content and final answer in content - // If content is null, we might need to extract from reasoning or return the reasoning itself - return resp.Choices[0].Message.ReasoningContent - } - } - - // For Grok, DeepSeek, Mistral and other providers, or if no special handling needed - return resp.Choices[0].Message.Content -} - -// getThinkingModel returns the appropriate thinking model for the provider -func (s *AIService) getThinkingModel() string { - switch s.provider { - case ProviderMistral: - return os.Getenv("MISTRAL_MODEL_THINKING") - case ProviderLongCat: - return os.Getenv("LONGCAT_MODEL_THINKING") - case ProviderGrok: - return os.Getenv("GROK_MODEL_THINKING") - case ProviderDeepSeek: - return os.Getenv("DEEPSEEK_MODEL_THINKING") - case ProviderOllama: - return os.Getenv("OLLAMA_MODEL_THINKING") - case ProviderOpenRouter: - return os.Getenv("OPENROUTER_MODEL_THINKING") - default: - return "" - } -} - -// callOpenRouter calls the OpenRouter API (OpenAI-compatible) -func (s *AIService) callOpenRouter(req AIRequest) (*AIResponse, error) { - apiKey := os.Getenv("OPENROUTER_API_KEY") - baseURL := os.Getenv("OPENROUTER_BASE_URL") - if baseURL == "" { - baseURL = "https://openrouter.ai/api" - } - - model := os.Getenv("OPENROUTER_MODEL") - if model == "" { - model = "openrouter/auto" - } - - if req.Model == "" { - req.Model = model - } - - jsonData, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonData))) - if err != nil { - return nil, err - } - - httpReq.Header.Set("Content-Type", "application/json") - if apiKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+apiKey) - } - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode) - } - - var orResp AIResponse - if err := json.NewDecoder(resp.Body).Decode(&orResp); err != nil { - return nil, err - } - - return &orResp, nil -} - -// callMistral calls Mistral AI API -func (s *AIService) callMistral(req AIRequest) (*AIResponse, error) { - apiKey := os.Getenv("MISTRAL_API_KEY") - baseURL := "https://api.mistral.ai/v1" - model := os.Getenv("MISTRAL_MODEL") - if model == "" { - model = "mistral-small-latest" - } - - if req.Model == "" { - req.Model = model - } - - jsonData, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData))) - if err != nil { - return nil, err - } - - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+apiKey) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Mistral API returned status %d", resp.StatusCode) - } - - var mistralResp AIResponse - if err := json.NewDecoder(resp.Body).Decode(&mistralResp); err != nil { - return nil, err - } - - return &mistralResp, nil -} - -// callLongCat calls LongCat AI API -func (s *AIService) callLongCat(req AIRequest) (*AIResponse, error) { - apiKey := os.Getenv("LONGCAT_API_KEY") - - // Determine format and endpoint - format := os.Getenv("LONGCAT_FORMAT") - if format == "" { - format = "openai" // Default to OpenAI format - } - - var baseURL string - switch format { - case "openai": - baseURL = "https://api.longcat.chat/openai" - case "anthropic": - baseURL = "https://api.longcat.chat/anthropic" - default: - baseURL = "https://api.longcat.chat/openai" - } - - model := os.Getenv("LONGCAT_MODEL") - if model == "" { - model = "LongCat-Flash-Chat" - } - - if req.Model == "" { - req.Model = model - } - - var jsonBody []byte - var httpReq *http.Request - var err error - - if format == "anthropic" { - // Convert to Anthropic format - anthropicReq := map[string]interface{}{ - "model": req.Model, - "max_tokens": req.MaxTokens, - "messages": req.Messages, - } - if req.Temperature > 0 { - anthropicReq["temperature"] = req.Temperature - } - - jsonBody, err = json.Marshal(anthropicReq) - if err != nil { - return nil, err - } - - httpReq, err = http.NewRequest("POST", baseURL+"/v1/messages", strings.NewReader(string(jsonBody))) - if err != nil { - return nil, err - } - - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+apiKey) - httpReq.Header.Set("anthropic-version", "2023-06-01") - } else { - // OpenAI format - jsonBody, err = json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err = http.NewRequest("POST", baseURL+"/v1/chat/completions", strings.NewReader(string(jsonBody))) - if err != nil { - return nil, err - } - - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+apiKey) - } - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("LongCat API returned status %d", resp.StatusCode) - } - - var longcatResp AIResponse - if err := json.NewDecoder(resp.Body).Decode(&longcatResp); err != nil { - return nil, err - } - - return &longcatResp, nil -} - -// callGrok calls Grok AI API -func (s *AIService) callGrok(req AIRequest) (*AIResponse, error) { - apiKey := os.Getenv("GROK_API_KEY") - baseURL := os.Getenv("GROK_BASE_URL") - if baseURL == "" { - baseURL = "https://api.x.ai/v1" - } - - model := os.Getenv("GROK_MODEL") - if model == "" { - model = "grok-beta" - } - - if req.Model == "" { - req.Model = model - } - - jsonData, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData))) - if err != nil { - return nil, err - } - - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+apiKey) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Grok API returned status %d", resp.StatusCode) - } - - var grokResp AIResponse - if err := json.NewDecoder(resp.Body).Decode(&grokResp); err != nil { - return nil, err - } - - return &grokResp, nil -} - -// callDeepSeek calls DeepSeek API -func (s *AIService) callDeepSeek(req AIRequest) (*AIResponse, error) { - apiKey := os.Getenv("DEEPSEEK_API_KEY") - baseURL := os.Getenv("DEEPSEEK_BASE_URL") - if baseURL == "" { - baseURL = "https://api.deepseek.com" - } - - model := os.Getenv("DEEPSEEK_MODEL") - if model == "" { - model = "deepseek-chat" - } - - if req.Model == "" { - req.Model = model - } - - jsonData, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", strings.NewReader(string(jsonData))) - if err != nil { - return nil, err - } - - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+apiKey) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("DeepSeek API returned status %d", resp.StatusCode) - } - - var deepseekResp AIResponse - if err := json.NewDecoder(resp.Body).Decode(&deepseekResp); err != nil { - return nil, err - } - - return &deepseekResp, nil -} - -// callOllama calls Ollama API -func (s *AIService) callOllama(req AIRequest) (*AIResponse, error) { - baseURL := os.Getenv("OLLAMA_BASE_URL") - if baseURL == "" { - baseURL = "http://localhost:11434" - } - - model := os.Getenv("OLLAMA_MODEL") - if model == "" { - model = "llama3.1" - } - - if req.Model == "" { - req.Model = model - } - - jsonData, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequest("POST", baseURL+"/api/chat", strings.NewReader(string(jsonData))) - if err != nil { - return nil, err - } - - httpReq.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 60 * time.Second} // Ollama can be slower - resp, err := client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Ollama API returned status %d", resp.StatusCode) - } - - var ollamaResp AIResponse - if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil { - return nil, err - } - - return &ollamaResp, nil -} - -// SetProvider changes the AI provider -func (s *AIService) SetProvider(provider AIProvider) { - s.provider = provider -} - -// GetProvider returns the current AI provider -func (s *AIService) GetProvider() AIProvider { - return s.provider -} diff --git a/backend/services/messages_realtime.go b/backend/services/messages_realtime.go deleted file mode 100644 index c85f65c..0000000 --- a/backend/services/messages_realtime.go +++ /dev/null @@ -1,172 +0,0 @@ -package services - -import ( - "encoding/json" - "sync" - "time" - - "github.com/gorilla/websocket" -) - -// WsEvent is a realtime event payload emitted by the messaging hub. -type WsEvent struct { - Type string `json:"type"` - ConversationID uint `json:"conversation_id,omitempty"` - Data interface{} `json:"data,omitempty"` - Timestamp time.Time `json:"timestamp"` -} - -// MessagesWSClient represents one websocket connection. -type MessagesWSClient struct { - UserID uint - Conn *websocket.Conn - Send chan []byte - Conversations map[uint]struct{} -} - -// MessagesHub coordinates room-based websocket fanout. -type MessagesHub struct { - mu sync.RWMutex - conversationClients map[uint]map[*MessagesWSClient]struct{} - clientConversations map[*MessagesWSClient]map[uint]struct{} -} - -var defaultMessagesHub = NewMessagesHub() - -// GetMessagesHub returns the shared messaging websocket hub. -func GetMessagesHub() *MessagesHub { - return defaultMessagesHub -} - -// NewMessagesHub creates a new messages websocket hub. -func NewMessagesHub() *MessagesHub { - return &MessagesHub{ - conversationClients: make(map[uint]map[*MessagesWSClient]struct{}), - clientConversations: make(map[*MessagesWSClient]map[uint]struct{}), - } -} - -// NewWSClient creates a ws client wrapper. -func NewWSClient(userID uint, conn *websocket.Conn) *MessagesWSClient { - return &MessagesWSClient{ - UserID: userID, - Conn: conn, - Send: make(chan []byte, 128), - Conversations: make(map[uint]struct{}), - } -} - -// AddClientToConversation subscribes a client to a conversation room. -func (h *MessagesHub) AddClientToConversation(client *MessagesWSClient, conversationID uint) { - h.mu.Lock() - defer h.mu.Unlock() - - if _, exists := h.conversationClients[conversationID]; !exists { - h.conversationClients[conversationID] = make(map[*MessagesWSClient]struct{}) - } - h.conversationClients[conversationID][client] = struct{}{} - - if _, exists := h.clientConversations[client]; !exists { - h.clientConversations[client] = make(map[uint]struct{}) - } - h.clientConversations[client][conversationID] = struct{}{} - client.Conversations[conversationID] = struct{}{} -} - -// RemoveClientFromConversation unsubscribes a client from one room. -func (h *MessagesHub) RemoveClientFromConversation(client *MessagesWSClient, conversationID uint) { - h.mu.Lock() - defer h.mu.Unlock() - - if clients, exists := h.conversationClients[conversationID]; exists { - delete(clients, client) - if len(clients) == 0 { - delete(h.conversationClients, conversationID) - } - } - - if convs, exists := h.clientConversations[client]; exists { - delete(convs, conversationID) - if len(convs) == 0 { - delete(h.clientConversations, client) - } - } - delete(client.Conversations, conversationID) -} - -// RemoveClient fully unregisters a client from all rooms. -func (h *MessagesHub) RemoveClient(client *MessagesWSClient) { - h.mu.Lock() - defer h.mu.Unlock() - - if convs, exists := h.clientConversations[client]; exists { - for convID := range convs { - if clients, ok := h.conversationClients[convID]; ok { - delete(clients, client) - if len(clients) == 0 { - delete(h.conversationClients, convID) - } - } - } - delete(h.clientConversations, client) - } - - close(client.Send) -} - -// Broadcast emits an event to all clients in one conversation room. -func (h *MessagesHub) Broadcast(conversationID uint, eventType string, data interface{}) { - event := WsEvent{ - Type: eventType, - ConversationID: conversationID, - Data: data, - Timestamp: time.Now(), - } - - raw, err := json.Marshal(event) - if err != nil { - return - } - - h.mu.RLock() - clients := h.conversationClients[conversationID] - h.mu.RUnlock() - - for client := range clients { - select { - case client.Send <- raw: - default: - go h.RemoveClient(client) - } - } -} - -// SendToUser emits an event to one user in a conversation room. -func (h *MessagesHub) SendToUser(conversationID, userID uint, eventType string, data interface{}) { - event := WsEvent{ - Type: eventType, - ConversationID: conversationID, - Data: data, - Timestamp: time.Now(), - } - - raw, err := json.Marshal(event) - if err != nil { - return - } - - h.mu.RLock() - clients := h.conversationClients[conversationID] - h.mu.RUnlock() - - for client := range clients { - if client.UserID != userID { - continue - } - select { - case client.Send <- raw: - default: - go h.RemoveClient(client) - } - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6983dbe..345df12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import { Bookmarks } from '@/pages/content/Bookmarks' import { Tasks } from '@/pages/productivity/Tasks' import { Files } from '@/pages/content/Files' import { Notes } from '@/pages/content/Notes' -import Chat from '@/pages/communication/Chat' import { Settings } from '@/pages/settings/Settings' import { Login } from '@/pages/auth/Login' import { Youtube } from '@/pages/content/Youtube' @@ -23,11 +22,11 @@ import { LearningPaths } from '@/pages/content/LearningPaths' import { GitHub } from '@/pages/content/GitHub' import { TimeTracking } from '@/pages/productivity/TimeTracking' import { Calendar } from '@/pages/productivity/Calendar' +import { WorkspaceSetup } from '@/pages/auth/WorkspaceSetup' import { AuthCallback } from '@/pages/auth/AuthCallback' import { AuthProvider, useAuth } from '@/lib/auth' import { Search } from '@/pages/content/Search' import { Analytics } from '@/pages/admin/Analytics' -import { Messages } from '@/pages/communication/Messages' import { ShareTarget } from '@/pages/misc/ShareTarget' import BrowserExtensionSettings from '@/pages/settings/BrowserExtensionSettings' import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode' @@ -40,29 +39,78 @@ const initializeDarkMode = () => { const savedTheme = localStorage.getItem('theme'); const user = localStorage.getItem('user') || localStorage.getItem('trackeep_user'); + const root = document.documentElement; + + root.style.removeProperty('--foreground'); + root.style.removeProperty('--colors-foreground'); + root.style.removeProperty('--background'); + root.style.removeProperty('--colors-background'); + root.style.removeProperty('--primary'); + root.style.removeProperty('--colors-primary'); + root.style.removeProperty('--muted'); + root.style.removeProperty('--colors-muted'); + root.style.removeProperty('--border'); + root.style.removeProperty('--colors-border'); + if (user) { try { const userData = JSON.parse(user); // Prefer user's saved theme from profile, fallback to localStorage const userTheme = userData.theme || savedTheme; if (userTheme === 'dark') { - document.documentElement.setAttribute('data-kb-theme', 'dark'); + root.setAttribute('data-kb-theme', 'dark'); } else { - document.documentElement.removeAttribute('data-kb-theme'); + root.removeAttribute('data-kb-theme'); } } catch (e) { // Fallback to localStorage or dark mode if user data is invalid if (savedTheme === 'dark') { - document.documentElement.setAttribute('data-kb-theme', 'dark'); + root.setAttribute('data-kb-theme', 'dark'); } else { - document.documentElement.removeAttribute('data-kb-theme'); + root.removeAttribute('data-kb-theme'); } } } else if (savedTheme === 'dark') { - document.documentElement.setAttribute('data-kb-theme', 'dark'); + root.setAttribute('data-kb-theme', 'dark'); } else { // Default to dark mode - document.documentElement.setAttribute('data-kb-theme', 'dark'); + root.setAttribute('data-kb-theme', 'dark'); + } + + const savedColorScheme = localStorage.getItem('colorScheme'); + if (savedColorScheme && savedColorScheme !== 'default') { + const schemeColors: Record = { + 'ocean': '#0077be', + 'forest': '#228b22', + 'sunset': '#ff6b35', + 'purple': '#8b5cf6', + }; + const primary = schemeColors[savedColorScheme]; + if (primary) { + const hexToHsl = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) return '0 0% 100%'; + let r = parseInt(result[1], 16) / 255; + let g = parseInt(result[2], 16) / 255; + let b = parseInt(result[3], 16) / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0, s = 0, l = (max + min) / 2; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; + }; + const hsl = hexToHsl(primary); + root.style.setProperty('--primary', hsl); + root.style.setProperty('--colors-primary', hsl); + } } }; @@ -143,6 +191,13 @@ function App() { )} /> + ( + + + + + + )} /> ( @@ -206,20 +261,7 @@ function App() { )} /> - ( - - - - - - )} /> - ( - - - - - - )} /> + ( diff --git a/frontend/src/components/AIProviderIcon.tsx b/frontend/src/components/AIProviderIcon.tsx deleted file mode 100644 index 92635a8..0000000 --- a/frontend/src/components/AIProviderIcon.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { createMemo, Show } from 'solid-js'; - -interface AIProviderIconProps { - providerId: string; - size?: string; - class?: string; - white?: boolean; -} - -const inlineSVGs: Record = { - openrouter: 'OpenRouter', - ollama: 'Ollama', - grok: 'Grok', -}; - -const iconPaths: Record = { - mistral: '/assets/mistral-color.svg', - longcat: '/assets/longcat-color.svg', - deepseek: '/assets/deepseek-color.svg', -}; - -const fallbackIcons: Record = { - mistral: 'M', - longcat: 'C', - grok: 'G', - deepseek: 'D', - ollama: 'O', - openrouter: 'OR', -}; - -export function AIProviderIcon(props: AIProviderIconProps) { - const inlineSVG = createMemo(() => inlineSVGs[props.providerId]); - const iconPath = createMemo(() => iconPaths[props.providerId]); - const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || 'AI'); - - // Use inline SVG if available (for openrouter, ollama, grok) - if (inlineSVG()) { - return ( -
- ); - } - - // Use image for other providers - return ( - - {fallbackIcon()} - - }> - {`${props.providerId} { - // Fallback to emoji if SVG fails to load - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - if (target.nextElementSibling) { - (target.nextElementSibling as HTMLElement).style.display = 'inline'; - } - }} - /> - - {fallbackIcon()} - - - ); -} diff --git a/frontend/src/components/layout/AIChatPanel.tsx b/frontend/src/components/layout/AIChatPanel.tsx deleted file mode 100644 index ee24cd6..0000000 --- a/frontend/src/components/layout/AIChatPanel.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { createSignal, For, Show } from 'solid-js' -import { IconSend, IconX, IconBrain, IconUser, IconChevronDown } from '@tabler/icons-solidjs' -import { AIProviderIcon } from '../AIProviderIcon' - -interface AIChatPanelProps { - isOpen: boolean - onClose: () => void -} - -interface Message { - id: string - role: 'user' | 'assistant' - content: string - timestamp: Date -} - -interface AIModel { - id: string - name: string - description: string - provider: string - category: string - iconId?: string -} - -export function AIChatPanel(props: AIChatPanelProps) { - const [messages, setMessages] = createSignal([ - { - id: '1', - role: 'assistant', - content: 'Hello! I\'m your AI assistant. How can I help you today?', - timestamp: new Date() - } - ]) - const [inputValue, setInputValue] = createSignal('') - const [selectedModel, setSelectedModel] = createSignal('longcat-flash-chat') - const [showModelPicker, setShowModelPicker] = createSignal(false) - - const aiModels: AIModel[] = [ - { id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient', provider: 'longcat', category: 'fast', iconId: 'longcat' }, - { id: 'mistral-standard', name: 'Mistral Standard', description: 'Mistral default model', provider: 'mistral', category: 'standard', iconId: 'mistral' }, - { id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' }, - { id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' }, - { id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' }, - { id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' }, - ] - - const handleSendMessage = () => { - const value = inputValue().trim() - if (!value) return - - const userMessage: Message = { - id: Date.now().toString(), - role: 'user', - content: value, - timestamp: new Date() - } - - setMessages(prev => [...prev, userMessage]) - setInputValue('') - - // Simulate AI response - setTimeout(() => { - const aiMessage: Message = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: 'I understand your question. Let me help you with that...', - timestamp: new Date() - } - setMessages(prev => [...prev, aiMessage]) - }, 1000) - } - - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSendMessage() - } - } - - return ( - <> - {/* Chat Panel */} -
- {/* Header */} -
-
-
- -
-
-

AI Assistant

-

Always here to help

-
-
-
- {/* Close Button */} - -
-
- - {/* Messages */} -
- - {(message) => ( -
- {message.role === 'assistant' && ( -
- -
- )} -
-

{message.content}

-
-

- {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -

- {message.role === 'user' && ( -
- )} -
-
- {message.role === 'user' && ( -
- -
- )} -
- )} -
-
- - {/* Input */} -
-
- setInputValue(e.currentTarget.value)} - onKeyPress={handleKeyPress} - placeholder="Type your message..." - class="flex-1 h-10 w-full rounded-full border border-border/50 bg-background/95 backdrop-blur-sm px-4 py-2 text-sm shadow-sm transition-all duration-200 focus:shadow-md focus:border-primary/50 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50" - /> - -
- - {/* Model Picker at Bottom */} -
-
-
- - - -
- - {model => ( - - )} - -
-
-
- -
- {aiModels.find(m => m.id === selectedModel())?.provider || 'LongCat'} - - AI settings - -
-
-
-
-
- - ) -} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 2658a06..9800f59 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,8 +1,6 @@ import { children, createSignal, onMount } from 'solid-js' import { Sidebar } from './Sidebar' import { Header } from './Header' -import { AIChatPanel } from './AIChatPanel' -import { IconBrain } from '@tabler/icons-solidjs' import { isEnvDemoMode } from '@/lib/demo-mode' export interface LayoutProps { @@ -14,7 +12,6 @@ export interface LayoutProps { export function Layout(props: LayoutProps) { const resolved = children(() => props.children) - const [isChatOpen, setIsChatOpen] = createSignal(false) const [isSidebarOpen, setIsSidebarOpen] = createSignal(true) onMount(() => { @@ -25,7 +22,6 @@ export function Layout(props: LayoutProps) { setIsSidebarOpen(window.innerWidth >= 768) } - // Initialize dark mode from localStorage or system preference const savedTheme = localStorage.getItem('theme') const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches @@ -38,49 +34,38 @@ export function Layout(props: LayoutProps) { document.documentElement.removeAttribute('data-kb-theme') } - // Initialize color scheme from localStorage const savedColorScheme = localStorage.getItem('colorScheme'); const savedCustomColors = localStorage.getItem('customColors'); if (savedColorScheme === 'custom' && savedCustomColors) { try { const colors = JSON.parse(savedCustomColors); - - // Apply custom colors const hexToHsl = (hex: string) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return '0 0% 100%'; - let r = parseInt(result[1], 16) / 255; let g = parseInt(result[2], 16) / 255; let b = parseInt(result[3], 16) / 255; - const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h = 0, s = 0, l = (max + min) / 2; - if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } - return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; }; - const root = document.documentElement; root.style.setProperty('--primary', hexToHsl(colors.primary)); root.style.setProperty('--background', hexToHsl(colors.background)); root.style.setProperty('--foreground', hexToHsl(colors.foreground)); root.style.setProperty('--muted', hexToHsl(colors.muted)); root.style.setProperty('--border', colors.border); - - // Also set as CSS custom properties for direct use root.style.setProperty('--colors-primary', hexToHsl(colors.primary)); root.style.setProperty('--colors-background', hexToHsl(colors.background)); root.style.setProperty('--colors-foreground', hexToHsl(colors.foreground)); @@ -90,7 +75,6 @@ export function Layout(props: LayoutProps) { console.error('Failed to load custom colors:', e); } } else if (savedColorScheme) { - // Apply predefined scheme const predefinedSchemes: Record = { 'default': { primary: '#5ab9ff', background: savedTheme === 'dark' ? '#1a1a1a' : '#ffffff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#262727' : '#f5f5f5', border: '#262626' }, 'ocean': { primary: '#0077be', background: savedTheme === 'dark' ? '#001f3f' : '#e6f3ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#003366' : '#cce7ff', border: '#004080' }, @@ -103,43 +87,34 @@ export function Layout(props: LayoutProps) { 'cyan': { primary: '#06b6d4', background: savedTheme === 'dark' ? '#022c3a' : '#ecfeff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#164e63' : '#cffafe', border: '#0891b2' }, 'indigo': { primary: '#6366f1', background: savedTheme === 'dark' ? '#1e1b4b' : '#eef2ff', foreground: savedTheme === 'dark' ? '#ffffff' : '#000000', muted: savedTheme === 'dark' ? '#312e81' : '#e0e7ff', border: '#4338ca' } }; - const scheme = predefinedSchemes[savedColorScheme]; if (scheme) { const hexToHsl = (hex: string) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return '0 0% 100%'; - let r = parseInt(result[1], 16) / 255; let g = parseInt(result[2], 16) / 255; let b = parseInt(result[3], 16) / 255; - const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h = 0, s = 0, l = (max + min) / 2; - if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } - return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; }; - const root = document.documentElement; root.style.setProperty('--primary', hexToHsl(scheme.primary)); root.style.setProperty('--background', hexToHsl(scheme.background)); root.style.setProperty('--foreground', hexToHsl(scheme.foreground)); root.style.setProperty('--muted', hexToHsl(scheme.muted)); root.style.setProperty('--border', scheme.border); - - // Also set as CSS custom properties for direct use root.style.setProperty('--colors-primary', hexToHsl(scheme.primary)); root.style.setProperty('--colors-background', hexToHsl(scheme.background)); root.style.setProperty('--colors-foreground', hexToHsl(scheme.foreground)); @@ -149,10 +124,6 @@ export function Layout(props: LayoutProps) { } }) - const toggleChat = () => { - setIsChatOpen(!isChatOpen()) - } - const toggleSidebar = () => { const nextValue = !isSidebarOpen() setIsSidebarOpen(nextValue) @@ -166,43 +137,19 @@ export function Layout(props: LayoutProps) { return (
-
- {/* Mobile Sidebar Overlay */} {isSidebarOpen() && ( -
+
)} - - {/* Sidebar */} - - {/* Main Content */}
- {/* Header */} {!props.fullBleed &&
} - - {/* Page Content */}
{resolved()}
- - {/* Floating AI Button */} - - - {/* AI Chat Panel */} - setIsChatOpen(false)} />
) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 40d3364..bfce9c4 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -12,16 +12,21 @@ import { IconChevronDown, IconTrash, IconUsers, - IconBrain, IconSchool, IconChartLine, IconBrandGithub, IconClock, IconCalendar, - IconMessageCircle, IconLogout, IconBuilding, - IconPlus + IconPlus, + IconWorld, + IconLock, + IconCheck, + IconStar, + IconHeart, + IconBriefcase, + IconEdit } from '@tabler/icons-solidjs' import { Input } from '../ui/Input' import { Button } from '../ui/Button' @@ -39,13 +44,11 @@ const navigation = [ { name: 'Calendar', href: '/app/calendar', icon: IconCalendar }, { name: 'Files', href: '/app/files', icon: IconFolder }, { name: 'Notes', href: '/app/notes', icon: IconNotebook }, - { name: 'Messages', href: '/app/messages', icon: IconMessageCircle }, { name: 'YouTube', href: '/app/youtube', icon: IconVideo }, { name: 'Members', href: '/app/members', icon: IconUsers }, { name: 'Learning', href: '/app/learning-paths', icon: IconSchool }, { name: 'Stats', href: '/app/stats', icon: IconChartLine }, { name: 'GitHub', href: '/app/github', icon: IconBrandGithub }, - { name: 'AI Assistant', href: '/app/chat', icon: IconBrain }, ] const API_BASE_URL = getApiV1BaseUrl() @@ -55,13 +58,25 @@ interface WorkspaceOption { id: string name: string icon: typeof IconFileText + iconId?: string + description?: string + is_public?: boolean } -const getWorkspaceIcon = (name: string) => { - const lower = name.toLowerCase() - if (lower.includes('team')) return IconUsers - if (lower.includes('personal')) return IconBuilding - return IconFileText +const WORKSPACE_ICONS = [ + { id: 'building', icon: IconBuilding }, + { id: 'world', icon: IconWorld }, + { id: 'lock', icon: IconLock }, + { id: 'check', icon: IconCheck }, + { id: 'star', icon: IconStar }, + { id: 'heart', icon: IconHeart }, + { id: 'home', icon: IconHome }, + { id: 'briefcase', icon: IconBriefcase }, +] + +const getWorkspaceIcon = (iconId: string) => { + const found = WORKSPACE_ICONS.find((i) => i.id === iconId) + return found ? found.icon : IconBuilding } const getAuthToken = () => localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '' @@ -85,6 +100,14 @@ export function Sidebar(props: SidebarProps) { const [workspaceIsPublic, setWorkspaceIsPublic] = createSignal(false) const [isCreatingWorkspace, setIsCreatingWorkspace] = createSignal(false) const [createWorkspaceError, setCreateWorkspaceError] = createSignal('') + const [isEditWorkspaceModalOpen, setIsEditWorkspaceModalOpen] = createSignal(false) + const [editingWorkspace, setEditingWorkspace] = createSignal(null) + const [editWorkspaceName, setEditWorkspaceName] = createSignal('') + const [editWorkspaceDescription, setEditWorkspaceDescription] = createSignal('') + const [editWorkspaceIsPublic, setEditWorkspaceIsPublic] = createSignal(false) + const [editWorkspaceIcon, setEditWorkspaceIcon] = createSignal('building') + const [isSavingWorkspace, setIsSavingWorkspace] = createSignal(false) + const [saveWorkspaceError, setSaveWorkspaceError] = createSignal('') const selectedWorkspace = () => { const list = workspaces() @@ -140,48 +163,27 @@ export function Sidebar(props: SidebarProps) { setIsWorkspaceDropdownOpen(!isWorkspaceDropdownOpen()) } - const normalizeWorkspace = (team: { id?: number | string; name?: string }): WorkspaceOption => { + const normalizeWorkspace = (team: { id?: number | string; name?: string; description?: string; is_public?: boolean; icon_id?: string }): WorkspaceOption => { const name = team.name?.trim() || DEFAULT_WORKSPACE_NAME + const iconId = team.icon_id || 'building' return { id: String(team.id ?? `workspace-${Date.now()}`), name, - icon: getWorkspaceIcon(name), + icon: getWorkspaceIcon(iconId), + iconId, + description: team.description, + is_public: team.is_public, } } - const createDefaultWorkspace = async (token: string): Promise => { - const response = await fetch(`${API_BASE_URL}/teams`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - name: DEFAULT_WORKSPACE_NAME, - description: 'Default workspace', - is_public: false, - }), - }) - - if (!response.ok) { - return null - } - - const data = await response.json() - if (!data?.team) { - return null - } - - return normalizeWorkspace(data.team) - } - const loadWorkspaces = async () => { const token = getAuthToken() if (!token) { const fallbackWorkspace = { id: 'local-default', name: DEFAULT_WORKSPACE_NAME, - icon: IconFileText, + icon: IconBuilding, + iconId: 'building', } setWorkspaces([fallbackWorkspace]) setSelectedWorkspaceId(fallbackWorkspace.id) @@ -204,20 +206,11 @@ export function Sidebar(props: SidebarProps) { } if (mappedWorkspaces.length === 0) { - const created = await createDefaultWorkspace(token) - if (created) { - mappedWorkspaces = [created] + const currentPath = window.location.pathname + if (currentPath === '/app' || currentPath === '/app/') { + window.location.href = '/app/workspace-setup' } - } - - if (mappedWorkspaces.length === 0) { - mappedWorkspaces = [ - { - id: 'local-default', - name: DEFAULT_WORKSPACE_NAME, - icon: IconFileText, - }, - ] + return } setWorkspaces(mappedWorkspaces) @@ -233,7 +226,8 @@ export function Sidebar(props: SidebarProps) { const fallbackWorkspace = { id: 'local-default', name: DEFAULT_WORKSPACE_NAME, - icon: IconFileText, + icon: IconBuilding, + iconId: 'building', } setWorkspaces([fallbackWorkspace]) setSelectedWorkspaceId(fallbackWorkspace.id) @@ -258,7 +252,8 @@ export function Sidebar(props: SidebarProps) { const localWorkspace = { id: `local-${Date.now()}`, name: trimmed, - icon: getWorkspaceIcon(trimmed), + icon: getWorkspaceIcon('building'), + iconId: 'building', } setWorkspaces((prev) => [localWorkspace, ...prev]) handleWorkspaceSelect(localWorkspace) @@ -307,6 +302,87 @@ export function Sidebar(props: SidebarProps) { } } + const openEditWorkspaceModal = (workspace: WorkspaceOption) => { + setEditingWorkspace(workspace) + setEditWorkspaceName(workspace.name) + setEditWorkspaceDescription(workspace.description || '') + setEditWorkspaceIsPublic(workspace.is_public || false) + setEditWorkspaceIcon(workspace.iconId || 'building') + setSaveWorkspaceError('') + setIsEditWorkspaceModalOpen(true) + setIsWorkspaceDropdownOpen(false) + } + + const closeEditWorkspaceModal = () => { + if (isSavingWorkspace()) return + setIsEditWorkspaceModalOpen(false) + setEditingWorkspace(null) + } + + const handleSaveWorkspace = async () => { + const workspace = editingWorkspace() + if (!workspace) return + const trimmed = editWorkspaceName().trim() + if (!trimmed) { + setSaveWorkspaceError('Workspace name required') + return + } + setSaveWorkspaceError('') + setIsSavingWorkspace(true) + const token = getAuthToken() + if (!token) { + setWorkspaces((prev) => + prev.map((w) => + w.id === workspace.id + ? { ...w, name: trimmed, description: editWorkspaceDescription(), is_public: editWorkspaceIsPublic(), iconId: editWorkspaceIcon(), icon: getWorkspaceIcon(editWorkspaceIcon()) } + : w + ) + ) + setIsEditWorkspaceModalOpen(false) + setIsSavingWorkspace(false) + return + } + + try { + const response = await fetch(`${API_BASE_URL}/teams/${workspace.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + name: trimmed, + description: editWorkspaceDescription().trim(), + is_public: editWorkspaceIsPublic(), + }), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data?.error || 'Failed to save workspace') + } + setWorkspaces((prev) => + prev.map((w) => + w.id === workspace.id + ? { ...w, name: trimmed, description: editWorkspaceDescription(), is_public: editWorkspaceIsPublic(), iconId: editWorkspaceIcon(), icon: getWorkspaceIcon(editWorkspaceIcon()) } + : w + ) + ) + if (selectedWorkspaceId() === workspace.id) { + persistSelectedWorkspace({ + ...workspace, + name: trimmed, + icon: getWorkspaceIcon(editWorkspaceIcon()), + iconId: editWorkspaceIcon() + }) + } + setIsEditWorkspaceModalOpen(false) + } catch (error) { + setSaveWorkspaceError(error instanceof Error ? error.message : 'Failed to save workspace') + } finally { + setIsSavingWorkspace(false) + } + } + // Close dropdown when clicking outside onMount(() => { void loadWorkspaces() @@ -375,22 +451,31 @@ export function Sidebar(props: SidebarProps) {
{(workspace) => ( - +
+ + +
)}
@@ -596,6 +681,102 @@ export function Sidebar(props: SidebarProps) { + + + + <> +
+
+
+
+

Edit Workspace

+

Update workspace details and icon.

+
+ +
+
+ + setEditWorkspaceName((event.currentTarget as HTMLInputElement).value)} + disabled={isSavingWorkspace()} + /> +
+ +
+ +