fixes
This commit is contained in:
@@ -18,6 +18,7 @@ func ClientError(err error) (msg string, ok bool) {
|
||||
errors.Is(err, ErrPlanNotFound),
|
||||
errors.Is(err, ErrAmountRequired),
|
||||
errors.Is(err, ErrStripeNotConfigured),
|
||||
errors.Is(err, ErrStripeSelfServeUnavailable),
|
||||
errors.Is(err, ErrStripePlanUnsupported),
|
||||
errors.Is(err, ErrStripePriceMissing),
|
||||
errors.Is(err, ErrStripeNoCustomer),
|
||||
|
||||
@@ -99,12 +99,32 @@ func (s *StripeService) cfg(ctx context.Context) StripeConfig {
|
||||
}
|
||||
|
||||
var (
|
||||
ErrStripeNotConfigured = errors.New("stripe not configured")
|
||||
ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout")
|
||||
ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term")
|
||||
ErrStripeBadSignature = errors.New("invalid stripe signature")
|
||||
ErrStripeNotConfigured = errors.New("stripe not configured")
|
||||
ErrStripeSelfServeUnavailable = errors.New("self-serve checkout is unavailable until Stripe is configured")
|
||||
ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout")
|
||||
ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term")
|
||||
ErrStripeBadSignature = errors.New("invalid stripe signature")
|
||||
)
|
||||
|
||||
// EnsureSelfServeCheckout allows live Stripe for any caller, and mock checkout
|
||||
// only for platform admins (so new tenant users cannot buy while STRIPE_MOCK is on).
|
||||
func (s *StripeService) EnsureSelfServeCheckout(ctx context.Context, platformAdmin bool) error {
|
||||
_, cfg, err := s.bindCfg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.MockMode() {
|
||||
return nil
|
||||
}
|
||||
if cfg.AllowMockPurchase() && platformAdmin {
|
||||
return nil
|
||||
}
|
||||
if cfg.AllowMockPurchase() {
|
||||
return ErrStripeSelfServeUnavailable
|
||||
}
|
||||
return ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
// CheckoutRequest is the body for POST /api/billing/checkout.
|
||||
// Set Pack for a one-time AI credit top-up, or Plan (+ Term) for a subscription.
|
||||
type CheckoutRequest struct {
|
||||
|
||||
@@ -93,6 +93,24 @@ func TestStripeConfigMockMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSelfServeCheckout(t *testing.T) {
|
||||
live := &StripeService{Cfg: StripeConfig{SecretKey: "sk_test_x"}}
|
||||
if err := live.EnsureSelfServeCheckout(context.TODO(), false); err != nil {
|
||||
t.Fatalf("live stripe should allow any admin: %v", err)
|
||||
}
|
||||
mock := &StripeService{Cfg: StripeConfig{ForceMock: true}}
|
||||
if err := mock.EnsureSelfServeCheckout(context.TODO(), true); err != nil {
|
||||
t.Fatalf("mock + platform admin should allow: %v", err)
|
||||
}
|
||||
if err := mock.EnsureSelfServeCheckout(context.TODO(), false); !errors.Is(err, ErrStripeSelfServeUnavailable) {
|
||||
t.Fatalf("mock + non-platform admin: want ErrStripeSelfServeUnavailable, got %v", err)
|
||||
}
|
||||
empty := &StripeService{Cfg: StripeConfig{}}
|
||||
if err := empty.EnsureSelfServeCheckout(context.TODO(), true); !errors.Is(err, ErrStripeNotConfigured) {
|
||||
t.Fatalf("empty secret: want ErrStripeNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCheckoutSessionFailsClosedWithoutSecret(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
|
||||
_, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// CloneResult summarizes a company catalog clone (source unchanged).
|
||||
type CloneResult struct {
|
||||
SourceCompanyID uuid.UUID `json:"source_company_id"`
|
||||
DestCompanyID uuid.UUID `json:"dest_company_id"`
|
||||
Counts map[string]int64 `json:"counts"`
|
||||
}
|
||||
|
||||
// ClearCompanyCatalog deletes operational catalog rows for a company.
|
||||
// Does not touch billing, memberships, api_keys, store connectors, or users.
|
||||
func ClearCompanyCatalog(ctx context.Context, tx pgx.Tx, companyID uuid.UUID) error {
|
||||
stmts := []string{
|
||||
`DELETE FROM processed_products WHERE company_id = $1`,
|
||||
`DELETE FROM raw_products WHERE company_id = $1`,
|
||||
`DELETE FROM export_feeds WHERE company_id = $1`,
|
||||
`DELETE FROM feed_mappings WHERE company_id = $1`,
|
||||
`DELETE FROM feed_sync_jobs WHERE company_id = $1`,
|
||||
`DELETE FROM schema_extraction_tasks WHERE company_id = $1`,
|
||||
`DELETE FROM feed_tag_mappings WHERE feed_id IN (SELECT id FROM input_feeds WHERE company_id = $1)
|
||||
OR tag_id IN (SELECT id FROM feed_tags WHERE company_id = $1)`,
|
||||
`DELETE FROM feed_tags WHERE company_id = $1`,
|
||||
`DELETE FROM input_feeds WHERE company_id = $1`,
|
||||
`DELETE FROM category_attributes WHERE company_id = $1`,
|
||||
`DELETE FROM categories WHERE company_id = $1`,
|
||||
`DELETE FROM attributes WHERE company_id = $1`,
|
||||
`DELETE FROM custom_variables WHERE company_id = $1`,
|
||||
`DELETE FROM standard_fields WHERE company_id = $1`,
|
||||
`DELETE FROM field_groups WHERE company_id = $1`,
|
||||
`DELETE FROM structured_description_fields WHERE company_id = $1`,
|
||||
`DELETE FROM files WHERE company_id = $1`,
|
||||
`DELETE FROM processing_job_products WHERE job_id IN (SELECT id FROM processing_jobs WHERE company_id = $1)`,
|
||||
`DELETE FROM processing_jobs WHERE company_id = $1`,
|
||||
`DELETE FROM tasks WHERE company_id = $1`,
|
||||
`DELETE FROM product_reviews WHERE company_id = $1`,
|
||||
`DELETE FROM woo_order_items WHERE company_id = $1`,
|
||||
`DELETE FROM woo_orders WHERE company_id = $1`,
|
||||
`DELETE FROM ai_prompt_templates WHERE company_id = $1`,
|
||||
`DELETE FROM company_brand WHERE company_id = $1`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := tx.Exec(ctx, q, companyID); err != nil {
|
||||
return fmt.Errorf("clear catalog: %s: %w", q, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloneCompanyCatalog copies operational catalog data from src into dest.
|
||||
// Source rows are never updated. Dest catalog is cleared first.
|
||||
// Skips plan/billing, memberships, api_keys, Shopify/Woo connectors, and AI BYOK secrets.
|
||||
func (s *Service) CloneCompanyCatalog(ctx context.Context, src, dest uuid.UUID) (*CloneResult, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return nil, fmt.Errorf("catalog service not configured")
|
||||
}
|
||||
if err := validateCloneCompanies(src, dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cloneCompanyCatalog(ctx, s.Pool, src, dest)
|
||||
}
|
||||
|
||||
func validateCloneCompanies(src, dest uuid.UUID) error {
|
||||
if src == uuid.Nil || dest == uuid.Nil {
|
||||
return ClientMsg("source_company_id and dest_company_id are required")
|
||||
}
|
||||
if src == dest {
|
||||
return ClientMsg("cannot clone a company catalog onto itself")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneCompanyCatalog(ctx context.Context, pool *pgxpool.Pool, src, dest uuid.UUID) (*CloneResult, error) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Large A1-sized catalogs need headroom inside one transaction.
|
||||
if _, err := tx.Exec(ctx, `SET LOCAL statement_timeout = '15min'`); err != nil {
|
||||
return nil, fmt.Errorf("set statement_timeout: %w", err)
|
||||
}
|
||||
|
||||
var srcOK, destOK bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM companies WHERE id = $1),
|
||||
EXISTS(SELECT 1 FROM companies WHERE id = $2)`, src, dest).Scan(&srcOK, &destOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !srcOK {
|
||||
return nil, ClientMsg("source company not found")
|
||||
}
|
||||
if !destOK {
|
||||
return nil, ClientMsg("destination company not found")
|
||||
}
|
||||
|
||||
if err := ClearCompanyCatalog(ctx, tx, dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE TEMP TABLE clone_id_map (
|
||||
kind TEXT NOT NULL,
|
||||
old_id UUID NOT NULL,
|
||||
new_id UUID NOT NULL,
|
||||
PRIMARY KEY (kind, old_id)
|
||||
) ON COMMIT DROP`); err != nil {
|
||||
return nil, fmt.Errorf("create clone_id_map: %w", err)
|
||||
}
|
||||
|
||||
counts := map[string]int64{}
|
||||
add := func(label string, n int64) { counts[label] = n }
|
||||
|
||||
// --- taxonomy / attributes ---
|
||||
ct, err := tx.Exec(ctx, `
|
||||
INSERT INTO categories (
|
||||
id, company_id, name, unique_id, parent_unique_id, path, level, position,
|
||||
is_active, description, prompt, metadata, config, title_template, description_template,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT gen_random_uuid(), $2, name, unique_id, parent_unique_id, path, level, position,
|
||||
is_active, description, prompt, metadata, config, title_template, description_template,
|
||||
created_at, now()
|
||||
FROM categories WHERE company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("categories: %w", err)
|
||||
}
|
||||
add("categories", ct.RowsAffected())
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO clone_id_map (kind, old_id, new_id)
|
||||
SELECT 'attribute', id, gen_random_uuid() FROM attributes WHERE company_id = $1`, src); err != nil {
|
||||
return nil, fmt.Errorf("map attributes: %w", err)
|
||||
}
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO attributes (
|
||||
id, company_id, attribute_key, name, value_type, unit, example, parent_key, created_at, updated_at
|
||||
)
|
||||
SELECT m.new_id, $2, a.attribute_key, a.name, a.value_type, a.unit, a.example, a.parent_key, a.created_at, now()
|
||||
FROM attributes a
|
||||
JOIN clone_id_map m ON m.kind = 'attribute' AND m.old_id = a.id
|
||||
WHERE a.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attributes: %w", err)
|
||||
}
|
||||
add("attributes", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO category_attributes (
|
||||
id, company_id, category_unique_id, attribute_id, required, created_at, updated_at
|
||||
)
|
||||
SELECT gen_random_uuid(), $2, ca.category_unique_id, m.new_id, ca.required, ca.created_at, now()
|
||||
FROM category_attributes ca
|
||||
JOIN clone_id_map m ON m.kind = 'attribute' AND m.old_id = ca.attribute_id
|
||||
WHERE ca.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("category_attributes: %w", err)
|
||||
}
|
||||
add("category_attributes", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO custom_variables (id, company_id, name, value, description, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $2, name, value, description, created_at, now()
|
||||
FROM custom_variables WHERE company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("custom_variables: %w", err)
|
||||
}
|
||||
add("custom_variables", ct.RowsAffected())
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO clone_id_map (kind, old_id, new_id)
|
||||
SELECT 'field_group', id, gen_random_uuid() FROM field_groups WHERE company_id = $1`, src); err != nil {
|
||||
return nil, fmt.Errorf("map field_groups: %w", err)
|
||||
}
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO field_groups (id, company_id, name, description, "order", is_system, created_at, updated_at)
|
||||
SELECT m.new_id, $2, fg.name, fg.description, fg."order", fg.is_system, fg.created_at, now()
|
||||
FROM field_groups fg
|
||||
JOIN clone_id_map m ON m.kind = 'field_group' AND m.old_id = fg.id
|
||||
WHERE fg.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field_groups: %w", err)
|
||||
}
|
||||
add("field_groups", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO standard_fields (
|
||||
id, company_id, name, key, type, group_id, is_required, description,
|
||||
default_value, validation, is_system, created_at, updated_at
|
||||
)
|
||||
SELECT gen_random_uuid(), $2, sf.name, sf.key, sf.type, m.new_id, sf.is_required, sf.description,
|
||||
sf.default_value, sf.validation, sf.is_system, sf.created_at, now()
|
||||
FROM standard_fields sf
|
||||
JOIN clone_id_map m ON m.kind = 'field_group' AND m.old_id = sf.group_id
|
||||
WHERE sf.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("standard_fields: %w", err)
|
||||
}
|
||||
add("standard_fields", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO structured_description_fields (id, company_id, field_key, type, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $2, field_key, type, created_at, now()
|
||||
FROM structured_description_fields WHERE company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("structured_description_fields: %w", err)
|
||||
}
|
||||
add("structured_description_fields", ct.RowsAffected())
|
||||
|
||||
// --- files / feeds ---
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO clone_id_map (kind, old_id, new_id)
|
||||
SELECT 'file', id, gen_random_uuid() FROM files WHERE company_id = $1`, src); err != nil {
|
||||
return nil, fmt.Errorf("map files: %w", err)
|
||||
}
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO files (
|
||||
id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
|
||||
)
|
||||
SELECT m.new_id, $2, NULL, f.name, f.path, f.content_type, f.size_bytes, f.status, f.metadata, f.created_at, now()
|
||||
FROM files f
|
||||
JOIN clone_id_map m ON m.kind = 'file' AND m.old_id = f.id
|
||||
WHERE f.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("files: %w", err)
|
||||
}
|
||||
add("files", ct.RowsAffected())
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO clone_id_map (kind, old_id, new_id)
|
||||
SELECT 'feed', id, gen_random_uuid() FROM input_feeds WHERE company_id = $1`, src); err != nil {
|
||||
return nil, fmt.Errorf("map input_feeds: %w", err)
|
||||
}
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO input_feeds (
|
||||
id, company_id, name, url, feed_type, status, sync_interval_minutes,
|
||||
last_synced_at, auth_config, options, created_at, updated_at
|
||||
)
|
||||
SELECT m.new_id, $2, f.name, f.url, f.feed_type, f.status, f.sync_interval_minutes,
|
||||
f.last_synced_at, f.auth_config, f.options, f.created_at, now()
|
||||
FROM input_feeds f
|
||||
JOIN clone_id_map m ON m.kind = 'feed' AND m.old_id = f.id
|
||||
WHERE f.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input_feeds: %w", err)
|
||||
}
|
||||
add("input_feeds", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO feed_mappings (id, feed_id, company_id, version, mappings, is_active, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), m.new_id, $2, fm.version, fm.mappings, fm.is_active, fm.created_at, now()
|
||||
FROM feed_mappings fm
|
||||
JOIN clone_id_map m ON m.kind = 'feed' AND m.old_id = fm.feed_id
|
||||
WHERE fm.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feed_mappings: %w", err)
|
||||
}
|
||||
add("feed_mappings", ct.RowsAffected())
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO clone_id_map (kind, old_id, new_id)
|
||||
SELECT 'feed_tag', id, gen_random_uuid() FROM feed_tags WHERE company_id = $1`, src); err != nil {
|
||||
return nil, fmt.Errorf("map feed_tags: %w", err)
|
||||
}
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO feed_tags (id, company_id, name, color, created_at)
|
||||
SELECT m.new_id, $2, t.name, t.color, t.created_at
|
||||
FROM feed_tags t
|
||||
JOIN clone_id_map m ON m.kind = 'feed_tag' AND m.old_id = t.id
|
||||
WHERE t.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feed_tags: %w", err)
|
||||
}
|
||||
add("feed_tags", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO feed_tag_mappings (feed_id, tag_id)
|
||||
SELECT fm.new_id, tm.new_id
|
||||
FROM feed_tag_mappings x
|
||||
JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = x.feed_id
|
||||
JOIN clone_id_map tm ON tm.kind = 'feed_tag' AND tm.old_id = x.tag_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feed_tag_mappings: %w", err)
|
||||
}
|
||||
add("feed_tag_mappings", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO export_feeds (
|
||||
id, company_id, name, source_feed_id, format, public_token, template, filters,
|
||||
is_active, last_generated_at, created_at, updated_at
|
||||
)
|
||||
SELECT gen_random_uuid(), $2, e.name, sm.new_id, e.format,
|
||||
encode(gen_random_bytes(16), 'hex'), e.template, e.filters,
|
||||
e.is_active, e.last_generated_at, e.created_at, now()
|
||||
FROM export_feeds e
|
||||
LEFT JOIN clone_id_map sm ON sm.kind = 'feed' AND sm.old_id = e.source_feed_id
|
||||
WHERE e.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export_feeds: %w", err)
|
||||
}
|
||||
add("export_feeds", ct.RowsAffected())
|
||||
|
||||
// --- products ---
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO clone_id_map (kind, old_id, new_id)
|
||||
SELECT 'raw_product', id, gen_random_uuid() FROM raw_products WHERE company_id = $1`, src); err != nil {
|
||||
return nil, fmt.Errorf("map raw_products: %w", err)
|
||||
}
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO raw_products (
|
||||
id, company_id, gtin, feed_id, feed_ids, raw_data, mapped_data, sync_job_id,
|
||||
is_processed, processing_status, file_id, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
rm.new_id,
|
||||
$2,
|
||||
rp.gtin,
|
||||
fm.new_id,
|
||||
CASE
|
||||
WHEN rp.feed_ids IS NULL THEN NULL
|
||||
WHEN jsonb_typeof(rp.feed_ids) <> 'array' THEN rp.feed_ids
|
||||
ELSE COALESCE((
|
||||
SELECT jsonb_agg(to_jsonb(COALESCE(xmm.new_id::text, elem)))
|
||||
FROM jsonb_array_elements_text(rp.feed_ids) AS t(elem)
|
||||
LEFT JOIN clone_id_map xmm
|
||||
ON xmm.kind = 'feed' AND xmm.old_id::text = t.elem
|
||||
), '[]'::jsonb)
|
||||
END,
|
||||
rp.raw_data,
|
||||
rp.mapped_data,
|
||||
NULL,
|
||||
rp.is_processed,
|
||||
rp.processing_status,
|
||||
filem.new_id,
|
||||
rp.created_at,
|
||||
now()
|
||||
FROM raw_products rp
|
||||
JOIN clone_id_map rm ON rm.kind = 'raw_product' AND rm.old_id = rp.id
|
||||
LEFT JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = rp.feed_id
|
||||
LEFT JOIN clone_id_map filem ON filem.kind = 'file' AND filem.old_id = rp.file_id
|
||||
WHERE rp.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("raw_products: %w", err)
|
||||
}
|
||||
add("raw_products", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO processed_products (
|
||||
id, company_id, user_id, product_id, name, category, description, processed_description,
|
||||
attributes, processed_attributes, status, gpt_response, total_tokens, feed_id, raw_product_id,
|
||||
processed_name, meta_title, meta_description, last_transition_at, structured_description,
|
||||
field_sources, created_at, updated_at, ai_provider_mode, localized_content
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
$2,
|
||||
NULL,
|
||||
pp.product_id,
|
||||
pp.name,
|
||||
pp.category,
|
||||
pp.description,
|
||||
pp.processed_description,
|
||||
pp.attributes,
|
||||
pp.processed_attributes,
|
||||
pp.status,
|
||||
pp.gpt_response,
|
||||
pp.total_tokens,
|
||||
fm.new_id,
|
||||
rm.new_id,
|
||||
pp.processed_name,
|
||||
pp.meta_title,
|
||||
pp.meta_description,
|
||||
pp.last_transition_at,
|
||||
pp.structured_description,
|
||||
pp.field_sources,
|
||||
pp.created_at,
|
||||
now(),
|
||||
COALESCE(pp.ai_provider_mode, 'internal'),
|
||||
COALESCE(pp.localized_content, '{}'::jsonb)
|
||||
FROM processed_products pp
|
||||
LEFT JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = pp.feed_id
|
||||
LEFT JOIN clone_id_map rm ON rm.kind = 'raw_product' AND rm.old_id = pp.raw_product_id
|
||||
WHERE pp.company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processed_products: %w", err)
|
||||
}
|
||||
add("processed_products", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO ai_prompt_templates (
|
||||
id, company_id, prompt_key, system_template, user_template, is_enabled,
|
||||
created_at, updated_at, language
|
||||
)
|
||||
SELECT gen_random_uuid(), $2, prompt_key, system_template, user_template, is_enabled,
|
||||
created_at, now(), language
|
||||
FROM ai_prompt_templates WHERE company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ai_prompt_templates: %w", err)
|
||||
}
|
||||
add("ai_prompt_templates", ct.RowsAffected())
|
||||
|
||||
ct, err = tx.Exec(ctx, `
|
||||
INSERT INTO company_brand (
|
||||
company_id, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, updated_at
|
||||
)
|
||||
SELECT $2, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, now()
|
||||
FROM company_brand WHERE company_id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("company_brand: %w", err)
|
||||
}
|
||||
add("company_brand", ct.RowsAffected())
|
||||
|
||||
// Copy content language settings onto dest (not name/plan/billing).
|
||||
ct, err = tx.Exec(ctx, `
|
||||
UPDATE companies AS d
|
||||
SET language = s.language,
|
||||
content_languages = s.content_languages,
|
||||
updated_at = now()
|
||||
FROM companies s
|
||||
WHERE d.id = $2 AND s.id = $1`, src, dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("companies language: %w", err)
|
||||
}
|
||||
add("company_language", ct.RowsAffected())
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CloneResult{
|
||||
SourceCompanyID: src,
|
||||
DestCompanyID: dest,
|
||||
Counts: counts,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestValidateCloneCompanies(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := uuid.MustParse("11111111-1111-4111-8111-111111111111")
|
||||
b := uuid.MustParse("22222222-2222-4222-8222-222222222222")
|
||||
|
||||
if err := validateCloneCompanies(uuid.Nil, b); err == nil {
|
||||
t.Fatal("expected error for nil source")
|
||||
}
|
||||
if err := validateCloneCompanies(a, uuid.Nil); err == nil {
|
||||
t.Fatal("expected error for nil dest")
|
||||
}
|
||||
if err := validateCloneCompanies(a, a); err == nil {
|
||||
t.Fatal("expected error for src==dest")
|
||||
}
|
||||
if err := validateCloneCompanies(a, b); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// handleAdminCloneCompanyCatalog copies operational catalog data from the URL
|
||||
// company into the admin's sandbox company (home / explicit dest). Source is
|
||||
// never mutated. Body: confirm=true required; optional dest_company_id.
|
||||
func (s *Server) handleAdminCloneCompanyCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Catalog == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "catalog service unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
srcID, err := uuid.Parse(strings.TrimSpace(chi.URLParam(r, "id")))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company id")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
DestCompanyID string `json:"dest_company_id"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if !body.Confirm {
|
||||
Error(w, http.StatusBadRequest, "confirm must be true (destination catalog will be replaced)")
|
||||
return
|
||||
}
|
||||
|
||||
destID, err := s.resolveCloneDestCompany(r, body.DestCompanyID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if platformsettings.IsSystemCompany(srcID) || platformsettings.IsSystemCompany(destID) {
|
||||
Error(w, http.StatusBadRequest, "cannot clone to or from the platform settings company")
|
||||
return
|
||||
}
|
||||
|
||||
var destLegacy, destName string
|
||||
if err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(legacy_company_id, ''), name FROM companies WHERE id = $1`, destID).
|
||||
Scan(&destLegacy, &destName); err != nil {
|
||||
Error(w, http.StatusBadRequest, "destination company not found")
|
||||
return
|
||||
}
|
||||
if billing.IsA1CohortCompany(destLegacy, destName) {
|
||||
Error(w, http.StatusBadRequest, "refusing to overwrite A1 cohort company; switch to your sandbox company")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.Catalog.CloneCompanyCatalog(r.Context(), srcID, destID)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not clone catalog", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"source_company_id": res.SourceCompanyID,
|
||||
"dest_company_id": res.DestCompanyID,
|
||||
"counts": res.Counts,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveCloneDestCompany prefers an explicit dest, then staff home (when acting
|
||||
// in another tenant), else the session active company.
|
||||
func (s *Server) resolveCloneDestCompany(r *http.Request, destRaw string) (uuid.UUID, error) {
|
||||
if raw := strings.TrimSpace(destRaw); raw != "" {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return uuid.Nil, errors.New("invalid dest_company_id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if home := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionStaffHomeCompanyKey)); home != "" {
|
||||
id, err := uuid.Parse(home)
|
||||
if err != nil {
|
||||
return uuid.Nil, errors.New("invalid staff home company")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
|
||||
return cid, nil
|
||||
}
|
||||
if cur := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)); cur != "" {
|
||||
id, err := uuid.Parse(cur)
|
||||
if err != nil {
|
||||
return uuid.Nil, errors.New("invalid session company")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
return uuid.Nil, errors.New("no destination company; select your sandbox company first")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminCloneCompanyCatalogNilCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/"+uuid.NewString()+"/clone-catalog", strings.NewReader(`{"confirm":true}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminCloneCompanyCatalog(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminCloneCatalogMounted locks POST /api/admin/companies/{id}/clone-catalog
|
||||
// after session + CSRF + platform-admin (503 with nil Catalog), not chi 404.
|
||||
func TestRouterAdminCloneCatalogMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
path := "/api/admin/companies/" + uuid.NewString() + "/clone-catalog"
|
||||
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 catalog) body=%s", mounted.Code, mounted.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -387,9 +387,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/users", s.handleAdminListUsers)
|
||||
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
|
||||
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
|
||||
r.Get("/staff", s.handleAdminListStaff)
|
||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||
r.Get("/companies", s.handleAdminListCompanies)
|
||||
r.Get("/staff", s.handleAdminListStaff)
|
||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||
r.Get("/companies", s.handleAdminListCompanies)
|
||||
r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog)
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
|
||||
@@ -45,6 +45,15 @@ func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "failed to verify platform admin", err)
|
||||
return
|
||||
}
|
||||
if err := s.stripeSvc().EnsureSelfServeCheckout(r.Context(), platformAdmin); err != nil {
|
||||
ClientOrLog(w, http.StatusServiceUnavailable, "checkout unavailable", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
var body billing.CheckoutRequest
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
|
||||
@@ -100,6 +100,29 @@ func TestHandleStripeCheckoutMockRequiresAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStripeCheckoutMockBlocksNonPlatformAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Config: config.Config{StripeMock: true},
|
||||
Stripe: &billing.StripeService{Cfg: billing.StripeConfig{ForceMock: true}},
|
||||
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
}
|
||||
cid := uuid.New()
|
||||
uid := uuid.New()
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", bytes.NewBufferString(`{"plan":"starter","term":"monthly"}`))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleStripeCheckout(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d body=%s want 503", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func signHandlerStripePayload(t *testing.T, secret string, payload []byte) string {
|
||||
t.Helper()
|
||||
ts := time.Now().Unix()
|
||||
|
||||
Reference in New Issue
Block a user