Files
petal/internal/docs/handlers.go
prosolis 8c6bc1604b Code-review follow-ups: httputil, validation caps, a11y
Backend:
- Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/
  ServerError); drop the triple-duplicated helpers in docs, suggestions,
  vocab. ServerError now logs the real error and returns a generic 500 so
  raw DB/internal errors never reach the client.
- vocab capture: validate doc_id ownership (blank -> none, unknown -> 400
  instead of a leaked FK 500); rune-safe clamp word/gloss/definition/
  phonetic/example.
- vocab review(): wrap the read-modify-write in a transaction (TOCTOU).
- /api request-size cap via MaxBytesReader middleware (2 MiB), exempting
  /api/images (own 10 MiB limit).

Frontend:
- StatusBar: drive the checking/voicing/collocating indicators from one
  array; llmDown uses !anyBusy.
- Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore)
  on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label.
- speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount.

Tests: add doc_id-validation and field-clamp coverage; full suite green.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 16:59:23 -07:00

235 lines
6.7 KiB
Go

// Package docs implements the document CRUD HTTP handlers — the create / list /
// read / update / delete surface that backs the editor and its 1.5s auto-save.
// All access is scoped to the single hardcoded local user while auth is deferred.
package docs
import (
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// Handler holds the dependencies shared by every document route.
type Handler struct {
DB *db.DB
}
// New constructs a Handler.
func New(database *db.DB) *Handler {
return &Handler{DB: database}
}
// Routes returns a router mounting the document CRUD endpoints. Mount it under
// "/docs" so the full paths are /api/docs, /api/docs/{id}, etc.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/", h.list)
r.Post("/", h.create)
r.Get("/{id}", h.get)
r.Put("/{id}", h.update)
r.Delete("/{id}", h.delete)
h.versionRoutes(r)
h.exportRoutes(r)
h.registerTagRoutes(r)
return r
}
// docSummary is the lightweight shape returned by the list endpoint — enough to
// render the DocList sidebar without shipping every document's full body. Tags
// ride along so the sidebar can show chips and filter without a second request.
type docSummary struct {
ID string `json:"id"`
Title string `json:"title"`
WordCount int `json:"word_count"`
UpdatedAt string `json:"updated_at"`
Tags []db.Tag `json:"tags"`
}
// list returns the local user's documents, most-recently-updated first, each
// decorated with its tags.
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
rows, err := h.DB.Query(
`SELECT id, title, word_count, updated_at
FROM documents
WHERE user_id = ?
ORDER BY updated_at DESC`,
db.LocalUserID,
)
if err != nil {
httputil.ServerError(w, err)
return
}
defer rows.Close()
out := []docSummary{} // non-nil so an empty list serializes as [] not null
ids := []string{}
for rows.Next() {
var d docSummary
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
httputil.ServerError(w, err)
return
}
out = append(out, d)
ids = append(ids, d.ID)
}
if err := rows.Err(); err != nil {
httputil.ServerError(w, err)
return
}
byDoc, err := h.tagsByDoc(ids)
if err != nil {
httputil.ServerError(w, err)
return
}
for i := range out {
out[i].Tags = byDoc[out[i].ID] // nil → JSON null is fine; client treats as none
if out[i].Tags == nil {
out[i].Tags = []db.Tag{}
}
}
httputil.WriteJSON(w, http.StatusOK, out)
}
// create inserts a fresh blank document and returns it in full.
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var doc db.Document
err := h.DB.QueryRow(
`INSERT INTO documents (user_id) VALUES (?)
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusCreated, doc)
}
// get returns a single full document by id.
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
doc, err := h.fetch(chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
}
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, doc)
}
// updateRequest is the auto-save payload. Every field is optional (a pointer) so
// the same endpoint serves both the editor's full save ({content, content_text,
// word_count, title}) and a DocList rename ({title} alone).
type updateRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
ContentText *string `json:"content_text"`
Tone *string `json:"tone"`
WordCount *int `json:"word_count"`
}
// update applies the provided fields to a document and returns the saved row.
// content and content_text are kept in sync by the client and written together.
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req updateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
badRequest(w, "invalid JSON body")
return
}
res, err := h.DB.Exec(
`UPDATE documents
SET title = COALESCE(?, title),
content = COALESCE(?, content),
content_text = COALESCE(?, content_text),
tone = COALESCE(?, tone),
word_count = COALESCE(?, word_count),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
)
if err != nil {
httputil.ServerError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
notFound(w)
return
}
doc, err := h.fetch(id)
if err != nil {
httputil.ServerError(w, err)
return
}
// Capture a throttled history snapshot when a real body save came through
// (not a bare rename) and the document has content. Best-effort: a failed
// snapshot must never fail the save itself.
if req.Content != nil && doc.ContentText != "" {
if err := h.maybeAutoSnapshot(doc); err != nil {
log.Printf("docs: auto-snapshot for %s failed: %v", id, err)
}
}
httputil.WriteJSON(w, http.StatusOK, doc)
}
// delete removes a document (suggestions cascade via the FK).
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
res, err := h.DB.Exec(
`DELETE FROM documents WHERE id = ? AND user_id = ?`,
chi.URLParam(r, "id"), db.LocalUserID,
)
if err != nil {
httputil.ServerError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
notFound(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
// fetch loads one full document scoped to the local user.
func (h *Handler) fetch(id string) (db.Document, error) {
var doc db.Document
err := h.DB.QueryRow(
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
FROM documents
WHERE id = ? AND user_id = ?`,
id, db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
return doc, err
}
// --- small response helpers -------------------------------------------------
//
// The generic JSON/error helpers live in internal/httputil; these are the
// document-specific shorthands that carry domain wording.
func badRequest(w http.ResponseWriter, msg string) { httputil.BadRequest(w, msg) }
func notFound(w http.ResponseWriter) {
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
}
func notFoundMsg(w http.ResponseWriter, msg string) { httputil.ErrorJSON(w, http.StatusNotFound, msg) }