Files
backend_fiesta/db/imagestore.go
Suriya fab06bb33e Bridge global catalogue DB to per-store product catalogue
Adds a separate CatalogueDB (pgvector) connection alongside the main
nearledb, plus a new catalogue module (repository/service/controller/
routes) to browse it by brand, category, and keyword, with brand
optional so the whole ~237-product catalogue can be browsed unfiltered.

Adds the actual bridge: importing a catalogue product snapshots it into
the tenant's own products table (keyed on brand+catalogueid, since a
catalogue row's bare id is only unique within its own brand table),
then links it via the existing productlocations upsert. Re-importing
tops up stock and refreshes price instead of duplicating. Also adds an
imported-refs endpoint so the frontend can badge already-imported items
without diffing full product lists, and wires the new AWS S3 image
store used to resolve catalogue product photos.

Bumps Go/Docker to 1.24 for the AWS SDK dependency this needs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 17:34:20 +05:30

156 lines
4.6 KiB
Go

package db
import (
"context"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// catalogueImagesPrefix is where product photos for the brand catalogue
// live in the bucket: daily/brands/{brand}/{image_id}/image_NNN.<ext>
const catalogueImagesPrefix = "daily/brands/"
// imageStore caches a public-URL listing of catalogue product images so
// GET requests never have to call out to S3 themselves.
type imageStore struct {
mu sync.RWMutex
images map[string]map[string][]string // brand -> image_id -> sorted public URLs
client *s3.Client
bucket string
publicURL string // base URL objects are served from, e.g. https://nearle.sgp1.digitaloceanspaces.com
}
var ImageStore *imageStore
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
// used to resolve catalogue product images. Like the catalogue DB, this must
// never block or fail app startup — if S3 env vars are absent, image URLs
// are simply omitted from catalogue responses.
func connectImageStore() {
if getEnv("USE_S3", "") != "true" {
fmt.Println("⚠️ S3 not enabled, skipping image store")
return
}
endpoint := getEnv("S3_ENDPOINT", "")
bucket := getEnv("S3_BUCKET", "")
accessKey := getEnv("S3_ACCESS_KEY", "")
secretKey := getEnv("S3_SECRET_KEY", "")
region := getEnv("S3_REGION", "")
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
fmt.Println("⚠️ S3 env vars incomplete, skipping image store")
return
}
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com).
// The SDK's virtual-hosted-style client re-prepends the bucket to whatever
// host it's given, so the client must be pointed at the bare region host
// instead, or listing requests end up addressed to "nearle.nearle...".
regionHost := strings.TrimPrefix(endpoint, "https://")
regionHost = strings.TrimPrefix(regionHost, "http://")
regionHost = strings.TrimPrefix(regionHost, bucket+".")
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
log.Println("❌ Could not configure S3 client:", err)
return
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String("https://" + regionHost)
})
ImageStore = &imageStore{
images: make(map[string]map[string][]string),
client: client,
bucket: bucket,
publicURL: strings.TrimSuffix(endpoint, "/"),
}
if err := ImageStore.refresh(); err != nil {
log.Println("❌ Initial catalogue image listing failed:", err)
} else {
fmt.Println("✅ Catalogue image store loaded")
}
go ImageStore.refreshLoop()
}
func (s *imageStore) refreshLoop() {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := s.refresh(); err != nil {
log.Println("⚠️ Catalogue image refresh failed:", err)
}
}
}
// refresh lists every object under daily/brands/ and rebuilds the
// brand -> image_id -> URLs map from scratch, then swaps it in atomically.
func (s *imageStore) refresh() error {
next := make(map[string]map[string][]string)
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(catalogueImagesPrefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.Background())
if err != nil {
return fmt.Errorf("listing %s: %w", catalogueImagesPrefix, err)
}
for _, obj := range page.Contents {
key := aws.ToString(obj.Key)
// daily/brands/{brand}/{image_id}/image_NNN.ext
parts := strings.SplitN(strings.TrimPrefix(key, catalogueImagesPrefix), "/", 3)
if len(parts) != 3 || parts[2] == "" {
continue
}
brand := strings.ToLower(parts[0])
imageID := parts[1]
if next[brand] == nil {
next[brand] = make(map[string][]string)
}
next[brand][imageID] = append(next[brand][imageID], s.publicURL+"/"+key)
}
}
for _, byImage := range next {
for _, urls := range byImage {
sort.Strings(urls)
}
}
s.mu.Lock()
s.images = next
s.mu.Unlock()
return nil
}
// GetImages returns the cached public image URLs for a brand + image_id.
// Safe to call even if the image store was never initialized.
func GetImages(brand, imageID string) []string {
if ImageStore == nil {
return nil
}
ImageStore.mu.RLock()
defer ImageStore.mu.RUnlock()
return ImageStore.images[strings.ToLower(brand)][imageID]
}