Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+619
View File
@@ -0,0 +1,619 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// migrateGapDomains loads settings, formulas (standard fields), tags, woo, and usage snapshots.
func migrateGapDomains(
ctx context.Context,
mysqlDB *sql.DB,
pg *pgxpool.Pool,
companyMap map[string]string,
feedMap map[string]string,
allow map[string]bool,
domains domainSet,
report map[string]int,
dryRun bool,
) {
if domains.has("settings") {
migrateCompanySettings(ctx, mysqlDB, pg, companyMap, allow, report, dryRun)
}
if domains.has("formulas") {
migrateFieldGroupsAndStandards(ctx, mysqlDB, pg, companyMap, allow, report, dryRun)
migrateStructuredDescriptionFields(ctx, mysqlDB, pg, companyMap, allow, report, dryRun)
}
if domains.has("tags") {
migrateFeedTags(ctx, mysqlDB, pg, companyMap, feedMap, allow, report, dryRun)
}
if domains.has("woo") {
migrateWooConfigs(ctx, mysqlDB, pg, companyMap, allow, report, dryRun)
}
if domains.has("usage") {
migrateUsageIntoSettings(ctx, mysqlDB, pg, companyMap, allow, report, dryRun)
}
}
func migrateCompanySettings(
ctx context.Context,
mysqlDB *sql.DB,
pg *pgxpool.Pool,
companyMap map[string]string,
allow map[string]bool,
report map[string]int,
dryRun bool,
) {
if !mysqlTableExists(ctx, mysqlDB, "company_settings") {
log.Printf("company_settings skipped: table missing")
return
}
lang := mysqlCoalesce(ctx, mysqlDB, "company_settings", "language", "'en'")
merge := mysqlCoalesce(ctx, mysqlDB, "company_settings", "merge_products", "1")
q := "SELECT company_id, " + lang + ", " + merge + " FROM company_settings WHERE company_id IS NOT NULL AND company_id <> ''"
clause, args := mysqlCompanyFilter("company_id", allow)
q += clause
rows, err := mysqlDB.QueryContext(ctx, q, args...)
if err != nil {
log.Printf("company_settings skipped: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var companyLegacy, language string
var mergeProducts int
if err := rows.Scan(&companyLegacy, &language, &mergeProducts); err != nil {
report["company_settings_skipped"]++
continue
}
cid, ok := companyMap[companyLegacy]
if !ok {
report["company_settings_skipped"]++
continue
}
if language == "" {
language = "en"
}
settings := map[string]any{
"language": language,
"merge_products": mergeProducts == 1,
"_legacy": true,
}
b, _ := json.Marshal(settings)
if dryRun {
report["company_settings"]++
continue
}
_, err = pg.Exec(ctx, `
INSERT INTO company_settings (company_id, settings, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (company_id) DO UPDATE SET
settings = company_settings.settings || EXCLUDED.settings,
updated_at = now()`, cid, string(b))
if err != nil {
log.Printf("company_settings %s: %v", companyLegacy, err)
report["company_settings_skipped"]++
continue
}
report["company_settings"]++
}
}
func migrateFieldGroupsAndStandards(
ctx context.Context,
mysqlDB *sql.DB,
pg *pgxpool.Pool,
companyMap map[string]string,
allow map[string]bool,
report map[string]int,
dryRun bool,
) {
if !mysqlTableExists(ctx, mysqlDB, "field_groups") {
log.Printf("field_groups skipped: table missing")
return
}
groupMap := map[string]string{} // legacy group uuid → new uuid
clause, args := mysqlCompanyFilter("company_id", allow)
orderCol := mustQuoteMySQLIdent("order")
q := "SELECT id, company_id, name, COALESCE(description, ''), COALESCE(" + orderCol + ", 0), COALESCE(is_system, 0) FROM field_groups WHERE company_id IS NOT NULL AND company_id <> ''" + clause
// MySQL may use order without backticks in some dumps — try fallbacks.
rows, err := mysqlDB.QueryContext(ctx, q, args...)
if err != nil {
q2 := `SELECT id, company_id, name, COALESCE(description, ''), 0, COALESCE(is_system, 0)
FROM field_groups WHERE company_id IS NOT NULL AND company_id <> ''` + clause
rows, err = mysqlDB.QueryContext(ctx, q2, args...)
}
if err != nil {
log.Printf("field_groups skipped: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var legacyID, companyLegacy, name, desc string
var order, isSystem int
if err := rows.Scan(&legacyID, &companyLegacy, &name, &desc, &order, &isSystem); err != nil {
report["field_groups_skipped"]++
continue
}
cid, ok := companyMap[companyLegacy]
if !ok {
report["field_groups_skipped"]++
continue
}
newID := uuid.New()
if parsed, err := uuid.Parse(legacyID); err == nil {
newID = parsed // preserve UUID when already uuid-shaped
}
groupMap[legacyID] = newID.String()
if dryRun {
report["field_groups"]++
continue
}
_, err = pg.Exec(ctx, `
INSERT INTO field_groups (id, company_id, name, description, "order", is_system)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
"order" = EXCLUDED."order",
updated_at = now()`,
newID, cid, name, desc, order, isSystem == 1)
if err != nil {
log.Printf("field_group %s: %v", legacyID, err)
report["field_groups_skipped"]++
delete(groupMap, legacyID)
continue
}
report["field_groups"]++
}
if !mysqlTableExists(ctx, mysqlDB, "standard_fields") {
return
}
keyCol := mustQuoteMySQLIdent("key")
sq := "SELECT id, company_id, name, " + keyCol + ", type, group_id, COALESCE(is_required, 0), COALESCE(description, ''), COALESCE(default_value, ''), validation, COALESCE(is_system, 0) FROM standard_fields WHERE company_id IS NOT NULL AND company_id <> ''" + clause
srows, err := mysqlDB.QueryContext(ctx, sq, args...)
if err != nil {
log.Printf("standard_fields skipped: %v", err)
return
}
defer srows.Close()
for srows.Next() {
var legacyID, companyLegacy, name, key, typ, groupLegacy string
var desc, defVal string
var required, isSystem int
var validation []byte
if err := srows.Scan(&legacyID, &companyLegacy, &name, &key, &typ, &groupLegacy,
&required, &desc, &defVal, &validation, &isSystem); err != nil {
report["standard_fields_skipped"]++
continue
}
cid, ok := companyMap[companyLegacy]
if !ok {
report["standard_fields_skipped"]++
continue
}
gid, okG := groupMap[groupLegacy]
if !okG {
// group may already exist in PG with same UUID
gid = groupLegacy
}
newID := uuid.New()
if parsed, err := uuid.Parse(legacyID); err == nil {
newID = parsed
}
if typ == "" {
typ = "string"
}
if dryRun {
report["standard_fields"]++
continue
}
_, err = pg.Exec(ctx, `
INSERT INTO standard_fields (
id, company_id, name, key, type, group_id, is_required,
description, default_value, validation, is_system
) VALUES (
$1, $2, $3, $4, $5, $6::uuid, $7, NULLIF($8, ''), NULLIF($9, ''),
COALESCE($10::jsonb, '{}'::jsonb), $11
)
ON CONFLICT (company_id, key) DO UPDATE SET
name = EXCLUDED.name,
type = EXCLUDED.type,
group_id = EXCLUDED.group_id,
is_required = EXCLUDED.is_required,
description = EXCLUDED.description,
default_value = EXCLUDED.default_value,
validation = EXCLUDED.validation,
updated_at = now()`,
newID, cid, name, key, typ, gid, required == 1, desc, defVal,
jsonOrNull(validation), isSystem == 1)
if err != nil {
log.Printf("standard_field %s: %v", legacyID, err)
report["standard_fields_skipped"]++
continue
}
report["standard_fields"]++
}
}
func migrateStructuredDescriptionFields(
ctx context.Context,
mysqlDB *sql.DB,
pg *pgxpool.Pool,
companyMap map[string]string,
allow map[string]bool,
report map[string]int,
dryRun bool,
) {
if !mysqlTableExists(ctx, mysqlDB, "structured_description_fields") {
log.Printf("structured_description_fields skipped: table missing")
return
}
clause, args := mysqlCompanyFilter("company_id", allow)
q := `SELECT id, company_id, field_key, COALESCE(type, 'text')
FROM structured_description_fields
WHERE company_id IS NOT NULL AND company_id <> ''` + clause
rows, err := mysqlDB.QueryContext(ctx, q, args...)
if err != nil {
log.Printf("structured_description_fields skipped: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var legacyID, companyLegacy, fieldKey, typ string
if err := rows.Scan(&legacyID, &companyLegacy, &fieldKey, &typ); err != nil {
report["structured_description_fields_skipped"]++
continue
}
cid, ok := companyMap[companyLegacy]
if !ok {
report["structured_description_fields_skipped"]++
continue
}
newID := uuid.New()
if parsed, err := uuid.Parse(legacyID); err == nil {
newID = parsed
}
if dryRun {
report["structured_description_fields"]++
continue
}
_, err = pg.Exec(ctx, `
INSERT INTO structured_description_fields (id, company_id, field_key, type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (company_id, field_key) DO UPDATE SET
type = EXCLUDED.type, updated_at = now()`,
newID, cid, fieldKey, typ)
if err != nil {
log.Printf("structured_description_field %s: %v", legacyID, err)
report["structured_description_fields_skipped"]++
continue
}
report["structured_description_fields"]++
}
}
func migrateFeedTags(
ctx context.Context,
mysqlDB *sql.DB,
pg *pgxpool.Pool,
companyMap, feedMap map[string]string,
allow map[string]bool,
report map[string]int,
dryRun bool,
) {
if !mysqlTableExists(ctx, mysqlDB, "feed_tags") {
log.Printf("feed_tags skipped: table missing")
return
}
tagMap := map[string]string{}
clause, args := mysqlCompanyFilter("company_id", allow)
q := `SELECT id, company_id, name, COALESCE(color, '#888888')
FROM feed_tags WHERE company_id IS NOT NULL AND company_id <> ''` + clause
rows, err := mysqlDB.QueryContext(ctx, q, args...)
if err != nil {
log.Printf("feed_tags skipped: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var legacyID int64
var companyLegacy, name, color string
if err := rows.Scan(&legacyID, &companyLegacy, &name, &color); err != nil {
report["feed_tags_skipped"]++
continue
}
cid, ok := companyMap[companyLegacy]
if !ok {
report["feed_tags_skipped"]++
continue
}
newID := uuid.New()
tagMap[strconv.FormatInt(legacyID, 10)] = newID.String()
if dryRun {
report["feed_tags"]++
continue
}
_, err = pg.Exec(ctx, `
INSERT INTO feed_tags (id, company_id, name, color)
VALUES ($1, $2, $3, $4)
ON CONFLICT (company_id, name) DO UPDATE SET color = EXCLUDED.color`, newID, cid, name, color)
if err != nil {
var existing uuid.UUID
if err2 := pg.QueryRow(ctx, `SELECT id FROM feed_tags WHERE company_id = $1 AND name = $2`, cid, name).Scan(&existing); err2 == nil {
tagMap[strconv.FormatInt(legacyID, 10)] = existing.String()
report["feed_tags"]++
continue
}
log.Printf("feed_tag %d: %v", legacyID, err)
report["feed_tags_skipped"]++
continue
}
var existing uuid.UUID
_ = pg.QueryRow(ctx, `SELECT id FROM feed_tags WHERE company_id = $1 AND name = $2`, cid, name).Scan(&existing)
if existing != uuid.Nil {
tagMap[strconv.FormatInt(legacyID, 10)] = existing.String()
}
report["feed_tags"]++
}
if !mysqlTableExists(ctx, mysqlDB, "feed_tag_mappings") || len(tagMap) == 0 {
return
}
mrows, err := mysqlDB.QueryContext(ctx, `SELECT feed_id, tag_id FROM feed_tag_mappings`)
if err != nil {
log.Printf("feed_tag_mappings skipped: %v", err)
return
}
defer mrows.Close()
for mrows.Next() {
var feedLegacy, tagLegacy int64
if err := mrows.Scan(&feedLegacy, &tagLegacy); err != nil {
report["feed_tag_mappings_skipped"]++
continue
}
fid, okF := feedMap[strconv.FormatInt(feedLegacy, 10)]
tid, okT := tagMap[strconv.FormatInt(tagLegacy, 10)]
if !okF || !okT {
report["feed_tag_mappings_skipped"]++
continue
}
if dryRun {
report["feed_tag_mappings"]++
continue
}
_, err = pg.Exec(ctx, `
INSERT INTO feed_tag_mappings (feed_id, tag_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, fid, tid)
if err != nil {
report["feed_tag_mappings_skipped"]++
continue
}
report["feed_tag_mappings"]++
}
}
func migrateWooConfigs(
ctx context.Context,
mysqlDB *sql.DB,
pg *pgxpool.Pool,
companyMap map[string]string,
allow map[string]bool,
report map[string]int,
dryRun bool,
) {
// Legacy Woo settings live as per-company custom_fields named wc_*.
if !mysqlTableExists(ctx, mysqlDB, "custom_fields") || !mysqlTableExists(ctx, mysqlDB, "feed_custom_field_values") {
log.Printf("woocommerce_configs skipped: custom_fields tables missing")
report["woocommerce_configs_note"] = 1
return
}
clause, args := mysqlCompanyFilter("cf.company_id", allow)
q := `
SELECT cf.company_id, cf.name, COALESCE(fcfv.value, '')
FROM custom_fields cf
JOIN feed_custom_field_values fcfv ON fcfv.custom_field_id = cf.id
WHERE cf.name LIKE 'wc_%'` + clause
rows, err := mysqlDB.QueryContext(ctx, q, args...)
if err != nil {
log.Printf("woocommerce_configs skipped: %v", err)
return
}
defer rows.Close()
byCompany := map[string]map[string]string{}
for rows.Next() {
var companyLegacy, name, value string
if err := rows.Scan(&companyLegacy, &name, &value); err != nil {
continue
}
if _, ok := companyMap[companyLegacy]; !ok {
continue
}
if byCompany[companyLegacy] == nil {
byCompany[companyLegacy] = map[string]string{}
}
byCompany[companyLegacy][name] = value
}
if len(byCompany) == 0 {
log.Printf("woocommerce_configs: no wc_* custom fields found (ok)")
report["woocommerce_configs"] = 0
return
}
for legacyCID, fields := range byCompany {
cid := companyMap[legacyCID]
enabled := strings.EqualFold(fields["wc_enabled"], "true") || fields["wc_enabled"] == "1"
storeURL := firstNonEmpty(fields["wc_store_url"], fields["wc_url"], fields["wc_store"])
consumerKey := firstNonEmpty(fields["wc_consumer_key"], fields["wc_key"])
consumerSecret := firstNonEmpty(fields["wc_consumer_secret"], fields["wc_secret"])
syncOpts, _ := json.Marshal(fields)
if dryRun {
report["woocommerce_configs"]++
continue
}
_, err = pg.Exec(ctx, `
INSERT INTO woocommerce_configs (
company_id, store_url, consumer_key, consumer_secret, is_enabled, sync_options
) VALUES ($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT (company_id) DO UPDATE SET
store_url = EXCLUDED.store_url,
consumer_key = EXCLUDED.consumer_key,
consumer_secret = EXCLUDED.consumer_secret,
is_enabled = EXCLUDED.is_enabled,
sync_options = EXCLUDED.sync_options,
updated_at = now()`,
cid, storeURL, consumerKey, consumerSecret, enabled, string(syncOpts))
if err != nil {
log.Printf("woocommerce_configs %s: %v", legacyCID, err)
report["woocommerce_configs_skipped"]++
continue
}
report["woocommerce_configs"]++
}
}
// migrateUsageIntoSettings folds latest usage_metrics into company_settings.settings._legacy_usage.
// v2 has no usage_metrics table; this preserves a portable snapshot without N+1.
func migrateUsageIntoSettings(
ctx context.Context,
mysqlDB *sql.DB,
pg *pgxpool.Pool,
companyMap map[string]string,
allow map[string]bool,
report map[string]int,
dryRun bool,
) {
if !mysqlTableExists(ctx, mysqlDB, "usage_metrics") {
log.Printf("usage_metrics skipped: table missing")
return
}
clause, args := mysqlCompanyFilter("company_id", allow)
q := `
SELECT company_id,
SUM(COALESCE(credits_used, 0)),
MAX(COALESCE(total_products, 0)),
COUNT(*)
FROM usage_metrics
WHERE company_id IS NOT NULL AND company_id <> ''` + clause + `
GROUP BY company_id`
rows, err := mysqlDB.QueryContext(ctx, q, args...)
if err != nil {
log.Printf("usage_metrics skipped: %v", err)
return
}
defer rows.Close()
type snap struct {
CreditsUsed float64 `json:"credits_used_sum"`
MaxProducts int `json:"max_total_products"`
MetricDays int `json:"metric_days"`
}
batch := make([][]any, 0)
for rows.Next() {
var companyLegacy string
var credits float64
var maxProducts, days int
if err := rows.Scan(&companyLegacy, &credits, &maxProducts, &days); err != nil {
report["usage_metrics_skipped"]++
continue
}
cid, ok := companyMap[companyLegacy]
if !ok {
report["usage_metrics_skipped"]++
continue
}
payload, _ := json.Marshal(map[string]any{
"_legacy_usage": snap{CreditsUsed: credits, MaxProducts: maxProducts, MetricDays: days},
})
if dryRun {
report["usage_metrics"]++
continue
}
batch = append(batch, []any{cid, string(payload)})
report["usage_metrics"]++
}
if dryRun || len(batch) == 0 {
return
}
tx, err := pg.Begin(ctx)
if err != nil {
log.Printf("usage_metrics tx: %v", err)
return
}
defer tx.Rollback(ctx)
b := &pgx.Batch{}
for _, row := range batch {
b.Queue(`
INSERT INTO company_settings (company_id, settings, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (company_id) DO UPDATE SET
settings = company_settings.settings || EXCLUDED.settings,
updated_at = now()`, row...)
}
br := tx.SendBatch(ctx, b)
if err := br.Close(); err != nil {
log.Printf("usage_metrics batch: %v", err)
return
}
if err := tx.Commit(ctx); err != nil {
log.Printf("usage_metrics commit: %v", err)
}
// usage_limits → settings._legacy_usage_limits when present
if mysqlTableExists(ctx, mysqlDB, "usage_limits") {
lq := `SELECT company_id, tokens_per_minute, requests_per_minute, tokens_per_day, cost_limit, COALESCE(is_active, 1)
FROM usage_limits WHERE company_id IS NOT NULL AND company_id <> ''`
lc, la := mysqlCompanyFilter("company_id", allow)
lrows, err := mysqlDB.QueryContext(ctx, lq+lc, la...)
if err == nil {
defer lrows.Close()
for lrows.Next() {
var companyLegacy string
var tpm, rpm, tpd int
var costLimit sql.NullInt64
var active int
if err := lrows.Scan(&companyLegacy, &tpm, &rpm, &tpd, &costLimit, &active); err != nil {
continue
}
cid, ok := companyMap[companyLegacy]
if !ok {
continue
}
lim := map[string]any{
"tokens_per_minute": tpm,
"requests_per_minute": rpm,
"tokens_per_day": tpd,
"is_active": active == 1,
}
if costLimit.Valid {
lim["cost_limit"] = costLimit.Int64
}
payload, _ := json.Marshal(map[string]any{"_legacy_usage_limits": lim})
_, _ = pg.Exec(ctx, `
INSERT INTO company_settings (company_id, settings, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (company_id) DO UPDATE SET
settings = company_settings.settings || EXCLUDED.settings,
updated_at = now()`, cid, string(payload))
report["usage_limits"]++
}
}
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}