package handlers import ( "database/sql" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/lib/pq" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Request / Response Types ──────────────── type createNoteRequest struct { Title string `json:"title" binding:"required"` Content string `json:"content" binding:"required"` FolderPath string `json:"folder_path"` Tags []string `json:"tags"` SourceChannelID string `json:"source_channel_id"` } type updateNoteRequest struct { Title *string `json:"title"` Content *string `json:"content"` FolderPath *string `json:"folder_path"` Tags []string `json:"tags"` // Mode controls how content is applied: "replace" (default), "append", "prepend" Mode string `json:"mode"` } type noteResponse struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` FolderPath string `json:"folder_path"` Tags []string `json:"tags"` SourceChannelID *string `json:"source_channel_id,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type noteListItem struct { ID string `json:"id"` Title string `json:"title"` FolderPath string `json:"folder_path"` Tags []string `json:"tags"` Preview string `json:"preview"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type searchResult struct { noteListItem Rank float64 `json:"rank"` Headline string `json:"headline"` } // NoteHandler handles notes CRUD. type NoteHandler struct{} // NewNoteHandler creates a new handler. func NewNoteHandler() *NoteHandler { return &NoteHandler{} } // ── Create ────────────────────────────────── // POST /api/v1/notes func (h *NoteHandler) Create(c *gin.Context) { var req createNoteRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := getUserID(c) folder := normalizeFolderPath(req.FolderPath) tags := req.Tags if tags == nil { tags = []string{} } var sourceChannelID *string if req.SourceChannelID != "" { sourceChannelID = &req.SourceChannelID } var note noteResponse var dbTags pq.StringArray err := database.DB.QueryRow(` INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text `, userID, req.Title, req.Content, folder, pq.Array(tags), sourceChannelID, ).Scan( ¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath, &dbTags, ¬e.SourceChannelID, ¬e.CreatedAt, ¬e.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"}) return } note.Tags = []string(dbTags) if note.Tags == nil { note.Tags = []string{} } c.JSON(http.StatusCreated, note) } // ── Get ───────────────────────────────────── // GET /api/v1/notes/:id func (h *NoteHandler) Get(c *gin.Context) { userID := getUserID(c) noteID := c.Param("id") var note noteResponse var dbTags pq.StringArray err := database.DB.QueryRow(` SELECT id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text FROM notes WHERE id = $1 AND user_id = $2 `, noteID, userID).Scan( ¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath, &dbTags, ¬e.SourceChannelID, ¬e.CreatedAt, ¬e.UpdatedAt, ) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get note"}) return } note.Tags = []string(dbTags) if note.Tags == nil { note.Tags = []string{} } c.JSON(http.StatusOK, note) } // ── Update ────────────────────────────────── // PUT /api/v1/notes/:id func (h *NoteHandler) Update(c *gin.Context) { var req updateNoteRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := getUserID(c) noteID := c.Param("id") // Verify ownership var exists bool err := database.DB.QueryRow( `SELECT EXISTS(SELECT 1 FROM notes WHERE id = $1 AND user_id = $2)`, noteID, userID, ).Scan(&exists) if err != nil || !exists { c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) return } // Build dynamic update setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Title != nil { setClauses = append(setClauses, "title = $"+strconv.Itoa(argIdx)) args = append(args, *req.Title) argIdx++ } if req.Content != nil { mode := strings.ToLower(req.Mode) switch mode { case "append": setClauses = append(setClauses, "content = content || $"+strconv.Itoa(argIdx)) case "prepend": setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)+" || content") default: // "replace" or empty setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)) } args = append(args, *req.Content) argIdx++ } if req.FolderPath != nil { setClauses = append(setClauses, "folder_path = $"+strconv.Itoa(argIdx)) args = append(args, normalizeFolderPath(*req.FolderPath)) argIdx++ } if req.Tags != nil { setClauses = append(setClauses, "tags = $"+strconv.Itoa(argIdx)) args = append(args, pq.Array(req.Tags)) argIdx++ } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } // WHERE clause args = append(args, noteID, userID) query := "UPDATE notes SET " + strings.Join(setClauses, ", ") + " WHERE id = $" + strconv.Itoa(argIdx) + " AND user_id = $" + strconv.Itoa(argIdx+1) + " RETURNING id, title, content, folder_path, tags, source_channel_id, created_at::text, updated_at::text" var note noteResponse var dbTags pq.StringArray err = database.DB.QueryRow(query, args...).Scan( ¬e.ID, ¬e.Title, ¬e.Content, ¬e.FolderPath, &dbTags, ¬e.SourceChannelID, ¬e.CreatedAt, ¬e.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"}) return } note.Tags = []string(dbTags) if note.Tags == nil { note.Tags = []string{} } c.JSON(http.StatusOK, note) } // ── Delete ────────────────────────────────── // DELETE /api/v1/notes/:id func (h *NoteHandler) Delete(c *gin.Context) { userID := getUserID(c) noteID := c.Param("id") result, err := database.DB.Exec( `DELETE FROM notes WHERE id = $1 AND user_id = $2`, noteID, userID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) return } c.JSON(http.StatusOK, gin.H{"deleted": true}) } // ── Bulk Delete ──────────────────────────── // POST /api/v1/notes/bulk-delete func (h *NoteHandler) BulkDelete(c *gin.Context) { var req struct { IDs []string `json:"ids" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.IDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"}) return } if len(req.IDs) > 100 { c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"}) return } userID := getUserID(c) result, err := database.DB.Exec( `DELETE FROM notes WHERE id = ANY($1) AND user_id = $2`, pq.Array(req.IDs), userID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"}) return } count, _ := result.RowsAffected() c.JSON(http.StatusOK, gin.H{"deleted": count}) } // GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc func (h *NoteHandler) List(c *gin.Context) { userID := getUserID(c) folder := c.Query("folder") tag := c.Query("tag") sort := c.DefaultQuery("sort", "updated_desc") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) if limit <= 0 || limit > 200 { limit = 50 } query := `SELECT id, title, folder_path, tags, LEFT(content, 200), created_at::text, updated_at::text FROM notes WHERE user_id = $1` args := []interface{}{userID} argIdx := 2 if folder != "" { query += " AND folder_path = $" + strconv.Itoa(argIdx) args = append(args, normalizeFolderPath(folder)) argIdx++ } if tag != "" { query += " AND $" + strconv.Itoa(argIdx) + " = ANY(tags)" args = append(args, tag) argIdx++ } // Sort options switch sort { case "created_asc": query += " ORDER BY created_at ASC" case "created_desc": query += " ORDER BY created_at DESC" case "updated_asc": query += " ORDER BY updated_at ASC" case "title_asc": query += " ORDER BY title ASC" case "title_desc": query += " ORDER BY title DESC" default: // "updated_desc" query += " ORDER BY updated_at DESC" } query += " LIMIT $" + strconv.Itoa(argIdx) + " OFFSET $" + strconv.Itoa(argIdx+1) args = append(args, limit, offset) rows, err := database.DB.Query(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"}) return } defer rows.Close() notes := make([]noteListItem, 0) for rows.Next() { var n noteListItem var dbTags pq.StringArray if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath, &dbTags, &n.Preview, &n.CreatedAt, &n.UpdatedAt); err != nil { continue } n.Tags = []string(dbTags) if n.Tags == nil { n.Tags = []string{} } notes = append(notes, n) } // Get total count var total int countQuery := `SELECT COUNT(*) FROM notes WHERE user_id = $1` countArgs := []interface{}{userID} if folder != "" { countQuery += " AND folder_path = $2" countArgs = append(countArgs, normalizeFolderPath(folder)) } _ = database.DB.QueryRow(countQuery, countArgs...).Scan(&total) c.JSON(http.StatusOK, gin.H{ "data": notes, "total": total, "limit": limit, "offset": offset, }) } // ── Search ────────────────────────────────── // GET /api/v1/notes/search?q=query&limit=20 func (h *NoteHandler) Search(c *gin.Context) { userID := getUserID(c) q := strings.TrimSpace(c.Query("q")) if q == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"}) return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) if limit <= 0 || limit > 100 { limit = 20 } // Use plainto_tsquery for natural language (not websearch_to_tsquery which // requires Postgres 11+ and has stricter syntax). rows, err := database.DB.Query(` SELECT id, title, folder_path, tags, LEFT(content, 200), created_at::text, updated_at::text, ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, ts_headline('english', content, plainto_tsquery('english', $2), 'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline FROM notes WHERE user_id = $1 AND search_vector @@ plainto_tsquery('english', $2) ORDER BY rank DESC LIMIT $3 `, userID, q, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } defer rows.Close() results := make([]searchResult, 0) for rows.Next() { var r searchResult var dbTags pq.StringArray if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Preview, &r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil { continue } r.Tags = []string(dbTags) if r.Tags == nil { r.Tags = []string{} } results = append(results, r) } c.JSON(http.StatusOK, gin.H{ "data": results, "query": q, "total": len(results), }) } // ── List Folders ──────────────────────────── // GET /api/v1/notes/folders func (h *NoteHandler) ListFolders(c *gin.Context) { userID := getUserID(c) rows, err := database.DB.Query(` SELECT DISTINCT folder_path, COUNT(*) AS count FROM notes WHERE user_id = $1 GROUP BY folder_path ORDER BY folder_path `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) return } defer rows.Close() type folderInfo struct { Path string `json:"path"` Count int `json:"count"` } folders := make([]folderInfo, 0) for rows.Next() { var f folderInfo if err := rows.Scan(&f.Path, &f.Count); err != nil { continue } folders = append(folders, f) } c.JSON(http.StatusOK, gin.H{"folders": folders}) } // ── Helpers ───────────────────────────────── // normalizeFolderPath ensures consistent folder path format. func normalizeFolderPath(p string) string { p = strings.TrimSpace(p) if p == "" { return "/" } if !strings.HasPrefix(p, "/") { p = "/" + p } if !strings.HasSuffix(p, "/") { p = p + "/" } // Collapse double slashes for strings.Contains(p, "//") { p = strings.ReplaceAll(p, "//", "/") } return p }