Phase 2: document CRUD + auto-save

Backend internal/docs: chi sub-router (list/create/get/update/delete)
mounted at /api/docs, scoped to the local user. Create uses RETURNING;
update is a COALESCE partial-update so rename and full editor save share
one PUT. JSON 404/400 errors; handlers_test.go walks the lifecycle.

Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush
before doc switch), EditorCore (Tiptap StarterKit/Underline/TextAlign/
Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar,
and an App.tsx that orchestrates load/select/create/delete with
optimistic sidebar patching. content + content_text + word_count are
emitted together on every edit. .petal-prose styling (Lora body, Nunito
headings).

Verified: tsc clean, vite build, go build, full CRUD smoke test incl.
CJK title round-trip and SPA serve.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 20:30:39 -07:00
parent 9c98e97030
commit 5e00cdce88
13 changed files with 1006 additions and 42 deletions

208
internal/docs/handlers.go Normal file
View File

@@ -0,0 +1,208 @@
// 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"
"net/http"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// 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)
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.
type docSummary struct {
ID string `json:"id"`
Title string `json:"title"`
WordCount int `json:"word_count"`
UpdatedAt string `json:"updated_at"`
}
// list returns the local user's documents, most-recently-updated first.
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 {
serverError(w, err)
return
}
defer rows.Close()
out := []docSummary{} // non-nil so an empty list serializes as [] not null
for rows.Next() {
var d docSummary
if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil {
serverError(w, err)
return
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
serverError(w, err)
return
}
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, word_count, created_at, updated_at`,
db.LocalUserID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
if err != nil {
serverError(w, err)
return
}
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 {
serverError(w, err)
return
}
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"`
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),
word_count = COALESCE(?, word_count),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID,
)
if err != nil {
serverError(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
notFound(w)
return
}
doc, err := h.fetch(id)
if err != nil {
serverError(w, err)
return
}
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 {
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, 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.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
)
return doc, err
}
// --- small response helpers -------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func errorJSON(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func serverError(w http.ResponseWriter, err error) {
errorJSON(w, http.StatusInternalServerError, err.Error())
}
func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) }
func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") }

View File

@@ -0,0 +1,103 @@
package docs
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// newTestServer spins up an isolated on-disk database and the docs router.
func newTestServer(t *testing.T) http.Handler {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
return New(database).Routes()
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, r)
return rec
}
func TestDocumentCRUD(t *testing.T) {
srv := newTestServer(t)
// Empty list serializes as [].
rec := do(t, srv, http.MethodGet, "/", "")
if rec.Code != http.StatusOK || bytes.TrimSpace(rec.Body.Bytes())[0] != '[' {
t.Fatalf("list empty: code=%d body=%s", rec.Code, rec.Body)
}
// Create.
rec = do(t, srv, http.MethodPost, "/", "")
if rec.Code != http.StatusCreated {
t.Fatalf("create: code=%d body=%s", rec.Code, rec.Body)
}
var created db.Document
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create: %v", err)
}
if created.ID == "" || created.Title != "Untitled" {
t.Fatalf("unexpected created doc: %+v", created)
}
// Update (auto-save shape) keeps content + content_text + word count together.
body := `{"title":"My Essay","content":"{\"x\":1}","content_text":"hello world","word_count":2}`
rec = do(t, srv, http.MethodPut, "/"+created.ID, body)
if rec.Code != http.StatusOK {
t.Fatalf("update: code=%d body=%s", rec.Code, rec.Body)
}
var updated db.Document
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
if updated.Title != "My Essay" || updated.ContentText != "hello world" || updated.WordCount != 2 {
t.Fatalf("update not applied: %+v", updated)
}
// Partial update (rename only) leaves other fields intact.
rec = do(t, srv, http.MethodPut, "/"+created.ID, `{"title":"Renamed"}`)
_ = json.Unmarshal(rec.Body.Bytes(), &updated)
if updated.Title != "Renamed" || updated.WordCount != 2 {
t.Fatalf("partial update clobbered fields: %+v", updated)
}
// Get.
rec = do(t, srv, http.MethodGet, "/"+created.ID, "")
if rec.Code != http.StatusOK {
t.Fatalf("get: code=%d", rec.Code)
}
// List now has one entry.
rec = do(t, srv, http.MethodGet, "/", "")
var summaries []docSummary
_ = json.Unmarshal(rec.Body.Bytes(), &summaries)
if len(summaries) != 1 || summaries[0].Title != "Renamed" {
t.Fatalf("list after create: %+v", summaries)
}
// Delete, then 404 on subsequent get/delete.
if rec = do(t, srv, http.MethodDelete, "/"+created.ID, ""); rec.Code != http.StatusNoContent {
t.Fatalf("delete: code=%d", rec.Code)
}
if rec = do(t, srv, http.MethodGet, "/"+created.ID, ""); rec.Code != http.StatusNotFound {
t.Fatalf("get after delete: code=%d", rec.Code)
}
if rec = do(t, srv, http.MethodPut, "/missing", `{"title":"x"}`); rec.Code != http.StatusNotFound {
t.Fatalf("update missing: code=%d", rec.Code)
}
}