package repository import ( "database/sql" "encoding/json" "fmt" "os" "strings" "sync" "floorvisualizer/internal/model" ) const selectCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, price_per_sqft, price_tier, price_source, is_official_price, main_image_url, room_image_url, source_url, description, status, COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''), size_label, COALESCE(width_in,0), COALESCE(length_in,0)` const insertCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, price_per_sqft, price_tier, price_source, is_official_price, main_image_url, room_image_url, source_url, description, status, coverage_sqft_per_box, wood_species, size_label, width_in, length_in` // ── Import ────────────────────────────────────────────────── func InsertProducts(d *sql.DB, products []model.Product) error { for _, p := range products { _, err := d.Exec(` INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) `, p.Brand, p.GroupName, p.SKU, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, p.PricePerSqft, p.PriceTier, p.PriceSource, p.IsOfficialPrice, p.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status, p.CoverageSqftPerBox, p.WoodSpecies, p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn)) if err != nil { return fmt.Errorf("insert %s: %w", p.SKU, err) } } return nil } func nf(v float64) interface{} { if v == 0 { return nil } return v } // ── Query types ───────────────────────────────────────────── type FilterParams struct { SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string Page, Limit int } type FilterOptions struct { Categories []string `json:"categories"` SeriesNames []string `json:"series_names"` Brands []model.BrandInfo `json:"brands"` } // ── Queries ───────────────────────────────────────────────── func QueryAllProducts(d *sql.DB) ([]model.Product, error) { prods, _, err := QueryFilteredProducts(d, FilterParams{Limit: 10000}) return prods, err } func QueryAllProductRows(d *sql.DB) ([]model.Product, error) { rows, err := d.Query("SELECT " + selectCols + " FROM products ORDER BY id") if err != nil { return nil, err } defer rows.Close() return ScanProducts(rows) } func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) { if p.Limit <= 0 { p.Limit = 20 } if p.Page <= 0 { p.Page = 1 } var conds []string var args []interface{} i := 1 add := func(clause string, val string) { if val != "" { conds = append(conds, fmt.Sprintf(clause, i)) args = append(args, val) i++ } } add("category = $%d", p.Category) add("series_name = $%d", p.SeriesName) add("brand = $%d", p.Brand) add("material = $%d", p.Material) add("color_tone = $%d", p.ColorTone) add("finish = $%d", p.Finish) add("wood_species = $%d", p.WoodSpecies) if p.Search != "" { pat := "%" + p.Search + "%" clauses := []string{ fmt.Sprintf("style_name ILIKE $%d", i), fmt.Sprintf("sku ILIKE $%d", i+1), fmt.Sprintf("brand ILIKE $%d", i+2), } conds = append(conds, "("+strings.Join(clauses, " OR ")+")") for k := 0; k < 3; k++ { args = append(args, pat) } i += 3 } where := "" if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") } // Count distinct styles (dedup same brand+style_name across different sizes) var total int d.QueryRow("SELECT COUNT(*) FROM (SELECT DISTINCT brand, style_name FROM products "+where+") AS t", args...).Scan(&total) offset := (p.Page - 1) * p.Limit // Use DISTINCT ON to keep one row per brand+style_name, pick the first by id query := fmt.Sprintf("SELECT DISTINCT ON (brand, style_name) "+selectCols+" FROM products %s ORDER BY brand, style_name, id LIMIT $%d OFFSET $%d", where, i, i+1) args = append(args, p.Limit, offset) rows, err := d.Query(query, args...) if err != nil { return nil, 0, err } defer rows.Close() prods, err := ScanProducts(rows) return prods, total, err } func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) { rows, err := d.Query(`SELECT `+selectCols+` FROM products WHERE sku = $1 LIMIT 1`, sku) if err != nil { return nil, err } defer rows.Close() prods, err := ScanProducts(rows) if err != nil { return nil, err } if len(prods) == 0 { return nil, sql.ErrNoRows } return &prods[0], nil } // GetVariantsByStyle returns all products with the same brand+style_name but different SKU. func GetVariantsByStyle(d *sql.DB, sku string) ([]model.Product, error) { rows, err := d.Query(` SELECT `+selectCols+` FROM products WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) AND brand = (SELECT brand FROM products WHERE sku = $1) AND sku != $1 ORDER BY width_in, length_in `, sku) if err != nil { return nil, err } defer rows.Close() return ScanProducts(rows) } func VariantCount(d *sql.DB, sku string) int { var n int d.QueryRow(`SELECT COUNT(*) FROM products WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) AND brand = (SELECT brand FROM products WHERE sku = $1) AND sku != $1`, sku).Scan(&n) return n } // GetSpecCounts returns (brand|style_name|series_name) → total variant count for a batch of SKUs. func GetSpecCounts(d *sql.DB, skus []string) map[string]int { result := map[string]int{} if len(skus) == 0 { return result } placeholders := make([]string, len(skus)) args := make([]interface{}, len(skus)) for i, sku := range skus { placeholders[i] = fmt.Sprintf("$%d", i+1) args[i] = sku } query := fmt.Sprintf(` SELECT p.brand, p.style_name, p.series_name, COUNT(*) AS total FROM products p WHERE (p.brand, p.style_name, p.series_name) IN ( SELECT brand, style_name, series_name FROM products WHERE sku IN (%s) ) GROUP BY p.brand, p.style_name, p.series_name `, strings.Join(placeholders, ",")) rows, err := d.Query(query, args...) if err != nil { return result } defer rows.Close() for rows.Next() { var brand, style, series string var total int if err := rows.Scan(&brand, &style, &series, &total); err == nil { result[brand+"|"+style+"|"+series] = total } } return result } func GetFilterOptions(d *sql.DB) (*FilterOptions, error) { opts := &FilterOptions{} for _, q := range []struct { query string out *[]string }{ {"SELECT DISTINCT category FROM products WHERE category != '' ORDER BY category", &opts.Categories}, {"SELECT DISTINCT series_name FROM products WHERE series_name != '' ORDER BY series_name", &opts.SeriesNames}, } { rows, err := d.Query(q.query) if err != nil { return nil, err } for rows.Next() { var v string rows.Scan(&v) *q.out = append(*q.out, v) } rows.Close() } opts.Brands = GetBrandInfos(d) return opts, nil } // ── Brand info with stats ─────────────────────────────────── var ( brandLogos map[string]string brandLogosOnce sync.Once ) func loadBrandLogos() map[string]string { brandLogosOnce.Do(func() { brandLogos = map[string]string{} data, err := os.ReadFile("brand_logos.json") if err != nil { return } json.Unmarshal(data, &brandLogos) }) return brandLogos } // GetBrandInfos returns all brands with style count, category count, and logo URL. func GetBrandInfos(d *sql.DB) []model.BrandInfo { logos := loadBrandLogos() rows, err := d.Query(` SELECT brand, COUNT(DISTINCT style_name) AS style_count, COUNT(DISTINCT SPLIT_PART(series_name, ' | ', 1)) AS collection_count FROM products WHERE brand != '' GROUP BY brand ORDER BY brand `) if err != nil { return nil } defer rows.Close() var out []model.BrandInfo for rows.Next() { var bi model.BrandInfo if err := rows.Scan(&bi.Name, &bi.StyleCount, &bi.CollectionCount); err != nil { continue } if u, ok := logos[bi.Name]; ok { bi.LogoURL = u } out = append(out, bi) } return out } func ScanProducts(rows *sql.Rows) ([]model.Product, error) { var out []model.Product for rows.Next() { var p model.Product if err := rows.Scan( &p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName, &p.Category, &p.Material, &p.ColorTone, &p.Finish, &p.PricePerSqft, &p.PriceTier, &p.PriceSource, &p.IsOfficialPrice, &p.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status, &p.CoverageSqftPerBox, &p.WoodSpecies, &p.SizeLabel, &p.WidthIn, &p.LengthIn, ); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() }