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>
This commit is contained in:
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,10 @@ import (
|
||||
|
||||
var (
|
||||
DB *gorm.DB
|
||||
// CatalogueDB is a separate connection to the pgvector/catalogue database.
|
||||
// It is intentionally isolated from DB (nearledb) so catalogue integration
|
||||
// never touches the main database.
|
||||
CatalogueDB *gorm.DB
|
||||
)
|
||||
|
||||
// --------------------
|
||||
@@ -36,6 +41,44 @@ func Connect() {
|
||||
|
||||
setupDB(DB)
|
||||
fmt.Println("✅ Database connected")
|
||||
|
||||
connectCatalogueDB()
|
||||
connectImageStore()
|
||||
}
|
||||
|
||||
// connectCatalogueDB opens the read-only connection to the catalogue
|
||||
// (pgvector) database. If its env vars are not set, catalogue endpoints
|
||||
// are simply unavailable — this must never block startup of the main app.
|
||||
func connectCatalogueDB() {
|
||||
host := getEnv("CATALOGUE_DB_HOST", "")
|
||||
if host == "" {
|
||||
fmt.Println("⚠️ Catalogue DB env vars not set, skipping catalogue DB connection")
|
||||
return
|
||||
}
|
||||
|
||||
// Built as a URL (not a keyword=value DSN) and percent-encoded via
|
||||
// net/url, because this credential's password contains characters
|
||||
// (quotes, #) that the keyword=value DSN format would misparse as
|
||||
// quoting/comment syntax.
|
||||
dsnURL := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(mustEnv("CATALOGUE_DB_USER"), mustEnv("CATALOGUE_DB_PASSWORD")),
|
||||
Host: fmt.Sprintf("%s:%s", host, getEnv("CATALOGUE_DB_PORT", "5432")),
|
||||
Path: "/" + mustEnv("CATALOGUE_DB_NAME"),
|
||||
}
|
||||
q := dsnURL.Query()
|
||||
q.Set("sslmode", "disable")
|
||||
dsnURL.RawQuery = q.Encode()
|
||||
|
||||
catalogueDB, err := gorm.Open(postgres.Open(dsnURL.String()), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Println("❌ Could not connect to catalogue database:", err)
|
||||
return
|
||||
}
|
||||
|
||||
setupDB(catalogueDB)
|
||||
CatalogueDB = catalogueDB
|
||||
fmt.Println("✅ Catalogue database connected")
|
||||
}
|
||||
|
||||
func setupDB(database *gorm.DB) {
|
||||
@@ -53,16 +96,17 @@ func setupDB(database *gorm.DB) {
|
||||
// --------------------
|
||||
|
||||
func CloseDB() {
|
||||
if DB == nil {
|
||||
return
|
||||
if DB != nil {
|
||||
if sqlDB, err := DB.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
log.Println("Error retrieving sql.DB:", err)
|
||||
return
|
||||
if CatalogueDB != nil {
|
||||
if sqlDB, err := CatalogueDB.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
fmt.Println("Connection closed Successfully")
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
// --------------------
|
||||
|
||||
155
db/imagestore.go
Normal file
155
db/imagestore.go
Normal file
@@ -0,0 +1,155 @@
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user