505 lines
16 KiB
Go
505 lines
16 KiB
Go
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.
|
|
}
|
|
}
|