package httpapi import ( "errors" "net/http" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/catalog" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) func v1CatalogListMeta(page, limit int, total int64) map[string]any { return v1ProductListMeta(page, limit, total) } func presentV1Category(item map[string]any) map[string]any { return map[string]any{ "id": item["id"], "unique_id": item["unique_id"], "name": item["name"], "created_at": formatV1Timestamp(item["created_at"]), "updated_at": formatV1Timestamp(item["updated_at"]), } } func presentV1Attribute(item map[string]any) map[string]any { out := map[string]any{ "id": item["id"], "key": item["attribute_key"], "name": item["name"], "type": item["value_type"], "unit": item["unit"], "required": false, "created_at": formatV1Timestamp(item["created_at"]), "updated_at": formatV1Timestamp(item["updated_at"]), } if v, ok := item["required"]; ok && v != nil { switch t := v.(type) { case bool: out["required"] = t } } if cid, ok := item["category_unique_id"]; ok && cid != nil && asMapString(cid) != "" { out["category_unique_id"] = cid } return out } // handleV1ListCategories serves GET /api/v1/categories with legacy { data, meta }. func (s *Server) handleV1ListCategories(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) page, limit, offset := ParsePageLimit(r) items, total, err := s.Catalog.ListCategories(r.Context(), cid, catalog.ListFilter{ Query: QuerySearch(r), Limit: limit, Offset: offset, }) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } data := make([]map[string]any, 0, len(items)) for _, item := range items { data = append(data, presentV1Category(item)) } v1OK(w, http.StatusOK, data, v1CatalogListMeta(page, limit, total)) } // handleV1CreateCategory serves POST /api/v1/categories and /categories/create. // Body: name + unique_id required; parent_id alias for parent_unique_id. func (s *Server) handleV1CreateCategory(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"` ParentID *string `json:"parent_id"` Description *string `json:"description"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } parent := body.ParentUniqueID if (parent == nil || strings.TrimSpace(*parent) == "") && body.ParentID != nil { parent = body.ParentID } item, err := s.Catalog.CreateCategory(r.Context(), cid, body.Name, body.UniqueID, parent, body.Description) if err != nil { if msg, ok := catalog.ClientError(err); ok { Error(w, http.StatusBadRequest, msg) return } ClientOrLog(w, http.StatusBadRequest, "could not create category", err, catalog.ClientError) return } v1OK(w, http.StatusCreated, map[string]any{ "id": item["id"], "unique_id": item["unique_id"], "name": item["name"], }, nil) } // handleV1DeleteCategory serves DELETE /api/v1/categories/{id} where {id} is unique_id. func (s *Server) handleV1DeleteCategory(w http.ResponseWriter, r *http.Request) { if !requireCompanyAdmin(w, r) { return } cid, _ := CompanyIDFromContext(r.Context()) uniqueID := strings.TrimSpace(chi.URLParam(r, "id")) if uniqueID == "" { Error(w, http.StatusBadRequest, "invalid category id") return } if err := s.Catalog.DeleteCategoryByUniqueID(r.Context(), cid, uniqueID); err != nil { if errors.Is(err, catalog.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if msg, ok := catalog.ClientError(err); ok { Error(w, http.StatusBadRequest, msg) return } Error(w, http.StatusInternalServerError, "delete failed") return } v1OK(w, http.StatusOK, map[string]any{"message": "Category deleted successfully"}, nil) } // handleV1ListAttributes serves GET /api/v1/attributes with legacy { data, meta }. func (s *Server) handleV1ListAttributes(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) page, limit, offset := ParsePageLimit(r) f := catalog.ListFilter{ Query: QuerySearch(r), Limit: limit, Offset: offset, Category: firstNonEmpty(r.URL.Query().Get("categoryId"), r.URL.Query().Get("category_id")), RootsOnly: r.URL.Query().Get("roots") == "1", ParentKey: firstNonEmpty(r.URL.Query().Get("parent_key"), r.URL.Query().Get("parentKey")), SortBy: firstNonEmpty(r.URL.Query().Get("sortBy"), r.URL.Query().Get("sort_by"), "updatedAt"), SortOrder: firstNonEmpty(r.URL.Query().Get("sortOrder"), r.URL.Query().Get("sort_order"), "desc"), } items, total, err := s.Catalog.ListAttributes(r.Context(), cid, f) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } data := make([]map[string]any, 0, len(items)) for _, item := range items { data = append(data, presentV1Attribute(item)) } v1OK(w, http.StatusOK, data, v1CatalogListMeta(page, limit, total)) } // handleV1CreateAttribute serves POST /api/v1/attributes and /attributes/create. // Requires name, attribute_key, value_type, category_unique_id (legacy contract). func (s *Server) handleV1CreateAttribute(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) var body struct { Name string `json:"name"` AttributeKey string `json:"attribute_key"` ValueType string `json:"value_type"` Unit *string `json:"unit"` Example *string `json:"example"` ParentKey *string `json:"parent_key"` CategoryUniqueID string `json:"category_unique_id"` Required bool `json:"required"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } if strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.AttributeKey) == "" || strings.TrimSpace(body.ValueType) == "" || strings.TrimSpace(body.CategoryUniqueID) == "" { Error(w, http.StatusBadRequest, "Missing required fields: name, attribute_key, value_type, category_unique_id") return } item, err := s.Catalog.CreateAttribute(r.Context(), cid, body.AttributeKey, body.Name, body.ValueType, body.Unit, body.Example, body.ParentKey) if err != nil { if msg, ok := catalog.ClientError(err); ok { Error(w, http.StatusBadRequest, msg) return } ClientOrLog(w, http.StatusBadRequest, "could not create attribute", err, catalog.ClientError) return } attrID, err := parseMapUUID(item["id"]) if err != nil { Error(w, http.StatusInternalServerError, "could not create attribute") return } if _, err := s.Catalog.LinkCategoryAttribute(r.Context(), cid, body.CategoryUniqueID, attrID, body.Required); err != nil { if msg, ok := catalog.ClientError(err); ok { status := http.StatusBadRequest if strings.Contains(strings.ToLower(msg), "not found") { status = http.StatusNotFound } Error(w, status, msg) return } ClientOrLog(w, http.StatusBadRequest, "could not link attribute", err, catalog.ClientError) return } v1OK(w, http.StatusCreated, map[string]any{ "id": item["id"], "key": item["attribute_key"], "name": item["name"], "type": item["value_type"], "unit": item["unit"], "category_unique_id": body.CategoryUniqueID, "required": body.Required, }, nil) } // handleV1DeleteAttribute serves DELETE /api/v1/attributes/{id} (UUID). func (s *Server) handleV1DeleteAttribute(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 attribute 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 } v1OK(w, http.StatusOK, map[string]any{"message": "Attribute deleted successfully"}, nil) } func parseMapUUID(v any) (uuid.UUID, error) { switch t := v.(type) { case uuid.UUID: return t, nil case string: return uuid.Parse(t) case [16]byte: return uuid.UUID(t), nil default: s := asMapString(v) if s == "" { return uuid.Nil, errors.New("invalid uuid") } return uuid.Parse(s) } }