// 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, 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 { 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"` 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 { 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, 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 ------------------------------------------------- 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") }