This commit is contained in:
2026-08-16 16:57:36 +02:00
parent 96f8c1115c
commit 532d439c41
106 changed files with 10147 additions and 494 deletions
@@ -0,0 +1,105 @@
package httpapi
import (
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// handleAdminFixCompanyCatalog runs in-place Fix A1 / catalog hygiene for one company.
// Single route: POST /api/admin/companies/{id}/fix-catalog (no separate fix-a1).
// Delegates to processing.FixCompanyCatalog, which:
// 1. Ensures category_attributes links (orphan purge only)
// 2. RepairCompanyCategoryEnhancePrompts (same map as RepairA1DemoCategoryEnhancePrompts)
// 3. Re-applies product_enhance BuiltInDefaults when AIPrompts is set
// 47. FixCatalogHygieneWithIDs + attribute sanitize + reprocess sample
//
// Body: confirm=true required; backfill_categories (default true);
// reprocess_sample_limit (default 25, max 200, 0 = counts only).
// May target A1 in place with confirm — prefer Platform Demo when unsure.
// Refuses system company. Does not use A1 as a clone destination.
//
// Flash (UI): result.prompts / hashes / categories → flash.admin.fixA1Success.
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
if s.Pool == nil {
Error(w, http.StatusServiceUnavailable, "database unavailable")
return
}
companyID, err := uuid.Parse(strings.TrimSpace(chi.URLParam(r, "id")))
if err != nil {
Error(w, http.StatusBadRequest, "invalid company id")
return
}
var body struct {
Confirm bool `json:"confirm"`
BackfillCategories *bool `json:"backfill_categories"`
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if !body.Confirm {
Error(w, http.StatusBadRequest, "confirm must be true")
return
}
if platformsettings.IsSystemCompany(companyID) {
Error(w, http.StatusBadRequest, "cannot fix the platform settings company")
return
}
var legacy, name string
if err := s.Pool.QueryRow(r.Context(), `
SELECT COALESCE(legacy_company_id, ''), name FROM companies WHERE id = $1`, companyID).
Scan(&legacy, &name); err != nil {
Error(w, http.StatusBadRequest, "company not found")
return
}
backfill := true
if body.BackfillCategories != nil {
backfill = *body.BackfillCategories
}
sampleLimit := 25
if body.ReprocessSampleLimit != nil {
sampleLimit = *body.ReprocessSampleLimit
}
if sampleLimit < 0 {
sampleLimit = 0
}
if sampleLimit > 200 {
sampleLimit = 200
}
result, err := processing.FixCompanyCatalog(
r.Context(),
s.Pool,
companyID,
name,
billing.IsA1CohortCompany(legacy, name),
processing.FixCompanyCatalogOpts{
BackfillCategories: backfill,
ReprocessSampleLimit: sampleLimit,
AIPrompts: s.AIPrompts,
},
)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not fix catalog", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{
"status": "ok",
"result": result,
"note": "Catalog was repaired in place; reprocess recommended products manually (no mass reprocess).",
})
}
@@ -0,0 +1,91 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
t.Parallel()
s := &Server{}
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/"+uuid.NewString()+"/fix-catalog", strings.NewReader(`{"confirm":true}`))
rec := httptest.NewRecorder()
s.handleAdminFixCompanyCatalog(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) {
t.Parallel()
// Pool nil short-circuits before confirm — use non-nil Pool stub via missing company path is harder.
// Confirm gate is covered when Pool is set; here we only assert invalid uuid.
s := &Server{Pool: nil}
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/not-a-uuid/fix-catalog", strings.NewReader(`{"confirm":false}`))
rec := httptest.NewRecorder()
s.handleAdminFixCompanyCatalog(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 (nil pool) body=%s", rec.Code, rec.Body.String())
}
}
// TestRouterAdminFixCatalogMounted locks POST /api/admin/companies/{id}/fix-catalog
// after session + CSRF + platform-admin (503 with nil Pool), not chi 404.
func TestRouterAdminFixCatalogMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
path := "/api/admin/companies/" + uuid.NewString() + "/fix-catalog"
csrf := csrfCookieForSession(t, h, sm, token)
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"confirm":true}`))
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("route not mounted: status=404 body=%s", mounted.Body.String())
}
if mounted.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 (nil pool) body=%s", mounted.Code, mounted.Body.String())
}
}
+1
View File
@@ -391,6 +391,7 @@ func (s *Server) Router() http.Handler {
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
r.Get("/companies", s.handleAdminListCompanies)
r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog)
r.Post("/companies/{id}/fix-catalog", s.handleAdminFixCompanyCatalog)
r.Get("/readiness", s.handleAdminReadiness)
r.Get("/diagnostics", s.handleAdminDiagnostics)
r.Get("/analytics", s.handleAdminAnalytics)
+142 -74
View File
@@ -43,7 +43,10 @@ info:
4. Store it securely. Later list/revoke shows only key_prefix (first 10
characters). Revoked keys fail auth immediately.
API keys are created in Settings -> API keys
### Cutover / migration (reissue)
API keys from the previous Descrybe platform were not migrated. After
cutover, integrations must create a new dk_ key in Settings -> API keys
(or Use my API key on /docs). Pre-cutover secrets return the same HTTP 401
Unauthorized as unknown keys — there is no separate “legacy key” error.
@@ -893,8 +896,8 @@ paths:
jobs omit items. Not the same shape as GET /process/{id} (flat ProcessingJob).
On COMPLETED items, id is the processed_products UUID (legacy). Additive aliases:
processed_product_id (same value as id) and raw_product_id (raw_products.id)
for dual-mode clients that also call raw_product_ids surfaces.
processed_product_id (same value as id), raw_product_id (raw_products.id), and
name (same value as title) for dual-mode clients / scorecards.
parameters:
- name: id
in: path
@@ -914,20 +917,36 @@ paths:
$ref: "#/components/schemas/LegacyProcessStatusEnvelope"
examples:
completed:
summary: Completed
summary: Completed A1-shaped item
value:
data:
status: COMPLETED
process_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
processing_type: full
items:
- ean: 0123456789012
- ean: '8606019604493'
id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
status: processed
category: electronics
title: Acme Wireless Earbuds ANC Black
category: '50'
category_name: Cookers
title: VOX electric cooker EHT 6020 WG
name: VOX electric cooker EHT 6020 WG
meta_title: VOX electric cooker EHT 6020 WG | 50
meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven."
attributes:
brand: Vox
product_model: EHT6020WG
main_image: https://images.example.com/products/vox-eht6020wg.jpg
more_images: null
eprel:
id: "1234567"
label: https://eprel.ec.europa.eu/label/Example
pdf: https://eprel.ec.europa.eu/fiches/Example.pdf
energy_class: A
energy_scale: A-G
total_items: 1
processed_at: '2026-08-04T10:04:12Z'
processing:
@@ -6256,78 +6275,117 @@ components:
format: date-time
nullable: true
LegacyProcessItem:
type: object
required:
- ean
properties:
type: object
description: |
One COMPLETED legacy process line (A1 / public contract). Successful items
expose category as categories.unique_id, a plain-text description string
(never a JSON array; HTML stripped), SEO meta_title / meta_description,
optional eprel object or null, clean attributes, images, and dual-mode ids.
Product display name is title; additive name mirrors the same processed title
(dual-mode for scorecards / legacy clients that read name).
required:
- ean
properties:
ean:
type: string
type: string
id:
type: string
format: uuid
description: |
Legacy field: processed_products.id when enrichment succeeded.
Do not treat as raw_products.id. Same value as processed_product_id.
type: string
format: uuid
description: |
Legacy field: processed_products.id when enrichment succeeded.
Do not treat as raw_products.id. Same value as processed_product_id.
processed_product_id:
type: string
format: uuid
description: |
Explicit alias of id (processed_products.id). Prefer this name in new
dual-mode clients; id remains for backward compatibility.
type: string
format: uuid
description: |
Explicit alias of id (processed_products.id). Prefer this name in new
dual-mode clients; id remains for backward compatibility.
raw_product_id:
type: string
format: uuid
description: |
raw_products.id for this job line. Use with POST /process raw_product_ids
or dashboard catalog APIs. Present whenever the job product row exists.
type: string
format: uuid
description: |
raw_products.id for this job line. Use with POST /process raw_product_ids
or dashboard catalog APIs. Present whenever the job product row exists.
category:
type: string
nullable: true
type: string
nullable: true
description: |
categories.unique_id for the assigned category. Opaque string — may be
numeric (e.g. "28" or "50") or slug-like. Not a URL path and not a UUID.
category_name:
type: string
nullable: true
type: string
nullable: true
description: Human-readable category display name (not the unique_id).
title:
type: string
nullable: true
type: string
nullable: true
description: |
Product display name (processed title). Primary legacy field; same value
as name when present.
name:
type: string
nullable: true
description: |
Additive alias of title (same processed display name). Prefer title in
new clients; name remains for scorecards and legacy readers.
meta_title:
type: string
nullable: true
type: string
nullable: true
description: |
SEO title. Filled from processing meta or synthesized from title / category
when empty so successful items are not left with null meta.
meta_description:
type: string
nullable: true
type: string
nullable: true
description: |
SEO description (word-safe truncate). Distinct from body description when
possible; synthesized from plain description when DB meta is empty.
description:
type: string
nullable: true
description: Plain-text product description (HTML stripped). attributes:
type: object
additionalProperties: true
nullable: true
type: string
nullable: true
description: |
Plain-text product body description. Always a string — never a one-element
JSON array. Feed HTML tags are stripped; newlines may remain between paragraphs.
attributes:
type: object
additionalProperties: true
nullable: true
description: |
Characteristic catalog attributes only (brand, model, dims, warranty, …).
Core fields and eprel_* keys are not duplicated here.
main_image:
type: string
nullable: true
type: string
nullable: true
more_images:
type: array
items:
type: string
nullable: true
type: array
items:
type: string
nullable: true
eprel:
nullable: true
type: object
properties:
nullable: true
type: object
description: |
EU energy label payload when EPREL data is available; otherwise null.
Prefer this object over raw eprel_* keys inside attributes.
Live shape keys: id, label, pdf, energy_class, energy_scale.
properties:
id:
type: string
description: EPREL product registration id (same as eprel_id).
label:
type: string
type: string
pdf:
type: string
type: string
energy_class:
type: string
type: string
energy_scale:
type: string
type: string
status:
type: string
description: Per-item outcome — processed on success; not_found / failed / cancelled otherwise
example: processed
type: string
description: Per-item outcome — processed on success; not_found / failed / cancelled otherwise
example: processed
error:
type: string
type: string
examples:
ProcessingJobAccepted:
summary: Single processing job accepted (POST /process)
@@ -6393,32 +6451,42 @@ components:
started_at: '2026-08-04T10:00:00Z'
created_at: '2026-08-04T09:59:50Z'
LegacyProcessCompleted:
summary: "Completed legacy poll (GET /products/process/{id})"
summary: "Completed legacy poll with A1-shaped item (unique_id category, plain description, meta, eprel)"
value:
data:
status: COMPLETED
process_id: 8f14e45f-ceea-467a-9e5d-9c6b0e8f3a21
processing_type: full
items:
- ean: '4548736132174'
- ean: '8606019604493'
id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
status: processed
category: electronics
category_name: Electronics
title: Sony WH-1000XM5 Wireless Noise Cancelling Headphones Black
meta_title: Sony WH-1000XM5 | Noise Cancelling Headphones
meta_description: Industry-leading noise cancellation with up to 30 hours battery life.
description: Industry-leading noise cancellation with up to 30 hours battery life.
category: '50'
category_name: Cookers
title: VOX electric cooker EHT 6020 WG
name: VOX electric cooker EHT 6020 WG
meta_title: VOX electric cooker EHT 6020 WG | 50
meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven. Energy class A with practical everyday capacity."
attributes:
color: Black
brand: Sony
battery_life_hours: '30'
main_image: https://images.example.com/products/wh1000xm5-black.jpg
brand: Vox
product_model: EHT6020WG
width: 0.6 m
height: 0.85 m
depth: 0.6 m
weight: 42.81 kg
warranty: 60 months
main_image: https://images.example.com/products/vox-eht6020wg.jpg
more_images:
- https://images.example.com/products/wh1000xm5-black-side.jpg
eprel: null
- https://images.example.com/products/vox-eht6020wg-side.jpg
eprel:
id: "1234567"
label: https://eprel.ec.europa.eu/label/Example
pdf: https://eprel.ec.europa.eu/fiches/Example.pdf
energy_class: A
energy_scale: A-G
total_items: 1
processed_at: '2026-08-04T10:04:12Z'
LegacyProcessInProgress:
@@ -415,6 +415,36 @@ func TestV1OpenAPIYAMLProcessDualIDsAndGatesDocumentsContracts(t *testing.T) {
}
}
// TestV1OpenAPIYAMLLegacyProcessItemEprelShape locks LegacyProcessItem.eprel
// to the live nested object from eprel.MergeInto / extractEPRELFromAttrs.
func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
comps, _ := root["components"].(map[string]any)
schemas, _ := comps["schemas"].(map[string]any)
item, _ := schemas["LegacyProcessItem"].(map[string]any)
itemProps, _ := item["properties"].(map[string]any)
eprel, _ := itemProps["eprel"].(map[string]any)
eprelProps, _ := eprel["properties"].(map[string]any)
if eprelProps == nil {
t.Fatal("LegacyProcessItem.eprel.properties missing")
}
want := []string{"id", "label", "pdf", "energy_class", "energy_scale"}
for _, key := range want {
if _, ok := eprelProps[key]; !ok {
t.Fatalf("LegacyProcessItem.eprel missing property %q (live shape)", key)
}
}
if len(eprelProps) != len(want) {
keys := make([]string, 0, len(eprelProps))
for k := range eprelProps {
keys = append(keys, k)
}
t.Fatalf("LegacyProcessItem.eprel properties = %v, want exactly %v", keys, want)
}
}
// TestV1OpenAPIYAMLDocumentsAPIKeyCutoverReissue locks cutover honesty: legacy
// API keys were not migrated and clients must create new keys.
func TestV1OpenAPIYAMLDocumentsAPIKeyCutoverReissue(t *testing.T) {