From a94a5aaad7ff75b67e379feffab6e7e038924750 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Sun, 16 Aug 2026 18:36:56 +0200 Subject: [PATCH] fix --- apps/api/cmd/seed-a1/category_backfill.go | 215 ++-------------- apps/api/cmd/seed-a1/dump_resolve.go | 62 +---- apps/api/cmd/seed-a1/main.go | 8 +- apps/api/cmd/seed-a1/recover_jobs.go | 57 +---- .../httpapi/admin_fix_catalog_handlers.go | 61 +++-- .../admin_fix_catalog_handlers_test.go | 67 ++++- apps/api/internal/httpapi/server.go | 3 +- .../processing/dump_category_backfill.go | 234 ++++++++++++++++++ .../internal/processing/mysql_dump_parse.go | 67 +++++ .../internal/processing/mysql_dump_path.go | 91 +++++++ .../processing/mysql_dump_path_test.go | 65 +++++ .../internal/processing/sync_company_a1.go | 93 +++++++ apps/web/src/lib/admin-orgs.ts | 48 +++- apps/web/src/lib/i18n/messages/de.ts | 20 +- apps/web/src/lib/i18n/messages/en.ts | 20 +- apps/web/src/lib/i18n/messages/es.ts | 20 +- apps/web/src/lib/i18n/messages/fr.ts | 20 +- apps/web/src/lib/i18n/messages/it.ts | 20 +- apps/web/src/lib/i18n/messages/ja.ts | 20 +- apps/web/src/lib/i18n/messages/nl.ts | 20 +- apps/web/src/lib/i18n/messages/pl.ts | 20 +- apps/web/src/lib/i18n/messages/pt.ts | 20 +- apps/web/src/lib/i18n/messages/sl.ts | 20 +- ...x-a1-keys.test.ts => sync-a1-keys.test.ts} | 40 +-- apps/web/src/routes/admin/users/+page.svelte | 81 +++--- scripts/seed/README.txt | 19 +- 26 files changed, 904 insertions(+), 507 deletions(-) create mode 100644 apps/api/internal/processing/dump_category_backfill.go create mode 100644 apps/api/internal/processing/mysql_dump_parse.go create mode 100644 apps/api/internal/processing/mysql_dump_path.go create mode 100644 apps/api/internal/processing/mysql_dump_path_test.go create mode 100644 apps/api/internal/processing/sync_company_a1.go rename apps/web/src/lib/i18n/{fix-a1-keys.test.ts => sync-a1-keys.test.ts} (52%) 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 { + opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number; skipDumpBackfill?: boolean } +): Promise { const body: { confirm: true; backfill_categories?: boolean; reprocess_sample_limit?: number; + skip_dump_backfill?: boolean; } = { confirm: true }; if (opts?.backfillCategories === false) { body.backfill_categories = false; @@ -282,12 +297,23 @@ export async function fixAdminCompanyCatalog( if (typeof opts?.reprocessSampleLimit === "number") { body.reprocess_sample_limit = opts.reprocessSampleLimit; } - return api(ADMIN_FIX_CATALOG_PATH(companyId), { + if (opts?.skipDumpBackfill) { + body.skip_dump_backfill = true; + } + return api(ADMIN_SYNC_A1_PATH(companyId), { method: "POST", body }); } +/** Compat alias — same as syncAdminCompanyA1 (fix-catalog route). */ +export async function fixAdminCompanyCatalog( + companyId: string, + opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number } +): Promise { + return syncAdminCompanyA1(companyId, opts); +} + export function isStaffRoleApiUnavailable(err: unknown): boolean { return err instanceof ApiError && (err.status === 404 || err.status === 501); } diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts index c85e6ee..1b2a451 100644 --- a/apps/web/src/lib/i18n/messages/de.ts +++ b/apps/web/src/lib/i18n/messages/de.ts @@ -812,14 +812,14 @@ export const de: MessageDict = { "admin.users.cloneCatalogCancel": "Abbrechen", "admin.users.cloneCatalogConfirm": "Katalog kopieren", "admin.users.cloneDestFallback": "Ihr Sandbox-Unternehmen", - "admin.users.fixA1": "A1-Katalog reparieren", - "admin.users.fixA1Aria": "KI-Prompts, Kategorien und Enhance-Hashes für {name} reparieren", - "admin.users.fixA1Title": "Unternehmenskatalog reparieren", - "admin.users.fixA1Desc": "Wendet korrigierte KI-Prompts erneut an, löst schwache Enhance-Hashes und füllt Kategorien aus mapped_data nach.", - "admin.users.fixA1Company": "Unternehmen: {name}", - "admin.users.fixA1Warning": "Bevorzugen Sie Platform Demo oder ein explizites Unternehmen. Löscht keine Feeds, Mappings oder Rohprodukte. Schützt A1-Kohorten-Überschreibregeln.", - "admin.users.fixA1Cancel": "Abbrechen", - "admin.users.fixA1Confirm": "Katalog reparieren", + "admin.users.syncA1": "A1 synchronisieren", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "A1-Katalog synchronisieren", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Abbrechen", + "admin.users.syncA1Confirm": "A1 synchronisieren", "admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.", "admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.", "admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen", @@ -2305,8 +2305,8 @@ export const de: MessageDict = { "flash.admin.staffRoleUnavailable": "Mitarbeiterrollen-Updates sind auf diesem Server noch nicht verfügbar.", "flash.admin.planAssigned": "Plan {name} zugewiesen.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "Katalogreparatur für {name} fehlgeschlagen.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "A1-Sync für {name} fehlgeschlagen.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssignedShort": "Plan zugewiesen.", "flash.admin.creditsUpdated": "Credits aktualisiert.", diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index 7a59aca..610602d 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -839,14 +839,14 @@ export const en: MessageDict = { "admin.users.cloneCatalogCancel": "Cancel", "admin.users.cloneCatalogConfirm": "Copy catalog", "admin.users.cloneDestFallback": "your sandbox company", - "admin.users.fixA1": "Fix A1 catalog", - "admin.users.fixA1Aria": "Repair AI prompts, categories, and enhance hashes for {name}", - "admin.users.fixA1Title": "Fix company catalog", - "admin.users.fixA1Desc": "In-place repair: category attribute links, category enhance prompts, weak enhance hashes, bidirectional category backfill (mapped↔processed when data exists), attribute sanitize. No mass reprocess. Does not invent categories for unmapped SKUs.", - "admin.users.fixA1Company": "Company: {name}", - "admin.users.fixA1Warning": "Repairs existing category data only. Products UI uses mapped_data.category — Fix cannot invent missing feed categories. For full A1 coverage run seed-a1 -mode backfill-categories with the MySQL dump, then clone A1 → sandbox.", - "admin.users.fixA1Cancel": "Cancel", - "admin.users.fixA1Confirm": "Fix catalog", + "admin.users.syncA1": "Sync A1", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "Sync A1 catalog", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Cancel", + "admin.users.syncA1Confirm": "Sync A1", "admin.users.noUsers": "No users match this filter.", "admin.users.noCompanies": "No companies match this filter.", "admin.users.assignRoleTitle": "Assign staff role", @@ -2334,8 +2334,8 @@ export const en: MessageDict = { "flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.", "flash.admin.planAssigned": "Plan assigned to {name}.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} products with mapped category. Open Products — uncategorized SKUs need dump backfill or categorize, not another Fix.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} raw products have mapped categories ({taxonomy} taxonomy rows).", - "flash.admin.fixA1Error": "Catalog repair failed for {name}.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "Sync A1 failed for {name}.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch active company to Platform Demo (or your staff home), then clone again. Clone refuses to overwrite A1.", "flash.admin.planAssignedShort": "Plan assigned.", "flash.admin.creditsUpdated": "Credits updated.", diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts index 931e17d..ef2dcc4 100644 --- a/apps/web/src/lib/i18n/messages/es.ts +++ b/apps/web/src/lib/i18n/messages/es.ts @@ -812,14 +812,14 @@ export const es: MessageDict = { "admin.users.cloneCatalogCancel": "Cancelar", "admin.users.cloneCatalogConfirm": "Copiar catálogo", "admin.users.cloneDestFallback": "tu empresa sandbox", - "admin.users.fixA1": "Reparar catálogo A1", - "admin.users.fixA1Aria": "Reparar prompts de IA, categorías y hashes de enhance para {name}", - "admin.users.fixA1Title": "Reparar catálogo de la empresa", - "admin.users.fixA1Desc": "Vuelve a aplicar prompts de IA corregidos, limpia hashes de enhance débiles y completa categorías desde mapped_data.", - "admin.users.fixA1Company": "Empresa: {name}", - "admin.users.fixA1Warning": "Prefiera Platform Demo o una empresa explícita. No elimina feeds, mapeos ni productos en bruto. Protege las reglas de sobrescritura de la cohorte A1.", - "admin.users.fixA1Cancel": "Cancelar", - "admin.users.fixA1Confirm": "Reparar catálogo", + "admin.users.syncA1": "Sincronizar A1", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "Sincronizar catálogo A1", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Cancelar", + "admin.users.syncA1Confirm": "Sincronizar A1", "admin.users.noUsers": "Ningún usuario coincide con este filtro.", "admin.users.noCompanies": "Ninguna empresa coincide con este filtro.", "admin.users.assignRoleTitle": "Asignar rol de personal", @@ -2304,8 +2304,8 @@ export const es: MessageDict = { "flash.admin.staffRoleUpdated": "Rol de personal actualizado para {email}.", "flash.admin.staffRoleUnavailable": "Las actualizaciones de rol de personal aún no están disponibles en este servidor.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "Falló la reparación del catálogo de {name}.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "Falló la sincronización A1 de {name}.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssigned": "Plan asignado a {name}.", "flash.admin.planAssignedShort": "Plan asignado.", diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts index 4dbf599..6dd12af 100644 --- a/apps/web/src/lib/i18n/messages/fr.ts +++ b/apps/web/src/lib/i18n/messages/fr.ts @@ -812,14 +812,14 @@ export const fr: MessageDict = { "admin.users.cloneCatalogCancel": "Annuler", "admin.users.cloneCatalogConfirm": "Copier le catalogue", "admin.users.cloneDestFallback": "votre entreprise sandbox", - "admin.users.fixA1": "Réparer le catalogue A1", - "admin.users.fixA1Aria": "Réparer les prompts IA, catégories et hashes enhance pour {name}", - "admin.users.fixA1Title": "Réparer le catalogue entreprise", - "admin.users.fixA1Desc": "Réapplique les prompts IA corrigés, efface les hashes enhance faibles et complète les catégories depuis mapped_data.", - "admin.users.fixA1Company": "Entreprise : {name}", - "admin.users.fixA1Warning": "Préférer Platform Demo ou une entreprise explicite. Ne supprime ni feeds, ni mappings, ni produits bruts. Protège les règles d'écrasement de la cohorte A1.", - "admin.users.fixA1Cancel": "Annuler", - "admin.users.fixA1Confirm": "Réparer le catalogue", + "admin.users.syncA1": "Synchroniser A1", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "Synchroniser le catalogue A1", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Annuler", + "admin.users.syncA1Confirm": "Synchroniser A1", "admin.users.noUsers": "Aucun utilisateur ne correspond à ce filtre.", "admin.users.noCompanies": "Aucune entreprise ne correspond à ce filtre.", "admin.users.assignRoleTitle": "Assigner un rôle du personnel", @@ -2304,8 +2304,8 @@ export const fr: MessageDict = { "flash.admin.staffRoleUpdated": "Rôle du personnel mis à jour pour {email}.", "flash.admin.staffRoleUnavailable": "Les mises à jour de rôle personnel ne sont pas encore disponibles sur ce serveur.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "Échec de la réparation du catalogue pour {name}.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "Échec de la synchronisation A1 pour {name}.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssigned": "Offre assignée à {name}.", "flash.admin.planAssignedShort": "Offre assignée.", diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts index 3a9412b..0f84342 100644 --- a/apps/web/src/lib/i18n/messages/it.ts +++ b/apps/web/src/lib/i18n/messages/it.ts @@ -812,14 +812,14 @@ export const it: MessageDict = { "admin.users.cloneCatalogCancel": "Annulla", "admin.users.cloneCatalogConfirm": "Copia catalogo", "admin.users.cloneDestFallback": "la tua azienda sandbox", - "admin.users.fixA1": "Ripara catalogo A1", - "admin.users.fixA1Aria": "Ripara prompt IA, categorie e hash enhance per {name}", - "admin.users.fixA1Title": "Ripara catalogo azienda", - "admin.users.fixA1Desc": "Riapplica i prompt IA corretti, cancella hash enhance deboli e completa le categorie da mapped_data.", - "admin.users.fixA1Company": "Azienda: {name}", - "admin.users.fixA1Warning": "Preferisci Platform Demo o un'azienda esplicita. Non elimina feed, mapping o prodotti grezzi. Protegge le regole di sovrascrittura della coorte A1.", - "admin.users.fixA1Cancel": "Annulla", - "admin.users.fixA1Confirm": "Ripara catalogo", + "admin.users.syncA1": "Sincronizza A1", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "Sincronizza catalogo A1", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Annulla", + "admin.users.syncA1Confirm": "Sincronizza A1", "admin.users.noUsers": "Nessun utente corrisponde a questo filtro.", "admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.", "admin.users.assignRoleTitle": "Assegna ruolo staff", @@ -2304,8 +2304,8 @@ export const it: MessageDict = { "flash.admin.staffRoleUpdated": "Ruolo staff aggiornato per {email}.", "flash.admin.staffRoleUnavailable": "Gli aggiornamenti del ruolo staff non sono ancora disponibili su questo server.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "Riparazione catalogo per {name} non riuscita.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "Sincronizzazione A1 per {name} non riuscita.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssigned": "Piano assegnato a {name}.", "flash.admin.planAssignedShort": "Piano assegnato.", diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts index 389e517..cbd7ada 100644 --- a/apps/web/src/lib/i18n/messages/ja.ts +++ b/apps/web/src/lib/i18n/messages/ja.ts @@ -812,14 +812,14 @@ export const ja: MessageDict = { "admin.users.cloneCatalogCancel": "キャンセル", "admin.users.cloneCatalogConfirm": "カタログをコピー", "admin.users.cloneDestFallback": "サンドボックス会社", - "admin.users.fixA1": "A1カタログを修復", - "admin.users.fixA1Aria": "{name} のAIプロンプト、カテゴリ、enhanceハッシュを修復", - "admin.users.fixA1Title": "会社カタログを修復", - "admin.users.fixA1Desc": "修正済みAIプロンプトを再適用し、弱いenhanceハッシュを消去し、mapped_dataからカテゴリを補完します。", - "admin.users.fixA1Company": "会社: {name}", - "admin.users.fixA1Warning": "Platform Demoまたは明示的な会社を優先してください。フィード、マッピング、生製品は削除しません。A1コホートの上書きルールを保護します。", - "admin.users.fixA1Cancel": "キャンセル", - "admin.users.fixA1Confirm": "カタログを修復", + "admin.users.syncA1": "A1を同期", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "A1カタログを同期", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "キャンセル", + "admin.users.syncA1Confirm": "A1を同期", "admin.users.noUsers": "このフィルタに一致するユーザーはいません。", "admin.users.noCompanies": "このフィルタに一致する会社はありません。", "admin.users.assignRoleTitle": "スタッフロールを割り当て", @@ -2304,8 +2304,8 @@ export const ja: MessageDict = { "flash.admin.staffRoleUpdated": "{email} のスタッフロールを更新しました。", "flash.admin.staffRoleUnavailable": "このサーバーではスタッフロールの更新はまだ利用できません。", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "{name} のカタログ修復に失敗しました。", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "{name} のA1同期に失敗しました。", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssigned": "{name} にプランを割り当てました。", "flash.admin.planAssignedShort": "プランを割り当てました。", diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts index 27ceb42..cbbda19 100644 --- a/apps/web/src/lib/i18n/messages/nl.ts +++ b/apps/web/src/lib/i18n/messages/nl.ts @@ -812,14 +812,14 @@ export const nl: MessageDict = { "admin.users.cloneCatalogCancel": "Annuleren", "admin.users.cloneCatalogConfirm": "Catalogus kopiëren", "admin.users.cloneDestFallback": "je sandboxbedrijf", - "admin.users.fixA1": "A1-catalogus repareren", - "admin.users.fixA1Aria": "AI-prompts, categorieën en enhance-hashes voor {name} repareren", - "admin.users.fixA1Title": "Bedrijfscatalogus repareren", - "admin.users.fixA1Desc": "Past gecorrigeerde AI-prompts opnieuw toe, wist zwakke enhance-hashes en vult categorieën bij vanuit mapped_data.", - "admin.users.fixA1Company": "Bedrijf: {name}", - "admin.users.fixA1Warning": "Geef voorkeur aan Platform Demo of een expliciet bedrijf. Verwijdert geen feeds, mappings of ruwe producten. Beschermt A1-cohort-overschrijfregels.", - "admin.users.fixA1Cancel": "Annuleren", - "admin.users.fixA1Confirm": "Catalogus repareren", + "admin.users.syncA1": "A1 synchroniseren", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "A1-catalogus synchroniseren", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Annuleren", + "admin.users.syncA1Confirm": "A1 synchroniseren", "admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.", "admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.", "admin.users.assignRoleTitle": "Medewerkerrol toewijzen", @@ -2304,8 +2304,8 @@ export const nl: MessageDict = { "flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.", "flash.admin.staffRoleUnavailable": "Personeelsrolupdates zijn op deze server nog niet beschikbaar.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "Catalogusreparatie voor {name} mislukt.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "A1-synchronisatie voor {name} mislukt.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssigned": "Plan toegewezen aan {name}.", "flash.admin.planAssignedShort": "Plan toegewezen.", diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts index ca4e7f0..e7cc282 100644 --- a/apps/web/src/lib/i18n/messages/pl.ts +++ b/apps/web/src/lib/i18n/messages/pl.ts @@ -812,14 +812,14 @@ export const pl: MessageDict = { "admin.users.cloneCatalogCancel": "Anuluj", "admin.users.cloneCatalogConfirm": "Kopiuj katalog", "admin.users.cloneDestFallback": "twoja firma sandbox", - "admin.users.fixA1": "Napraw katalog A1", - "admin.users.fixA1Aria": "Napraw prompty AI, kategorie i hashe enhance dla {name}", - "admin.users.fixA1Title": "Napraw katalog firmy", - "admin.users.fixA1Desc": "Ponownie stosuje poprawione prompty AI, czyÅ›ci sÅ‚abe hashe enhance i uzupeÅ‚nia kategorie z mapped_data.", - "admin.users.fixA1Company": "Firma: {name}", - "admin.users.fixA1Warning": "Preferuj Platform Demo lub wskazanÄ… firmÄ™. Nie usuwa feedów, mapowaÅ„ ani surowych produktów. Chroni reguÅ‚y nadpisywania kohorty A1.", - "admin.users.fixA1Cancel": "Anuluj", - "admin.users.fixA1Confirm": "Napraw katalog", + "admin.users.syncA1": "Synchronizuj A1", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "Synchronizuj katalog A1", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Anuluj", + "admin.users.syncA1Confirm": "Synchronizuj A1", "admin.users.noUsers": "Å»aden użytkownik nie pasuje do tego filtra.", "admin.users.noCompanies": "Å»adna firma nie pasuje do tego filtra.", "admin.users.assignRoleTitle": "Przypisz rolÄ™ personelu", @@ -2304,8 +2304,8 @@ export const pl: MessageDict = { "flash.admin.staffRoleUpdated": "Zaktualizowano rolÄ™ personelu dla {email}.", "flash.admin.staffRoleUnavailable": "Aktualizacje ról personelu nie sÄ… jeszcze dostÄ™pne na tym serwerze.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "Naprawa katalogu dla {name} nie powiodÅ‚a siÄ™.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "Synchronizacja A1 dla {name} nie powiodła się.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssigned": "Przypisano plan do {name}.", "flash.admin.planAssignedShort": "Przypisano plan.", diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts index 534ec1e..0249a4f 100644 --- a/apps/web/src/lib/i18n/messages/pt.ts +++ b/apps/web/src/lib/i18n/messages/pt.ts @@ -812,14 +812,14 @@ export const pt: MessageDict = { "admin.users.cloneCatalogCancel": "Cancelar", "admin.users.cloneCatalogConfirm": "Copiar catálogo", "admin.users.cloneDestFallback": "a sua empresa sandbox", - "admin.users.fixA1": "Reparar catálogo A1", - "admin.users.fixA1Aria": "Reparar prompts de IA, categorias e hashes de enhance para {name}", - "admin.users.fixA1Title": "Reparar catálogo da empresa", - "admin.users.fixA1Desc": "Reaplica prompts de IA corrigidos, limpa hashes de enhance fracos e preenche categorias a partir de mapped_data.", - "admin.users.fixA1Company": "Empresa: {name}", - "admin.users.fixA1Warning": "Prefira Platform Demo ou uma empresa explícita. Não elimina feeds, mapeamentos ou produtos brutos. Protege as regras de substituição da coorte A1.", - "admin.users.fixA1Cancel": "Cancelar", - "admin.users.fixA1Confirm": "Reparar catálogo", + "admin.users.syncA1": "Sincronizar A1", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "Sincronizar catálogo A1", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Cancelar", + "admin.users.syncA1Confirm": "Sincronizar A1", "admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.", "admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.", "admin.users.assignRoleTitle": "Atribuir função de equipa", @@ -2304,8 +2304,8 @@ export const pt: MessageDict = { "flash.admin.staffRoleUpdated": "Função de pessoal atualizada para {email}.", "flash.admin.staffRoleUnavailable": "As atualizações de função de pessoal ainda não estão disponíveis neste servidor.", "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", - "flash.admin.fixA1Success": "Catalog repair finished for {name}: {prompts} prompts, {hashes} hashes, {categories} processed←mapped, {mapped_backfilled} mapped←processed. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", - "flash.admin.fixA1Error": "Falha na reparação do catálogo de {name}.", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "Falha na sincronização A1 de {name}.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.planAssigned": "Plano atribuído a {name}.", "flash.admin.planAssignedShort": "Plano atribuído.", diff --git a/apps/web/src/lib/i18n/messages/sl.ts b/apps/web/src/lib/i18n/messages/sl.ts index 482c759..28c6cab 100644 --- a/apps/web/src/lib/i18n/messages/sl.ts +++ b/apps/web/src/lib/i18n/messages/sl.ts @@ -2,15 +2,15 @@ /** Slovenian (sl) UI strings for admin Fix A1 — ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */ export const sl: MessageDict = { - "admin.users.fixA1": "Popravi katalog A1", - "admin.users.fixA1Aria": "Popravi AI pozive, kategorije in zgoščevalne vrednosti za {name}", - "admin.users.fixA1Title": "Popravi katalog podjetja", - "admin.users.fixA1Desc": "Ponovno uporabi popravljene AI pozive, počisti Å¡ibke enhance zgoščevalne vrednosti in dopolni kategorije iz mapped podatkov.", - "admin.users.fixA1Company": "Podjetje: {name}", - "admin.users.fixA1Warning": "Raje Platform Demo ali izrecno podjetje. Ne briÅ¡e feedov, mapiranj ali surovih izdelkov. Ščiti pravila prepisovanja A1 kohorte.", - "admin.users.fixA1Cancel": "Prekliči", - "admin.users.fixA1Confirm": "Popravi katalog", - "flash.admin.fixA1Success": "Popravilo kataloga za {name}: pozivi {prompts}, zgoščene {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Pokritost: {mapped_with}/{mapped_total} ({taxonomy} taksonomija).", - "flash.admin.fixA1Error": "Popravilo kataloga za {name} ni uspelo.", + "admin.users.syncA1": "Sinhroniziraj A1", + "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}", + "admin.users.syncA1Title": "Sinhroniziraj katalog A1", + "admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.", + "admin.users.syncA1Company": "Company: {name}", + "admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.", + "admin.users.syncA1Cancel": "Prekliči", + "admin.users.syncA1Confirm": "Sinhroniziraj A1", + "flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).", + "flash.admin.syncA1Error": "Sinhronizacija A1 za {name} ni uspela.", "flash.admin.cloneDestMissing": "Ni ciljnega sandbox podjetja. Preklopite na Platform Demo, nato klonirajte.", }; diff --git a/apps/web/src/lib/i18n/fix-a1-keys.test.ts b/apps/web/src/lib/i18n/sync-a1-keys.test.ts similarity index 52% rename from apps/web/src/lib/i18n/fix-a1-keys.test.ts rename to apps/web/src/lib/i18n/sync-a1-keys.test.ts index 102bcc9..dd113f4 100644 --- a/apps/web/src/lib/i18n/fix-a1-keys.test.ts +++ b/apps/web/src/lib/i18n/sync-a1-keys.test.ts @@ -1,5 +1,5 @@ -/** - * Focused check: admin Fix A1 i18n keys exist in every UI_LOCALES pack. +/** + * Focused check: admin Sync A1 i18n keys exist in every UI_LOCALES pack. */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; @@ -9,32 +9,32 @@ import { en } from "./messages/en.ts"; import { loadAllMessages, messagesFor } from "./messages/catalog.ts"; import { sl } from "./messages/sl.ts"; -const FIX_A1_KEYS = [ - "admin.users.fixA1", - "admin.users.fixA1Aria", - "admin.users.fixA1Title", - "admin.users.fixA1Desc", - "admin.users.fixA1Company", - "admin.users.fixA1Warning", - "admin.users.fixA1Cancel", - "admin.users.fixA1Confirm", - "flash.admin.fixA1Success", - "flash.admin.fixA1Error" +const SYNC_A1_KEYS = [ + "admin.users.syncA1", + "admin.users.syncA1Aria", + "admin.users.syncA1Title", + "admin.users.syncA1Desc", + "admin.users.syncA1Company", + "admin.users.syncA1Warning", + "admin.users.syncA1Cancel", + "admin.users.syncA1Confirm", + "flash.admin.syncA1Success", + "flash.admin.syncA1Error" ] as const; -describe("admin Fix A1 i18n keys", () => { - it("English defines every Fix A1 key", () => { - for (const key of FIX_A1_KEYS) { +describe("admin Sync A1 i18n keys", () => { + it("English defines every Sync A1 key", () => { + for (const key of SYNC_A1_KEYS) { assert.equal(typeof en[key], "string", key); assert.ok(en[key].trim().length > 0, key); } }); - it("every registered UI locale has Fix A1 keys", async () => { + it("every registered UI locale has Sync A1 keys", async () => { await loadAllMessages(); for (const { code } of UI_LOCALES) { const pack = code === "en" ? en : messagesFor(code); - for (const key of FIX_A1_KEYS) { + for (const key of SYNC_A1_KEYS) { const value = pack[key]; assert.equal(typeof value, "string", `${code}:${key}`); assert.ok(String(value).trim().length > 0, `${code}:${key}`); @@ -42,8 +42,8 @@ describe("admin Fix A1 i18n keys", () => { } }); - it("Slovenian ready pack has Fix A1 keys (not in UI_LOCALES)", () => { - for (const key of FIX_A1_KEYS) { + it("Slovenian ready pack has Sync A1 keys (outside UI_LOCALES)", () => { + for (const key of SYNC_A1_KEYS) { assert.equal(typeof sl[key], "string", key); assert.ok(sl[key].trim().length > 0, key); } diff --git a/apps/web/src/routes/admin/users/+page.svelte b/apps/web/src/routes/admin/users/+page.svelte index 87f47dd..d2ae85f 100644 --- a/apps/web/src/routes/admin/users/+page.svelte +++ b/apps/web/src/routes/admin/users/+page.svelte @@ -11,7 +11,7 @@ STAFF_ROLE_OPTIONS, assignAdminPlan, cloneAdminCompanyCatalog, - fixAdminCompanyCatalog, + syncAdminCompanyA1, companyPlanBadge, isStaffRoleApiUnavailable, listAdminCompanies, @@ -55,7 +55,7 @@ TabsList, TabsTrigger } from "$lib/components/ui"; - import { Building2, Copy, KeyRound, Search, Shield, UserPlus, Users, Wrench } from "@lucide/svelte"; + import { Building2, Copy, KeyRound, RefreshCw, Search, Shield, UserPlus, Users } from "@lucide/svelte"; type TabKey = "users" | "companies"; @@ -94,8 +94,8 @@ let cloneOpen = $state(false); let cloneCompany = $state(null); - let fixOpen = $state(false); - let fixCompany = $state(null); + let syncOpen = $state(false); + let syncCompany = $state(null); const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1); const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE))); @@ -366,28 +366,29 @@ success = ""; } - function openFixDialog(company: AdminOrgCompany) { - fixCompany = company; - fixOpen = true; + function openSyncDialog(company: AdminOrgCompany) { + syncCompany = company; + syncOpen = true; error = ""; success = ""; } - async function confirmFixCatalog() { - if (!fixCompany) return; + async function confirmSyncA1() { + if (!syncCompany) return; busy = true; error = ""; success = ""; - const targetName = fixCompany.name; + const targetName = syncCompany.name; try { - const res = await fixAdminCompanyCatalog(fixCompany.id, { + const res = await syncAdminCompanyA1(syncCompany.id, { reprocessSampleLimit: 25 }); const r = res.result; const mappedWith = Number(r.mapped_with_category ?? 0); const mappedWithout = Number(r.mapped_without_category ?? 0); const mappedTotal = mappedWith + mappedWithout; - success = i18n.t("flash.admin.fixA1Success", { + const dumpStatus = String(r.dump_status ?? (r.dump_found ? "ok" : "missing")); + success = i18n.t("flash.admin.syncA1Success", { name: targetName, prompts: String(r.prompts ?? r.category_prompts_updated ?? 0), hashes: String(r.hashes ?? r.weak_hashes_cleared ?? 0), @@ -395,12 +396,14 @@ mapped_backfilled: String(r.mapped_backfilled ?? r.mapped_categories_backfilled ?? 0), mapped_with: String(mappedWith), mapped_total: String(mappedTotal), - taxonomy: String(r.taxonomy_categories ?? 0) + taxonomy: String(r.taxonomy_categories ?? 0), + dump_mapped: String(r.dump_mapped_updated ?? 0), + dump_status: dumpStatus }); - fixOpen = false; - fixCompany = null; + syncOpen = false; + syncCompany = null; } catch (err) { - error = failureMessage(err, i18n.t("flash.admin.fixA1Error", { name: targetName })); + error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName })); } finally { busy = false; } @@ -827,18 +830,18 @@
- + -
diff --git a/scripts/seed/README.txt b/scripts/seed/README.txt index f30e541..1cb1ed7 100644 --- a/scripts/seed/README.txt +++ b/scripts/seed/README.txt @@ -31,15 +31,22 @@ Category assignment (dump -> PG): mapped_data.category from a MySQL dump when available. Dump path (first match wins): - 1. -mysql-dump flag - 2. SEED_A1_MYSQL_DUMP - 3. Auto-detect: ~/Downloads/descrybe_new (1).sql or descrybe_new.sql + 1. -mysql-dump flag / admin body mysql_dump + 2. SEED_A1_MYSQL_DUMP (API process env on Git-Syncer) + 3. Auto-detect: scripts/seed/descrybe_new.sql (deploy root), + ~/Downloads/descrybe_new (1).sql or descrybe_new.sql - One-off without wipe: + Git-Syncer / production: copy the dump onto the API host, e.g. + /scripts/seed/descrybe_new.sql + or set SEED_A1_MYSQL_DUMP=/absolute/path/descrybe_new.sql in the API env. + Then use Admin → Companies → Sync A1 (no CLI DATABASE_URL needed). + + One-off without wipe (CLI; loads monorepo-root .env when unset): go run ./cmd/seed-a1 -mode backfill-categories - That also deletes Postman Elkotex fixture EANs (5905575903198, 6970995789942) - from non-A1 tenants (Platform Demo must not mirror them). + Admin Sync A1 = dump category backfill (when dump present) + Fix hygiene + (prompts, weak hashes, bidirectional category backfill, attr sanitize). + It does NOT wipe/reimport the ~25k catalog. Note: MySQL processed description/attributes are usually NULL; feed-origin original description + specs live on raw_products.mapped_data in the archive.