`main.go` only ever loaded `.env`; the `APP_ENV` switch described in `.env.local` / `.env.production` did not exist, and a missing variable surfaced one restart at a time as a log.Fatalf inside db.Connect. config.Load now picks `.env.<APP_ENV>` (default local) then `.env`, with real environment winning, reads every setting into one typed Config and reports everything missing in one message. Production insists on a POS signing secret; local warns when DB_HOST is not a local address. db, redis and the image store take the Config instead of reading env themselves. Also: - livehub read MQTT_USERNAME while everything else uses MQTT_USER, so the console stream connected to the broker unauthenticated. Both accepted. - .dockerignore: `COPY . .` was baking .env.production into the image. Dockerfile sets APP_ENV=production. - Drop utils/config.go (dead viper loader) and create_table.go (unused, hardcoded production DSN); go mod tidy removes viper. - .env.example lists every variable the code reads; docs/ENVIRONMENT.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
4.1 KiB
Go
150 lines
4.1 KiB
Go
// Backfills products.productimages for everything imported before the column
|
|
// existed.
|
|
//
|
|
// The import used to keep `Images[0]` and discard the rest, so roughly six
|
|
// thousand products carry one photo where the global catalogue has up to ten.
|
|
// Re-importing would work but rewrites price, category and stock links; this
|
|
// only fills the gallery.
|
|
//
|
|
// The images are re-listed rather than reconstructed. Filenames are
|
|
// `image_000.jpeg`, `image_001.jpg` … — the extension varies within a single
|
|
// product, so the sibling URLs cannot be derived from the first one. The
|
|
// `image_id` is taken out of the stored URL and the same S3 listing the API
|
|
// uses is asked for the rest, which is why this boots the app's own
|
|
// connections instead of talking to Postgres alone.
|
|
//
|
|
// Dry by default; pass --apply to write.
|
|
//
|
|
// go run ./scratch/productimages # report only
|
|
// go run ./scratch/productimages --apply # write
|
|
// go run ./scratch/productimages --apply --tenant 1087
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
|
|
"nearle/config"
|
|
"nearle/db"
|
|
)
|
|
|
|
type row struct {
|
|
Productid int
|
|
Tenantid int
|
|
Productbrand string
|
|
Productimage string
|
|
}
|
|
|
|
func main() {
|
|
apply := flag.Bool("apply", false, "write the changes; otherwise report only")
|
|
tenant := flag.Int("tenant", 0, "restrict to one tenant")
|
|
flag.Parse()
|
|
|
|
db.Connect(config.MustLoad())
|
|
|
|
if db.ImageStore == nil {
|
|
log.Fatal("the image store is not configured (USE_S3 / S3_*), so there is nothing to list images from")
|
|
}
|
|
|
|
q := `
|
|
SELECT productid, tenantid,
|
|
COALESCE(productbrand,'') AS productbrand,
|
|
COALESCE(productimage,'') AS productimage
|
|
FROM products
|
|
WHERE COALESCE(productimage,'') <> ''
|
|
AND COALESCE(productimages::text,'') IN ('', 'null', '[]')`
|
|
args := []interface{}{}
|
|
if *tenant > 0 {
|
|
q += ` AND tenantid = ?`
|
|
args = append(args, *tenant)
|
|
}
|
|
q += ` ORDER BY productid`
|
|
|
|
var rows []row
|
|
if err := db.DB.Raw(q, args...).Scan(&rows).Error; err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
fmt.Printf("candidates: %d\n\n", len(rows))
|
|
|
|
var filled, single, unparsed, nolist int
|
|
for _, r := range rows {
|
|
brand, imageID := parseImageRef(r.Productimage)
|
|
if brand == "" || imageID == "" {
|
|
unparsed++
|
|
continue
|
|
}
|
|
// The stored brand is authoritative where it exists; the URL is only a
|
|
// fallback, because a product renamed in the back office keeps its
|
|
// original image path.
|
|
if b := strings.ToLower(strings.TrimSpace(r.Productbrand)); b != "" {
|
|
brand = b
|
|
}
|
|
|
|
images := db.GetImages(brand, imageID)
|
|
if len(images) == 0 {
|
|
nolist++
|
|
continue
|
|
}
|
|
if len(images) == 1 {
|
|
// Nothing gained — leave the column null rather than writing a
|
|
// one-element array that says the same as productimage.
|
|
single++
|
|
continue
|
|
}
|
|
|
|
encoded, err := json.Marshal(images)
|
|
if err != nil {
|
|
log.Printf("product %d: encoding failed: %v", r.Productid, err)
|
|
continue
|
|
}
|
|
|
|
if *apply {
|
|
if err := db.DB.Exec(
|
|
`UPDATE products SET productimages = ?::jsonb, updated = NOW() WHERE productid = ?`,
|
|
string(encoded), r.Productid).Error; err != nil {
|
|
log.Printf("product %d: update failed: %v", r.Productid, err)
|
|
continue
|
|
}
|
|
}
|
|
filled++
|
|
if filled <= 10 {
|
|
fmt.Printf(" product %-7d %-10s %-34s %d images\n", r.Productid, brand, imageID, len(images))
|
|
}
|
|
}
|
|
|
|
fmt.Printf(`
|
|
would fill (>1 image) : %d
|
|
only one image : %d
|
|
image_id unparseable : %d
|
|
nothing listed in S3 : %d
|
|
`, filled, single, unparsed, nolist)
|
|
|
|
if !*apply {
|
|
fmt.Println("\ndry run — nothing written. Re-run with --apply.")
|
|
}
|
|
}
|
|
|
|
// parseImageRef pulls the brand and image_id out of a stored image URL.
|
|
//
|
|
// https://…/daily/brands/nestle/nestle_maggi_615a9fa9/image_000.jpeg
|
|
// ^brand ^image_id
|
|
//
|
|
// Returns empty strings for anything that does not match, so a hand-uploaded
|
|
// image URL is skipped rather than guessed at.
|
|
func parseImageRef(url string) (brand, imageID string) {
|
|
const marker = "/daily/brands/"
|
|
i := strings.Index(url, marker)
|
|
if i < 0 {
|
|
return "", ""
|
|
}
|
|
parts := strings.Split(strings.Trim(url[i+len(marker):], "/"), "/")
|
|
if len(parts) < 3 {
|
|
return "", ""
|
|
}
|
|
return strings.ToLower(parts[0]), parts[1]
|
|
}
|