This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/notes.go
2026-02-21 19:03:19 +00:00

505 lines
13 KiB
Go

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(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.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(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.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(
&note.ID, &note.Title, &note.Content, &note.FolderPath,
&dbTags, &note.SourceChannelID,
&note.CreatedAt, &note.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
}