This commit is contained in:
2026-08-17 09:33:07 +02:00
parent 0dceb3a404
commit fe94c2fb9c
40 changed files with 2360 additions and 340 deletions
@@ -27,6 +27,7 @@ func TestMemberForbiddenOnSensitiveMutations(t *testing.T) {
body string
}{
{name: "create_api_key", fn: s.handleCreateAPIKey, body: `{"name":"x"}`},
{name: "update_api_key", fn: s.handleUpdateAPIKey, body: `{"name":"renamed"}`},
{name: "revoke_api_key", fn: s.handleRevokeAPIKey, body: ""},
{name: "put_email", fn: s.handlePutEmailIntegration, body: `{}`},
{name: "verify_email", fn: s.handleVerifyEmailIntegration, body: ""},
@@ -29,11 +29,13 @@ const syncA1MaxBodyBytes = catalog.MaxWPCategorySQLBytes + (2 << 20)
//
// Body (JSON): confirm=true required; backfill_categories (default true);
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
// skip_dump_backfill (default false); wp_product_categories_sql_b64 optional
// base64 of wp_product_categories.sql (primary for category prompts).
// skip_dump_backfill (default false). Category prompts load from repo seed
// (scripts/seed/wp_product_categories.sql) via ResolveWPCategoryPromptsPath;
// wp_product_categories_sql_b64 remains accepted but unused by the admin UI.
//
// Body (multipart/form-data): same fields as form values; file field
// wp_product_categories or wp_categories_sql for the SQL dump upload.
// Body (multipart/form-data): same fields as form values; optional file field
// wp_product_categories / wp_categories_sql is still accepted for API clients
// but the admin Sync A1 UI no longer uploads.
//
// Flash (UI): result → flash.admin.syncA1Success.
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
@@ -116,8 +118,10 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
}
if len(parsed.WPCategoriesSQL) > 0 {
note = "Applied uploaded wp_product_categories.sql (Title/Description/Meta/Attributes sections) + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
} else if strings.TrimSpace(result.WPCategoriesPath) == "" {
note += " Upload wp_product_categories.sql on Sync A1 for force-applied category prompts when the dump is not on the API host."
} else if strings.TrimSpace(result.WPCategoriesPath) != "" {
note = "Applied category prompts from repo seed (" + result.WPCategoriesPath + ") + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
} else {
note += " Place scripts/seed/wp_product_categories.sql on the API host (or set SEED_A1_WP_CATEGORIES) for force-applied category prompts."
}
JSON(w, http.StatusOK, map[string]any{
@@ -1,11 +1,14 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
@@ -80,6 +83,61 @@ func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleUpdateAPIKey(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
if !s.requireFeatures(w, r, "settings.api_keys", "capability.api_access") {
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Name string `json:"name"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
Error(w, http.StatusBadRequest, "name required")
return
}
if len(name) > 120 {
Error(w, http.StatusBadRequest, "name too long")
return
}
var (
outID uuid.UUID
outName string
prefix string
lastUsed any
created any
)
err = s.Pool.QueryRow(r.Context(), `
UPDATE api_keys
SET name = $3, updated_at = now()
WHERE id = $1 AND company_id = $2 AND revoked_at IS NULL
RETURNING id, name, key_prefix, last_used_at, created_at`,
id, cid, name).Scan(&outID, &outName, &prefix, &lastUsed, &created)
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "update failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"id": outID, "name": outName, "key_prefix": prefix, "last_used_at": lastUsed, "created_at": created,
})
}
func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
if !s.allowCompanyAdminOrPlatform(w, r) {
@@ -0,0 +1,57 @@
package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func TestHandleUpdateAPIKeyRejectsEmptyName(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
keyID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", keyID.String())
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req := httptest.NewRequest(http.MethodPatch, "/api/api-keys/"+keyID.String(), bytes.NewBufferString(`{"name":" "}`)).WithContext(ctx)
rec := httptest.NewRecorder()
s.handleUpdateAPIKey(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
}
}
func TestHandleUpdateAPIKeyRejectsInvalidID(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "not-a-uuid")
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req := httptest.NewRequest(http.MethodPatch, "/api/api-keys/not-a-uuid", bytes.NewBufferString(`{"name":"ok"}`)).WithContext(ctx)
rec := httptest.NewRecorder()
s.handleUpdateAPIKey(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
}
}
@@ -247,6 +247,15 @@ func TestAuthSessionCoreEndpoints(t *testing.T) {
t.Fatalf("create api-key payload=%v", created)
}
rec = do(http.MethodPatch, "/api/api-keys/"+keyID, `{"name":"auth-smoke-key-renamed"}`, true)
if rec.Code != http.StatusOK {
t.Fatalf("rename api-key status=%d body=%s", rec.Code, rec.Body.String())
}
renamed := decode(t, rec)
if fmt.Sprint(renamed["name"]) != "auth-smoke-key-renamed" {
t.Fatalf("rename api-key name=%v", renamed["name"])
}
// Public v1 with the new key (CSRF skipped).
v1 := httptest.NewRecorder()
v1Req := httptest.NewRequest(http.MethodGet, "/api/v1/products?limit=1", nil)
+1
View File
@@ -453,6 +453,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api-keys", s.handleListAPIKeys)
r.Post("/api-keys", s.handleCreateAPIKey)
r.Patch("/api-keys/{id}", s.handleUpdateAPIKey)
r.Delete("/api-keys/{id}", s.handleRevokeAPIKey)
r.Get("/billing/credits", s.handleCreditsOverview)
+37 -51
View File
@@ -118,8 +118,9 @@ info:
- GET /products data[].id = processed_products.id (enriched row)
- GET /products data[].raw_product_id = raw_products.id (use this for raw_product_ids)
- POST ... raw_product_ids[] must be raw_products.id — never PresentProduct.id
- GET /products/process/{id} COMPLETED items[].id = processed_products.id (legacy);
additive processed_product_id (same as id) and raw_product_id (raw_products.id)
- GET /products/process/{id} COMPLETED items[] omit internal UUIDs (id /
processed_product_id / raw_product_id). Use GET /products when a UUID is needed.
Display name is items[].name (title omitted when identical).
Note: Dashboard JSON under /api/* uses session cookies + CSRF and is separate
from this public API-key surface. Other legacy path aliases
@@ -895,9 +896,10 @@ paths:
Completed jobs return items[] (EAN-keyed enrichment). In-progress and failed
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), raw_product_id (raw_products.id), and
name (same value as title) for dual-mode clients / scorecards.
On COMPLETED items, name is the product display name (title is omitted when
identical). Internal UUIDs (id / processed_product_id / raw_product_id) are
omitted — use GET /products for those. A1 cohort / Platform Demo / A1-prompt
companies omit meta_title and meta_description even if stored.
parameters:
- name: id
in: path
@@ -925,17 +927,11 @@ paths:
processing_type: full
items:
- 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: Cookers
category_id: '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
@@ -6207,12 +6203,24 @@ components:
process_id:
type: string
format: uuid
status:
type: string
description: |
Job lifecycle status on enqueue. Always pending (or processing if the
worker already picked it up). Never COMPLETED on start — poll GET
/products/process/{id} for completion.
example: pending
message:
type: string
total_items:
type: integer
description: Number of products accepted into the job (enqueue size).
processed_items:
type: integer
description: |
Count of items that finished processing. Always 0 on start while the
job is pending/processing; increments as the worker completes products.
example: 0
job_count:
type: integer
description: Present when StartJob auto-splits
@@ -6282,35 +6290,19 @@ components:
expose category as the human-readable display name (category_id holds
categories.unique_id), a description string that may include category formula
HTML (h1/h2/h3/h4, p, ul — never a JSON array), optional SEO meta_title /
meta_description (plain text; omitted for A1 cohort), 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).
Property order prefers human-readable fields first (ean, title, category,
description, attributes, images, eprel) with internal ids last.
meta_description (plain text; omitted for A1 cohort, Platform Demo, and any
company with A1-style category role-section prompts — even if stored in DB),
optional eprel object or null, clean attributes, and images.
Product display name is name (primary). title is omitted when identical to name.
Internal UUIDs (id, processed_product_id, raw_product_id) are omitted from this
public shape — use catalog APIs when a product UUID is required.
Property order prefers human-readable fields first (ean, name, category,
description, attributes, images, eprel).
required:
- ean
properties:
ean:
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.
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.
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.
category:
type: string
nullable: true
@@ -6330,32 +6322,32 @@ components:
nullable: true
description: |
Human-readable category display name (mirrors category when both are set).
title:
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.
Product display name (processed title). Primary field for clients.
title:
type: string
nullable: true
description: |
Optional legacy alias of name. Omitted when identical to name.
meta_title:
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.
Omitted for A1 cohort (SEO meta is not used).
Omitted for A1 cohort, Platform Demo, and companies with A1-style category
prompts (SEO meta is not used).
meta_description:
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.
Omitted for A1 cohort (SEO meta is not used).
Omitted for A1 cohort, Platform Demo, and companies with A1-style category
prompts (SEO meta is not used).
description:
type: string
nullable: true
@@ -6477,17 +6469,11 @@ components:
processing_type: full
items:
- 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: Cookers
category_id: '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:
brand: Vox
@@ -160,11 +160,14 @@ func (s *Server) handleV1StartProcess(w http.ResponseWriter, r *http.Request) {
}
primary := jobs[0]
// processed_items is the completed count — stay 0 until the worker finishes.
// total_items is the enqueue size; status stays pending/processing until poll COMPLETED.
resp := map[string]any{
"process_id": primary.ID.String(),
"status": "pending",
"message": fmt.Sprintf("Processing started for %d product(s)", len(rawIDs)),
"total_items": totalItems,
"processed_items": len(rawIDs),
"processed_items": 0,
}
if len(jobs) > 1 {
siblings := make([]string, 0, len(jobs)-1)
@@ -194,6 +194,7 @@ func TestHandleV1StartProcessItemsEANReturnsProcessID(t *testing.T) {
var envelope struct {
Data struct {
ProcessID string `json:"process_id"`
Status string `json:"status"`
Message string `json:"message"`
TotalItems int `json:"total_items"`
ProcessedItems int `json:"processed_items"`
@@ -205,8 +206,11 @@ func TestHandleV1StartProcessItemsEANReturnsProcessID(t *testing.T) {
if envelope.Data.ProcessID != jobID.String() {
t.Fatalf("process_id=%q want %s", envelope.Data.ProcessID, jobID)
}
if envelope.Data.TotalItems != 1 || envelope.Data.ProcessedItems != 1 {
t.Fatalf("counts=%+v", envelope.Data)
if envelope.Data.Status != "pending" {
t.Fatalf("status=%q want pending", envelope.Data.Status)
}
if envelope.Data.TotalItems != 1 || envelope.Data.ProcessedItems != 0 {
t.Fatalf("counts=%+v (processed_items must be 0 on start)", envelope.Data)
}
if enqueued != jobID {
t.Fatalf("enqueued=%s", enqueued)