Changeset 0.7.2 (#40)
This commit is contained in:
504
server/handlers/notes.go
Normal file
504
server/handlers/notes.go
Normal file
@@ -0,0 +1,504 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user