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-28 01:40:31 +00:00

503 lines
13 KiB
Go

package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── 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 {
stores store.Stores
}
// NewNoteHandler creates a new handler.
func NewNoteHandler(s ...store.Stores) *NoteHandler {
h := &NoteHandler{}
if len(s) > 0 {
h.stores = s[0]
}
return h
}
// toNoteResponse converts a models.Note to a noteResponse.
func toNoteResponse(n *models.Note) noteResponse {
tags := n.Tags
if tags == nil {
tags = []string{}
}
return noteResponse{
ID: n.ID,
Title: n.Title,
Content: n.Content,
FolderPath: n.FolderPath,
Tags: tags,
SourceChannelID: n.SourceChannelID,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func toNoteListItem(n models.Note) noteListItem {
tags := n.Tags
if tags == nil {
tags = []string{}
}
preview := n.Content
if len(preview) > 200 {
preview = preview[:200]
}
return noteListItem{
ID: n.ID,
Title: n.Title,
FolderPath: n.FolderPath,
Tags: tags,
Preview: preview,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ── 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
}
note := &models.Note{
UserID: userID,
Title: req.Title,
Content: req.Content,
FolderPath: folder,
Tags: tags,
SourceChannelID: sourceChannelID,
}
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
return
}
c.JSON(http.StatusCreated, toNoteResponse(note))
}
// ── Get ─────────────────────────────────────
// GET /api/v1/notes/:id
func (h *NoteHandler) Get(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Verify ownership
if note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, toNoteResponse(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
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Build fields map
fields := map[string]interface{}{}
if req.Title != nil {
fields["title"] = *req.Title
}
if req.Content != nil {
mode := strings.ToLower(req.Mode)
switch mode {
case "append":
fields["content"] = existing.Content + *req.Content
case "prepend":
fields["content"] = *req.Content + existing.Content
default: // "replace" or empty
fields["content"] = *req.Content
}
}
if req.FolderPath != nil {
fields["folder_path"] = normalizeFolderPath(*req.FolderPath)
}
if req.Tags != nil {
fields["tags"] = req.Tags
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Notes.Update(c.Request.Context(), noteID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
return
}
// Re-fetch to get updated timestamps
updated, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated note"})
return
}
c.JSON(http.StatusOK, toNoteResponse(updated))
}
// ── Delete ──────────────────────────────────
// DELETE /api/v1/notes/:id
func (h *NoteHandler) Delete(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if err := h.stores.Notes.Delete(c.Request.Context(), noteID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
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)
count, err := h.stores.Notes.BulkDelete(c.Request.Context(), req.IDs, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
return
}
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
}
opts := store.NoteListOptions{
ListOptions: store.ListOptions{
Limit: limit,
Offset: offset,
},
FolderPath: normalizeFolderPath(folder),
Tag: tag,
}
if folder == "" {
opts.FolderPath = ""
}
// Map sort parameter
switch sort {
case "created_asc":
opts.Sort = "created_at"
opts.Order = "ASC"
case "created_desc":
opts.Sort = "created_at"
opts.Order = "DESC"
case "updated_asc":
opts.Sort = "updated_at"
opts.Order = "ASC"
case "title_asc":
opts.Sort = "title"
opts.Order = "ASC"
case "title_desc":
opts.Sort = "title"
opts.Order = "DESC"
default: // "updated_desc"
opts.Sort = ""
opts.Order = ""
}
notes, total, err := h.stores.Notes.ListForUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
return
}
items := make([]noteListItem, 0, len(notes))
for _, n := range notes {
items = append(items, toNoteListItem(n))
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"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 Postgres full-text search when available, LIKE fallback on SQLite
if database.IsPostgres() {
h.searchPostgres(c, userID, q, limit)
return
}
// SQLite: use store's LIKE-based search
notes, _, err := h.stores.Notes.Search(c.Request.Context(), userID, q, store.ListOptions{Limit: limit})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
results := make([]searchResult, 0, len(notes))
for _, n := range notes {
results = append(results, searchResult{
noteListItem: toNoteListItem(n),
Rank: 1.0,
Headline: "",
})
}
c.JSON(http.StatusOK, gin.H{
"data": results,
"query": q,
"total": len(results),
})
}
// searchPostgres uses Postgres full-text search with ts_rank and ts_headline.
func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int) {
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()
// Import pq at call site to avoid pulling it in for SQLite builds
results := make([]searchResult, 0)
for rows.Next() {
var r searchResult
var tags []string
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, pgScanStringArray(&tags), &r.Preview,
&r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil {
continue
}
if tags == nil {
tags = []string{}
}
r.Tags = tags
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(database.Q(`
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
}