// Command seed-support-kb upserts platform Support Knowledge articles from JSON. // // Targets table support_kb_articles (migration 032). Idempotent on slug. // // Usage (from apps/api, DATABASE_URL set or passed): // // go run ./cmd/seed-support-kb -postgres "$DATABASE_URL" // go run ./cmd/seed-support-kb -file ../../scripts/seed/support-kb-articles.json // go run ./cmd/seed-support-kb -file ../../scripts/seed/support-kb-articles-tech.json package main import ( "context" "encoding/json" "flag" "fmt" "log" "os" "path/filepath" "strings" "time" "unicode" "unicode/utf8" "github.com/jackc/pgx/v5/pgxpool" ) const ( maxSlugLen = 120 maxTitleLen = 200 maxBodyLen = 20000 maxKeywordLen = 64 maxKeywords = 40 maxIntents = 20 maxCatSlugs = 20 ) type seedFile struct { Version int `json:"version"` Articles []seedArticle `json:"articles"` } type seedArticle struct { Slug string `json:"slug"` Title string `json:"title"` BodyMD string `json:"body_md"` CategorySlugs []string `json:"category_slugs"` Keywords []string `json:"keywords"` IntentKeys []string `json:"intent_keys"` IsPublished bool `json:"is_published"` PriorityWeight int `json:"priority_weight"` } func main() { postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") filePath := flag.String("file", "", "Path to support-kb-articles.json (default: repo scripts/seed/...)") flag.Parse() if strings.TrimSpace(*postgresURL) == "" { log.Fatal("-postgres / DATABASE_URL is required") } path := strings.TrimSpace(*filePath) if path == "" { path = defaultArticlesPath() } raw, err := os.ReadFile(path) if err != nil { log.Fatalf("read %s: %v", path, err) } var sf seedFile if err := json.Unmarshal(raw, &sf); err != nil { log.Fatalf("parse json: %v", err) } if len(sf.Articles) == 0 { log.Fatal("no articles in seed file") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() pool, err := pgxpool.New(ctx, *postgresURL) if err != nil { log.Fatalf("postgres: %v", err) } defer pool.Close() var tableOK bool if err := pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'support_kb_articles' )`).Scan(&tableOK); err != nil { log.Fatalf("check table: %v", err) } if !tableOK { log.Fatal("support_kb_articles missing — run goose migrations through 032_support_kb_auto_reply first") } upserted := 0 for i, a := range sf.Articles { slug, err := normalizeSlug(a.Slug) if err != nil { log.Fatalf("article[%d] slug: %v", i, err) } title := clipRunes(strings.TrimSpace(a.Title), maxTitleLen) body := clipRunes(strings.TrimSpace(a.BodyMD), maxBodyLen) if title == "" || body == "" { log.Fatalf("article[%d] (%s): title and body_md are required", i, slug) } cats := normalizeList(a.CategorySlugs, maxKeywordLen, maxCatSlugs) keywords := normalizeList(a.Keywords, maxKeywordLen, maxKeywords) intents := normalizeList(a.IntentKeys, maxKeywordLen, maxIntents) tag, err := pool.Exec(ctx, ` INSERT INTO support_kb_articles ( slug, title, body_md, category_slugs, keywords, intent_keys, is_published, priority_weight, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now()) ON CONFLICT (slug) DO UPDATE SET title = EXCLUDED.title, body_md = EXCLUDED.body_md, category_slugs = EXCLUDED.category_slugs, keywords = EXCLUDED.keywords, intent_keys = EXCLUDED.intent_keys, is_published = EXCLUDED.is_published, priority_weight = EXCLUDED.priority_weight, updated_at = now()`, slug, title, body, cats, keywords, intents, a.IsPublished, a.PriorityWeight) if err != nil { log.Fatalf("upsert %s: %v", slug, err) } if tag.RowsAffected() > 0 { upserted++ fmt.Printf("upserted %s (%s)\n", slug, title) } } var published, total int64 _ = pool.QueryRow(ctx, `SELECT count(*) FROM support_kb_articles`).Scan(&total) _ = pool.QueryRow(ctx, `SELECT count(*) FROM support_kb_articles WHERE is_published`).Scan(&published) fmt.Printf("done: %d articles from file; table total=%d published=%d\n", upserted, total, published) } func defaultArticlesPath() string { // Prefer repo-relative path when run from apps/api. candidates := []string{ filepath.Join("..", "..", "scripts", "seed", "support-kb-articles.json"), filepath.Join("scripts", "seed", "support-kb-articles.json"), } if wd, err := os.Getwd(); err == nil { candidates = append(candidates, filepath.Join(wd, "scripts", "seed", "support-kb-articles.json"), filepath.Join(wd, "..", "..", "scripts", "seed", "support-kb-articles.json"), ) } for _, c := range candidates { if st, err := os.Stat(c); err == nil && !st.IsDir() { return c } } return candidates[0] } func normalizeSlug(s string) (string, error) { s = strings.ToLower(strings.TrimSpace(s)) s = strings.ReplaceAll(s, " ", "-") if s == "" { return "", fmt.Errorf("empty slug") } if utf8.RuneCountInString(s) > maxSlugLen { return "", fmt.Errorf("slug too long") } for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { continue } return "", fmt.Errorf("invalid slug char %q", r) } return s, nil } func normalizeList(in []string, maxItem, maxCount int) []string { seen := make(map[string]struct{}, len(in)) out := make([]string, 0, len(in)) for _, raw := range in { s := strings.ToLower(strings.TrimSpace(raw)) if s == "" { continue } s = clipRunes(s, maxItem) if _, ok := seen[s]; ok { continue } seen[s] = struct{}{} out = append(out, s) if len(out) >= maxCount { break } } if out == nil { return []string{} } return out } func clipRunes(s string, max int) string { if max <= 0 || utf8.RuneCountInString(s) <= max { return s } return string([]rune(s)[:max]) }