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