This commit is contained in:
2026-08-16 18:36:56 +02:00
parent d981147ff2
commit a94a5aaad7
26 changed files with 904 additions and 507 deletions
+19 -196
View File
@@ -1,26 +1,15 @@
package main package main
import ( import (
"bufio"
"context" "context"
"fmt"
"io" "io"
"log" "log"
"os"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "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 { type categoryBackfillResult struct {
ProcessedUpdated int64 ProcessedUpdated int64
ProcessedInserted int64 ProcessedInserted int64
@@ -34,204 +23,38 @@ type categoryBackfillResult struct {
MappedWithoutCat int MappedWithoutCat int
} }
// backfillMappedCategoriesFromProcessed copies processed_products.category into // a1FixtureEANs aliases processing.A1FixtureEANs for seed-a1 tests.
// raw_products.mapped_data.category for A1 only. Delegates to processing. var a1FixtureEANs = processing.A1FixtureEANs
func backfillMappedCategoriesFromProcessed(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) (int64, error) { func backfillMappedCategoriesFromProcessed(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID) (int64, error) {
return processing.BackfillMappedCategoriesFromProcessed(ctx, pg, companyID) 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) { func backfillCategoriesFromMySQLDump(ctx context.Context, pg *pgxpool.Pool, companyID uuid.UUID, dumpPath string) (categoryBackfillResult, error) {
var out categoryBackfillResult res, err := processing.BackfillCategoriesFromMySQLDump(ctx, pg, companyID, dumpPath)
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 { if err != nil {
return out, fmt.Errorf("open mysql dump: %w", err) return categoryBackfillResult{}, err
} }
defer f.Close() return categoryBackfillResult{
ProcessedUpdated: res.ProcessedUpdated,
byGTIN, err := scanA1ProcessedCategories(f, legacyCompany) ProcessedInserted: res.ProcessedInserted,
if err != nil { MappedUpdated: res.MappedUpdated,
return out, err DumpPairs: res.DumpPairs,
} PurgedOtherRaw: res.PurgedOtherRaw,
out.DumpPairs = len(byGTIN) PurgedOtherPP: res.PurgedOtherPP,
if len(byGTIN) == 0 { ProcessedWithCat: res.ProcessedWithCat,
return out, fmt.Errorf("no A1 processed_products categories for legacy %s in dump", legacyCompany) ProcessedWithoutCat: res.ProcessedWithoutCat,
} MappedWithCat: res.MappedWithCat,
log.Printf("dump: %d A1 gtin→category pairs", len(byGTIN)) MappedWithoutCat: res.MappedWithoutCat,
}, nil
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
} }
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) { func purgeA1FixtureEANsFromOtherTenants(ctx context.Context, pg *pgxpool.Pool, a1CompanyID uuid.UUID) (rawN, ppN int64, err error) {
ct, err := pg.Exec(ctx, ` return processing.PurgeA1FixtureEANsFromOtherTenants(ctx, pg, a1CompanyID)
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
} }
func scanA1ProcessedCategories(r io.Reader, legacyCompany string) (map[string]string, error) { func scanA1ProcessedCategories(r io.Reader, legacyCompany string) (map[string]string, error) {
br := bufio.NewReaderSize(r, 1<<20) return processing.ScanA1ProcessedCategories(r, legacyCompany)
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
} }
func logCategoryBackfillResult(res categoryBackfillResult, source string) { func logCategoryBackfillResult(res categoryBackfillResult, source string) {
+10 -52
View File
@@ -4,70 +4,28 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
"path/filepath"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// resolveMySQLDumpPath picks an explicit path, else the first readable candidate // resolveMySQLDumpPath delegates to processing (shared with admin Sync A1).
// under common local locations documented in scripts/seed/README.txt.
func resolveMySQLDumpPath(explicit string) string { func resolveMySQLDumpPath(explicit string) string {
if p := strings.TrimSpace(explicit); p != "" { return processing.ResolveMySQLDumpPath(explicit)
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 ""
} }
func mysqlDumpCandidates() []string { func mysqlDumpCandidates() []string {
var out []string return processing.MySQLDumpCandidates()
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
} }
type mappedCoverage struct { type mappedCoverage struct {
Total int Total int
WithDesc int WithDesc int
WithCat int WithCat int
WithAttrs int WithAttrs int
Processed int Processed int
Jobs int Jobs int
} }
func (c mappedCoverage) pct(n int) float64 { func (c mappedCoverage) pct(n int) float64 {
+6 -2
View File
@@ -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-categories -mysql-dump path/to/descrybe_new.sql
// go run ./cmd/seed-a1 -mode backfill-attributes // 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`. // 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. // 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 package main
import ( import (
@@ -61,6 +62,7 @@ import (
"compress/gzip" "compress/gzip"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@@ -82,6 +84,8 @@ type tableSpec struct {
} }
func main() { func main() {
config.LoadDotEnv()
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL (DATABASE_URL)") 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") 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)") file := flag.String("file", "", "gzipped seed archive path (required for export/reimport)")
@@ -93,7 +97,7 @@ func main() {
flag.Parse() flag.Parse()
if strings.TrimSpace(*postgresURL) == "" { 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)) companyID, err := uuid.Parse(strings.TrimSpace(*company))
if err != nil { if err != nil {
+4 -53
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -419,65 +420,15 @@ func dumpSectionEnded(line, table string) bool {
} }
func looksLikeTupleLine(line string) bool { func looksLikeTupleLine(line string) bool {
s := strings.TrimLeft(line, " \t") return processing.LooksLikeMySQLTupleLine(line)
return strings.HasPrefix(s, "(")
} }
func parseMySQLTupleFields(line string) []string { 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 { func parseMySQLTupleFieldsN(line string, maxFields int) []string {
start := strings.Index(line, "(") return processing.ParseMySQLTupleFieldsN(line, maxFields)
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
} }
func parseDumpJobID(raw string) (uuid.UUID, error) { func parseDumpJobID(raw string) (uuid.UUID, error) {
@@ -12,21 +12,20 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// handleAdminFixCompanyCatalog runs in-place Fix A1 / catalog hygiene for one company. // handleAdminSyncCompanyA1 runs dump category backfill (when dump is on the API
// Single route: POST /api/admin/companies/{id}/fix-catalog (no separate fix-a1). // host filesystem) plus FixCompanyCatalog hygiene. Does not wipe/reimport.
// Delegates to processing.FixCompanyCatalog, which: //
// 1. Ensures category_attributes links (orphan purge only) // Routes (same handler):
// 2. RepairCompanyCategoryEnhancePrompts (same map as RepairA1DemoCategoryEnhancePrompts) //
// 3. Re-applies product_enhance BuiltInDefaults when AIPrompts is set // POST /api/admin/companies/{id}/sync-a1
// 47. FixCatalogHygieneWithIDs + attribute sanitize + reprocess sample // POST /api/admin/companies/{id}/fix-catalog (compat alias)
// //
// Body: confirm=true required; backfill_categories (default true); // Body: confirm=true required; backfill_categories (default true);
// reprocess_sample_limit (default 25, max 200, 0 = counts only). // reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
// May target A1 in place with confirm — prefer Platform Demo when unsure. // skip_dump_backfill (default false).
// Refuses system company. Does not use A1 as a clone destination.
// //
// Flash (UI): result.prompts / hashes / categories → flash.admin.fixA1Success. // Flash (UI): result → flash.admin.syncA1Success.
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
if s.Pool == nil { if s.Pool == nil {
Error(w, http.StatusServiceUnavailable, "database unavailable") Error(w, http.StatusServiceUnavailable, "database unavailable")
return return
@@ -39,9 +38,11 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
} }
var body struct { var body struct {
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
BackfillCategories *bool `json:"backfill_categories"` BackfillCategories *bool `json:"backfill_categories"`
ReprocessSampleLimit *int `json:"reprocess_sample_limit"` ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
MySQLDump string `json:"mysql_dump"`
SkipDumpBackfill bool `json:"skip_dump_backfill"`
} }
if err := DecodeJSON(r, &body); err != nil { if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json") Error(w, http.StatusBadRequest, "invalid json")
@@ -53,7 +54,7 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
} }
if platformsettings.IsSystemCompany(companyID) { 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 return
} }
@@ -80,26 +81,40 @@ func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Req
sampleLimit = 200 sampleLimit = 200
} }
result, err := processing.FixCompanyCatalog( result, err := processing.SyncCompanyA1(
r.Context(), r.Context(),
s.Pool, s.Pool,
companyID, companyID,
name, name,
billing.IsA1CohortCompany(legacy, name), billing.IsA1CohortCompany(legacy, name),
processing.FixCompanyCatalogOpts{ processing.SyncCompanyA1Opts{
BackfillCategories: backfill, FixCompanyCatalogOpts: processing.FixCompanyCatalogOpts{
ReprocessSampleLimit: sampleLimit, BackfillCategories: backfill,
AIPrompts: s.AIPrompts, ReprocessSampleLimit: sampleLimit,
AIPrompts: s.AIPrompts,
},
MySQLDumpPath: strings.TrimSpace(body.MySQLDump),
SkipDumpBackfill: body.SkipDumpBackfill,
}, },
) )
if err != nil { 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 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{ JSON(w, http.StatusOK, map[string]any{
"status": "ok", "status": "ok",
"result": result, "result": result,
"note": "Catalog was repaired in place; reprocess recommended products manually (no mass reprocess).", "note": note,
}) })
} }
// handleAdminFixCompanyCatalog is a compat alias of Sync A1 (same behavior).
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
s.handleAdminSyncCompanyA1(w, r)
}
@@ -13,6 +13,17 @@ import (
"github.com/google/uuid" "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) { func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
t.Parallel() t.Parallel()
s := &Server{} s := &Server{}
@@ -26,8 +37,6 @@ func TestHandleAdminFixCompanyCatalogNilPool(t *testing.T) {
func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) { func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) {
t.Parallel() 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} s := &Server{Pool: nil}
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/not-a-uuid/fix-catalog", strings.NewReader(`{"confirm":false}`)) req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/not-a-uuid/fix-catalog", strings.NewReader(`{"confirm":false}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -37,8 +46,58 @@ func TestHandleAdminFixCompanyCatalogRequiresConfirm(t *testing.T) {
} }
} }
// TestRouterAdminFixCatalogMounted locks POST /api/admin/companies/{id}/fix-catalog func TestRouterAdminSyncA1Mounted(t *testing.T) {
// after session + CSRF + platform-admin (503 with nil Pool), not chi 404. 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) { func TestRouterAdminFixCatalogMounted(t *testing.T) {
t.Parallel() t.Parallel()
sm := scs.New() sm := scs.New()
+2 -1
View File
@@ -391,7 +391,8 @@ func (s *Server) Router() http.Handler {
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword) r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
r.Get("/companies", s.handleAdminListCompanies) r.Get("/companies", s.handleAdminListCompanies)
r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog) 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("/readiness", s.handleAdminReadiness)
r.Get("/diagnostics", s.handleAdminDiagnostics) r.Get("/diagnostics", s.handleAdminDiagnostics)
r.Get("/analytics", s.handleAdminAnalytics) r.Get("/analytics", s.handleAdminAnalytics)
@@ -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
}
@@ -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
}
@@ -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
}
}
@@ -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'<p>ai</p>',\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"])
}
}
@@ -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
}
+37 -11
View File
@@ -1,7 +1,7 @@
/** /**
* Admin orgs UI client users + companies directory, staff roles, plan assign, * Admin orgs UI client users + companies directory, staff roles, plan assign,
* clone-catalog, and Fix A1 (`POST …/fix-catalog`). * clone-catalog, and Sync A1 (`POST …/sync-a1`; fix-catalog is a compat alias).
* Flash Fix A1: result.prompts / hashes / categories flash.admin.fixA1Success. * 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 * Contract: docs/admin-roles-support/04-contract.md · Docs: 10-admin-orgs-ui.md
*/ */
import { api, ApiError } from "$lib/api"; 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`; `${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/clone-catalog`;
export const ADMIN_FIX_CATALOG_PATH = (companyId: string) => export const ADMIN_FIX_CATALOG_PATH = (companyId: string) =>
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/fix-catalog`; `${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) => export const ADMIN_STAFF_ROLE_PATH = (userId: string) =>
`/api/admin/users/${encodeURIComponent(userId)}/staff-role`; `/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_id: string;
company_name: string; company_name: string;
a1_cohort?: boolean; a1_cohort?: boolean;
category_attribute_orphans_removed?: number; category_attribute_orphans_removed?: number;
category_attribute_links?: 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; prompts?: number;
category_prompts_updated?: number; category_prompts_updated?: number;
product_enhance_languages?: number; product_enhance_languages?: number;
@@ -258,23 +260,36 @@ export type FixCatalogResult = {
products_scanned?: number; products_scanned?: number;
reprocess_needed_count?: number; reprocess_needed_count?: number;
reprocess_sample_raw_product_ids?: string[]; 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; status: string;
result: FixCatalogResult; result: SyncA1Result;
note?: string; note?: string;
}; };
/** In-place Fix A1 / catalog hygiene. Never clears catalog; no mass reprocess. */ /** @deprecated Use SyncA1Result — kept for type aliases during UI rename. */
export async function fixAdminCompanyCatalog( 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, companyId: string,
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number } opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number; skipDumpBackfill?: boolean }
): Promise<FixCatalogResponse> { ): Promise<SyncA1Response> {
const body: { const body: {
confirm: true; confirm: true;
backfill_categories?: boolean; backfill_categories?: boolean;
reprocess_sample_limit?: number; reprocess_sample_limit?: number;
skip_dump_backfill?: boolean;
} = { confirm: true }; } = { confirm: true };
if (opts?.backfillCategories === false) { if (opts?.backfillCategories === false) {
body.backfill_categories = false; body.backfill_categories = false;
@@ -282,12 +297,23 @@ export async function fixAdminCompanyCatalog(
if (typeof opts?.reprocessSampleLimit === "number") { if (typeof opts?.reprocessSampleLimit === "number") {
body.reprocess_sample_limit = opts.reprocessSampleLimit; body.reprocess_sample_limit = opts.reprocessSampleLimit;
} }
return api<FixCatalogResponse>(ADMIN_FIX_CATALOG_PATH(companyId), { if (opts?.skipDumpBackfill) {
body.skip_dump_backfill = true;
}
return api<SyncA1Response>(ADMIN_SYNC_A1_PATH(companyId), {
method: "POST", method: "POST",
body body
}); });
} }
/** Compat alias — same as syncAdminCompanyA1 (fix-catalog route). */
export async function fixAdminCompanyCatalog(
companyId: string,
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number }
): Promise<SyncA1Response> {
return syncAdminCompanyA1(companyId, opts);
}
export function isStaffRoleApiUnavailable(err: unknown): boolean { export function isStaffRoleApiUnavailable(err: unknown): boolean {
return err instanceof ApiError && (err.status === 404 || err.status === 501); return err instanceof ApiError && (err.status === 404 || err.status === 501);
} }
+10 -10
View File
@@ -812,14 +812,14 @@ export const de: MessageDict = {
"admin.users.cloneCatalogCancel": "Abbrechen", "admin.users.cloneCatalogCancel": "Abbrechen",
"admin.users.cloneCatalogConfirm": "Katalog kopieren", "admin.users.cloneCatalogConfirm": "Katalog kopieren",
"admin.users.cloneDestFallback": "Ihr Sandbox-Unternehmen", "admin.users.cloneDestFallback": "Ihr Sandbox-Unternehmen",
"admin.users.fixA1": "A1-Katalog reparieren", "admin.users.syncA1": "A1 synchronisieren",
"admin.users.fixA1Aria": "KI-Prompts, Kategorien und Enhance-Hashes für {name} reparieren", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Unternehmenskatalog reparieren", "admin.users.syncA1Title": "A1-Katalog synchronisieren",
"admin.users.fixA1Desc": "Wendet korrigierte KI-Prompts erneut an, löst schwache Enhance-Hashes und füllt Kategorien aus mapped_data nach.", "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.fixA1Company": "Unternehmen: {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Abbrechen", "admin.users.syncA1Cancel": "Abbrechen",
"admin.users.fixA1Confirm": "Katalog reparieren", "admin.users.syncA1Confirm": "A1 synchronisieren",
"admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.", "admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.",
"admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.", "admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.",
"admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen", "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.staffRoleUnavailable": "Mitarbeiterrollen-Updates sind auf diesem Server noch nicht verfügbar.",
"flash.admin.planAssigned": "Plan {name} zugewiesen.", "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.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.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.fixA1Error": "Katalogreparatur für {name} fehlgeschlagen.", "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.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssignedShort": "Plan zugewiesen.", "flash.admin.planAssignedShort": "Plan zugewiesen.",
"flash.admin.creditsUpdated": "Credits aktualisiert.", "flash.admin.creditsUpdated": "Credits aktualisiert.",
+10 -10
View File
@@ -839,14 +839,14 @@ export const en: MessageDict = {
"admin.users.cloneCatalogCancel": "Cancel", "admin.users.cloneCatalogCancel": "Cancel",
"admin.users.cloneCatalogConfirm": "Copy catalog", "admin.users.cloneCatalogConfirm": "Copy catalog",
"admin.users.cloneDestFallback": "your sandbox company", "admin.users.cloneDestFallback": "your sandbox company",
"admin.users.fixA1": "Fix A1 catalog", "admin.users.syncA1": "Sync A1",
"admin.users.fixA1Aria": "Repair AI prompts, categories, and enhance hashes for {name}", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Fix company catalog", "admin.users.syncA1Title": "Sync A1 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.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.fixA1Company": "Company: {name}", "admin.users.syncA1Company": "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.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.fixA1Cancel": "Cancel", "admin.users.syncA1Cancel": "Cancel",
"admin.users.fixA1Confirm": "Fix catalog", "admin.users.syncA1Confirm": "Sync A1",
"admin.users.noUsers": "No users match this filter.", "admin.users.noUsers": "No users match this filter.",
"admin.users.noCompanies": "No companies match this filter.", "admin.users.noCompanies": "No companies match this filter.",
"admin.users.assignRoleTitle": "Assign staff role", "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.staffRoleUnavailable": "Staff role updates are not available on this server yet.",
"flash.admin.planAssigned": "Plan assigned to {name}.", "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.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.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.fixA1Error": "Catalog repair failed for {name}.", "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.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.planAssignedShort": "Plan assigned.",
"flash.admin.creditsUpdated": "Credits updated.", "flash.admin.creditsUpdated": "Credits updated.",
+10 -10
View File
@@ -812,14 +812,14 @@ export const es: MessageDict = {
"admin.users.cloneCatalogCancel": "Cancelar", "admin.users.cloneCatalogCancel": "Cancelar",
"admin.users.cloneCatalogConfirm": "Copiar catálogo", "admin.users.cloneCatalogConfirm": "Copiar catálogo",
"admin.users.cloneDestFallback": "tu empresa sandbox", "admin.users.cloneDestFallback": "tu empresa sandbox",
"admin.users.fixA1": "Reparar catálogo A1", "admin.users.syncA1": "Sincronizar A1",
"admin.users.fixA1Aria": "Reparar prompts de IA, categorías y hashes de enhance para {name}", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Reparar catálogo de la empresa", "admin.users.syncA1Title": "Sincronizar catálogo A1",
"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.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.fixA1Company": "Empresa: {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Cancelar", "admin.users.syncA1Cancel": "Cancelar",
"admin.users.fixA1Confirm": "Reparar catálogo", "admin.users.syncA1Confirm": "Sincronizar A1",
"admin.users.noUsers": "Ningún usuario coincide con este filtro.", "admin.users.noUsers": "Ningún usuario coincide con este filtro.",
"admin.users.noCompanies": "Ninguna empresa coincide con este filtro.", "admin.users.noCompanies": "Ninguna empresa coincide con este filtro.",
"admin.users.assignRoleTitle": "Asignar rol de personal", "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.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.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.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.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.fixA1Error": "Falló la reparación del catálogo de {name}.", "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.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssigned": "Plan asignado a {name}.", "flash.admin.planAssigned": "Plan asignado a {name}.",
"flash.admin.planAssignedShort": "Plan asignado.", "flash.admin.planAssignedShort": "Plan asignado.",
+10 -10
View File
@@ -812,14 +812,14 @@ export const fr: MessageDict = {
"admin.users.cloneCatalogCancel": "Annuler", "admin.users.cloneCatalogCancel": "Annuler",
"admin.users.cloneCatalogConfirm": "Copier le catalogue", "admin.users.cloneCatalogConfirm": "Copier le catalogue",
"admin.users.cloneDestFallback": "votre entreprise sandbox", "admin.users.cloneDestFallback": "votre entreprise sandbox",
"admin.users.fixA1": "Réparer le catalogue A1", "admin.users.syncA1": "Synchroniser A1",
"admin.users.fixA1Aria": "Réparer les prompts IA, catégories et hashes enhance pour {name}", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Réparer le catalogue entreprise", "admin.users.syncA1Title": "Synchroniser le catalogue A1",
"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.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.fixA1Company": "Entreprise : {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Annuler", "admin.users.syncA1Cancel": "Annuler",
"admin.users.fixA1Confirm": "Réparer le catalogue", "admin.users.syncA1Confirm": "Synchroniser A1",
"admin.users.noUsers": "Aucun utilisateur ne correspond à ce filtre.", "admin.users.noUsers": "Aucun utilisateur ne correspond à ce filtre.",
"admin.users.noCompanies": "Aucune entreprise ne correspond à ce filtre.", "admin.users.noCompanies": "Aucune entreprise ne correspond à ce filtre.",
"admin.users.assignRoleTitle": "Assigner un rôle du personnel", "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.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.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.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.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.fixA1Error": "Échec de la réparation du catalogue pour {name}.", "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.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssigned": "Offre assignée à {name}.", "flash.admin.planAssigned": "Offre assignée à {name}.",
"flash.admin.planAssignedShort": "Offre assignée.", "flash.admin.planAssignedShort": "Offre assignée.",
+10 -10
View File
@@ -812,14 +812,14 @@ export const it: MessageDict = {
"admin.users.cloneCatalogCancel": "Annulla", "admin.users.cloneCatalogCancel": "Annulla",
"admin.users.cloneCatalogConfirm": "Copia catalogo", "admin.users.cloneCatalogConfirm": "Copia catalogo",
"admin.users.cloneDestFallback": "la tua azienda sandbox", "admin.users.cloneDestFallback": "la tua azienda sandbox",
"admin.users.fixA1": "Ripara catalogo A1", "admin.users.syncA1": "Sincronizza A1",
"admin.users.fixA1Aria": "Ripara prompt IA, categorie e hash enhance per {name}", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Ripara catalogo azienda", "admin.users.syncA1Title": "Sincronizza catalogo A1",
"admin.users.fixA1Desc": "Riapplica i prompt IA corretti, cancella hash enhance deboli e completa le categorie da mapped_data.", "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.fixA1Company": "Azienda: {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Annulla", "admin.users.syncA1Cancel": "Annulla",
"admin.users.fixA1Confirm": "Ripara catalogo", "admin.users.syncA1Confirm": "Sincronizza A1",
"admin.users.noUsers": "Nessun utente corrisponde a questo filtro.", "admin.users.noUsers": "Nessun utente corrisponde a questo filtro.",
"admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.", "admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.",
"admin.users.assignRoleTitle": "Assegna ruolo staff", "admin.users.assignRoleTitle": "Assegna ruolo staff",
@@ -2304,8 +2304,8 @@ export const it: MessageDict = {
"flash.admin.staffRoleUpdated": "Ruolo staff aggiornato per {email}.", "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.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.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.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.fixA1Error": "Riparazione catalogo per {name} non riuscita.", "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.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssigned": "Piano assegnato a {name}.", "flash.admin.planAssigned": "Piano assegnato a {name}.",
"flash.admin.planAssignedShort": "Piano assegnato.", "flash.admin.planAssignedShort": "Piano assegnato.",
+10 -10
View File
@@ -812,14 +812,14 @@ export const ja: MessageDict = {
"admin.users.cloneCatalogCancel": "キャンセル", "admin.users.cloneCatalogCancel": "キャンセル",
"admin.users.cloneCatalogConfirm": "カタログをコピー", "admin.users.cloneCatalogConfirm": "カタログをコピー",
"admin.users.cloneDestFallback": "サンドボックス会社", "admin.users.cloneDestFallback": "サンドボックス会社",
"admin.users.fixA1": "A1カタログを修復", "admin.users.syncA1": "A1を同期",
"admin.users.fixA1Aria": "{name} のAIプロンプト、カテゴリ、enhanceハッシュを修復", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "会社カタログを修復", "admin.users.syncA1Title": "A1カタログを同期",
"admin.users.fixA1Desc": "修正済みAIプロンプトを再適用し、弱いenhanceハッシュを消去し、mapped_dataからカテゴリを補完します。", "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.fixA1Company": "会社: {name}", "admin.users.syncA1Company": "Company: {name}",
"admin.users.fixA1Warning": "Platform Demoまたは明示的な会社を優先してください。フィード、マッピング、生製品は削除しません。A1コホートの上書きルールを保護します。", "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.fixA1Cancel": "キャンセル", "admin.users.syncA1Cancel": "キャンセル",
"admin.users.fixA1Confirm": "カタログを修復", "admin.users.syncA1Confirm": "A1を同期",
"admin.users.noUsers": "このフィルタに一致するユーザーはいません。", "admin.users.noUsers": "このフィルタに一致するユーザーはいません。",
"admin.users.noCompanies": "このフィルタに一致する会社はありません。", "admin.users.noCompanies": "このフィルタに一致する会社はありません。",
"admin.users.assignRoleTitle": "スタッフロールを割り当て", "admin.users.assignRoleTitle": "スタッフロールを割り当て",
@@ -2304,8 +2304,8 @@ export const ja: MessageDict = {
"flash.admin.staffRoleUpdated": "{email} のスタッフロールを更新しました。", "flash.admin.staffRoleUpdated": "{email} のスタッフロールを更新しました。",
"flash.admin.staffRoleUnavailable": "このサーバーではスタッフロールの更新はまだ利用できません。", "flash.admin.staffRoleUnavailable": "このサーバーではスタッフロールの更新はまだ利用できません。",
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.", "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.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.fixA1Error": "{name} のカタログ修復に失敗しました。", "flash.admin.syncA1Error": "{name} のA1同期に失敗しました。",
"flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.", "flash.admin.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssigned": "{name} にプランを割り当てました。", "flash.admin.planAssigned": "{name} にプランを割り当てました。",
"flash.admin.planAssignedShort": "プランを割り当てました。", "flash.admin.planAssignedShort": "プランを割り当てました。",
+10 -10
View File
@@ -812,14 +812,14 @@ export const nl: MessageDict = {
"admin.users.cloneCatalogCancel": "Annuleren", "admin.users.cloneCatalogCancel": "Annuleren",
"admin.users.cloneCatalogConfirm": "Catalogus kopiëren", "admin.users.cloneCatalogConfirm": "Catalogus kopiëren",
"admin.users.cloneDestFallback": "je sandboxbedrijf", "admin.users.cloneDestFallback": "je sandboxbedrijf",
"admin.users.fixA1": "A1-catalogus repareren", "admin.users.syncA1": "A1 synchroniseren",
"admin.users.fixA1Aria": "AI-prompts, categorieën en enhance-hashes voor {name} repareren", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Bedrijfscatalogus repareren", "admin.users.syncA1Title": "A1-catalogus synchroniseren",
"admin.users.fixA1Desc": "Past gecorrigeerde AI-prompts opnieuw toe, wist zwakke enhance-hashes en vult categorieën bij vanuit mapped_data.", "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.fixA1Company": "Bedrijf: {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Annuleren", "admin.users.syncA1Cancel": "Annuleren",
"admin.users.fixA1Confirm": "Catalogus repareren", "admin.users.syncA1Confirm": "A1 synchroniseren",
"admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.", "admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.",
"admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.", "admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.",
"admin.users.assignRoleTitle": "Medewerkerrol toewijzen", "admin.users.assignRoleTitle": "Medewerkerrol toewijzen",
@@ -2304,8 +2304,8 @@ export const nl: MessageDict = {
"flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.", "flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.",
"flash.admin.staffRoleUnavailable": "Personeelsrolupdates zijn op deze server nog niet beschikbaar.", "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.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.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.fixA1Error": "Catalogusreparatie voor {name} mislukt.", "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.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssigned": "Plan toegewezen aan {name}.", "flash.admin.planAssigned": "Plan toegewezen aan {name}.",
"flash.admin.planAssignedShort": "Plan toegewezen.", "flash.admin.planAssignedShort": "Plan toegewezen.",
+10 -10
View File
@@ -812,14 +812,14 @@ export const pl: MessageDict = {
"admin.users.cloneCatalogCancel": "Anuluj", "admin.users.cloneCatalogCancel": "Anuluj",
"admin.users.cloneCatalogConfirm": "Kopiuj katalog", "admin.users.cloneCatalogConfirm": "Kopiuj katalog",
"admin.users.cloneDestFallback": "twoja firma sandbox", "admin.users.cloneDestFallback": "twoja firma sandbox",
"admin.users.fixA1": "Napraw katalog A1", "admin.users.syncA1": "Synchronizuj A1",
"admin.users.fixA1Aria": "Napraw prompty AI, kategorie i hashe enhance dla {name}", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Napraw katalog firmy", "admin.users.syncA1Title": "Synchronizuj katalog A1",
"admin.users.fixA1Desc": "Ponownie stosuje poprawione prompty AI, czyści słabe hashe enhance i uzupełnia kategorie z mapped_data.", "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.fixA1Company": "Firma: {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Anuluj", "admin.users.syncA1Cancel": "Anuluj",
"admin.users.fixA1Confirm": "Napraw katalog", "admin.users.syncA1Confirm": "Synchronizuj A1",
"admin.users.noUsers": "Żaden użytkownik nie pasuje do tego filtra.", "admin.users.noUsers": "Żaden użytkownik nie pasuje do tego filtra.",
"admin.users.noCompanies": "Żadna firma nie pasuje do tego filtra.", "admin.users.noCompanies": "Żadna firma nie pasuje do tego filtra.",
"admin.users.assignRoleTitle": "Przypisz rolÄ™ personelu", "admin.users.assignRoleTitle": "Przypisz rolÄ™ personelu",
@@ -2304,8 +2304,8 @@ export const pl: MessageDict = {
"flash.admin.staffRoleUpdated": "Zaktualizowano rolÄ™ personelu dla {email}.", "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.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.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.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.fixA1Error": "Naprawa katalogu dla {name} nie powiodÅ‚a siÄ™.", "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.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssigned": "Przypisano plan do {name}.", "flash.admin.planAssigned": "Przypisano plan do {name}.",
"flash.admin.planAssignedShort": "Przypisano plan.", "flash.admin.planAssignedShort": "Przypisano plan.",
+10 -10
View File
@@ -812,14 +812,14 @@ export const pt: MessageDict = {
"admin.users.cloneCatalogCancel": "Cancelar", "admin.users.cloneCatalogCancel": "Cancelar",
"admin.users.cloneCatalogConfirm": "Copiar catálogo", "admin.users.cloneCatalogConfirm": "Copiar catálogo",
"admin.users.cloneDestFallback": "a sua empresa sandbox", "admin.users.cloneDestFallback": "a sua empresa sandbox",
"admin.users.fixA1": "Reparar catálogo A1", "admin.users.syncA1": "Sincronizar A1",
"admin.users.fixA1Aria": "Reparar prompts de IA, categorias e hashes de enhance para {name}", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Reparar catálogo da empresa", "admin.users.syncA1Title": "Sincronizar catálogo A1",
"admin.users.fixA1Desc": "Reaplica prompts de IA corrigidos, limpa hashes de enhance fracos e preenche categorias a partir de mapped_data.", "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.fixA1Company": "Empresa: {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Cancelar", "admin.users.syncA1Cancel": "Cancelar",
"admin.users.fixA1Confirm": "Reparar catálogo", "admin.users.syncA1Confirm": "Sincronizar A1",
"admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.", "admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.",
"admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.", "admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.",
"admin.users.assignRoleTitle": "Atribuir função de equipa", "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.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.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.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.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.fixA1Error": "Falha na reparação do catálogo de {name}.", "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.cloneDestMissing": "No sandbox destination. Switch to Platform Demo (staff home), then clone again.",
"flash.admin.planAssigned": "Plano atribuído a {name}.", "flash.admin.planAssigned": "Plano atribuído a {name}.",
"flash.admin.planAssignedShort": "Plano atribuído.", "flash.admin.planAssignedShort": "Plano atribuído.",
+10 -10
View File
@@ -2,15 +2,15 @@
/** Slovenian (sl) UI strings for admin Fix A1 — ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */ /** Slovenian (sl) UI strings for admin Fix A1 — ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */
export const sl: MessageDict = { export const sl: MessageDict = {
"admin.users.fixA1": "Popravi katalog A1", "admin.users.syncA1": "Sinhroniziraj A1",
"admin.users.fixA1Aria": "Popravi AI pozive, kategorije in zgoščevalne vrednosti za {name}", "admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.fixA1Title": "Popravi katalog podjetja", "admin.users.syncA1Title": "Sinhroniziraj katalog A1",
"admin.users.fixA1Desc": "Ponovno uporabi popravljene AI pozive, počisti šibke enhance zgoščevalne vrednosti in dopolni kategorije iz mapped podatkov.", "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.fixA1Company": "Podjetje: {name}", "admin.users.syncA1Company": "Company: {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.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.fixA1Cancel": "Prekliči", "admin.users.syncA1Cancel": "Prekliči",
"admin.users.fixA1Confirm": "Popravi katalog", "admin.users.syncA1Confirm": "Sinhroniziraj A1",
"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.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.fixA1Error": "Popravilo kataloga za {name} ni uspelo.", "flash.admin.syncA1Error": "Sinhronizacija A1 za {name} ni uspela.",
"flash.admin.cloneDestMissing": "Ni ciljnega sandbox podjetja. Preklopite na Platform Demo, nato klonirajte.", "flash.admin.cloneDestMissing": "Ni ciljnega sandbox podjetja. Preklopite na Platform Demo, nato klonirajte.",
}; };
@@ -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 assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
@@ -9,32 +9,32 @@ import { en } from "./messages/en.ts";
import { loadAllMessages, messagesFor } from "./messages/catalog.ts"; import { loadAllMessages, messagesFor } from "./messages/catalog.ts";
import { sl } from "./messages/sl.ts"; import { sl } from "./messages/sl.ts";
const FIX_A1_KEYS = [ const SYNC_A1_KEYS = [
"admin.users.fixA1", "admin.users.syncA1",
"admin.users.fixA1Aria", "admin.users.syncA1Aria",
"admin.users.fixA1Title", "admin.users.syncA1Title",
"admin.users.fixA1Desc", "admin.users.syncA1Desc",
"admin.users.fixA1Company", "admin.users.syncA1Company",
"admin.users.fixA1Warning", "admin.users.syncA1Warning",
"admin.users.fixA1Cancel", "admin.users.syncA1Cancel",
"admin.users.fixA1Confirm", "admin.users.syncA1Confirm",
"flash.admin.fixA1Success", "flash.admin.syncA1Success",
"flash.admin.fixA1Error" "flash.admin.syncA1Error"
] as const; ] as const;
describe("admin Fix A1 i18n keys", () => { describe("admin Sync A1 i18n keys", () => {
it("English defines every Fix A1 key", () => { it("English defines every Sync A1 key", () => {
for (const key of FIX_A1_KEYS) { for (const key of SYNC_A1_KEYS) {
assert.equal(typeof en[key], "string", key); assert.equal(typeof en[key], "string", key);
assert.ok(en[key].trim().length > 0, 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(); await loadAllMessages();
for (const { code } of UI_LOCALES) { for (const { code } of UI_LOCALES) {
const pack = code === "en" ? en : messagesFor(code); 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]; const value = pack[key];
assert.equal(typeof value, "string", `${code}:${key}`); assert.equal(typeof value, "string", `${code}:${key}`);
assert.ok(String(value).trim().length > 0, `${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)", () => { it("Slovenian ready pack has Sync A1 keys (outside UI_LOCALES)", () => {
for (const key of FIX_A1_KEYS) { for (const key of SYNC_A1_KEYS) {
assert.equal(typeof sl[key], "string", key); assert.equal(typeof sl[key], "string", key);
assert.ok(sl[key].trim().length > 0, key); assert.ok(sl[key].trim().length > 0, key);
} }
+42 -39
View File
@@ -11,7 +11,7 @@
STAFF_ROLE_OPTIONS, STAFF_ROLE_OPTIONS,
assignAdminPlan, assignAdminPlan,
cloneAdminCompanyCatalog, cloneAdminCompanyCatalog,
fixAdminCompanyCatalog, syncAdminCompanyA1,
companyPlanBadge, companyPlanBadge,
isStaffRoleApiUnavailable, isStaffRoleApiUnavailable,
listAdminCompanies, listAdminCompanies,
@@ -55,7 +55,7 @@
TabsList, TabsList,
TabsTrigger TabsTrigger
} from "$lib/components/ui"; } 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"; type TabKey = "users" | "companies";
@@ -94,8 +94,8 @@
let cloneOpen = $state(false); let cloneOpen = $state(false);
let cloneCompany = $state<AdminOrgCompany | null>(null); let cloneCompany = $state<AdminOrgCompany | null>(null);
let fixOpen = $state(false); let syncOpen = $state(false);
let fixCompany = $state<AdminOrgCompany | null>(null); let syncCompany = $state<AdminOrgCompany | null>(null);
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1); const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE))); const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
@@ -366,28 +366,29 @@
success = ""; success = "";
} }
function openFixDialog(company: AdminOrgCompany) { function openSyncDialog(company: AdminOrgCompany) {
fixCompany = company; syncCompany = company;
fixOpen = true; syncOpen = true;
error = ""; error = "";
success = ""; success = "";
} }
async function confirmFixCatalog() { async function confirmSyncA1() {
if (!fixCompany) return; if (!syncCompany) return;
busy = true; busy = true;
error = ""; error = "";
success = ""; success = "";
const targetName = fixCompany.name; const targetName = syncCompany.name;
try { try {
const res = await fixAdminCompanyCatalog(fixCompany.id, { const res = await syncAdminCompanyA1(syncCompany.id, {
reprocessSampleLimit: 25 reprocessSampleLimit: 25
}); });
const r = res.result; const r = res.result;
const mappedWith = Number(r.mapped_with_category ?? 0); const mappedWith = Number(r.mapped_with_category ?? 0);
const mappedWithout = Number(r.mapped_without_category ?? 0); const mappedWithout = Number(r.mapped_without_category ?? 0);
const mappedTotal = mappedWith + mappedWithout; 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, name: targetName,
prompts: String(r.prompts ?? r.category_prompts_updated ?? 0), prompts: String(r.prompts ?? r.category_prompts_updated ?? 0),
hashes: String(r.hashes ?? r.weak_hashes_cleared ?? 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_backfilled: String(r.mapped_backfilled ?? r.mapped_categories_backfilled ?? 0),
mapped_with: String(mappedWith), mapped_with: String(mappedWith),
mapped_total: String(mappedTotal), 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; syncOpen = false;
fixCompany = null; syncCompany = null;
} catch (err) { } catch (err) {
error = failureMessage(err, i18n.t("flash.admin.fixA1Error", { name: targetName })); error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName }));
} finally { } finally {
busy = false; busy = false;
} }
@@ -827,18 +830,18 @@
<TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell> <TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell>
<TableCell stickyRight> <TableCell stickyRight>
<div class="flex flex-wrap items-center justify-end gap-1"> <div class="flex flex-wrap items-center justify-end gap-1">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onclick={() => openFixDialog(company)} onclick={() => openSyncDialog(company)}
aria-label={i18n.t("admin.users.fixA1Aria", { aria-label={i18n.t("admin.users.syncA1Aria", {
name: company.name name: company.name
})} })}
data-testid="admin-fix-catalog" data-testid="admin-sync-a1"
> >
<Wrench class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" /> <RefreshCw class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.fixA1")}</span> <span class="hidden lg:inline">{i18n.t("admin.users.syncA1")}</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -1021,21 +1024,21 @@
</Dialog> </Dialog>
<Dialog <Dialog
bind:open={fixOpen} bind:open={syncOpen}
title={i18n.t("admin.users.fixA1Title")} title={i18n.t("admin.users.syncA1Title")}
description={i18n.t("admin.users.fixA1Desc")} description={i18n.t("admin.users.syncA1Desc")}
> >
<div class="space-y-4"> <div class="space-y-4">
{#if error} {#if error}
<p class="text-sm text-destructive" role="alert">{error}</p> <p class="text-sm text-destructive" role="alert">{error}</p>
{/if} {/if}
{#if fixCompany} {#if syncCompany}
<p class="text-sm text-foreground"> <p class="text-sm text-foreground">
{i18n.t("admin.users.fixA1Company", { name: fixCompany.name })} {i18n.t("admin.users.syncA1Company", { name: syncCompany.name })}
</p> </p>
{/if} {/if}
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{i18n.t("admin.users.fixA1Warning")} {i18n.t("admin.users.syncA1Warning")}
</p> </p>
<div class="flex flex-wrap justify-end gap-2"> <div class="flex flex-wrap justify-end gap-2">
<Button <Button
@@ -1043,14 +1046,14 @@
variant="outline" variant="outline"
disabled={busy} disabled={busy}
onclick={() => { onclick={() => {
fixOpen = false; syncOpen = false;
fixCompany = null; syncCompany = null;
}} }}
> >
{i18n.t("admin.users.fixA1Cancel")} {i18n.t("admin.users.syncA1Cancel")}
</Button> </Button>
<Button type="button" loading={busy} onclick={() => confirmFixCatalog()}> <Button type="button" loading={busy} onclick={() => confirmSyncA1()}>
{i18n.t("admin.users.fixA1Confirm")} {i18n.t("admin.users.syncA1Confirm")}
</Button> </Button>
</div> </div>
</div> </div>
+13 -6
View File
@@ -31,15 +31,22 @@ Category assignment (dump -> PG):
mapped_data.category from a MySQL dump when available. mapped_data.category from a MySQL dump when available.
Dump path (first match wins): Dump path (first match wins):
1. -mysql-dump flag 1. -mysql-dump flag / admin body mysql_dump
2. SEED_A1_MYSQL_DUMP 2. SEED_A1_MYSQL_DUMP (API process env on Git-Syncer)
3. Auto-detect: ~/Downloads/descrybe_new (1).sql or descrybe_new.sql 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.
<deploy-root>/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 go run ./cmd/seed-a1 -mode backfill-categories
That also deletes Postman Elkotex fixture EANs (5905575903198, 6970995789942) Admin Sync A1 = dump category backfill (when dump present) + Fix hygiene
from non-A1 tenants (Platform Demo must not mirror them). (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 Note: MySQL processed description/attributes are usually NULL; feed-origin
original description + specs live on raw_products.mapped_data in the archive. original description + specs live on raw_products.mapped_data in the archive.