Files
Suriyakumarvijayanayagam 4474479735 Load .env.<APP_ENV>, validate config at boot, keep secrets out of the image
`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>
2026-09-15 17:04:33 +05:30

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]
}