Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,462 @@
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/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)
}
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
}
}
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
}
JSON(w, http.StatusOK, item)
}