// Category management UI and handlers (FR-14). // // The category list is editable: add, rename, and remove. JEV uses whatever list // is current at classification time, and renaming rewrites existing // classifications so the triage list stays consistent. package server import ( "errors" "fmt" "net/http" "net/url" "strconv" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) type categoriesData struct { Categories []store.Category CSRFToken string Note string Error string Total int Classified int } // handleCategories renders the category editor the inbox nav links to. func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) { s.renderCategories(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error")) } func (s *Server) renderCategories(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) { categories, err := s.store.ListCategories(r.Context()) if err != nil { s.logger.Error("list categories", "error", err) http.Error(w, "could not load categories", http.StatusInternalServerError) return } coverage, err := s.store.ClassificationCoverage(r.Context()) if err != nil { s.logger.Error("classification coverage", "error", err) } data := categoriesData{ Categories: categories, CSRFToken: s.csrfToken(w, r), Note: note, Error: errMsg, Total: coverage.Total, Classified: coverage.Classified, } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(code) if err := s.tmpl.ExecuteTemplate(w, "categories.html", data); err != nil { s.logger.Error("render categories", "error", err) } } // handleCategoryAdd creates a category (FR-14). func (s *Server) handleCategoryAdd(w http.ResponseWriter, r *http.Request) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if err := r.ParseForm(); err != nil { s.redirectCategories(w, r, "", "Could not read the form.") return } name, description := r.FormValue("name"), r.FormValue("description") cat, err := s.store.AddCategory(r.Context(), name, description) switch { case err == nil: s.logger.Info("category added", "name", cat.Name) _ = s.store.LogAction(r.Context(), "category_add", "Sam", "name="+cat.Name) s.redirectCategories(w, r, fmt.Sprintf("Added category %q.", cat.Name), "") case errors.Is(err, store.ErrCategoryExists): s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name)) default: s.redirectCategories(w, r, "", err.Error()) } } // handleCategoryRename changes a category's name and updates existing // classifications (FR-14). func (s *Server) handleCategoryRename(w http.ResponseWriter, r *http.Request) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if err := r.ParseForm(); err != nil { s.redirectCategories(w, r, "", "Could not read the form.") return } id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) if err != nil { s.redirectCategories(w, r, "", "Unknown category.") return } name := r.FormValue("name") switch err := s.store.RenameCategory(r.Context(), id, name); { case err == nil: s.logger.Info("category renamed", "id", id, "name", name) _ = s.store.LogAction(r.Context(), "category_rename", "Sam", fmt.Sprintf("id=%d name=%s", id, name)) s.redirectCategories(w, r, fmt.Sprintf("Renamed category to %q.", name), "") case errors.Is(err, store.ErrCategoryExists): s.redirectCategories(w, r, "", fmt.Sprintf("A category named %q already exists.", name)) default: s.redirectCategories(w, r, "", err.Error()) } } // handleCategoryRemove deletes a category and moves its messages to the first // remaining category (FR-14). func (s *Server) handleCategoryRemove(w http.ResponseWriter, r *http.Request) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if err := r.ParseForm(); err != nil { s.redirectCategories(w, r, "", "Could not read the form.") return } id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) if err != nil { s.redirectCategories(w, r, "", "Unknown category.") return } switch err := s.store.RemoveCategory(r.Context(), id); { case err == nil: s.logger.Info("category removed", "id", id) _ = s.store.LogAction(r.Context(), "category_remove", "Sam", fmt.Sprintf("id=%d", id)) s.redirectCategories(w, r, "Removed category; its messages moved to the first remaining category.", "") case errors.Is(err, store.ErrLastCategory): s.redirectCategories(w, r, "", "At least one category is required.") default: s.redirectCategories(w, r, "", err.Error()) } } // redirectCategories is POST/redirect/GET so a refresh does not resubmit. func (s *Server) redirectCategories(w http.ResponseWriter, r *http.Request, note, errMsg string) { q := url.Values{} if note != "" { q.Set("note", note) } if errMsg != "" { q.Set("error", errMsg) } http.Redirect(w, r, "/categories?"+q.Encode(), http.StatusSeeOther) }