Changeset 0.17.1 (#76)
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
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"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
@@ -59,11 +59,55 @@ type searchResult struct {
|
||||
}
|
||||
|
||||
// NoteHandler handles notes CRUD.
|
||||
type NoteHandler struct{}
|
||||
type NoteHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewNoteHandler creates a new handler.
|
||||
func NewNoteHandler() *NoteHandler {
|
||||
return &NoteHandler{}
|
||||
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 ──────────────────────────────────
|
||||
@@ -89,29 +133,21 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, note)
|
||||
c.JSON(http.StatusCreated, toNoteResponse(note))
|
||||
}
|
||||
|
||||
// ── Get ─────────────────────────────────────
|
||||
@@ -121,31 +157,18 @@ 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 {
|
||||
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
|
||||
if err != nil {
|
||||
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"})
|
||||
// Verify ownership
|
||||
if note.UserID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
return
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, note)
|
||||
c.JSON(http.StatusOK, toNoteResponse(note))
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────
|
||||
@@ -162,82 +185,57 @@ func (h *NoteHandler) Update(c *gin.Context) {
|
||||
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 {
|
||||
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 dynamic update
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
// Build fields map
|
||||
fields := map[string]interface{}{}
|
||||
|
||||
if req.Title != nil {
|
||||
setClauses = append(setClauses, "title = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, *req.Title)
|
||||
argIdx++
|
||||
fields["title"] = *req.Title
|
||||
}
|
||||
|
||||
if req.Content != nil {
|
||||
mode := strings.ToLower(req.Mode)
|
||||
switch mode {
|
||||
case "append":
|
||||
setClauses = append(setClauses, "content = content || $"+strconv.Itoa(argIdx))
|
||||
fields["content"] = existing.Content + *req.Content
|
||||
case "prepend":
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx)+" || content")
|
||||
fields["content"] = *req.Content + existing.Content
|
||||
default: // "replace" or empty
|
||||
setClauses = append(setClauses, "content = $"+strconv.Itoa(argIdx))
|
||||
fields["content"] = *req.Content
|
||||
}
|
||||
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++
|
||||
fields["folder_path"] = normalizeFolderPath(*req.FolderPath)
|
||||
}
|
||||
|
||||
if req.Tags != nil {
|
||||
setClauses = append(setClauses, "tags = $"+strconv.Itoa(argIdx))
|
||||
args = append(args, pq.Array(req.Tags))
|
||||
argIdx++
|
||||
fields["tags"] = req.Tags
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
if len(fields) == 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 {
|
||||
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
|
||||
}
|
||||
note.Tags = []string(dbTags)
|
||||
if note.Tags == nil {
|
||||
note.Tags = []string{}
|
||||
|
||||
// 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, note)
|
||||
c.JSON(http.StatusOK, toNoteResponse(updated))
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────
|
||||
@@ -247,17 +245,15 @@ 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"})
|
||||
// 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
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
|
||||
|
||||
if err := h.stores.Notes.Delete(c.Request.Context(), noteID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,18 +282,15 @@ func (h *NoteHandler) BulkDelete(c *gin.Context) {
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM notes WHERE id = ANY($1) AND user_id = $2`,
|
||||
pq.Array(req.IDs), userID,
|
||||
)
|
||||
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
|
||||
}
|
||||
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) {
|
||||
@@ -312,77 +305,53 @@ func (h *NoteHandler) List(c *gin.Context) {
|
||||
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++
|
||||
opts := store.NoteListOptions{
|
||||
ListOptions: store.ListOptions{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
},
|
||||
FolderPath: normalizeFolderPath(folder),
|
||||
Tag: tag,
|
||||
}
|
||||
if tag != "" {
|
||||
query += " AND $" + strconv.Itoa(argIdx) + " = ANY(tags)"
|
||||
args = append(args, tag)
|
||||
argIdx++
|
||||
if folder == "" {
|
||||
opts.FolderPath = ""
|
||||
}
|
||||
|
||||
// Sort options
|
||||
// Map sort parameter
|
||||
switch sort {
|
||||
case "created_asc":
|
||||
query += " ORDER BY created_at ASC"
|
||||
opts.Sort = "created_at"
|
||||
opts.Order = "ASC"
|
||||
case "created_desc":
|
||||
query += " ORDER BY created_at DESC"
|
||||
opts.Sort = "created_at"
|
||||
opts.Order = "DESC"
|
||||
case "updated_asc":
|
||||
query += " ORDER BY updated_at ASC"
|
||||
opts.Sort = "updated_at"
|
||||
opts.Order = "ASC"
|
||||
case "title_asc":
|
||||
query += " ORDER BY title ASC"
|
||||
opts.Sort = "title"
|
||||
opts.Order = "ASC"
|
||||
case "title_desc":
|
||||
query += " ORDER BY title DESC"
|
||||
opts.Sort = "title"
|
||||
opts.Order = "DESC"
|
||||
default: // "updated_desc"
|
||||
query += " ORDER BY updated_at DESC"
|
||||
opts.Sort = ""
|
||||
opts.Order = ""
|
||||
}
|
||||
|
||||
query += " LIMIT $" + strconv.Itoa(argIdx) +
|
||||
" OFFSET $" + strconv.Itoa(argIdx+1)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := database.DB.Query(query, args...)
|
||||
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
|
||||
}
|
||||
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)
|
||||
items := make([]noteListItem, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
items = append(items, toNoteListItem(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,
|
||||
"data": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -405,8 +374,37 @@ func (h *NoteHandler) Search(c *gin.Context) {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Use plainto_tsquery for natural language (not websearch_to_tsquery which
|
||||
// requires Postgres 11+ and has stricter syntax).
|
||||
// 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,
|
||||
@@ -419,25 +417,25 @@ func (h *NoteHandler) Search(c *gin.Context) {
|
||||
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 dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Preview,
|
||||
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
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
r.Tags = tags
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
@@ -454,12 +452,12 @@ func (h *NoteHandler) Search(c *gin.Context) {
|
||||
func (h *NoteHandler) ListFolders(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
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)
|
||||
`), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user