Changeset 0.17.1 (#76)

This commit is contained in:
2026-02-28 01:40:31 +00:00
parent c9141a6896
commit 856dc9b0ac
64 changed files with 8037 additions and 1657 deletions

View File

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