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:
@@ -0,0 +1,257 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user