// 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) }