diff --git a/apps/api/cmd/seed-a1/category_backfill.go b/apps/api/cmd/seed-a1/category_backfill.go index 6daae70..010bc46 100644 --- a/apps/api/cmd/seed-a1/category_backfill.go +++ b/apps/api/cmd/seed-a1/category_backfill.go @@ -1,26 +1,15 @@ package main import ( - "bufio" "context" - "fmt" "io" "log" - "os" - "strings" - "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) -// Classic A1 Postman / Elkotex fixture EANs — must never live on Platform Demo. -var a1FixtureEANs = []string{ - "5905575903198", - "6970995789942", -} - type categoryBackfillResult struct { ProcessedUpdated int64 ProcessedInserted int64 @@ -34,204 +23,38 @@ type categoryBackfillResult struct { MappedWithoutCat int } -// backfillMappedCategoriesFromProcessed copies processed_products.category into -// raw_products.mapped_data.category for A1 only. Delegates to processing. +// a1FixtureEANs aliases processing.A1FixtureEANs for seed-a1 tests. +var a1FixtureEANs = processing.A1FixtureEANs + func backfillMappedCategoriesFromProcessed(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) (int64, error) { return processing.BackfillMappedCategoriesFromProcessed(ctx, pg, companyID) } -// backfillCategoriesFromMySQLDump streams dump processed_products for the A1 -// legacy company and writes product_id (GTIN) → category onto A1 Postgres -// mapped_data.category (and updates any existing processed_products.category). -// It does not insert processed rows — A1 demo seed stays at processed=0. func backfillCategoriesFromMySQLDump(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, dumpPath string) (categoryBackfillResult, error) { - var out categoryBackfillResult - legacyCompany := billing.A1LegacyCompanyID - var legacy string - _ = pg.QueryRow(ctx, `SELECT COALESCE(legacy_company_id::text, '') FROM companies WHERE id = $1`, companyID).Scan(&legacy) - if legacy != "" { - legacyCompany = legacy - } - - f, err := os.Open(dumpPath) + res, err := processing.BackfillCategoriesFromMySQLDump(ctx, pg, companyID, dumpPath) if err != nil { - return out, fmt.Errorf("open mysql dump: %w", err) + return categoryBackfillResult{}, err } - defer f.Close() - - byGTIN, err := scanA1ProcessedCategories(f, legacyCompany) - if err != nil { - return out, err - } - out.DumpPairs = len(byGTIN) - if len(byGTIN) == 0 { - return out, fmt.Errorf("no A1 processed_products categories for legacy %s in dump", legacyCompany) - } - log.Printf("dump: %d A1 gtin→category pairs", len(byGTIN)) - - gtins := make([]string, 0, len(byGTIN)) - cats := make([]string, 0, len(byGTIN)) - for g, c := range byGTIN { - gtins = append(gtins, g) - cats = append(cats, c) - } - - ct, err := pg.Exec(ctx, ` - UPDATE processed_products p - SET category = v.category, - updated_at = now() - FROM unnest($2::text[], $3::text[]) AS v(gtin, category) - WHERE p.company_id = $1 - AND p.product_id = v.gtin - AND COALESCE(NULLIF(trim(v.category), ''), '') <> '' - AND ( - COALESCE(NULLIF(trim(p.category), ''), '') = '' - OR lower(trim(p.category)) = 'none' - OR p.category IS DISTINCT FROM v.category - )`, companyID, gtins, cats) - if err != nil { - return out, fmt.Errorf("update processed category from dump: %w", err) - } - out.ProcessedUpdated = ct.RowsAffected() - - // A1 demo seed keeps processed=0. Do not INSERT processed rows from the dump — - // only refresh mapped_data.category (and any existing processed rows if present). - ct, err = pg.Exec(ctx, ` - UPDATE raw_products r - SET mapped_data = jsonb_set( - COALESCE(r.mapped_data, '{}'::jsonb), - '{category}', - to_jsonb(v.category), - true - ), - updated_at = now() - FROM unnest($2::text[], $3::text[]) AS v(gtin, category) - WHERE r.company_id = $1 - AND r.gtin = v.gtin - AND COALESCE(NULLIF(trim(v.category), ''), '') <> '' - AND ( - COALESCE(NULLIF(trim(r.mapped_data->>'category'), ''), '') = '' - OR r.mapped_data->>'category' IS DISTINCT FROM v.category - )`, companyID, gtins, cats) - if err != nil { - return out, fmt.Errorf("update mapped category from dump: %w", err) - } - out.MappedUpdated = ct.RowsAffected() - - n, err := backfillMappedCategoriesFromProcessed(ctx, pg, companyID) - if err != nil { - return out, err - } - out.MappedUpdated += n - if err := fillCategoryCoverage(ctx, pg, companyID, &out); err != nil { - return out, err - } - return out, nil + return categoryBackfillResult{ + ProcessedUpdated: res.ProcessedUpdated, + ProcessedInserted: res.ProcessedInserted, + MappedUpdated: res.MappedUpdated, + DumpPairs: res.DumpPairs, + PurgedOtherRaw: res.PurgedOtherRaw, + PurgedOtherPP: res.PurgedOtherPP, + ProcessedWithCat: res.ProcessedWithCat, + ProcessedWithoutCat: res.ProcessedWithoutCat, + MappedWithCat: res.MappedWithCat, + MappedWithoutCat: res.MappedWithoutCat, + }, nil } -func fillCategoryCoverage(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, out *categoryBackfillResult) error { - err := pg.QueryRow(ctx, ` - SELECT - COUNT(*) FILTER ( - WHERE COALESCE(NULLIF(trim(category), ''), '') <> '' - AND lower(trim(category)) <> 'none' - ), - COUNT(*) FILTER ( - WHERE COALESCE(NULLIF(trim(category), ''), '') = '' - OR lower(trim(category)) = 'none' - ) - FROM processed_products - WHERE company_id = $1`, companyID).Scan(&out.ProcessedWithCat, &out.ProcessedWithoutCat) - if err != nil { - return fmt.Errorf("count processed categories: %w", err) - } - err = pg.QueryRow(ctx, ` - SELECT - COUNT(*) FILTER ( - WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> '' - ), - COUNT(*) FILTER ( - WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') = '' - ) - FROM raw_products - WHERE company_id = $1`, companyID).Scan(&out.MappedWithCat, &out.MappedWithoutCat) - if err != nil { - return fmt.Errorf("count mapped categories: %w", err) - } - return nil -} - -// purgeA1FixtureEANsFromOtherTenants deletes the Postman Elkotex fixture EANs -// from every company except A1 (Platform Demo must not mirror A1 fixtures). func purgeA1FixtureEANsFromOtherTenants(ctx context.Context, pg *pgxpool.Pool, a1CompanyID uuid.UUID) (rawN, ppN int64, err error) { - ct, err := pg.Exec(ctx, ` - DELETE FROM processing_job_products pjp - WHERE pjp.raw_product_id IN ( - SELECT id FROM raw_products - WHERE company_id <> $1 AND gtin = ANY($2::text[]) - ) - OR pjp.processed_product_id IN ( - SELECT id FROM processed_products - WHERE company_id <> $1 AND product_id = ANY($2::text[]) - )`, a1CompanyID, a1FixtureEANs) - if err != nil { - return 0, 0, fmt.Errorf("purge fixture job products: %w", err) - } - _ = ct - - ct, err = pg.Exec(ctx, ` - DELETE FROM processed_products - WHERE company_id <> $1 AND product_id = ANY($2::text[])`, a1CompanyID, a1FixtureEANs) - if err != nil { - return 0, 0, fmt.Errorf("purge fixture processed: %w", err) - } - ppN = ct.RowsAffected() - - ct, err = pg.Exec(ctx, ` - DELETE FROM raw_products - WHERE company_id <> $1 AND gtin = ANY($2::text[])`, a1CompanyID, a1FixtureEANs) - if err != nil { - return 0, 0, fmt.Errorf("purge fixture raw: %w", err) - } - rawN = ct.RowsAffected() - return rawN, ppN, nil + return processing.PurgeA1FixtureEANsFromOtherTenants(ctx, pg, a1CompanyID) } func scanA1ProcessedCategories(r io.Reader, legacyCompany string) (map[string]string, error) { - br := bufio.NewReaderSize(r, 1<<20) - inTable := false - out := make(map[string]string, 4096) - for { - line, err := br.ReadString('\n') - if len(line) > 0 { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "INSERT INTO `processed_products`") || - strings.HasPrefix(trimmed, "INSERT INTO processed_products") { - inTable = true - } else if inTable && strings.HasPrefix(trimmed, "CREATE TABLE") { - break - } else if inTable && strings.HasPrefix(trimmed, "INSERT INTO `") && - !strings.Contains(trimmed, "processed_products") { - break - } else if inTable && looksLikeTupleLine(line) && strings.Contains(line, legacyCompany) { - fields := parseMySQLTupleFieldsN(line, 6) - if len(fields) >= 5 { - gtin := strings.TrimSpace(fields[2]) - cat := strings.TrimSpace(fields[4]) - if gtin != "" && cat != "" && !strings.EqualFold(cat, "NULL") { - out[gtin] = cat - } - } - } - } - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - } - return out, nil + return processing.ScanA1ProcessedCategories(r, legacyCompany) } func logCategoryBackfillResult(res categoryBackfillResult, source string) { diff --git a/apps/api/cmd/seed-a1/dump_resolve.go b/apps/api/cmd/seed-a1/dump_resolve.go index 822f0b9..c9050ed 100644 --- a/apps/api/cmd/seed-a1/dump_resolve.go +++ b/apps/api/cmd/seed-a1/dump_resolve.go @@ -4,70 +4,28 @@ import ( "context" "fmt" "log" - "os" - "path/filepath" - "strings" + "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) -// resolveMySQLDumpPath picks an explicit path, else the first readable candidate -// under common local locations documented in scripts/seed/README.txt. +// resolveMySQLDumpPath delegates to processing (shared with admin Sync A1). func resolveMySQLDumpPath(explicit string) string { - if p := strings.TrimSpace(explicit); p != "" { - if st, err := os.Stat(p); err == nil && !st.IsDir() { - return p - } - log.Printf("warning: mysql dump not found at %q — trying auto-detect", p) - } - for _, c := range mysqlDumpCandidates() { - if strings.TrimSpace(explicit) != "" && filepath.Clean(c) == filepath.Clean(strings.TrimSpace(explicit)) { - continue - } - if st, err := os.Stat(c); err == nil && !st.IsDir() { - return c - } - } - return "" + return processing.ResolveMySQLDumpPath(explicit) } func mysqlDumpCandidates() []string { - var out []string - if v := strings.TrimSpace(os.Getenv("SEED_A1_MYSQL_DUMP")); v != "" { - out = append(out, v) - } - home, _ := os.UserHomeDir() - names := []string{ - "descrybe_new (1).sql", - "descrybe_new.sql", - "descrybe_new(1).sql", - } - if home != "" { - for _, n := range names { - out = append(out, filepath.Join(home, "Downloads", n)) - out = append(out, filepath.Join(home, "downloads", n)) - } - } - // Repo-relative guesses (cwd may be apps/api or repo root). - for _, n := range names { - out = append(out, - n, - filepath.Join("..", "..", n), - filepath.Join("scripts", "seed", n), - filepath.Join("..", "..", "scripts", "seed", n), - ) - } - return out + return processing.MySQLDumpCandidates() } type mappedCoverage struct { - Total int - WithDesc int - WithCat int - WithAttrs int - Processed int - Jobs int + Total int + WithDesc int + WithCat int + WithAttrs int + Processed int + Jobs int } func (c mappedCoverage) pct(n int) float64 { diff --git a/apps/api/cmd/seed-a1/main.go b/apps/api/cmd/seed-a1/main.go index c7e9133..39db0cd 100644 --- a/apps/api/cmd/seed-a1/main.go +++ b/apps/api/cmd/seed-a1/main.go @@ -41,9 +41,10 @@ // go run ./cmd/seed-a1 -mode backfill-categories -mysql-dump path/to/descrybe_new.sql // go run ./cmd/seed-a1 -mode backfill-attributes // -// DATABASE_URL / -postgres required. +// DATABASE_URL / -postgres required (also loaded from monorepo-root .env via config.LoadDotEnv). // Day-to-day: `npm run seed:a1` (reimport + category prompts + mapped category backfill). Maintainer snapshot: `npm run seed:a1:export`. // recover-jobs is opt-in only and will reintroduce job history. +// Prefer admin UI Sync A1 on the company row (uses API DATABASE_URL; no CLI env needed). package main import ( @@ -61,6 +62,7 @@ import ( "compress/gzip" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" + "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -82,6 +84,8 @@ type tableSpec struct { } func main() { + config.LoadDotEnv() + postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL (DATABASE_URL)") mode := flag.String("mode", "reimport", "export | reimport | recover-jobs | apply-category-prompts | backfill-categories | backfill-attributes") file := flag.String("file", "", "gzipped seed archive path (required for export/reimport)") @@ -93,7 +97,7 @@ func main() { flag.Parse() if strings.TrimSpace(*postgresURL) == "" { - log.Fatal("-postgres / DATABASE_URL is required") + log.Fatal("-postgres / DATABASE_URL is required (export it, pass -postgres, or put DATABASE_URL in monorepo-root .env — same as the API; prefer admin Sync A1)") } companyID, err := uuid.Parse(strings.TrimSpace(*company)) if err != nil { diff --git a/apps/api/cmd/seed-a1/recover_jobs.go b/apps/api/cmd/seed-a1/recover_jobs.go index 5d9df1b..4c7cef7 100644 --- a/apps/api/cmd/seed-a1/recover_jobs.go +++ b/apps/api/cmd/seed-a1/recover_jobs.go @@ -12,6 +12,7 @@ import ( "time" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" + "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) @@ -419,65 +420,15 @@ func dumpSectionEnded(line, table string) bool { } func looksLikeTupleLine(line string) bool { - s := strings.TrimLeft(line, " \t") - return strings.HasPrefix(s, "(") + return processing.LooksLikeMySQLTupleLine(line) } func parseMySQLTupleFields(line string) []string { - return parseMySQLTupleFieldsN(line, 0) + return processing.ParseMySQLTupleFields(line) } -// parseMySQLTupleFieldsN parses up to maxFields (0 = all) from the first (...) tuple on the line. func parseMySQLTupleFieldsN(line string, maxFields int) []string { - start := strings.Index(line, "(") - if start < 0 { - return nil - } - body := line[start+1:] - var out []string - for i := 0; i < len(body); { - if maxFields > 0 && len(out) >= maxFields { - break - } - for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == ',') { - i++ - } - if i >= len(body) || body[i] == ')' { - break - } - if body[i] == '\'' { - i++ - var b strings.Builder - for i < len(body) { - ch := body[i] - if ch == '\\' && i+1 < len(body) { - b.WriteByte(body[i+1]) - i += 2 - continue - } - if ch == '\'' { - if i+1 < len(body) && body[i+1] == '\'' { - b.WriteByte('\'') - i += 2 - continue - } - i++ - break - } - b.WriteByte(ch) - i++ - } - out = append(out, b.String()) - continue - } - j := i - for j < len(body) && body[j] != ',' && body[j] != ')' { - j++ - } - out = append(out, strings.TrimSpace(body[i:j])) - i = j - } - return out + return processing.ParseMySQLTupleFieldsN(line, maxFields) } func parseDumpJobID(raw string) (uuid.UUID, error) { diff --git a/apps/api/internal/httpapi/admin_fix_catalog_handlers.go b/apps/api/internal/httpapi/admin_fix_catalog_handlers.go index 15cafca..855c998 100644 --- a/apps/api/internal/httpapi/admin_fix_catalog_handlers.go +++ b/apps/api/internal/httpapi/admin_fix_catalog_handlers.go @@ -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) +} diff --git a/apps/api/internal/httpapi/admin_fix_catalog_handlers_test.go b/apps/api/internal/httpapi/admin_fix_catalog_handlers_test.go index 4675f0a..8ec3009 100644 --- a/apps/api/internal/httpapi/admin_fix_catalog_handlers_test.go +++ b/apps/api/internal/httpapi/admin_fix_catalog_handlers_test.go @@ -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() diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 8b8a19f..028df54 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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) diff --git a/apps/api/internal/processing/dump_category_backfill.go b/apps/api/internal/processing/dump_category_backfill.go new file mode 100644 index 0000000..b454221 --- /dev/null +++ b/apps/api/internal/processing/dump_category_backfill.go @@ -0,0 +1,234 @@ +package processing + +import ( + "bufio" + "context" + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/descrybe/descrybe-v2/apps/api/internal/billing" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Classic A1 Postman / Elkotex fixture EANs — must never live on Platform Demo. +var A1FixtureEANs = []string{ + "5905575903198", + "6970995789942", +} + +// DumpCategoryBackfillResult is the outcome of BackfillCategoriesFromMySQLDump. +type DumpCategoryBackfillResult struct { + ProcessedUpdated int64 `json:"processed_updated"` + ProcessedInserted int64 `json:"processed_inserted"` + MappedUpdated int64 `json:"mapped_updated"` + DumpPairs int `json:"dump_pairs"` + PurgedOtherRaw int64 `json:"purged_other_raw"` + PurgedOtherPP int64 `json:"purged_other_pp"` + ProcessedWithCat int `json:"processed_with_cat"` + ProcessedWithoutCat int `json:"processed_without_cat"` + MappedWithCat int `json:"mapped_with_cat"` + MappedWithoutCat int `json:"mapped_without_cat"` +} + +// BackfillCategoriesFromMySQLDump streams dump processed_products for the company +// legacy id and writes product_id (GTIN) → category onto Postgres mapped_data.category +// (and updates any existing processed_products.category). It does not insert +// processed rows — A1 demo seed stays at processed=0. +func BackfillCategoriesFromMySQLDump(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, dumpPath string) (DumpCategoryBackfillResult, error) { + var out DumpCategoryBackfillResult + if pg == nil { + return out, fmt.Errorf("dump category backfill: nil pool") + } + if companyID == uuid.Nil { + return out, fmt.Errorf("dump category backfill: empty company id") + } + legacyCompany := billing.A1LegacyCompanyID + var legacy string + _ = pg.QueryRow(ctx, `SELECT COALESCE(legacy_company_id::text, '') FROM companies WHERE id = $1`, companyID).Scan(&legacy) + if legacy != "" { + legacyCompany = legacy + } + + f, err := os.Open(dumpPath) + if err != nil { + return out, fmt.Errorf("open mysql dump: %w", err) + } + defer f.Close() + + byGTIN, err := ScanA1ProcessedCategories(f, legacyCompany) + if err != nil { + return out, err + } + out.DumpPairs = len(byGTIN) + if len(byGTIN) == 0 { + return out, fmt.Errorf("no A1 processed_products categories for legacy %s in dump", legacyCompany) + } + log.Printf("dump: %d A1 gtin→category pairs", len(byGTIN)) + + gtins := make([]string, 0, len(byGTIN)) + cats := make([]string, 0, len(byGTIN)) + for g, c := range byGTIN { + gtins = append(gtins, g) + cats = append(cats, c) + } + + ct, err := pg.Exec(ctx, ` + UPDATE processed_products p + SET category = v.category, + updated_at = now() + FROM unnest($2::text[], $3::text[]) AS v(gtin, category) + WHERE p.company_id = $1 + AND p.product_id = v.gtin + AND COALESCE(NULLIF(trim(v.category), ''), '') <> '' + AND ( + COALESCE(NULLIF(trim(p.category), ''), '') = '' + OR lower(trim(p.category)) = 'none' + OR p.category IS DISTINCT FROM v.category + )`, companyID, gtins, cats) + if err != nil { + return out, fmt.Errorf("update processed category from dump: %w", err) + } + out.ProcessedUpdated = ct.RowsAffected() + + ct, err = pg.Exec(ctx, ` + UPDATE raw_products r + SET mapped_data = jsonb_set( + COALESCE(r.mapped_data, '{}'::jsonb), + '{category}', + to_jsonb(v.category), + true + ), + updated_at = now() + FROM unnest($2::text[], $3::text[]) AS v(gtin, category) + WHERE r.company_id = $1 + AND r.gtin = v.gtin + AND COALESCE(NULLIF(trim(v.category), ''), '') <> '' + AND ( + COALESCE(NULLIF(trim(r.mapped_data->>'category'), ''), '') = '' + OR r.mapped_data->>'category' IS DISTINCT FROM v.category + )`, companyID, gtins, cats) + if err != nil { + return out, fmt.Errorf("update mapped category from dump: %w", err) + } + out.MappedUpdated = ct.RowsAffected() + + n, err := BackfillMappedCategoriesFromProcessed(ctx, pg, companyID) + if err != nil { + return out, err + } + out.MappedUpdated += n + if err := fillDumpCategoryCoverage(ctx, pg, companyID, &out); err != nil { + return out, err + } + return out, nil +} + +func fillDumpCategoryCoverage(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, out *DumpCategoryBackfillResult) error { + err := pg.QueryRow(ctx, ` + SELECT + COUNT(*) FILTER ( + WHERE COALESCE(NULLIF(trim(category), ''), '') <> '' + AND lower(trim(category)) <> 'none' + ), + COUNT(*) FILTER ( + WHERE COALESCE(NULLIF(trim(category), ''), '') = '' + OR lower(trim(category)) = 'none' + ) + FROM processed_products + WHERE company_id = $1`, companyID).Scan(&out.ProcessedWithCat, &out.ProcessedWithoutCat) + if err != nil { + return fmt.Errorf("count processed categories: %w", err) + } + err = pg.QueryRow(ctx, ` + SELECT + COUNT(*) FILTER ( + WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> '' + ), + COUNT(*) FILTER ( + WHERE COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') = '' + ) + FROM raw_products + WHERE company_id = $1`, companyID).Scan(&out.MappedWithCat, &out.MappedWithoutCat) + if err != nil { + return fmt.Errorf("count mapped categories: %w", err) + } + return nil +} + +// PurgeA1FixtureEANsFromOtherTenants deletes the Postman Elkotex fixture EANs +// from every company except A1 (Platform Demo must not mirror them). +func PurgeA1FixtureEANsFromOtherTenants(ctx context.Context, pg *pgxpool.Pool, a1CompanyID uuid.UUID) (rawN, ppN int64, err error) { + _, err = pg.Exec(ctx, ` + DELETE FROM processing_job_products pjp + WHERE pjp.raw_product_id IN ( + SELECT id FROM raw_products + WHERE company_id <> $1 AND gtin = ANY($2::text[]) + ) + OR pjp.processed_product_id IN ( + SELECT id FROM processed_products + WHERE company_id <> $1 AND product_id = ANY($2::text[]) + )`, a1CompanyID, A1FixtureEANs) + if err != nil { + return 0, 0, fmt.Errorf("purge fixture job products: %w", err) + } + + ct, err := pg.Exec(ctx, ` + DELETE FROM processed_products + WHERE company_id <> $1 AND product_id = ANY($2::text[])`, a1CompanyID, A1FixtureEANs) + if err != nil { + return 0, 0, fmt.Errorf("purge fixture processed: %w", err) + } + ppN = ct.RowsAffected() + + ct, err = pg.Exec(ctx, ` + DELETE FROM raw_products + WHERE company_id <> $1 AND gtin = ANY($2::text[])`, a1CompanyID, A1FixtureEANs) + if err != nil { + return 0, 0, fmt.Errorf("purge fixture raw: %w", err) + } + rawN = ct.RowsAffected() + return rawN, ppN, nil +} + +// ScanA1ProcessedCategories reads mysqldump INSERT rows for processed_products +// belonging to legacyCompany and returns gtin → category unique_id. +func ScanA1ProcessedCategories(r io.Reader, legacyCompany string) (map[string]string, error) { + br := bufio.NewReaderSize(r, 1<<20) + inTable := false + out := make(map[string]string, 4096) + for { + line, err := br.ReadString('\n') + if len(line) > 0 { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "INSERT INTO `processed_products`") || + strings.HasPrefix(trimmed, "INSERT INTO processed_products") { + inTable = true + } else if inTable && strings.HasPrefix(trimmed, "CREATE TABLE") { + break + } else if inTable && strings.HasPrefix(trimmed, "INSERT INTO `") && + !strings.Contains(trimmed, "processed_products") { + break + } else if inTable && LooksLikeMySQLTupleLine(line) && strings.Contains(line, legacyCompany) { + fields := ParseMySQLTupleFieldsN(line, 6) + if len(fields) >= 5 { + gtin := strings.TrimSpace(fields[2]) + cat := strings.TrimSpace(fields[4]) + if gtin != "" && cat != "" && !strings.EqualFold(cat, "NULL") { + out[gtin] = cat + } + } + } + } + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + } + return out, nil +} diff --git a/apps/api/internal/processing/mysql_dump_parse.go b/apps/api/internal/processing/mysql_dump_parse.go new file mode 100644 index 0000000..5263ae7 --- /dev/null +++ b/apps/api/internal/processing/mysql_dump_parse.go @@ -0,0 +1,67 @@ +package processing + +import "strings" + +// LooksLikeMySQLTupleLine reports whether line starts a mysqldump VALUES tuple. +func LooksLikeMySQLTupleLine(line string) bool { + s := strings.TrimLeft(line, " \t") + return strings.HasPrefix(s, "(") +} + +// ParseMySQLTupleFields parses all fields from the first (...) tuple on the line. +func ParseMySQLTupleFields(line string) []string { + return ParseMySQLTupleFieldsN(line, 0) +} + +// ParseMySQLTupleFieldsN parses up to maxFields (0 = all) from the first (...) tuple. +func ParseMySQLTupleFieldsN(line string, maxFields int) []string { + start := strings.Index(line, "(") + if start < 0 { + return nil + } + body := line[start+1:] + var out []string + for i := 0; i < len(body); { + if maxFields > 0 && len(out) >= maxFields { + break + } + for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == ',') { + i++ + } + if i >= len(body) || body[i] == ')' { + break + } + if body[i] == '\'' { + i++ + var b strings.Builder + for i < len(body) { + ch := body[i] + if ch == '\\' && i+1 < len(body) { + b.WriteByte(body[i+1]) + i += 2 + continue + } + if ch == '\'' { + if i+1 < len(body) && body[i+1] == '\'' { + b.WriteByte('\'') + i += 2 + continue + } + i++ + break + } + b.WriteByte(ch) + i++ + } + out = append(out, b.String()) + continue + } + j := i + for j < len(body) && body[j] != ',' && body[j] != ')' { + j++ + } + out = append(out, strings.TrimSpace(body[i:j])) + i = j + } + return out +} diff --git a/apps/api/internal/processing/mysql_dump_path.go b/apps/api/internal/processing/mysql_dump_path.go new file mode 100644 index 0000000..199c78a --- /dev/null +++ b/apps/api/internal/processing/mysql_dump_path.go @@ -0,0 +1,91 @@ +package processing + +import ( + "log" + "os" + "path/filepath" + "strings" +) + +// ResolveMySQLDumpPath picks an explicit path, else the first readable candidate +// under common local locations documented in scripts/seed/README.txt. +// Used by seed-a1 CLI and admin Sync A1 (API process filesystem). +func ResolveMySQLDumpPath(explicit string) string { + if p := strings.TrimSpace(explicit); p != "" { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p + } + log.Printf("warning: mysql dump not found at %q — trying auto-detect", p) + } + for _, c := range MySQLDumpCandidates() { + if strings.TrimSpace(explicit) != "" && filepath.Clean(c) == filepath.Clean(strings.TrimSpace(explicit)) { + continue + } + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c + } + } + return "" +} + +// MySQLDumpCandidates lists paths seed-a1 / Sync A1 try when SEED_A1_MYSQL_DUMP +// or -mysql-dump is unset. First readable file wins via ResolveMySQLDumpPath. +// +// ASSUMPTION: On servers (e.g. Git-Syncer) the dump must live on the API host +// filesystem — prefer scripts/seed/descrybe_new.sql under the deploy root, or set +// SEED_A1_MYSQL_DUMP in the API/worker environment. +func MySQLDumpCandidates() []string { + var out []string + if v := strings.TrimSpace(os.Getenv("SEED_A1_MYSQL_DUMP")); v != "" { + out = append(out, v) + } + home, _ := os.UserHomeDir() + names := []string{ + "descrybe_new (1).sql", + "descrybe_new.sql", + "descrybe_new(1).sql", + } + if home != "" { + for _, n := range names { + out = append(out, filepath.Join(home, "Downloads", n)) + out = append(out, filepath.Join(home, "downloads", n)) + } + } + // Repo-relative guesses (cwd may be apps/api or repo root). + for _, n := range names { + out = append(out, + n, + filepath.Join("..", "..", n), + filepath.Join("scripts", "seed", n), + filepath.Join("..", "..", "scripts", "seed", n), + ) + } + if root, ok := findMonorepoRootFromCwd(); ok { + for _, n := range names { + out = append(out, filepath.Join(root, "scripts", "seed", n)) + } + } + return out +} + +func findMonorepoRootFromCwd() (string, bool) { + cwd, err := os.Getwd() + if err != nil { + return "", false + } + dir := cwd + for { + api := filepath.Join(dir, "apps", "api") + web := filepath.Join(dir, "apps", "web") + if st, err := os.Stat(api); err == nil && st.IsDir() { + if st, err := os.Stat(web); err == nil && st.IsDir() { + return dir, true + } + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + dir = parent + } +} diff --git a/apps/api/internal/processing/mysql_dump_path_test.go b/apps/api/internal/processing/mysql_dump_path_test.go new file mode 100644 index 0000000..ff93637 --- /dev/null +++ b/apps/api/internal/processing/mysql_dump_path_test.go @@ -0,0 +1,65 @@ +package processing + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveMySQLDumpPathExplicit(t *testing.T) { + t.Parallel() + dir := t.TempDir() + p := filepath.Join(dir, "descrybe_new.sql") + if err := os.WriteFile(p, []byte("-- dump\n"), 0o644); err != nil { + t.Fatal(err) + } + got := ResolveMySQLDumpPath(p) + if got != p { + t.Fatalf("got %q want %q", got, p) + } +} + +func TestResolveMySQLDumpPathMissingExplicitFallsBack(t *testing.T) { + dir := t.TempDir() + want := filepath.Join(dir, "descrybe_new (1).sql") + if err := os.WriteFile(want, []byte("-- dump\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("SEED_A1_MYSQL_DUMP", want) + got := ResolveMySQLDumpPath(filepath.Join(dir, "missing.sql")) + if got != want { + t.Fatalf("got %q want fallback %q", got, want) + } +} + +func TestMySQLDumpCandidatesIncludeDownloadsName(t *testing.T) { + t.Parallel() + found := false + for _, c := range MySQLDumpCandidates() { + if filepath.Base(c) == "descrybe_new (1).sql" { + found = true + break + } + } + if !found { + t.Fatal("expected Downloads/descrybe_new (1).sql among candidates") + } +} + +func TestScanA1ProcessedCategoriesKeepsDescriptionNullSafe(t *testing.T) { + t.Parallel() + const legacy = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7" + dump := strings.Join([]string{ + "INSERT INTO `processed_products` VALUES", + "(1,\t'u',\t'790069217715',\t'Name',\t'103',\tNULL,\t'
ai
',\tNULL,\tNULL,\t'completed',\tNULL,\t0,\t'2026-01-01 00:00:00',\t'2026-01-01 00:00:00',\t1,\t'" + legacy + "',\t1);", + "CREATE TABLE `x` (", + }, "\n") + got, err := ScanA1ProcessedCategories(strings.NewReader(dump), legacy) + if err != nil { + t.Fatal(err) + } + if got["790069217715"] != "103" { + t.Fatalf("category=%q want 103", got["790069217715"]) + } +} diff --git a/apps/api/internal/processing/sync_company_a1.go b/apps/api/internal/processing/sync_company_a1.go new file mode 100644 index 0000000..067d3d8 --- /dev/null +++ b/apps/api/internal/processing/sync_company_a1.go @@ -0,0 +1,93 @@ +package processing + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// SyncCompanyA1Opts controls admin Sync A1 (dump category backfill + Fix hygiene). +// Default is backfill-categories + hygiene — never a full wipe/reimport. +type SyncCompanyA1Opts struct { + FixCompanyCatalogOpts + // MySQLDumpPath is an explicit dump path; empty triggers auto-detect + // (SEED_A1_MYSQL_DUMP + MySQLDumpCandidates). + MySQLDumpPath string + // SkipDumpBackfill skips dump scan even when a dump is found. + SkipDumpBackfill bool +} + +// SyncCompanyA1Result combines dump category backfill with FixCompanyCatalogResult. +type SyncCompanyA1Result struct { + FixCompanyCatalogResult + + DumpFound bool `json:"dump_found"` + DumpPath string `json:"dump_path,omitempty"` + DumpSkippedReason string `json:"dump_skipped_reason,omitempty"` + DumpPairs int `json:"dump_pairs"` + DumpMappedUpdated int64 `json:"dump_mapped_updated"` + DumpProcessedUpdated int64 `json:"dump_processed_updated"` + PurgedOtherRaw int64 `json:"purged_other_raw"` + PurgedOtherPP int64 `json:"purged_other_pp"` + // DumpStatus is a short label for flash UI ({dump_status}). + DumpStatus string `json:"dump_status"` +} + +// SyncCompanyA1 runs dump→category backfill when a MySQL dump is available, then +// FixCompanyCatalog hygiene. Does not wipe or reimport the catalog. +func SyncCompanyA1(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, companyName string, a1Cohort bool, opts SyncCompanyA1Opts) (SyncCompanyA1Result, error) { + out := SyncCompanyA1Result{ + FixCompanyCatalogResult: FixCompanyCatalogResult{ + CompanyID: companyID, + CompanyName: companyName, + A1Cohort: a1Cohort, + }, + DumpStatus: "skipped", + } + if pool == nil { + return out, fmt.Errorf("nil pool") + } + + dumpPath := strings.TrimSpace(opts.MySQLDumpPath) + if !opts.SkipDumpBackfill { + resolved := ResolveMySQLDumpPath(dumpPath) + if resolved != "" { + out.DumpFound = true + out.DumpPath = resolved + dumpRes, err := BackfillCategoriesFromMySQLDump(ctx, pool, companyID, resolved) + if err != nil { + return out, fmt.Errorf("dump category backfill: %w", err) + } + out.DumpPairs = dumpRes.DumpPairs + out.DumpMappedUpdated = dumpRes.MappedUpdated + out.DumpProcessedUpdated = dumpRes.ProcessedUpdated + out.DumpStatus = "ok" + + rawN, ppN, err := PurgeA1FixtureEANsFromOtherTenants(ctx, pool, companyID) + if err != nil { + return out, fmt.Errorf("purge fixture eans: %w", err) + } + out.PurgedOtherRaw = rawN + out.PurgedOtherPP = ppN + } else if dumpPath != "" { + out.DumpSkippedReason = fmt.Sprintf("mysql dump not found at %q and no auto-detect match", dumpPath) + out.DumpStatus = "missing" + } else { + out.DumpSkippedReason = "no MySQL dump (set SEED_A1_MYSQL_DUMP or place descrybe_new.sql in scripts/seed or ~/Downloads); continuing with DB hygiene only" + out.DumpStatus = "missing" + } + } else { + out.DumpSkippedReason = "dump backfill skipped by request" + out.DumpStatus = "skipped" + } + + fixRes, err := FixCompanyCatalog(ctx, pool, companyID, companyName, a1Cohort, opts.FixCompanyCatalogOpts) + if err != nil { + return out, err + } + out.FixCompanyCatalogResult = fixRes + return out, nil +} diff --git a/apps/web/src/lib/admin-orgs.ts b/apps/web/src/lib/admin-orgs.ts index 2025a4a..3423350 100644 --- a/apps/web/src/lib/admin-orgs.ts +++ b/apps/web/src/lib/admin-orgs.ts @@ -1,7 +1,7 @@ /** * Admin orgs UI client — users + companies directory, staff roles, plan assign, - * clone-catalog, and Fix A1 (`POST …/fix-catalog`). - * Flash Fix A1: result.prompts / hashes / categories → flash.admin.fixA1Success. + * clone-catalog, and Sync A1 (`POST …/sync-a1`; fix-catalog is a compat alias). + * Flash Sync A1: result.prompts / hashes / categories / dump_* → flash.admin.syncA1Success. * Contract: docs/admin-roles-support/04-contract.md · Docs: 10-admin-orgs-ui.md */ import { api, ApiError } from "$lib/api"; @@ -22,6 +22,8 @@ export const ADMIN_CLONE_CATALOG_PATH = (companyId: string) => `${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/clone-catalog`; export const ADMIN_FIX_CATALOG_PATH = (companyId: string) => `${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/fix-catalog`; +export const ADMIN_SYNC_A1_PATH = (companyId: string) => + `${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/sync-a1`; export const ADMIN_STAFF_ROLE_PATH = (userId: string) => `/api/admin/users/${encodeURIComponent(userId)}/staff-role`; @@ -230,13 +232,13 @@ export async function cloneAdminCompanyCatalog( }); } -export type FixCatalogResult = { +export type SyncA1Result = { company_id: string; company_name: string; a1_cohort?: boolean; category_attribute_orphans_removed?: number; category_attribute_links?: number; - /** Alias of category_prompts_updated for flash.admin.fixA1Success {prompts}. */ + /** Alias of category_prompts_updated for flash.admin.syncA1Success {prompts}. */ prompts?: number; category_prompts_updated?: number; product_enhance_languages?: number; @@ -258,23 +260,36 @@ export type FixCatalogResult = { products_scanned?: number; reprocess_needed_count?: number; reprocess_sample_raw_product_ids?: string[]; + dump_found?: boolean; + dump_path?: string; + dump_skipped_reason?: string; + dump_pairs?: number; + dump_mapped_updated?: number; + dump_processed_updated?: number; + dump_status?: string; }; -export type FixCatalogResponse = { +export type SyncA1Response = { status: string; - result: FixCatalogResult; + result: SyncA1Result; note?: string; }; -/** In-place Fix A1 / catalog hygiene. Never clears catalog; no mass reprocess. */ -export async function fixAdminCompanyCatalog( +/** @deprecated Use SyncA1Result — kept for type aliases during UI rename. */ +export type FixCatalogResult = SyncA1Result; +/** @deprecated Use SyncA1Response */ +export type FixCatalogResponse = SyncA1Response; + +/** Sync A1: dump category backfill (when dump on API host) + Fix hygiene. No mass reprocess. */ +export async function syncAdminCompanyA1( companyId: string, - opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number } -): Promise