505 lines
13 KiB
Go
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(
|
|
¬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
|
|
}
|