fix
This commit is contained in:
@@ -12,21 +12,20 @@ import (
|
||||
"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
|
||||
// 4–7. FixCatalogHygieneWithIDs + attribute sanitize + reprocess sample
|
||||
// handleAdminSyncCompanyA1 runs dump category backfill (when dump is on the API
|
||||
// host filesystem) plus FixCompanyCatalog hygiene. Does not wipe/reimport.
|
||||
//
|
||||
// Routes (same handler):
|
||||
//
|
||||
// POST /api/admin/companies/{id}/sync-a1
|
||||
// POST /api/admin/companies/{id}/fix-catalog (compat alias)
|
||||
//
|
||||
// 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.
|
||||
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
|
||||
// skip_dump_backfill (default false).
|
||||
//
|
||||
// Flash (UI): result.prompts / hashes / categories → flash.admin.fixA1Success.
|
||||
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
// Flash (UI): result → flash.admin.syncA1Success.
|
||||
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Pool == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
@@ -39,9 +38,11 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
BackfillCategories *bool `json:"backfill_categories"`
|
||||
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
|
||||
Confirm bool `json:"confirm"`
|
||||
BackfillCategories *bool `json:"backfill_categories"`
|
||||
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
|
||||
MySQLDump string `json:"mysql_dump"`
|
||||
SkipDumpBackfill bool `json:"skip_dump_backfill"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
@@ -53,7 +54,7 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
if platformsettings.IsSystemCompany(companyID) {
|
||||
Error(w, http.StatusBadRequest, "cannot fix the platform settings company")
|
||||
Error(w, http.StatusBadRequest, "cannot sync the platform settings company")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,26 +81,40 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
|
||||
sampleLimit = 200
|
||||
}
|
||||
|
||||
result, err := processing.FixCompanyCatalog(
|
||||
result, err := processing.SyncCompanyA1(
|
||||
r.Context(),
|
||||
s.Pool,
|
||||
companyID,
|
||||
name,
|
||||
billing.IsA1CohortCompany(legacy, name),
|
||||
processing.FixCompanyCatalogOpts{
|
||||
BackfillCategories: backfill,
|
||||
ReprocessSampleLimit: sampleLimit,
|
||||
AIPrompts: s.AIPrompts,
|
||||
processing.SyncCompanyA1Opts{
|
||||
FixCompanyCatalogOpts: processing.FixCompanyCatalogOpts{
|
||||
BackfillCategories: backfill,
|
||||
ReprocessSampleLimit: sampleLimit,
|
||||
AIPrompts: s.AIPrompts,
|
||||
},
|
||||
MySQLDumpPath: strings.TrimSpace(body.MySQLDump),
|
||||
SkipDumpBackfill: body.SkipDumpBackfill,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not fix catalog", err, catalog.ClientError)
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not sync A1 catalog", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
note := "Synced in place (dump categories when available + hygiene); reprocess recommended products manually (no mass reprocess)."
|
||||
if !result.DumpFound {
|
||||
note = "Hygiene completed without dump backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy dump."
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"result": result,
|
||||
"note": "Catalog was repaired in place; reprocess recommended products manually (no mass reprocess).",
|
||||
"note": note,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminFixCompanyCatalog is a compat alias of Sync A1 (same behavior).
|
||||
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAdminSyncCompanyA1(w, r)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminSyncCompanyA1NilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/"+uuid.NewString()+"/sync-a1", strings.NewReader(`{"confirm":true}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSyncCompanyA1(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
@@ -26,8 +37,6 @@ func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
|
||||
|
||||
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()
|
||||
@@ -37,8 +46,58 @@ func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminFixCatalogMounted locks POST /api/admin/companies/{id}/fix-catalog
|
||||
// after session + CSRF + platform-admin (503 with nil Pool), not chi 404.
|
||||
func TestRouterAdminSyncA1Mounted(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() + "/sync-a1"
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminFixCatalogMounted locks POST …/fix-catalog (compat alias).
|
||||
func TestRouterAdminFixCatalogMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
|
||||
@@ -391,7 +391,8 @@ 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.Post("/companies/{id}/sync-a1", s.handleAdminSyncCompanyA1)
|
||||
r.Post("/companies/{id}/fix-catalog", s.handleAdminFixCompanyCatalog) // compat alias → Sync A1
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
|
||||
Reference in New Issue
Block a user