diff --git a/apps/api/internal/catalog/clone.go b/apps/api/internal/catalog/clone.go index 0ba7f16..7dc180a 100644 --- a/apps/api/internal/catalog/clone.go +++ b/apps/api/internal/catalog/clone.go @@ -433,6 +433,25 @@ func cloneCompanyCatalog(ctx context.Context, pool *pgxpool.Pool, src, dest uuid } add("company_language", ct.RowsAffected()) + ct, err = tx.Exec(ctx, ` + INSERT INTO company_settings (company_id, settings, updated_at) + SELECT $2, settings, now() + FROM company_settings WHERE company_id = $1 + ON CONFLICT (company_id) DO UPDATE + SET settings = EXCLUDED.settings, updated_at = now()`, src, dest) + if err != nil { + return nil, fmt.Errorf("company_settings: %w", err) + } + add("company_settings", ct.RowsAffected()) + + // Cloned raws are ready to process on the destination (source flags may say processed). + if _, err := tx.Exec(ctx, ` + UPDATE raw_products + SET is_processed = false, processing_status = 'unprocessed', updated_at = now() + WHERE company_id = $1`, dest); err != nil { + return nil, fmt.Errorf("reset raw processing flags: %w", err) + } + if err := tx.Commit(ctx); err != nil { return nil, err } diff --git a/apps/api/internal/catalog/raw_v1.go b/apps/api/internal/catalog/raw_v1.go index 7a9f553..4677f1d 100644 --- a/apps/api/internal/catalog/raw_v1.go +++ b/apps/api/internal/catalog/raw_v1.go @@ -42,6 +42,7 @@ func NormalizeGTIN(ean string) string { } // BuildMappedDataFromV1Item mirrors legacy buildMappedDataFromItem + image field storage. +// Empty optional fields are omitted (not null) so jsonb || merges cannot wipe catalog titles. func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any { mapped := map[string]any{ "ean": item.EAN, @@ -49,23 +50,15 @@ func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any { if item.Title != "" { mapped["title"] = item.Title mapped["name"] = item.Title - } else { - mapped["title"] = nil } if item.Description != "" { mapped["description"] = item.Description - } else { - mapped["description"] = nil } if len(item.Specifications) > 0 { mapped["specifications"] = item.Specifications - } else { - mapped["specifications"] = []any{} } if item.Search != "" { mapped["search"] = item.Search - } else { - mapped["search"] = nil } if item.CategoryUniqueID != "" { mapped["category"] = item.CategoryUniqueID @@ -77,6 +70,21 @@ func BuildMappedDataFromV1Item(item V1ProcessItem) map[string]any { return mapped } +// v1ItemHasContent reports whether the request carries feed/product fields beyond EAN. +// EAN-only requests must resolve an existing catalog row (e.g. after admin clone). +func v1ItemHasContent(item V1ProcessItem) bool { + if strings.TrimSpace(item.Title) != "" || strings.TrimSpace(item.Description) != "" { + return true + } + if strings.TrimSpace(item.CategoryUniqueID) != "" || strings.TrimSpace(item.Search) != "" { + return true + } + if len(item.Specifications) > 0 { + return true + } + return len(mappedImageFieldsFromV1Item(item)) > 0 +} + func mappedImageFieldsFromV1Item(item V1ProcessItem) map[string]any { source := map[string]any{} putIf := func(k, v string) { @@ -279,6 +287,10 @@ type EnsureRawResult struct { // EnsureRawProductsFromV1Items finds or creates/updates raw_products by EAN for the company. // Returns successfully resolved IDs and per-item error messages (non-fatal for partial batches). +// +// EAN-only items reuse existing catalog mapped_data (e.g. after admin clone-from-company). +// Creating a brand-new row requires at least one content field; otherwise callers get empty +// "Product {ean}" stubs with no feed data. func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uuid.UUID, items []V1ProcessItem) (ids []uuid.UUID, results []EnsureRawResult, errs []string, err error) { if s == nil || s.Pool == nil { return nil, nil, nil, fmt.Errorf("catalog not configured") @@ -309,24 +321,23 @@ func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uu if qErr == nil { merged := map[string]any{} _ = json.Unmarshal(existingMapped, &merged) - img := mappedImageFieldsFromV1Item(item) - if len(img) > 0 { - for k, v := range img { - merged[k] = v + changed := false + for k, v := range mapped { + if v == nil { + continue } - if item.CategoryUniqueID != "" { - merged["category"] = item.CategoryUniqueID - merged["category_unique_id"] = item.CategoryUniqueID + if s, ok := v.(string); ok && strings.TrimSpace(s) == "" { + continue } - mergedJSON, _ := json.Marshal(merged) - _, _ = s.Pool.Exec(ctx, ` - UPDATE raw_products - SET mapped_data = $3::jsonb, updated_at = now() - WHERE id = $1 AND company_id = $2`, existingID, companyID, string(mergedJSON)) - } else if item.CategoryUniqueID != "" { - _ = json.Unmarshal(existingMapped, &merged) + merged[k] = v + changed = true + } + if item.CategoryUniqueID != "" { merged["category"] = item.CategoryUniqueID merged["category_unique_id"] = item.CategoryUniqueID + changed = true + } + if changed { mergedJSON, _ := json.Marshal(merged) _, _ = s.Pool.Exec(ctx, ` UPDATE raw_products @@ -342,6 +353,14 @@ func (s *Service) EnsureRawProductsFromV1Items(ctx context.Context, companyID uu continue } + if !v1ItemHasContent(item) { + errs = append(errs, fmt.Sprintf( + "No catalog product for EAN %s. Use a GTIN from the cloned/synced feed, or include title/description/images in the request.", + item.EAN, + )) + continue + } + var newID uuid.UUID insErr := s.Pool.QueryRow(ctx, ` INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, is_processed) diff --git a/apps/api/internal/catalog/raw_v1_test.go b/apps/api/internal/catalog/raw_v1_test.go index f827e67..4be3f2b 100644 --- a/apps/api/internal/catalog/raw_v1_test.go +++ b/apps/api/internal/catalog/raw_v1_test.go @@ -38,3 +38,19 @@ func TestBuildMappedDataFromV1Item(t *testing.T) { t.Fatalf("more=%s", b) } } + +func TestBuildMappedDataFromV1ItemOmitsEmptyNulls(t *testing.T) { + mapped := BuildMappedDataFromV1Item(V1ProcessItem{EAN: "123"}) + if _, ok := mapped["title"]; ok { + t.Fatalf("empty title must be omitted, got %v", mapped) + } + if _, ok := mapped["description"]; ok { + t.Fatalf("empty description must be omitted, got %v", mapped) + } + if !v1ItemHasContent(V1ProcessItem{EAN: "1", Title: "x"}) { + t.Fatal("title should count as content") + } + if v1ItemHasContent(V1ProcessItem{EAN: "1"}) { + t.Fatal("EAN-only must not count as content") + } +} diff --git a/apps/api/internal/httpapi/admin_clone_catalog_handlers.go b/apps/api/internal/httpapi/admin_clone_catalog_handlers.go index 5baaf05..8c4e45b 100644 --- a/apps/api/internal/httpapi/admin_clone_catalog_handlers.go +++ b/apps/api/internal/httpapi/admin_clone_catalog_handlers.go @@ -69,11 +69,25 @@ func (s *Server) handleAdminCloneCompanyCatalog(w http.ResponseWriter, r *http.R ClientOrLog(w, http.StatusBadRequest, "could not clone catalog", err, catalog.ClientError) return } + + // Point the admin session at the destination so Products / process use the cloned catalog. + if s.Sessions != nil { + s.Sessions.Put(r.Context(), auth.SessionCompanyIDKey, destID.String()) + if s.Auth != nil { + if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil { + if _, memErr := s.Auth.EnsureMembership(r.Context(), uid, destID); memErr == nil { + s.Sessions.Remove(r.Context(), auth.SessionStaffHomeCompanyKey) + } + } + } + } + JSON(w, http.StatusOK, map[string]any{ "status": "ok", "source_company_id": res.SourceCompanyID, "dest_company_id": res.DestCompanyID, "counts": res.Counts, + "active_company_id": destID, }) } diff --git a/apps/web/src/lib/admin-orgs.ts b/apps/web/src/lib/admin-orgs.ts index db8f2d5..70b6d5a 100644 --- a/apps/web/src/lib/admin-orgs.ts +++ b/apps/web/src/lib/admin-orgs.ts @@ -207,6 +207,7 @@ export type CloneCatalogResult = { status: string; source_company_id: string; dest_company_id: string; + active_company_id?: string; counts: Record; }; diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index 03e95d2..f00da88 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -2321,7 +2321,7 @@ export const en: MessageDict = { "flash.admin.staffRoleUpdated": "Staff role updated for {email}.", "flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.", "flash.admin.planAssigned": "Plan assigned to {name}.", - "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} categories.", + "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} categories. Open Products and process catalog GTINs (EAN-only API calls need a matching feed product).", "flash.admin.planAssignedShort": "Plan assigned.", "flash.admin.creditsUpdated": "Credits updated.", "flash.admin.cyclesProcessed": "Billing cycles processed: {count}", diff --git a/apps/web/src/routes/admin/users/+page.svelte b/apps/web/src/routes/admin/users/+page.svelte index ac7808a..2e3806d 100644 --- a/apps/web/src/routes/admin/users/+page.svelte +++ b/apps/web/src/routes/admin/users/+page.svelte @@ -378,6 +378,7 @@ }); const products = Number(res.counts?.raw_products ?? 0); const categories = Number(res.counts?.categories ?? 0); + const activeDest = (res.active_company_id || res.dest_company_id || "").trim(); success = i18n.t("flash.admin.catalogCloned", { source: cloneCompany.name, dest: cloneDestLabel, @@ -386,6 +387,18 @@ }); cloneOpen = false; cloneCompany = null; + if (activeDest) { + try { + await api("/api/auth/select-company", { + method: "POST", + body: { company_id: activeDest } + }); + } catch { + /* clone handler may already have switched the session */ + } + window.location.assign("/products"); + return; + } } catch (err) { error = failureMessage(err, "Clone catalog failed"); } finally {