package httpapi import ( "errors" "net/http" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/catalog" "github.com/descrybe/descrybe-v2/apps/api/internal/company" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) func (s *Server) handleListCategories(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) max := maxPageLimit if r.URL.Query().Get("tree") == "1" { max = maxTreePageLimit } limit, offset := ParseLimitOffsetMax(r, max) f := catalog.ListFilter{ Query: QuerySearch(r), Limit: limit, Offset: offset, } items, total, err := s.Catalog.ListCategories(r.Context(), cid, f) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } JSON(w, http.StatusOK, map[string]any{"categories": items, "total": total, "limit": limit, "offset": offset}) } func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) var body struct { Name string `json:"name"` UniqueID string `json:"unique_id"` ParentUniqueID *string `json:"parent_unique_id"` Description *string `json:"description"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Catalog.CreateCategory(r.Context(), cid, body.Name, body.UniqueID, body.ParentUniqueID, body.Description) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not create category", err, catalog.ClientError) return } JSON(w, http.StatusCreated, item) } func (s *Server) handleGetCategory(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } item, err := s.Catalog.GetCategory(r.Context(), cid, id) if err != nil { Error(w, http.StatusNotFound, "not found") return } JSON(w, http.StatusOK, item) } func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body map[string]any if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Catalog.UpdateCategory(r.Context(), cid, id, body) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update category", err, catalog.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request) { if !requireCompanyAdmin(w, r) { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } if err := s.Catalog.DeleteCategory(r.Context(), cid, id); err != nil { Error(w, http.StatusInternalServerError, "delete failed") return } JSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleUpdateTitleFormula(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body struct { TitleTemplate any `json:"title_template"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Catalog.UpdateTitleFormula(r.Context(), cid, id, body.TitleTemplate) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update title formula", err, catalog.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleUpdateDescriptionFormula(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body struct { DescriptionTemplate any `json:"description_template"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Catalog.UpdateDescriptionFormula(r.Context(), cid, id, body.DescriptionTemplate) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update description formula", err, catalog.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleUpdateCategoryPrompt(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body struct { Prompt string `json:"prompt"` Language string `json:"language"` Prompts map[string]string `json:"prompts"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } prompts := body.Prompts if prompts == nil { prompts = map[string]string{} lang := strings.TrimSpace(body.Language) if lang == "" { lang = company.LoadLanguage(r.Context(), s.Pool, cid) } // Legacy single-prompt body: set/clear one language, preserve others. existing, gerr := s.Catalog.GetCategory(r.Context(), cid, id) if gerr == nil { if m, ok := existing["prompts"].(company.LangPromptMap); ok { for k, v := range m { prompts[k] = v } } else if raw, ok := existing["prompts"].(map[string]string); ok { for k, v := range raw { prompts[k] = v } } else if raw, ok := existing["prompts"].(map[string]any); ok { for k, v := range raw { if s, ok := v.(string); ok { prompts[k] = s } } } } prompts[lang] = body.Prompt } item, err := s.Catalog.UpdateCategoryPrompt(r.Context(), cid, id, prompts) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update category prompt", err, catalog.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleListVariables(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit) page, total, err := s.Catalog.ListVariables(r.Context(), cid, catalog.ListFilter{Limit: limit, Offset: offset}) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } JSON(w, http.StatusOK, map[string]any{"variables": page, "total": total, "limit": limit, "offset": offset}) } func (s *Server) handleCreateVariable(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) var body struct { Name string `json:"name"` Value string `json:"value"` Label string `json:"label"` Description *string `json:"description"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } value := body.Value if value == "" && body.Label != "" { value = body.Label } item, err := s.Catalog.CreateVariable(r.Context(), cid, body.Name, value, body.Description) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not create variable", err, catalog.ClientError) return } JSON(w, http.StatusCreated, item) } func (s *Server) handleDeleteVariable(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } if err := s.Catalog.DeleteVariable(r.Context(), cid, id); err != nil { if errors.Is(err, catalog.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } ClientOrLog(w, http.StatusNotFound, "could not delete variable", err, catalog.ClientError) return } JSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleListAttributes(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) limit, offset := ParseLimitOffset(r) f := catalog.ListFilter{ Query: QuerySearch(r), Limit: limit, Offset: offset, RootsOnly: r.URL.Query().Get("roots") == "1", ParentKey: r.URL.Query().Get("parent_key"), } items, total, err := s.Catalog.ListAttributes(r.Context(), cid, f) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } JSON(w, http.StatusOK, map[string]any{"attributes": items, "total": total, "limit": limit, "offset": offset}) } func (s *Server) handleCreateAttribute(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) var body struct { AttributeKey string `json:"attribute_key"` Name string `json:"name"` ValueType string `json:"value_type"` Unit *string `json:"unit"` Example *string `json:"example"` ParentKey *string `json:"parent_key"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Catalog.CreateAttribute(r.Context(), cid, body.AttributeKey, body.Name, body.ValueType, body.Unit, body.Example, body.ParentKey) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not create attribute", err, catalog.ClientError) return } JSON(w, http.StatusCreated, item) } func (s *Server) handleUpdateAttribute(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body map[string]any if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Catalog.UpdateAttribute(r.Context(), cid, id, body) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update attribute", err, catalog.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleDeleteAttribute(w http.ResponseWriter, r *http.Request) { if !requireCompanyAdmin(w, r) { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } if err := s.Catalog.DeleteAttribute(r.Context(), cid, id); err != nil { if errors.Is(err, catalog.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } Error(w, http.StatusInternalServerError, "delete failed") return } JSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) limit, offset := ParseLimitOffset(r) cursor := strings.TrimSpace(r.URL.Query().Get("cursor")) afterID := strings.TrimSpace(firstNonEmpty(r.URL.Query().Get("after_id"), r.URL.Query().Get("afterId"))) f := catalog.ListFilter{ Query: QuerySearch(r), Status: r.URL.Query().Get("status"), Category: r.URL.Query().Get("category"), FeedID: firstNonEmpty(r.URL.Query().Get("feed_id"), r.URL.Query().Get("feedId")), Coverage: firstNonEmpty(r.URL.Query().Get("coverage"), r.URL.Query().Get("missing")), Eprel: firstNonEmpty(r.URL.Query().Get("eprel"), r.URL.Query().Get("has_eprel")), SyncChange: firstNonEmpty(r.URL.Query().Get("sync_change"), r.URL.Query().Get("syncChange"), r.URL.Query().Get("feed_change")), SortBy: firstNonEmpty(r.URL.Query().Get("sort_by"), r.URL.Query().Get("sortBy")), SortOrder: firstNonEmpty(r.URL.Query().Get("sort_order"), r.URL.Query().Get("sortOrder")), Limit: limit, Offset: offset, Cursor: cursor, AfterID: afterID, } if catalog.HasProductCursor(f) { offset = 0 } kind := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind"))) // UI and some clients send kind=unprocessed for the raw inventory tab. if kind == "raw" || kind == "unprocessed" { items, total, err := s.Catalog.ListRawProducts(r.Context(), cid, f) if err != nil { if msg, ok := catalog.ClientError(err); ok { Error(w, http.StatusBadRequest, msg) return } Error(w, http.StatusInternalServerError, "list failed") return } resp := map[string]any{"products": items, "total": total, "kind": "raw", "limit": limit, "offset": offset} if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" { if nextCursor != "" { resp["next_cursor"] = nextCursor } if nextAfter != "" { resp["next_after_id"] = nextAfter } } JSON(w, http.StatusOK, resp) return } detailed := QueryDetailed(r) var ( items []map[string]any total int64 err error ) if detailed { items, total, err = s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f) } else { items, total, err = s.Catalog.ListProcessedProducts(r.Context(), cid, f) } if err != nil { if msg, ok := catalog.ClientError(err); ok { Error(w, http.StatusBadRequest, msg) return } Error(w, http.StatusInternalServerError, "list failed") return } if detailed { attachProductQuality(items) } stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), items...) resp := map[string]any{"products": items, "total": total, "kind": "processed", "limit": limit, "offset": offset, "detailed": detailed} if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" { if nextCursor != "" { resp["next_cursor"] = nextCursor } if nextAfter != "" { resp["next_after_id"] = nextAfter } } JSON(w, http.StatusOK, resp) } func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } item, err := s.Catalog.GetProcessedProduct(r.Context(), cid, id) if err != nil { if !errors.Is(err, pgx.ErrNoRows) { Error(w, http.StatusInternalServerError, "lookup failed") return } item, err = s.Catalog.GetRawProduct(r.Context(), cid, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { Error(w, http.StatusNotFound, "not found") return } Error(w, http.StatusInternalServerError, "lookup failed") return } } // Deliberately NOT stripped here. The omit rule hides SEO meta from list views // for cohorts that do not sell on it, but this payload feeds the review screen // and meta the pipeline actually generated has to be reviewable. // // The cohort flag still travels, so the panel can say "disabled for this // company" instead of "the AI generated nothing" — without it an empty block is // unexplained, which is exactly what made SEO meta look missing. if item != nil && processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid) { item["seo_meta_omitted"] = true } JSON(w, http.StatusOK, item) } func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body map[string]any if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Catalog.UpdateProcessedProduct(r.Context(), cid, id, body) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update product", err, catalog.ClientError) return } stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item) JSON(w, http.StatusOK, item) } // stripCatalogSEOMetaIfOmitted drops meta_title / meta_description from dashboard // product payloads when omit is true (same detection as V1 process / CompanyOmitsSEOMeta). // // Only EMPTY values are dropped. The omit cohort does not generate SEO meta, but // rows enriched before that rule (or by an earlier pipeline) still carry it, and // hiding stored copy from the dashboard meant Review could not show what the AI had // actually produced. Generation still respects the cohort — this only stops the // read path from concealing data that exists. func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) { if !omit { return } for _, item := range items { if item == nil { continue } // Tell the client the cohort does not generate SEO meta, so an empty block // reads as "disabled here" instead of "the AI produced nothing". item["seo_meta_omitted"] = true if asMapString(item["meta_title"]) == "" { delete(item, "meta_title") } if asMapString(item["meta_description"]) == "" { delete(item, "meta_description") } // Localized meta is left exactly as stored, for the same reason. } }