diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..640ecaf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Nothing in here reaches the image. +# +# `.env*` most of all: the Dockerfile does `COPY . .`, and the production +# credentials in `.env.production` were being baked into every image built +# from this folder. The running container gets its environment from the +# platform (Dokploy / Kubernetes), never from a file. +.env +.env.* +!.env.example + +.git +.claude +.DS_Store +docs +scratch +init +nearle +server +docker-compose.local.yml diff --git a/.env b/.env index 2914051..82a4e5e 100644 --- a/.env +++ b/.env @@ -1,16 +1,18 @@ -# Fiesta configuration. +# Fiesta configuration — the shared base file. # -# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one -# exception is this file) — and they are gitignored for a reason: this -# repository's history already contains a committed `.env` from before -# 2026-08-03, so those database credentials are in the history and should be -# rotated. Do not add another. +# Loaded AFTER `.env.` (see config/config.go), and godotenv never +# overwrites a value that is already set, so anything here is a fallback for +# what the environment file and the real environment leave unset. Keep it +# local: with APP_ENV unset this is loaded straight after `.env.local`, and a +# production value here would silently reach a local run. # -# `godotenv.Load()` in main.go reads `.env` from the working directory, so -# `go run .` from this folder picks it up with no flags. +# go run . → .env.local, then this file +# APP_ENV=production go run . → .env.production, then this file +# +# The full list of settings, with what each one does, is in `.env.example`. # ── Where it listens ──────────────────────────────────────────────────────── -# 1122 is what production serves on. Change it locally to run a second copy +# 1122 locally; production serves on 1009. Change it to run a second copy # beside something else; the console then points at the same number. APP_PORT=1122 @@ -59,5 +61,8 @@ REDIS_USER= REDIS_DB=0 # ── Auth ──────────────────────────────────────────────────────────────────── +# Signs POS terminal sessions (16+ characters). A throwaway value so a till can +# sign in against the local stack; production sets its own in the platform. +POS_TOKEN_SECRET=local-dev-signing-secret-not-real JWT_SECRET_KEY= USER_CONTEXT_KEY= diff --git a/.env.example b/.env.example index 0f3b6d8..85f9d5d 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,41 @@ -# Fiesta configuration. +# Fiesta configuration — the complete list of settings, with local values. # -# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one -# exception is this file) — and they are gitignored for a reason: this -# repository's history already contains a committed `.env` from before -# 2026-08-03, so those database credentials are in the history and should be -# rotated. Do not add another. +# How the files are picked (config/config.go): # -# `godotenv.Load()` in main.go reads `.env` from the working directory, so -# `go run .` from this folder picks it up with no flags. +# APP_ENV unset / local → .env.local then .env +# APP_ENV=production → .env.production then .env +# +# A real environment variable always wins over a file, and no file has to +# exist: on the deployed host the values come from the platform's environment +# settings (Dokploy / Kubernetes), not from a file. Anything added here must be +# added there too — a variable in this file and not in the platform is a +# variable that is unset in production. +# +# Startup checks every required setting and prints everything that is missing +# in one go, before any connection is attempted. +# +# The credentials in the committed .env.production have to be treated as +# public: rotate them, and keep new values out of git. + +# ── Environment ───────────────────────────────────────────────────────────── +# local | production. Production requires POS_TOKEN_SECRET and never falls +# back to localhost defaults. Set in the real environment, not in a file: a +# file cannot decide which file gets loaded. +#APP_ENV=local # ── Where it listens ──────────────────────────────────────────────────────── -# 1122 is what production serves on. Change it locally to run a second copy -# beside something else; the console then points at the same number. +# 1122 locally; production serves on 1009 (matching the Dockerfile's EXPOSE). APP_PORT=1122 -ENV=development - # ── The main database (nearledb) ──────────────────────────────────────────── # # ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION. # # There is no "local mode" that protects you: `go run .` against the live host # creates real tenants, real logins and real stock movements, and main.go runs -# schema migrations on boot. If the point of running locally is to try a change -# before it is deployed, a local Postgres with a dump restored into it is the -# only version that actually does that. +# schema migrations on boot. Startup warns when APP_ENV=local and DB_HOST is +# not a local address, but it does not stop you. +# # These match docker-compose.local.yml, so `docker compose -f # docker-compose.local.yml up -d` and `go run .` work together with no edits. DB_HOST=localhost @@ -36,10 +47,9 @@ DB_PASSWORD=localdev # ── The catalogue database (pgvector) ─────────────────────────────────────── # # A separate connection on purpose, so catalogue work never touches nearledb. -# Leave blank to start without it: catalogue endpoints then fail at query time -# rather than at boot, which is fine for testing anything else. -# 5434, not 5432: a developer machine usually has something on 5432 already, -# and a silent connection to the wrong database is worse than a refused one. +# Leave CATALOGUE_DB_HOST blank to start without it: catalogue endpoints then +# fail at query time rather than at boot. With a host set, the other four are +# required. 5434, not 5432: a developer machine usually has something on 5432. CATALOGUE_DB_HOST=localhost CATALOGUE_DB_PORT=5434 CATALOGUE_DB_NAME=cataloguedb @@ -48,12 +58,65 @@ CATALOGUE_DB_PASSWORD=localdev # ── Redis — POS terminal presence, under a TTL ────────────────────────────── # -# Optional. Losing the health board is an inconvenience; losing a sale is not, -# so the API runs without it. +# Optional: leave REDIS_HOST blank to run without it. Losing the health board +# is an inconvenience; losing a sale is not, so the API runs without it. +REDIS_HOST=localhost REDIS_PORT=6379 -REDIS_USER= +REDIS_USER=default +REDIS_PASSWORD= REDIS_DB=0 +# ── DigitalOcean Spaces (S3-compatible) — catalogue product images ────────── +# +# Optional locally. With USE_S3=true every S3_* value below is required. +USE_S3=false +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_ENDPOINT= +S3_BUCKET= +S3_REGION= + +# ── POS terminals — the MQTT broker the in-store tills publish to ─────────── +# +# Optional locally: with MQTT_URL blank the ingest and the console live stream +# stay quiet and the HTTP endpoints still work. In production a blank MQTT_URL +# means every till queues its bills silently — on startup you should see +# three lines reading "pos: subscribed to nearle/pos/+/+/...". +MQTT_URL= +MQTT_USER= +MQTT_PASSWORD= +# Unique per replica: a second connection with the same id evicts the first. +# Defaults to HOSTNAME (the pod name) when unset. +MQTT_CLIENT_ID= +# always | never — otherwise a StatefulSet pod ending in "-0" is elected and +# anything else consumes. See messaging/posmqtt.go. +#POS_MQTT_CONSUMER= + # ── Auth ──────────────────────────────────────────────────────────────────── +# Signs POS terminal sessions; at least 16 characters. Falls back to +# JWT_SECRET_KEY when unset. Required in production. +POS_TOKEN_SECRET=local-dev-signing-secret-not-real JWT_SECRET_KEY= -USER_CONTEXT_KEY= +USER_CONTEXT_KEY=nearle +# true to make the POS routes require a terminal session. +#POS_AUTH_REQUIRED=false + +# ── Scan-to-order — the embedding model behind product recognition ───────── +# +# MUST be the model that filled the catalogue's `embedding` column: vectors +# from two models are not comparable and pgvector will rank garbage without +# complaint. The first search reads the column's width and refuses a mismatch +# with an error that names both numbers. +# Optional: with no provider the search matches on words alone (works, ranks +# worse). openai = any OpenAI-compatible /embeddings endpoint (set +# EMBEDDING_BASE_URL for Azure, Ollama, vLLM...); gemini = Google AI Studio. +EMBEDDING_PROVIDER= +EMBEDDING_MODEL= +EMBEDDING_API_KEY= +EMBEDDING_BASE_URL= +# 0 = the model's default width. +EMBEDDING_DIMENSIONS=0 + +# ── Geocoding ─────────────────────────────────────────────────────────────── +# Google Geocoding when set; OpenStreetMap's Nominatim otherwise. +GEOCODER_API_KEY= diff --git a/.env.local b/.env.local index dea5037..4de96f5 100644 --- a/.env.local +++ b/.env.local @@ -7,13 +7,10 @@ # Keep every host here pointing at localhost. The whole point of the split is # that running the server locally cannot reach live data by accident. # -# `.env`, `.env.local` and `.env.production` are all gitignored (`.env.example` -# is the one exception) — and for a reason: this repository's history already -# contains a committed `.env` from before 2026-08-03, so those credentials are -# in the history and should be rotated. Do not add another. -# -# `loadEnv()` in main.go reads `.env.$APP_ENV` from the working directory, so -# `go run .` from this folder picks this file up with no flags. +# `config.Load()` reads `.env.$APP_ENV` and then `.env` from the working +# directory, so `go run .` from this folder picks this file up with no flags. +# A real environment variable always wins over either file. The full list of +# settings is in `.env.example`. # ── Where it listens ──────────────────────────────────────────────────────── # Production serves on 1009 (see .env.production). Change this locally to run @@ -64,5 +61,8 @@ REDIS_USER= REDIS_DB=0 # ── Auth ──────────────────────────────────────────────────────────────────── +# Signs POS terminal sessions (16+ characters). A throwaway value so a till can +# sign in against the local stack; production sets its own in the platform. +POS_TOKEN_SECRET=local-dev-signing-secret-not-real JWT_SECRET_KEY= USER_CONTEXT_KEY= diff --git a/.env.production b/.env.production index e98c517..30e4cf7 100644 --- a/.env.production +++ b/.env.production @@ -11,13 +11,15 @@ # run. If the point is to try a change before it ships, use `.env.local` with a # dump restored into the local Postgres — that is the only version that does. # -# Gitignored by `.env.*`. Never commit it, and never paste it into a chat, an -# issue or a PR description: the credentials below have to be rotated if it -# leaves this machine. +# On the deployed host these values come from the platform's environment +# settings (Dokploy / Kubernetes), never from this file: the image is built +# without any `.env.*` (see .dockerignore). Keep the two in step — a variable +# added here and not there is a variable that is unset in production, and +# startup will refuse to boot on a missing required one. # -# On the deployed host these values come from Dokploy's environment settings -# rather than from this file. Keep the two in step — a variable added here and -# not there is a variable that is unset in production. +# This file is currently committed to git, which means every credential in it +# has to be treated as public: rotate them, and keep the new values out of +# the repository. # ── Where it listens ──────────────────────────────────────────────────────── APP_PORT=1009 diff --git a/Dockerfile b/Dockerfile index 51abd03..46f8e32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,13 @@ WORKDIR /app COPY --from=builder /app/server /app COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json . +# No `.env.*` is copied in (see .dockerignore), so this only decides which +# rules config.Load applies: production insists on a signing secret and never +# falls back to localhost values. Every real value comes from the platform's +# environment settings. +ENV APP_ENV=production + +# Must match APP_PORT in the platform's environment (1009 in production). EXPOSE 1009 CMD ["/app/server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5b1165 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Fiesta backend (`nearle`) + +The Go/Fiber API behind the Nearle Daily merchant console, the customer app, +the rider app and the in-store POS terminals. Postgres (`nearledb`) for +tenants, stores, products, stock and orders; a separate pgvector database for +the global product catalogue; Redis for POS presence; MQTT for the tills. + +This page is the map. Each section says what a thing is, how to use it, and +where the detail lives. + +## Run it + +```sh +export PATH="$PATH:$HOME/go/bin" # Go 1.24 lives there on the dev Macs +docker compose -f docker-compose.local.yml up -d # postgres :5433, pgvector :5434, redis :6379 +go run . # APP_ENV unset → .env.local, listens on :1122 +go test ./... +``` + +An empty database is not enough — startup runs migrations that assume the +live schema. `init/README.md` explains loading a schema dump first. + +Startup prints what it loaded and where it is pointed: + +``` +config: loaded .env.local +config: APP_ENV=local, listening on :1122, database nearle@localhost:5433/nearledb +scan: product search uses openai/all-minilm # or: EMBEDDING_PROVIDER not set, text-only +``` + +## Configuration + +Everything comes from environment variables, read once by `config.Load()`. + +| You want to… | Do this | +|---|---| +| Run locally | Nothing — `.env.local` is loaded by default | +| Run against production settings | `APP_ENV=production go run .` (⚠ every write is real) | +| See every variable and what it does | `.env.example` | +| Add a new setting | Add it to `config.Config` + `Load()` + `.env.example`, **and to the cluster** (`nearle-config` ConfigMap or `app-secrets` Secret in namespace `nearle`) — a variable in the file and not in the cluster is unset in production | +| Find out why it won't boot | Read the message — it lists *every* missing variable at once | + +Precedence is `real environment > .env. > .env`. The container gets +no `.env` file at all (`.dockerignore`); the `Dockerfile` sets +`APP_ENV=production` and the values come from Kubernetes. + +Full detail, including the committed-credentials situation: +**`docs/ENVIRONMENT.md`**. + +## Layout + +``` +main.go boot: config → databases → migrations → routes → MQTT → listen +config/ env-file loading, typed Config, validation +db/ Postgres (nearledb + catalogue), Redis, S3 image store +facade/ wires repositories → services → controllers (add new modules here) +routes/ one file per module; /live/api/v1/web/... (console) and /v1/mob/... (apps) +controllers/ HTTP in, HTTP out — parse, call the service, shape the envelope +services/ the rules; no SQL, no HTTP +repositories/ the SQL; nothing else +models/ request/response and table shapes +messaging/ MQTT ingest from tills, console live stream +utils/ small shared helpers (tokens, geo, embeddings, geocoding) +docs/ integration specs for the frontends and handoff notes +scratch/ one-off read/verify tools run with `go run ./scratch/` +init/ schema/seed for the local database +``` + +Every response uses the same envelope: +`{ "code": 200, "status": true, "message": "…", "details": … }`. +Business outcomes ("out of stock", "not registered") are 200s with a reason +in the body; HTTP errors mean the request could not be served at all. + +## Adding an endpoint + +1. **Model** the request/response in `models/`. +2. **Repository** method(s) in `repositories/` — SQL only, take a + `context.Context`, return `error`. +3. **Service** in `services/` — the rules, with sentinel errors + (`ErrXxxBadRequest`, `ErrXxxNotFound`) the controller can map to statuses. +4. **Controller** in `controllers/` — `BodyParser`/`Query`, call the service, + map sentinel errors to 400/404/503, everything else to 500. +5. **Routes** file in `routes/`, registered in `routes/routes.go`. +6. **Wire** it in `facade/container.go`. +7. **Test** the service with a fake repository (see `services/scan_test.go` + for the pattern) — no database needed. + +`services/scanService.go` + `controllers/scanController.go` are a complete, +current example of all seven. + +## Features with their own docs + +| Feature | For | Doc | +|---|---|---| +| Environment & deployment | everyone | `docs/ENVIRONMENT.md` | +| Scan-to-order (camera → product → nearest store with stock) | mobile app | `docs/SCAN_TO_ORDER.md` | +| Catalogue import into a store | console | `docs/CATALOGUE_IMPORT_INTEGRATION.md` | +| POS terminal ingest, login, API | POS / tills | `docs/POS_*.md` | +| Access-control audit and what is still open | everyone | `docs/SECURITY_HANDOFF.md` | + +## Things to know before you get surprised + +- **There is no auth layer.** `customerid` / `tenantid` in a request are + trusted. `docs/SECURITY_HANDOFF.md` §1 is the standing issue. +- **Login errors mean what they say.** `409 Invalid Email` = the query ran + and matched nobody. `500 Login is temporarily unavailable` = the database + could not answer (it used to be reported as Invalid Email; see + `services/userService.go` `lookupLogin`). +- **Stock is a ledger.** Live stock is always `SUM(in) − SUM(out)` of + `productstocks` at an outlet, never a stored number. Filter on the same + expression you display (`services/productVisibility.go` explains why). +- **The catalogue is a different database** and must never be reached + through the `nearledb` handle. Its per-brand tables are discovered from + `information_schema`; brands appear and columns vary. +- **Catalogue ids are not stable** across re-scrapes; `imageid` is the + durable key (`models.Products.Imageid`). +- **Migrations run on boot** and are guarded by `IF NOT EXISTS` / schema + checks, not a version table. Read the comments in `main.go` before adding + one — several have bitten before. +- **One MQTT client id per replica.** A second connection with the same id + evicts the first. Never run a local process with the production + `MQTT_URL`. +- **`scratch/` tools read production** when run with `.env.production`. + They are read-only by construction; keep them that way. + +## Operations cheat-sheet (Kubernetes, namespace `nearle`) + +```sh +kubectl get pods -n nearle # fiesta-0/1/2 (StatefulSet) +kubectl logs -n nearle fiesta-0 | grep -E 'config:|scan:|pos:' +kubectl exec -n nearle fiesta-0 -- env | grep EMBEDDING_ +kubectl set env statefulset/fiesta -n nearle KEY=value # adds a var and rolls the pods +kubectl rollout status statefulset/fiesta -n nearle +``` + +The embedding model behind scan-to-order is served by the cluster's Ollama +(`ollama.krow.svc.cluster.local:11434`); `docs/SCAN_TO_ORDER.md` has the +exact settings and why that model. diff --git a/config/config.go b/config/config.go index 5152979..5e7e2cd 100644 --- a/config/config.go +++ b/config/config.go @@ -1,49 +1,361 @@ +// Package config is the one place the process reads its environment. +// +// Two jobs, in order: +// +// 1. Pick the right `.env` file for the environment we are in and load it. +// 2. Read every setting into a typed Config and refuse to start if anything +// required is missing — all of it, in one message, before a single +// connection is attempted. +// +// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and +// `.env.production` described an `APP_ENV` switch that did not exist, so the +// only way to run against production was to overwrite `.env` by hand, and the +// only way to find out a variable was missing was a `log.Fatalf` from inside +// `db.Connect()` — one variable per restart. +// +// # Which file loads +// +// `APP_ENV` names the environment and defaults to "local": +// +// go run . → .env.local, then .env +// APP_ENV=production go run . → .env.production, then .env +// +// `.env` is a shared base loaded after the environment file. godotenv never +// overwrites a variable that is already set, so the order of precedence is: +// +// real environment > .env. > .env +// +// Neither file has to exist. On the deployed host every value comes from the +// platform's environment settings (Dokploy today, ConfigMaps/Secrets under +// Kubernetes) and there is no file at all — which is exactly why the +// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the +// image carries no `.env.*`, so it cannot fall back to localhost values that +// happen to be lying around in the build context. package config import ( + "errors" + "fmt" "log" "os" + "strconv" + "strings" + + "github.com/joho/godotenv" ) +// Environment names. Anything else is accepted (a staging file works the same +// way) but only these two change behaviour. +const ( + EnvLocal = "local" + EnvProduction = "production" +) + +// Config is everything the process reads from its environment. +// +// A few settings are still read directly with os.Getenv at the point of use, +// because they are consulted per request or per connection rather than once +// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED +// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_* +// and POS_* settings in package messaging. They are listed and validated here +// so that a misconfiguration is still caught at startup. type Config struct { - Env string - Port string - DBName string - DBUser string - DBPassword string - DBPort string - DBHost string - UserContextKey string + // AppEnv is the value of APP_ENV: "local" or "production". + AppEnv string + // Port the API listens on. APP_PORT, default 1122 (production sets 1009). + Port string + + DB DBConfig + Catalogue DBConfig // Host empty → catalogue endpoints disabled. + Redis RedisConfig + S3 S3Config + MQTT MQTTConfig + Embedding EmbeddingConfig + + // POSTokenSecret signs terminal sessions. Falls back to JWTSecret when + // unset, matching utils/postoken.go. + POSTokenSecret string JWTSecret string + UserContextKey string + GeocoderAPIKey string } -func Load() *Config { +// DBConfig is one Postgres connection. +type DBConfig struct { + Host string + Port string + Name string + User string + Password string +} + +// Enabled reports whether a host was configured at all. Only meaningful for +// the optional catalogue connection; the main database is required. +func (d DBConfig) Enabled() bool { return d.Host != "" } + +// RedisConfig is the shared Redis used for POS terminal presence. Optional: +// Host empty means presence is disabled and bills still commit. +type RedisConfig struct { + Host string + Port string + User string + Password string + DB int +} + +func (r RedisConfig) Enabled() bool { return r.Host != "" } + +// S3Config is the DigitalOcean Spaces bucket holding catalogue product images. +type S3Config struct { + Enabled bool // USE_S3=true + Endpoint string + Bucket string + AccessKey string + SecretKey string + Region string +} + +// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty +// means the MQTT ingest and the console live stream stay quiet. +type MQTTConfig struct { + URL string + User string + Password string + ClientID string +} + +func (m MQTTConfig) Enabled() bool { return m.URL != "" } + +// EmbeddingConfig is the text-embedding model behind the scan-to-product +// search (services/scanService.go). It MUST be the model that filled the +// catalogue's `embedding` column — vectors from two different models are not +// comparable, and pgvector will happily rank garbage. Optional: with no +// provider the search falls back to plain text matching. +type EmbeddingConfig struct { + Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini" + Model string + APIKey string + BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1 + Dimensions int // 0 = the model's default +} + +func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" } + +// IsProduction is true under APP_ENV=production. +func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction } + +// Load picks and loads the environment files, reads every setting and +// validates them. The returned error lists every problem at once. +func Load() (*Config, error) { + loadEnvFiles() + cfg := &Config{ - Env: getEnv("ENV", "production"), - Port: getEnv("APP_PORT", "1009"), + AppEnv: env("APP_ENV", EnvLocal), + Port: env("APP_PORT", "1122"), - // ✅ STANDARDIZED DB ENV KEYS - DBName: getEnv("DB_NAME", ""), - DBUser: getEnv("DB_USER", ""), - DBPassword: getEnv("DB_PASSWORD", ""), - DBPort: getEnv("DB_PORT", "5432"), - DBHost: getEnv("DB_HOST", "localhost"), + DB: DBConfig{ + Host: env("DB_HOST", ""), + Port: env("DB_PORT", "5433"), + Name: env("DB_NAME", ""), + User: env("DB_USER", ""), + Password: env("DB_PASSWORD", ""), + }, + Catalogue: DBConfig{ + Host: env("CATALOGUE_DB_HOST", ""), + Port: env("CATALOGUE_DB_PORT", "5432"), + Name: env("CATALOGUE_DB_NAME", ""), + User: env("CATALOGUE_DB_USER", ""), + Password: env("CATALOGUE_DB_PASSWORD", ""), + }, + Redis: RedisConfig{ + Host: env("REDIS_HOST", ""), + Port: env("REDIS_PORT", "6379"), + User: env("REDIS_USER", "default"), + Password: env("REDIS_PASSWORD", ""), + }, + S3: S3Config{ + Enabled: strings.EqualFold(env("USE_S3", ""), "true"), + Endpoint: env("S3_ENDPOINT", ""), + Bucket: env("S3_BUCKET", ""), + AccessKey: env("S3_ACCESS_KEY", ""), + SecretKey: env("S3_SECRET_KEY", ""), + Region: env("S3_REGION", ""), + }, + MQTT: MQTTConfig{ + URL: env("MQTT_URL", ""), + // MQTT_USERNAME is accepted because livehub.go read that name for + // a while, so an existing deployment may still set it. + User: env("MQTT_USER", env("MQTT_USERNAME", "")), + Password: env("MQTT_PASSWORD", ""), + ClientID: env("MQTT_CLIENT_ID", ""), + }, - UserContextKey: getEnv("USER_CONTEXT_KEY", "nearle"), - JWTSecret: getEnv("JWT_SECRET_KEY", ""), + Embedding: EmbeddingConfig{ + Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")), + Model: env("EMBEDDING_MODEL", ""), + APIKey: env("EMBEDDING_API_KEY", ""), + BaseURL: env("EMBEDDING_BASE_URL", ""), + }, + + POSTokenSecret: env("POS_TOKEN_SECRET", ""), + JWTSecret: env("JWT_SECRET_KEY", ""), + UserContextKey: env("USER_CONTEXT_KEY", "nearle"), + GeocoderAPIKey: env("GEOCODER_API_KEY", ""), } - // ✅ Correct validation - if cfg.DBPassword == "" { - log.Println("Warning: DB_PASSWORD is not set") + if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil { + cfg.Redis.DB = db + } else { + return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", "")) } + if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" { + n, err := strconv.Atoi(dims) + if err != nil || n < 0 { + return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims) + } + cfg.Embedding.Dimensions = n + } + + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// MustLoad is Load for main(): every problem is printed and the process exits. +func MustLoad() *Config { + cfg, err := Load() + if err != nil { + log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err) + } + log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s", + cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name) return cfg } -func getEnv(key, fallback string) string { - if v := os.Getenv(key); v != "" { +// validate collects every problem rather than stopping at the first, so one +// restart is enough to learn everything that is wrong. +func (c *Config) validate() error { + var problems []string + missing := func(key string) { problems = append(problems, " - "+key+" is required") } + + if c.DB.Host == "" { + missing("DB_HOST") + } + if c.DB.User == "" { + missing("DB_USER") + } + if c.DB.Password == "" { + missing("DB_PASSWORD") + } + if c.DB.Name == "" { + missing("DB_NAME") + } + + // Optional subsystems are either fully configured or absent. Half a + // configuration used to be skipped with a warning, which reads as "fine" + // in a log and turns into "why are there no images" a week later. + if c.Catalogue.Enabled() { + if c.Catalogue.User == "" { + missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)") + } + if c.Catalogue.Password == "" { + missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)") + } + if c.Catalogue.Name == "" { + missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)") + } + } + if c.S3.Enabled { + if c.S3.Endpoint == "" { + missing("S3_ENDPOINT (USE_S3=true)") + } + if c.S3.Bucket == "" { + missing("S3_BUCKET (USE_S3=true)") + } + if c.S3.AccessKey == "" { + missing("S3_ACCESS_KEY (USE_S3=true)") + } + if c.S3.SecretKey == "" { + missing("S3_SECRET_KEY (USE_S3=true)") + } + if c.S3.Region == "" { + missing("S3_REGION (USE_S3=true)") + } + } + + if c.Embedding.Enabled() { + switch c.Embedding.Provider { + case "openai", "gemini": + default: + problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider) + } + if c.Embedding.Model == "" { + missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)") + } + if c.Embedding.APIKey == "" { + missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)") + } + } + + if c.IsProduction() { + // utils/postoken.go refuses to sign with a short secret at request + // time; catching it here means the first till login is not the first + // anyone hears of it. + secret := c.POSTokenSecret + if secret == "" { + secret = c.JWTSecret + } + if strings.TrimSpace(secret) == "" { + missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)") + } else if len(strings.TrimSpace(secret)) < 16 { + problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters") + } + } else if c.DB.Host != "" && !isLocalHost(c.DB.Host) { + // Not fatal: a dump restored on another machine on the LAN is a valid + // local setup. But `.env.local` pointing at the live host is the + // mistake every comment in that file warns about, so say it out loud. + log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real", + c.AppEnv, c.DB.Host) + } + + if len(problems) == 0 { + return nil + } + return errors.New(strings.Join(problems, "\n")) +} + +// loadEnvFiles loads `.env.` and then `.env`, each only if present. +// +// APP_ENV is read from the real environment before any file, so a file cannot +// change which environment it is loaded for. +func loadEnvFiles() { + appEnv := env("APP_ENV", EnvLocal) + + for _, name := range []string{".env." + appEnv, ".env"} { + if _, err := os.Stat(name); err != nil { + continue + } + if err := godotenv.Load(name); err != nil { + log.Printf("config: could not read %s: %v", name, err) + continue + } + log.Printf("config: loaded %s", name) + } +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } + +func isLocalHost(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1", "host.docker.internal": + return true + } + return strings.HasPrefix(host, "127.") +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..ed5ddc0 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,251 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Every key Load reads, so a test starts from nothing rather than from +// whatever the developer's shell happens to export. +var allKeys = []string{ + "APP_ENV", "APP_PORT", + "DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD", + "CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_NAME", "CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", + "REDIS_HOST", "REDIS_PORT", "REDIS_USER", "REDIS_PASSWORD", "REDIS_DB", + "USE_S3", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION", + "MQTT_URL", "MQTT_USER", "MQTT_USERNAME", "MQTT_PASSWORD", "MQTT_CLIENT_ID", + "POS_TOKEN_SECRET", "JWT_SECRET_KEY", "USER_CONTEXT_KEY", "GEOCODER_API_KEY", + "EMBEDDING_PROVIDER", "EMBEDDING_MODEL", "EMBEDDING_API_KEY", "EMBEDDING_BASE_URL", "EMBEDDING_DIMENSIONS", +} + +// cleanEnv clears every setting and moves into an empty directory so no +// `.env` file is picked up by accident. t.Setenv registers the restore; the +// Unsetenv after it matters because godotenv treats a variable that is present +// but empty as set and will not fill it from a file. +func cleanEnv(t *testing.T) string { + t.Helper() + unsetAll(t) + dir := t.TempDir() + t.Chdir(dir) + return dir +} + +func unsetAll(t *testing.T) { + t.Helper() + for _, k := range allKeys { + t.Setenv(k, "") + os.Unsetenv(k) + } +} + +func setMainDB(t *testing.T) { + t.Helper() + t.Setenv("DB_HOST", "localhost") + t.Setenv("DB_USER", "nearle") + t.Setenv("DB_PASSWORD", "localdev") + t.Setenv("DB_NAME", "nearledb") +} + +func write(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestLoadReportsEveryMissingRequiredKeyAtOnce(t *testing.T) { + cleanEnv(t) + + _, err := Load() + if err == nil { + t.Fatal("expected an error with no database configured") + } + for _, key := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} { + if !strings.Contains(err.Error(), key) { + t.Errorf("error should name %s, got:\n%s", key, err) + } + } +} + +func TestLoadDefaults(t *testing.T) { + cleanEnv(t) + setMainDB(t) + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.AppEnv != EnvLocal { + t.Errorf("AppEnv = %q, want local", cfg.AppEnv) + } + if cfg.IsProduction() { + t.Error("IsProduction should be false by default") + } + if cfg.Port != "1122" { + t.Errorf("Port = %q, want 1122", cfg.Port) + } + if cfg.DB.Port != "5433" { + t.Errorf("DB.Port = %q, want 5433 (the port production and the local compose share)", cfg.DB.Port) + } + if cfg.Catalogue.Enabled() || cfg.Redis.Enabled() || cfg.S3.Enabled || cfg.MQTT.Enabled() { + t.Error("optional subsystems should be off when unset") + } + if cfg.Redis.User != "default" || cfg.Redis.Port != "6379" || cfg.Redis.DB != 0 { + t.Errorf("redis defaults wrong: %+v", cfg.Redis) + } +} + +func TestAppEnvSelectsTheEnvFile(t *testing.T) { + dir := cleanEnv(t) + write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=local\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1122\n") + write(t, dir, ".env.production", "DB_HOST=db.internal\nDB_USER=prod\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1009\nPOS_TOKEN_SECRET=0123456789abcdef\n") + // The shared base: only fills in what the environment file left unset. + write(t, dir, ".env", "DB_USER=base\nUSER_CONTEXT_KEY=from-base\n") + + t.Run("default is local", func(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.DB.User != "local" || cfg.Port != "1122" { + t.Errorf("expected .env.local values, got user=%s port=%s", cfg.DB.User, cfg.Port) + } + if cfg.UserContextKey != "from-base" { + t.Errorf(".env should fill in what .env.local left unset, got %q", cfg.UserContextKey) + } + }) + + t.Run("APP_ENV=production", func(t *testing.T) { + // Clears what godotenv loaded in the sibling above; restored on return. + unsetAll(t) + t.Setenv("APP_ENV", EnvProduction) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if !cfg.IsProduction() || cfg.DB.User != "prod" || cfg.Port != "1009" { + t.Errorf("expected .env.production values, got env=%s user=%s port=%s", cfg.AppEnv, cfg.DB.User, cfg.Port) + } + }) +} + +func TestRealEnvironmentBeatsTheFile(t *testing.T) { + dir := cleanEnv(t) + write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=file\nDB_PASSWORD=x\nDB_NAME=nearledb\n") + t.Setenv("DB_USER", "shell") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.DB.User != "shell" { + t.Errorf("a variable already in the environment must not be overwritten by the file, got %q", cfg.DB.User) + } +} + +func TestProductionRequiresASigningSecret(t *testing.T) { + cleanEnv(t) + setMainDB(t) + t.Setenv("APP_ENV", EnvProduction) + + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "POS_TOKEN_SECRET") { + t.Fatalf("production without a secret should fail naming POS_TOKEN_SECRET, got %v", err) + } + + t.Setenv("POS_TOKEN_SECRET", "short") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "16 characters") { + t.Fatalf("a short secret should be refused, got %v", err) + } + + t.Setenv("POS_TOKEN_SECRET", "") + t.Setenv("JWT_SECRET_KEY", "a-long-enough-fallback-secret") + if _, err := Load(); err != nil { + t.Fatalf("JWT_SECRET_KEY should be accepted as the fallback, got %v", err) + } +} + +func TestHalfConfiguredSubsystemsAreRefused(t *testing.T) { + cleanEnv(t) + setMainDB(t) + + t.Setenv("CATALOGUE_DB_HOST", "localhost") + t.Setenv("USE_S3", "true") + t.Setenv("S3_BUCKET", "nearle") + + _, err := Load() + if err == nil { + t.Fatal("expected an error") + } + for _, want := range []string{"CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME", "S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should name %s, got:\n%s", want, err) + } + } + if strings.Contains(err.Error(), "S3_BUCKET") { + t.Error("S3_BUCKET was set and must not be reported") + } +} + +func TestMQTTUsernameFallback(t *testing.T) { + cleanEnv(t) + setMainDB(t) + t.Setenv("MQTT_URL", "tcp://broker:1883") + t.Setenv("MQTT_USERNAME", "legacy") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.MQTT.User != "legacy" { + t.Errorf("MQTT_USERNAME should still be honoured, got %q", cfg.MQTT.User) + } + + t.Setenv("MQTT_USER", "current") + cfg, err = Load() + if err != nil { + t.Fatal(err) + } + if cfg.MQTT.User != "current" { + t.Errorf("MQTT_USER should win over MQTT_USERNAME, got %q", cfg.MQTT.User) + } +} + +func TestRedisDBMustBeNumeric(t *testing.T) { + cleanEnv(t) + setMainDB(t) + t.Setenv("REDIS_DB", "zero") + + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "REDIS_DB") { + t.Fatalf("expected REDIS_DB error, got %v", err) + } +} + +func TestEmbeddingProviderNeedsModelAndKey(t *testing.T) { + cleanEnv(t) + setMainDB(t) + t.Setenv("EMBEDDING_PROVIDER", "openai") + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "EMBEDDING_MODEL") || !strings.Contains(err.Error(), "EMBEDDING_API_KEY") { + t.Fatalf("a provider without model and key should be refused naming both, got %v", err) + } + + t.Setenv("EMBEDDING_PROVIDER", "cohere") + t.Setenv("EMBEDDING_MODEL", "x") + t.Setenv("EMBEDDING_API_KEY", "y") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "EMBEDDING_PROVIDER") { + t.Fatalf("an unknown provider should be refused, got %v", err) + } + + t.Setenv("EMBEDDING_PROVIDER", "Gemini") + t.Setenv("EMBEDDING_DIMENSIONS", "768") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Embedding.Provider != "gemini" || cfg.Embedding.Dimensions != 768 || !cfg.Embedding.Enabled() { + t.Fatalf("unexpected embedding config: %+v", cfg.Embedding) + } +} diff --git a/controllers/scanController.go b/controllers/scanController.go new file mode 100644 index 0000000..302d899 --- /dev/null +++ b/controllers/scanController.go @@ -0,0 +1,101 @@ +package controllers + +import ( + "errors" + "nearle/models" + "nearle/services" + "net/http" + + "github.com/gofiber/fiber/v2" +) + +// ScanController is the scan-to-order surface for the customer app: +// +// POST /v1/mob/scan/lookup a label → the product, and which of my stores has it +// POST /v1/mob/scan/confirm I picked a store and a size → still there? else where? +// GET /v1/mob/scan/stores my stores, nearest first +// +// Business outcomes ("out of stock", "not registered with that store") are +// 200s with a reason in the body: the app renders them, it does not retry +// them. HTTP errors are reserved for a request that cannot be served at all. +type ScanController struct { + scanService services.ScanService +} + +func NewScanController(scanService services.ScanService) *ScanController { + return &ScanController{scanService: scanService} +} + +func (ctl *ScanController) Lookup(c *fiber.Ctx) error { + var req models.ScanLookupRequest + if err := c.BodyParser(&req); err != nil { + return scanBadRequest(c, "Invalid request body") + } + resp, err := ctl.scanService.Lookup(c.Context(), req) + if err != nil { + return scanError(c, err, "Could not look up that product") + } + return c.Status(http.StatusOK).JSON(fiber.Map{ + "code": http.StatusOK, + "status": true, + "message": resp.Message, + "details": resp, + }) +} + +func (ctl *ScanController) Confirm(c *fiber.Ctx) error { + var req models.ScanConfirmRequest + if err := c.BodyParser(&req); err != nil { + return scanBadRequest(c, "Invalid request body") + } + resp, err := ctl.scanService.Confirm(c.Context(), req) + if err != nil { + return scanError(c, err, "Could not check that store") + } + return c.Status(http.StatusOK).JSON(fiber.Map{ + "code": http.StatusOK, + "status": true, + "message": resp.Message, + "details": resp, + }) +} + +func (ctl *ScanController) Stores(c *fiber.Ctx) error { + customerid, _ := c.QueryInt("customerid"), 0 + stores, err := ctl.scanService.Stores(c.Context(), customerid, + models.FlexibleString(c.Query("latitude")), models.FlexibleString(c.Query("longitude"))) + if err != nil { + return scanError(c, err, "Could not list your stores") + } + return c.Status(http.StatusOK).JSON(fiber.Map{ + "code": http.StatusOK, + "status": true, + "message": "Success", + "details": stores, + }) +} + +func scanBadRequest(c *fiber.Ctx, msg string) error { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "status": false, + "message": msg, + }) +} + +func scanError(c *fiber.Ctx, err error, fallback string) error { + code, msg := http.StatusInternalServerError, fallback + switch { + case errors.Is(err, services.ErrScanBadRequest): + code, msg = http.StatusBadRequest, err.Error() + case errors.Is(err, services.ErrScanCustomerNotFound): + code, msg = http.StatusNotFound, "Customer not found" + case errors.Is(err, services.ErrScanCatalogueDown): + code, msg = http.StatusServiceUnavailable, "Product search is temporarily unavailable" + } + return c.Status(code).JSON(fiber.Map{ + "code": code, + "status": false, + "message": msg, + }) +} diff --git a/create_table.go b/create_table.go deleted file mode 100644 index 6f306c6..0000000 --- a/create_table.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "fmt" - "log" - "gorm.io/driver/postgres" - "gorm.io/gorm" -) - -func CreateStockRequestsTable() { - dsn := "host=66.116.207.225 user=admin password=Package@123# dbname=nearledb port=5433 sslmode=disable TimeZone=Asia/Kolkata" - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - log.Fatalf("failed to connect database: %v", err) - } - - query := `CREATE TABLE IF NOT EXISTS stockrequests ( - requestid SERIAL PRIMARY KEY, - tenantid INT NOT NULL, - locationid INT NOT NULL, - productid INT NOT NULL, - qty INT NOT NULL, - status VARCHAR(50) DEFAULT 'Pending', - created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP - );` - - if err := db.Exec(query).Error; err != nil { - log.Fatalf("failed to create table: %v", err) - } - fmt.Println("Table stockrequests created successfully") -} diff --git a/db/connect.go b/db/connect.go index 8fc5169..def9707 100644 --- a/db/connect.go +++ b/db/connect.go @@ -3,8 +3,8 @@ package db import ( "fmt" "log" + "nearle/config" "net/url" - "os" "time" "gorm.io/driver/postgres" @@ -23,14 +23,18 @@ var ( // DATABASE CONNECTION // -------------------- -func Connect() { +// Connect opens the main database and then the optional catalogue database +// and image store. Everything it needs has already been validated by +// config.Load, so a missing variable can no longer surface here as a +// log.Fatal halfway through boot. +func Connect(cfg *config.Config) { dsn := fmt.Sprintf( "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata", - mustEnv("DB_HOST"), - mustEnv("DB_USER"), - mustEnv("DB_PASSWORD"), - mustEnv("DB_NAME"), - getEnv("DB_PORT", "5433"), + cfg.DB.Host, + cfg.DB.User, + cfg.DB.Password, + cfg.DB.Name, + cfg.DB.Port, ) var err error @@ -42,17 +46,16 @@ func Connect() { setupDB(DB) fmt.Println("✅ Database connected") - connectCatalogueDB() - connectImageStore() + connectCatalogueDB(cfg.Catalogue) + connectImageStore(cfg.S3) } // 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") +func connectCatalogueDB(c config.DBConfig) { + if !c.Enabled() { + fmt.Println("⚠️ CATALOGUE_DB_HOST not set, skipping catalogue DB connection") return } @@ -62,9 +65,9 @@ func connectCatalogueDB() { // 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"), + User: url.UserPassword(c.User, c.Password), + Host: fmt.Sprintf("%s:%s", c.Host, c.Port), + Path: "/" + c.Name, } q := dsnURL.Query() q.Set("sslmode", "disable") @@ -108,22 +111,3 @@ func CloseDB() { } fmt.Println("Connection closed Successfully") } - -// -------------------- -// ENV HELPERS -// -------------------- - -func mustEnv(key string) string { - val := os.Getenv(key) - if val == "" { - log.Fatalf("Missing required env variable: %s", key) - } - return val -} - -func getEnv(key, fallback string) string { - if val := os.Getenv(key); val != "" { - return val - } - return fallback -} diff --git a/db/imagestore.go b/db/imagestore.go index f453ade..cb68354 100644 --- a/db/imagestore.go +++ b/db/imagestore.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "nearle/config" "sort" "strings" "sync" @@ -33,23 +34,20 @@ 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") +// never block or fail app startup — with USE_S3 unset, image URLs are simply +// omitted from catalogue responses. (USE_S3=true with a key missing is caught +// by config.Load before we get here.) +func connectImageStore(c config.S3Config) { + if !c.Enabled { + fmt.Println("⚠️ USE_S3 not set, 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 - } + endpoint := c.Endpoint + bucket := c.Bucket + accessKey := c.AccessKey + secretKey := c.SecretKey + region := c.Region // 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 diff --git a/db/redis.go b/db/redis.go index 6126dee..fad62f8 100644 --- a/db/redis.go +++ b/db/redis.go @@ -3,9 +3,7 @@ package db import ( "context" "log" - "os" - "strconv" - "strings" + "nearle/config" "time" "github.com/redis/go-redis/v9" @@ -31,23 +29,18 @@ var RedisCtx = context.Background() // Redis is optional here: without it the POS health board goes dark, but bills // still arrive and commit. That is the right failure — losing presence is an // inconvenience, losing a sale is not — so this never aborts startup. -func InitRedis() { - host := strings.TrimSpace(os.Getenv("REDIS_HOST")) - if host == "" { +func InitRedis(c config.RedisConfig) { + if !c.Enabled() { log.Println("redis: REDIS_HOST not set, POS presence disabled") return } - port := getEnv("REDIS_PORT", "6379") - dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0")) - if err != nil { - dbIndex = 0 - } + host, port, dbIndex := c.Host, c.Port, c.DB Rdb = redis.NewClient(&redis.Options{ Addr: host + ":" + port, - Username: getEnv("REDIS_USER", "default"), - Password: os.Getenv("REDIS_PASSWORD"), + Username: c.User, + Password: c.Password, DB: dbIndex, // Short on purpose. A degraded Redis must fail fast rather than tie up diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md new file mode 100644 index 0000000..bcd3e66 --- /dev/null +++ b/docs/ENVIRONMENT.md @@ -0,0 +1,103 @@ +# Environment & configuration + +Everything the API reads from its environment goes through `config/config.go`. +It loads the right `.env` file, reads every setting into one `Config`, and +refuses to start — listing *everything* that is wrong in one message — before +a single connection is attempted. + +## Which file loads + +`APP_ENV` names the environment. It defaults to `local`. + +| Command | Files loaded, in order | +|----------------------------------|---------------------------------| +| `go run .` | `.env.local`, then `.env` | +| `APP_ENV=production go run .` | `.env.production`, then `.env` | +| `APP_ENV=staging go run .` | `.env.staging`, then `.env` | + +Precedence, highest first: + +``` +real environment > .env. > .env +``` + +A variable that is already set is never overwritten by a file, and no file has +to exist. `APP_ENV` itself is read from the real environment before any file +is opened — a file cannot decide which file gets loaded. + +| File | Role | +|-------------------|------------------------------------------------------------| +| `.env.example` | The complete list of settings, with comments. Start here. | +| `.env.local` | The docker-compose stack. Every host is `localhost`. | +| `.env.production` | The live hosts. Loaded only when asked for. | +| `.env` | Shared base: fallbacks for whatever the file above left out. Keep it local. | + +## Running locally + +```sh +docker compose -f docker-compose.local.yml up -d # postgres :5433, pgvector :5434, redis :6379 +go run . # APP_ENV unset → .env.local +``` + +An empty database is not enough — `main.go` runs migrations that assume the +live schema. See `init/README.md` for loading a schema dump first. + +Startup prints what it loaded and where it is pointed: + +``` +config: loaded .env.local +config: loaded .env +config: APP_ENV=local, listening on :1122, database nearle@localhost:5433/nearledb +``` + +If `DB_HOST` is not a local address under `APP_ENV=local`, it says so: + +``` +⚠️ APP_ENV=local but DB_HOST=66.116.x.x is not a local address — every write goes to that database for real +``` + +That is a warning, not a stop. There is no "local mode" that protects +production: `go run .` against the live host creates real tenants and real +logins, and runs schema migrations on boot. + +## Running in production + +The container gets **no `.env` file at all** — `.dockerignore` keeps every +`.env*` out of the image — and the `Dockerfile` sets `APP_ENV=production`. +Every value comes from the platform's environment settings (Dokploy today; +ConfigMaps/Secrets under Kubernetes). + +Under `APP_ENV=production` startup additionally insists on: + +- `POS_TOKEN_SECRET` (or `JWT_SECRET_KEY` as a fallback), at least 16 characters. + +A variable added to `.env.production` and not to the platform is a variable +that is unset in production. Missing required ones stop the boot with the +full list; missing optional ones (`MQTT_URL`, `REDIS_HOST`, `USE_S3`, +`CATALOGUE_DB_HOST`) silently disable that subsystem — check the startup log +lines when something is "not working". + +## What is required + +| Always | Only when enabled | +|----------------------------------------------|---------------------------------------------------------| +| `DB_HOST` `DB_USER` `DB_PASSWORD` `DB_NAME` | `CATALOGUE_DB_HOST` set → `CATALOGUE_DB_USER/PASSWORD/NAME` | +| (production) `POS_TOKEN_SECRET` | `USE_S3=true` → `S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY/REGION` | + +A half-configured subsystem is an error, not a warning: a warning reads as +"fine" in a log and turns into "why are there no images" a week later. + +## The committed credentials + +The three `.env` files, including `.env.production`, are currently tracked in +git (commit `be47435`), and an earlier `.env` was committed before +2026-08-03. Every credential in them has to be treated as public: + +1. Rotate the database, catalogue, Spaces, MQTT and Redis credentials and the + POS signing secret, and update them in the platform. +2. Take the files back out of the index and restore the ignore rules: + ```sh + git rm --cached .env .env.local .env.production + printf '.env\n.env.*\n!.env.example\n' >> .gitignore + ``` + The files stay on disk; they just stop being committed. diff --git a/docs/PORTFOLIO.md b/docs/PORTFOLIO.md new file mode 100644 index 0000000..7d5e694 --- /dev/null +++ b/docs/PORTFOLIO.md @@ -0,0 +1,778 @@ +# Nearle "Fiesta" backend — what was built + +A Go monolith that serves a multi-tenant retail platform: neighbourhood shops +(tenants) with one or more outlets, selling through a consumer app, a rider +delivery fleet, and — the newest and largest piece of work — **physical +point-of-sale terminals sitting on shop counters**. + +There are really five distinct systems in here. They are ordered below by how +much original engineering they represent. + +--- + +## 1. The POS terminal integration (the centrepiece) + +### What it is + +Retail tills in shops run a Flutter app with its own local SQLite database. They +keep selling with **no network at all** — a village shop's connection drops for +hours. When connectivity returns, each till uploads the bills it rang while +offline, pulls down an updated product catalogue, and reports its own health. + +This backend is the other end of that conversation. It has to solve the classic +offline-first sync problem under a hard constraint: *a sale that has already been +paid for in cash must never be lost, and must never be counted twice.* + +The problem it solves for the business: shops that were doing counter sales +entirely off-platform now have those sales in the same database as their app +orders, deducting from the same stock, appearing in the same revenue reports. + +### How it's built + +``` + ┌─────────────────────────────────────────────────────────┐ + │ Till (Flutter + SQLite) — not in this repo │ + │ sale committed locally first, sync_status = 0 │ + └───────────────┬──────────────────────────┬──────────────┘ + │ │ + (transport A) MQTT (transport B) HTTPS + nearle/pos/{loc}/{term}/order POST /live/api/v1/pos/orders + nearle/pos/{loc}/{term}/customer POST .../customers + nearle/pos/{loc}/{term}/health POST .../health + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌────────────────────────┐ + │ messaging/posmqtt.go │ │ controllers/ │ + │ • leader election │ │ posController.go │ + │ • bounded worker │ │ • PosAuth middleware │ + │ pools (ingest, │ │ verifies token + │ + │ health) │ │ outlet ownership │ + └──────────┬───────────┘ └───────────┬────────────┘ + └────────────┬──────────────┘ + ▼ + services.PosService ← one code path for both + │ + ┌─────────────────┼──────────────────┐ + ▼ ▼ ▼ + posRepository posSalesRepository posPresence + (ingest, catalogue) (read-back) (Redis) + │ │ │ + ▼ ▼ ▼ + ┌──────────────────────────────┐ ┌──────────────┐ + │ POSTGRES (nearledb) │ │ REDIS │ + │ pos_orders │ │ pos:terminal:│ + │ pos_order_items │ │ {id} HASH │ + │ productstocks ← shared │ │ TTL 90s │ + │ customers, app_users │ │ pos:location:│ + └──────────────────────────────┘ │ {id} SET │ + └──────────────┘ + │ + ▼ + ack → nearle/pos/{loc}/{term}/ack (or HTTP 200 body) + │ + ▼ + Till marks bill synced, keeps its copy 7 more days +``` + +The storage split is deliberate and explained in the code: + +- **Postgres `pos_orders` / `pos_order_items`** — the permanent record of counter + bills, kept *separate* from the app's `orders` table. A bill carries a cashier, + a terminal id, a rounding adjustment, promo campaigns, loyalty movement and a + payment split across several tenders; `orders` has nowhere to put any of that. + The stated cost of the split is that every revenue query has to union both — + which was done, in `orderRepository.posRevenue` and `posSalesTotals`. +- **Postgres `productstocks`** — stock is deliberately *not* split. A counter sale + writes the same "out" ledger rows an app order does, through the same helper, so + the catalogue pushed down to a till reflects the till's own trading. +- **Redis** — terminal presence only, with a 90-second TTL. Shared with a separate + Express backend (the rider app reads the board), namespaced `pos:*` so it cannot + collide with that service's `delivery:*` / `city:*` keys. + +### How the problem was solved — the approach + +Six interlocking decisions. + +**(a) The acknowledgement protocol is the whole design.** Delivery is +at-least-once. The rule, stated in `models.PosAck`: *a till marks a record synced +if and only if its id appears in `accepted`.* Silence is not acceptance — an empty +ack, a dropped connection, or a 200 with no body all leave the record pending, and +it gets re-sent. `rejected` is a separate, deliberate verdict meaning "stop +retrying this one, fetch a human" — used for a malformed bill, never for "the +database is having a bad minute." That distinction is enforced right up in the +HTTP layer: `posIngestError` decides 4xx (permanent — the till halts and shows a +person) versus 5xx (unknown — the till keeps everything and backs off). + +**(b) Idempotency: a duplicate is a success, not a failure.** Each bill carries a +UUID minted at the till, stored as `terminalorderid` with a unique index. On +arrival, `importPosOrder` opens a transaction, takes +`pg_advisory_xact_lock(hashtext('possale:' || id))`, then checks whether the bill +is already held. If it is, the transaction rolls back and the bill is +**accepted** — stock untouched. Calling a re-delivered bill a failure would strand +a day's takings on the till forever. The advisory lock turns what would otherwise +be a unique-constraint violation into an orderly "already held," and closes the +race where two redeliveries arrive simultaneously. + +**(c) Ordering inside the transaction — locks, then availability, then writes.** +`stockLedger.go` holds the shared machinery. `lockStockRows` takes +`SELECT … FOR UPDATE` on every `(tenant, location, product)` the sale touches, +**sorted by (productid, locationid)** so two concurrent sales sharing products +always contend in the same sequence and block rather than deadlock. Only then does +`assertStockAvailable` read balances, and only then are rows written. This is the +same path an app order and a spreadsheet import take — the code was extracted +specifically so there aren't three implementations of the anti-overselling rule +drifting apart. + +**(d) Line-item reconciliation.** A subtle one. The till has already apportioned +bill-level discounts across its lines to get tax right, but it sends each line at +its *pre-apportionment* value. Left alone, summing line items gives the subtotal +while the header carries the total, and two reports disagree. So the code computes +`amountFactor = (total − roundoff) / Σ line_total` and +`taxFactor = header_tax / Σ line_tax`, and scales each line onto what was actually +collected. The till stays authoritative for the bill as a whole; this only decides +attribution *within* it. + +**(e) The catalogue downlink is a delta protocol with a safety invariant.** +`GET /pos/catalogue` answers either a full snapshot or a change set. The terminal +treats `is_delta: false` as a snapshot and **withdraws every product the response +does not mention** — so mislabelling a filtered result empties the shop's shelf. +The code therefore derives both the filter and the flag from one value: +`cutoff := posRevisionCutoff(...)`; zero cutoff ⇒ no filter ⇒ `is_delta: false`, +non-zero ⇒ filtered ⇒ `is_delta: true`. There is no path that filters without +setting the flag. Supporting details: + +- The revision is an opaque token `loc{id}-{YYYYMMDDTHHMMSSZ}` the till stores and + hands back. If it is unreadable, malformed, or belongs to a *different outlet*, + the cutoff is zero and you get a full snapshot — failing toward "send + everything" is the only safe direction. +- **The revision only advances on the final page.** Mid-pagination it echoes back + whatever the till already had. A till that dies half way through a paginated + pull must not end up holding a revision claiming it saw pages it never received + — those products would be excluded from every future delta, silently, forever. +- The revision stamp is taken **one second in the past**, so a product written + during the same second the query ran cannot land on the wrong side of the next + cutoff. Costs one redundant row; cannot lose one. +- "Changed" is one predicate covering three things: the product row, its + per-location row (price/availability), or its stock ledger. Stock is in there + because a shop's count drifts on every sale rung at another counter. +- Acknowledged limitation, in a comment: a product *deleted* from + `productlocations` leaves no tombstone, so a delta cannot know to withdraw it. + Only a full pull collects those — hence "pull without a revision every morning." + +**(f) Presence is a TTL, not a table.** A heartbeat is a fact with an expiry date. +In Postgres it would be ~288k writes/day across a hundred tills plus a reaper job +to mark them dead. A Redis hash with a 90s TTL (three missed 30s heartbeats — "two +would make a GPRS hiccup look like a dead till; five would take 2½ minutes to +notice a real one") ages out for free. The location→terminals SET has *no* TTL: +it is an index of what exists, not a claim anything is alive. A till whose hash +expired comes back as a stub marked `offline` with a reason, rather than being +omitted — because the missing till is exactly what someone is looking for. The +write also `HDEL`s fields the till stopped reporting, so a hash never lingers at a +stale battery reading. + +#### Tricky cases the code explicitly handles + +| Case | Handling | +|---|---| +| Bill with no id | Rejected — nothing to dedupe on, and it would double on every retry | +| Fractional quantity (1.5 kg onions) vs integer stock column | `roundStockQty` rounds **up** — conservative, never records more stock than is physically there. Flagged in-code as a workaround, not a fix | +| Timestamps without a timezone offset (older terminal builds) | Accepted, with a comment stating the instant will be wrong by the offset and is unrecoverable — but the *business date* is right, which is what daily figures use | +| `jsonb` columns left at Go's zero value | Forced through `posJSON`, which emits `"null"` — an empty string reaches Postgres as invalid JSON and takes the whole bill down | +| Terminal id present on the batch but not the bill | `posTerminalFor` falls back, trimming first so `" "` is not mistaken for a real code | +| Broker Last Will (`{"status":"offline"}`) | Arrives on the same handler and is recorded verbatim — exactly right for a till that lost power | +| Customer registrations replayed | Insert-if-absent, **never update** — a profile corrected at head office must not be reverted by a till replaying months-old data | +| Loyalty points on the uplink | Deliberately absent from the wire format. Points are derived from the bill stream (idempotent, sees every counter); accepting a till's local balance would make "last till to sync wins" | + +#### Trade-offs, and what they buy + +- Bills separate from `orders` → full fidelity, at the cost of every report needing + a union. +- Payment mode denormalised to "largest tender" for grouping, with the full split + kept verbatim in `paymentsjson` → fast reports, no lost reconciliation data. +- `businessdate` denormalised as a `YYYY-MM-DD` string → a day's takings is one + indexed equality match instead of a range scan with timezone arithmetic. +- Barcode generation: `products.productsku` cannot be trusted for the till's + *unique* barcode index (in live data, thousands of products share a single SKU + value), so a SKU is only used if it looks like a real EAN/UPC — 8–14 digits — + and otherwise the product id stands in. Scanning physical barcodes will not work + until real ones are populated; the comment says so plainly and notes it starts + working with no code change. + +--- + +## 2. Transport, concurrency and delivery guarantees + +### What it is + +The same ingest, over two transports (MQTT and HTTP), running under multiple +replicas, with backpressure that reaches all the way back to the till. + +### How it's built + +`messaging/posmqtt.go` (broker client, topic routing, ack publishing) plus +`messaging/posworkers.go` (a bounded worker pool). Both hand off to the same +`PosService` the HTTP controller uses — the facade exposes `f.PosService()` +specifically so a bill cannot behave differently depending on how it arrived. + +### The approach + +**Leader election with no coordination service.** MQTT has no queue groups — every +subscriber gets every message, so three replicas would each commit the same bill +and publish three acks. The ingest is idempotent so nothing double-counts, but it +is 3× the database work. The fix: a StatefulSet gives pods stable ordinal names, +so **ordinal 0 is the elected consumer** — no lease, no lock, no extra dependency. +Overridable via `POS_MQTT_CONSUMER=always|never`; a non-ordinal hostname (bare +container, local dev) is elected, because "a single instance that refused to +consume would be a far more confusing failure." + +**Backpressure by construction.** paho delivers on one goroutine, so naively every +bill commits serially — a bill is a full transaction (advisory lock, dedup, row +locks, availability, four inserts, commit) at ~10–30 ms, giving 30–100 bills/sec, +and a shop-wide backlog after an outage takes minutes. paho *can* call handlers +concurrently, but it spawns without limit — a storm would open a transaction per +message, exhaust the connection pool, and stall everything at once. + +So: a fixed pool behind a bounded queue, and `submit` **blocks** when the queue is +full. That is the point — paho stops acking, the broker's in-flight window fills, +it stops sending, and the till holds its bills and retries. `SetOrderMatters(true)` +is kept on precisely because single-goroutine delivery is what makes that chain +work; concurrent delivery would let paho keep reading no matter how far behind the +workers were. + +**Separate pools for bills and heartbeats.** A heartbeat is one Redis write; a bill +is a transaction. Sharing a queue would delay presence behind a bill backlog, and +every till would appear to go dark at the exact moment the system was busiest. + +**The pool's shutdown race is handled explicitly.** A plain `select` over a +done-channel and the job channel is not enough — once both are ready Go picks at +random, and picking the send panics on a closed channel. So there is an `RWMutex` +held for *reading across the whole of `submit`*, and `stop` takes the write lock +before closing. The comment works through why this cannot deadlock: workers only +exit once the channel is closed, which happens under the write lock the in-flight +send is holding off. Post-close submissions run **inline** rather than being +dropped — discarding a bill that already reached you is worse than doing it slowly. + +**Identity comes from the topic, never the body.** `topicIdentity` parses store and +terminal out of `nearle/pos/{store}/{terminal}/{kind}`. A till that could name its +own store in a payload could post sales into another shop's books. There is a test +named exactly that: `TestABodyCannotOverrideTheTopicIdentity`. + +**Shutdown order is load-bearing.** `Close()` drains the worker pools *before* +disconnecting, so a bill mid-commit still gets its ack out. Disconnecting first +would strand it — committed here, unacknowledged there, re-sent on the till's next +attempt. + +Payloads are copied in `wrapHandler` because paho reuses its buffer once the +handler returns, and the work now happens after that. + +The test suite here is genuinely good: bounded concurrency, blocking-not-dropping, +drain-on-stop, idempotent stop, payload copying, ordinal election, and "a failed +presence write does not stop the till." + +--- + +## 3. Terminal authentication and shop-managed staff + +### What it is + +Before this work, the POS surface was completely open: a till held a store id +typed into a Settings screen and a password compiled into the app, so +`store_id=1185` in a URL was enough to read another tenant's catalogue or post +bills into their books. One leaked build opened every tenant on the platform. This +subsystem replaces that with a real session, and adds shop-run staff management on +top. + +### How it's built + +``` +POST /pos/login (the only unguarded route — it's where tokens come from) + │ authname|contactno + password [+ optional configid, location_id] + ▼ +posAuthRepository.PosLogin + │ reads the SAME app_users rows the web console authenticates against + │ resolves the outlet FROM the user's record — never from the wire + ▼ +posService.mint → utils.MintPosToken + │ base64url(payload) "." base64url(HMAC-SHA256) + │ claims: uid, tid, lid, rid, cid, trm, iat, exp TTL 30 days + ▼ +PosSession { token, expires_at, role, can_manage_staff, + tenant + GSTIN + address (for the printed invoice), + locations[] (picker for multi-outlet owners), + staff[] (so the till can trade immediately) } + │ + ▼ +every other /pos/* route → middleware.PosAuth + 1. verify signature 2. is the named outlet owned by the token's tenant? +``` + +### The approach + +**A signed, self-describing token rather than a session table.** Reasoning given in +`utils/postoken.go`: a till is not a browser. It signs in when the shop opens and +bills for a whole day on a connection that comes and goes, so the credential must +survive reboot, network loss, and an hour in a drawer. A server-side session table +fails that (a till that cannot reach you must still be able to prove who it is when +it returns), and so does a short expiry. + +**Deliberately not JWT.** One issuer, one audience, one algorithm — the header JWT +spends bytes negotiating is a constant. And `alg` is the source of JWT's +worst-known footgun (`alg: none`); a format with no algorithm field cannot have +that bug. The MAC is taken over the *encoded* payload so verification never +re-serialises anything. + +**Verification order is deliberate:** signature first, *then* expiry. Reading `exp` +out of an unverified payload would mean taking the attacker's word for when their +own token runs out. Comparison is `hmac.Equal` (constant time). A token that +verifies but names no outlet is refused, so it cannot be mistaken for one that +authorises everything. + +**The middleware's second check is the one that matters.** A valid token is not a +licence to name *any* outlet — it is a licence to name *your* outlets. +`requestedLocation` reads all three spellings the routes use (`store_id`, +`locationid`, `location_id`) rather than breaking terminals in the field by +normalising, and — crucially — **searches the JSON body, not just the query +string**, because the two routes that *write* carry `store_id` in the batch and +never in the URL. It handles the id being sent quoted or bare, since accepting only +one shape would silently skip the check, and "a skipped check reads exactly like a +passed one." + +**A migration escape hatch, honestly labelled.** `POS_AUTH_REQUIRED` defaults to +**off**, because terminals are already in shops billing real customers against +unauthenticated endpoints and flipping enforcement at deploy would stop every one +of them mid-trade. While off, a token that *is* sent is still fully verified and a +wrong-tenant request is still refused — the flag only governs requests carrying +none. + +**Two-tier sign-in: password opens the terminal, PIN switches the operator.** + +- The session token is the security boundary. A four-digit PIN is not: + `POST /pos/login/pin` sits *behind* the guard, so guesses are confined to one + already-opened outlet's own staff. +- PIN login mints a **fresh** token rather than reusing the presented one, so a + cashier taking over from a supervisor drops the supervisor's permissions instead + of inheriting them. +- PINs travel in the clear over TLS, and the model file argues the case rather than + hiding it: four digits is brute-forceable in microseconds whatever it is wrapped + in, so hashing here buys the appearance of strength; meanwhile the terminal salts + every PIN with its own random salt, so a hash computed server-side could never be + verified there without inventing and maintaining a shared scheme across two + codebases. The honest framing: **a PIN is shift attribution, not a security + boundary.** + +**Schema archaeology, handled rather than wished away:** + +- `authname` is not unique in `app_users` — live data has the same address twice + under one config. Rather than `LIMIT 1` (which would let a stranger's account + shadow the one a person meant, and on a POS means billing into the wrong tenant), + multiple matches are **refused** with an actionable message. +- Inactive accounts are excluded from the *match*, not matched-then-refused, so a + deactivated leaver cannot make a live login ambiguous. +- `configid` (which tenant portal an account belongs to) is asked for by the web + console because the browser knows it — but a person at a counter has never seen + the number. So it is honoured when sent, inferred when not, and an ambiguous + inference is reported rather than guessed. `PosConfigidFor` infers it from + whichever value the tenant's existing accounts most commonly carry. +- Staff come from **two** sources unioned: the purpose-built `tenantstaffs` table + (a dozen rows on the entire platform) and `app_users.locationid` (where staff + actually ended up). Reading either alone returns the wrong answer. +- Duplicate PINs are dropped from the response, because live data has one PIN + shared across many accounts — a shared PIN would attribute a bill to whichever + row was read first. +- PINs are bounded 1000–9999 with **no leading zero**, because `app_users.pin` is a + `bigint`: "0451" stores as 451, and the cashier types four digits and is refused + forever. That costs 1000 of 10000 combinations and buys a PIN that means the same + thing in both directions. Obvious PINs (1234, 1111, …) are rejected. +- `userid` is left to Postgres's identity column, with a comment explaining the + earlier mistake: `information_schema.column_default` is empty for identity + columns, which reads like "no default," and a hand-rolled MAX+1 leaves two + allocators racing. +- Emails go through `NULLIF(?, '')` because a unique constraint means a second + PIN-only cashier would collide on the empty string, whereas NULLs do not collide + in Postgres. + +**The inversion, applied to people.** `PosUserRequest` has no tenant and no +location field. A supervisor creating staff can only ever create them at their own +outlet, and *no field in the struct can say otherwise* — the same inversion that +stopped a till naming its own shop. Updates are scoped by tenant *and* location in +the `WHERE` clause rather than checked first, so a wrong user id updates zero rows +and is reported, instead of quietly editing another shop's staff. Deactivation is a +status change, never a delete, because bills carry the cashier's name. You cannot +deactivate the account you are signed in as, or the last supervisor could lock the +whole shop out with one tap. + +The same service calls are exposed to the web console under `/web/tenants/*` and +`/mob/tenants/*` — deliberately the same code, not a parallel implementation, +"because two code paths writing one table is exactly how that stops being true." +The route file itself flags that this half is weaker: the console *asserts* its +outlet where a terminal *proves* it, and says these should move behind a session +guard as soon as the console can hold one. + +--- + +## 4. The shared order + stock engine + +### What it is + +One transactional path that every sale in the platform goes through, regardless of +channel: an app order, a spreadsheet import of historical counter sales, or a POS +bill. + +### How it's built + +``` +CreateOrder (app) UploadOfflineSales (spreadsheet) importPosOrder (till) + │ │ │ + │ per-bill tx + advisory lock per-bill tx + advisory lock + │ + remarks-based dedup + terminalorderid dedup + ▼ ▼ │ + ┌──────────────────────── createOrderTx ──────────────────────┐ │ + │ 0. lockStockRows (FOR UPDATE, sorted, deduped) │ │ + │ 1. assertStockAvailable (ledger balance, all lines first) │◄───────┤ (uses the same + │ 1b. priceOrderLines (fill unpriced lines from catalogue) │ │ stockLedger.go + │ 2. nextSequenceNo (UPDATE…RETURNING inside the tx) │ │ helpers directly) + │ 3. insert header, insert lines, recordStockOut per line │ │ + │ → syncProductLocationStatus re-derives availability │ │ + └─────────────────────────────────────────────────────────────┘ │ + │ contract: on failure it has already rolled back; │ + │ on success tx is left OPEN so the caller can │ + │ include its own dedup guard in the same tx │ + ▼ ▼ + COMMIT pos_orders + productstocks +``` + +### The approach + +**Stock is derived, never stored.** Availability is `SUM(in) − SUM(out)` over +`productstocks`, computed under the row locks. `productlocations.status` is a +*derived cache* re-synced from that balance after every movement, in the same +transaction, by the same rule on both the sale side and the receiving side. A +commit message captures the earlier bug this replaced: receiving stock used to +overwrite `products.productstatus` — a per-product **lifecycle** column — with an +**availability** value, destroying the lifecycle state of well over a hundred +products. Availability is a per-outlet fact and a single column on `products` +cannot express it, since the same product can be stocked at one outlet and empty at +another. + +**Order-number allocation.** `nextSequenceNo` does read-and-increment in a single +`UPDATE … RETURNING` inside the caller's transaction. The doc comment is a small +forensic report on the previous implementation: two separate calls on `r.db` (not +the transaction), so concurrent orders read the same value; a `NULL` counter made +`COALESCE(MAX(x)+1, 1)` evaluate `NULL+1 = NULL` and fall through to a hardcoded +`"-1"`, so a whole cohort of live orders share one id; tenants with +multiple sequence rows hit a `GROUP BY` where the read kept the first row and the +write updated all of them. The fix pins to `MIN(sequenceid)`, seeds a NULL from the +tenant's existing order count (guaranteed ≥ any id already issued, so recovery never +reissues), and creates the row on first use. + +**Two rounding conventions, kept apart on purpose.** `legacyOrderQty` (truncate, +floor at 1) is preserved *exactly* for app orders — changing it would silently +alter stock deduction for every order in production. `roundStockQty` (ceil) is used +by the POS path. Both are named, tested, and flagged in a comment as something to +reconcile once someone owns the decision. That is the right call: the divergence is +documented rather than papered over. + +**Deduplication without a natural key.** The spreadsheet importer dedupes on +`orders.remarks = "OFFLINE:"` under an advisory lock. When the sheet has no +bill number, an **FNV-1a hash of the bill's own contents** (date, mobile, payment +mode, and each line's product/qty/price) stands in — so re-uploading the same file +is a no-op rather than a double stock deduction. The trade-off is stated: two +genuinely separate identical baskets on the same day with no bill numbers will +collide, and the result *names* the collision rather than hiding it. + +**Referential scaffolding.** The order-listing query INNER JOINs five tables, so an +imported order with a zero `applocationid` or `customerid` would be written +successfully and then be **invisible in every screen**. +`resolveOfflineLocationContext` is the single place where "this location belongs to +this tenant" is established (so editing a locationid in a spreadsheet reaches +nothing), and it fills the scaffolding from `tenantlocations` plus the most recent +real order at that outlet — because `tenantlocations` carries 0 for +`moduleid`/`partnerid` at outlets whose live orders use non-zero values. It refuses +outright rather than writing an order that will never be visible. + +**Batch semantics.** Each bill is its own transaction, so one bad row cannot undo +the rest, and the response says exactly which landed, which were duplicates, and +which failed with why. Branch context and catalogue are memoised per outlet so a +workbook covering six branches does not re-run both queries per bill. + +Also here: `priceOrderLines` fills lines the client sent unpriced from the +merchant's own catalogue, using arithmetic that *deliberately matches* the offline +importer exactly — gross, minus discount, tax extracted from the landing amount +because shelf prices are MRP (tax inside). One convention for both channels, so the +same basket rings up the same either way. This fixed a real bug where +catalogue-imported products had no per-store price, so real delivered orders +recorded zero revenue. + +--- + +## 5. Brand catalogue bridge and the rest of the platform + +### What it is + +A **second, isolated Postgres database** holds a curated master catalogue of +packaged goods, organised one table per brand, with rich metadata (title, +description, nutrients, highlights, FSSAI licence, size, variant key, providers). +Shop owners browse it and "import" products into their own store catalogue rather +than typing them in. Product photography lives in S3-compatible object storage. + +### How it's built + +``` +CatalogueDB (separate conn, may be nil) Object storage (S3-compatible) + brand_ tables daily/brands/{brand}/{image_id}/* + │ │ + │ catalogueRepository │ db/imagestore.go + │ (table name from a fixed allowlist) │ full LIST → in-memory map, + │ │ swapped atomically, 30-min refresh + └──────────────┬───────────────────────────────┘ + ▼ + productService.ImportCatalogueProduct + │ snapshot into tenant `products` (keyed brand+catalogueid) + │ + link via productlocations (price, stock, status) + ▼ + nearledb: products / productlocations / productstocks + ▼ + POS catalogue pull · customer app · order lines +``` + +### The approach + +- **Isolation is enforced structurally.** `NewCatalogueRepository` takes the + catalogue connection and *never* `db.DB`. If the catalogue env vars are absent, + the connection is simply nil and catalogue endpoints return a normal error — it + must never block startup of the main app. Same rule for Redis and the image + store: optional dependencies degrade, they do not kill the process. The one + dependency that *is* fatal on misconfiguration is the MQTT broker, and the + comment says why: "coming up healthy while every till quietly queues is the worse + failure." +- **Table names cannot be parameterised in SQL**, so brand → table goes through a + fixed allowlist map. That is the correct pattern. +- The catalogue DSN is built as a URL and percent-encoded via `net/url` rather than + a `keyword=value` DSN, because that password contains characters the keyword + format would misparse as quoting/comment syntax. +- GORM's raw scan silently drops slice-kind destination fields, so `text[]` columns + are cast to text in SQL and parsed in Go. +- Cross-brand browsing merges and sorts in Go, with an explicit note that this is + fine at a few hundred rows and should become a `UNION ALL` if it grows. +- The image store caches a full object listing so no GET ever calls out to S3, + rebuilt from scratch every 30 minutes and swapped under a write lock. A nice + deployment detail: the endpoint is bucket-qualified, and the SDK's + virtual-hosted-style client re-prepends the bucket — so the client is pointed at + the bare region host or requests get addressed to `bucket.bucket.…`. +- Import is idempotent on `(tenant, brand, catalogueid)`: re-import updates pricing + rather than creating a duplicate product. + +**The surrounding platform** — roughly two thirds of the file count — is a +conventional layered Fiber/GORM app: orders, deliveries (rider dispatch, status +lifecycle with mirrored timestamps, rider/report summaries), products and stock, +tenants and outlets, customers, partners, users, and FCM push. Most of it is CRUD +and reporting SQL. The parts worth noting are the *repairs*, which are documented +in-place with the evidence that motivated them: + +- `UpdateDelivery` used to write the parent order with `WHERE orderheaderid = ?` + from a client-supplied field. Clients often omitted it, making it `= 0`, matching + nothing — and GORM reports no error for an update affecting zero rows, so the API + answered success while the order silently kept its old status. Hundreds of + deliveries were marked delivered against orders still reading pending. Fixed by + deriving the link from `deliveryid` (the one field every caller must send) and + failing loudly when the row is not there. +- The rider push-notification route had been commented out since the initial commit + — the handler, the model, the Firebase service account and the Dockerfile `COPY` + were all in place; only the route registration was missing. So every rider push + the admin console ever sent returned 404, and riders were assigned deliveries and + never told. +- New store outlets and their logins were being forced to `InActive`, which blocked + the spawned manager login before it could ever reach the password-setup screen. +- Tenant onboarding created admin users with `configid = 0`, which the web login + (which queries `configid = 1`) could never find — permanently unfindable accounts. +- Order line items were being silently dropped. + +--- + +## The stack + +**Language / runtime** + +- Go 1.24 + +**Web / API** + +- Fiber v2 (`gofiber/fiber/v2`), CORS middleware, custom `PosAuth` middleware + +**Data** + +- PostgreSQL (primary, `nearledb`) via GORM 1.25 + `pgx/v5` driver — heavily raw + SQL, GORM mostly as a connection/scan layer +- A second PostgreSQL instance for the brand catalogue (described in code as + pgvector) +- Redis 7-family via `go-redis/v9` — TTL-based presence, shared with a separate + Node/Express service +- Postgres features used directly: advisory locks (`pg_advisory_xact_lock`), + `SELECT … FOR UPDATE`, `UPDATE … RETURNING`, `jsonb`, identity columns + +**Messaging** + +- Eclipse Mosquitto over MQTT, `eclipse/paho.mqtt.golang` v1.5 — QoS 1, persistent + sessions, retained messages, Last Will + +**Crypto / auth** + +- `crypto/hmac` + SHA-256, custom compact token format (not JWT) + +**Cloud / integrations** + +- AWS SDK Go v2 S3 client pointed at an S3-compatible object store +- Firebase Cloud Messaging via `golang.org/x/oauth2` JWT service-account flow + (`firebase.google.com/go` present) + +**Config / ops** + +- `godotenv`, `spf13/viper`, `time/tzdata` (Asia/Kolkata baked in) +- Multi-stage Dockerfile → static binary on Alpine +- Kubernetes StatefulSet *(inferred — from the `HOSTNAME` ordinal election logic + and the `-0` convention, not from manifests in this repo)* + +**Testing** + +- Go stdlib `testing`, table-driven, with hand-rolled fakes for the MQTT client and + the POS service — no mocking framework + +**Consumers of this API** *(not in this repo; described in docs and comments)*: a +Flutter POS terminal app with local SQLite, a React/TypeScript merchant console, a +consumer mobile app, a rider app, and a separate Node/Express backend sharing the +Redis instance. + +--- + +## What's genuinely hard here + +**1. The ack protocol and idempotency, together.** Anyone can write "insert if not +exists." What is hard is the discipline that follows from at-least-once delivery +when the payload is *money that has already changed hands*: a duplicate must be +reported as success, silence must never be interpreted as acceptance, "reject" must +be reserved for permanent faults, transport errors must produce *no* ack at all, +and the whole thing has to hold under a shutdown. The code gets all five right and +the reasoning is written down at each decision point. The +advisory-lock-then-check pattern — turning a constraint violation into an orderly +"already held" — is the specific move to point to. + +**2. The delta/snapshot invariant in the catalogue pull.** The failure mode — +`is_delta: false` on a filtered response empties a real shop's shelf — is the kind +of bug that only shows up in a store, at a counter, with a queue. Deriving the +filter and the flag from a *single* value so no code path can set them +inconsistently is the right structural answer, not a defensive check. The +pagination detail (never advance the revision mid-pull) and the one-second cutoff +overlap are both real distributed-systems reasoning: each is a choice about which +direction to fail in, and each picks "redundant work" over "silent permanent data +loss." + +**3. Backpressure that reaches the physical device.** The chain is: bounded queue +blocks → paho's single delivery goroutine stalls → broker's in-flight window fills +→ broker stops sending → till holds its bills. Every link is a deliberate +configuration choice (`SetOrderMatters(true)` exists solely to preserve link two). +The alternative designs are both worse in ways that are only obvious once you have +reasoned it through: unbounded goroutines exhaust the connection pool and stall +everything at once; dropping work loses a sale you had already accepted. Plus the +pool's close race — recognising that `select` over done-and-jobs picks randomly and +can panic on a closed channel, and solving it with a read-lock held *across the +send* — is a genuinely subtle piece of Go concurrency. + +**4. Retrofitting authorisation onto a live, unauthenticated fleet.** The +intellectual move is small and correct: **invert the direction of the store id.** +It was an input (typed into Settings, believed on the wire); it becomes an output +(derived from the authenticated user's record, sealed under a signature). +Everything else follows — `PosUserRequest` having no tenant field, the middleware's +tenant-owns-outlet cross-check, the read-the-body-not-just-the-query detail that +closes the hole on the exact routes that write. The rollout strategy (ship the +endpoint, let the fleet adopt, then flip `POS_AUTH_REQUIRED`) is how you do this +without stopping a hundred shops trading, and the code is honest that the flag is a +temporary state and not a design. + +**5. Sharing one transactional path across three channels without forking it.** +`createOrderTx`'s contract — *on failure it has already rolled back; on success the +transaction is left open so the caller can put its own dedup guard inside the same +transaction* — is unusual and slightly dangerous, but it is what allows an app +order, a spreadsheet import and a POS bill to share row-locking, availability +checks, ledger writes and sequence allocation. The alternative (three +implementations of the anti-overselling rule) is exactly the drift that produces +"empty in the database, full on the shelf." + +**6. Making a hostile schema work without a migration.** This is unglamorous and it +is a lot of the actual difficulty. Non-unique `authname`; a `bigint` PIN column +that eats leading zeros; a `roleid` of 0 that is not a role; `app_roles` with six +rows for four roles and most accounts carrying an id that is not in it; a +`registrationno` column that is really the GSTIN; `configid` varying per tenant +with no way to look it up; a SKU column where thousands of products share one +value; tax rates in live data that include 3, 7, 15 and −1 when Indian GST only has +0/5/12/18/28. Each of these gets a handler *and a written justification measured +against the actual data*, rather than a schema change nobody has the appetite to +run. The negative-GST floor is a good example of why this matters: a negative rate +would put negative tax on a bill and a negative figure in a slab on a **filed tax +return**. + +**7. The commenting itself.** Worth calling out explicitly. Nearly every non-obvious +decision carries a comment that states the alternative considered, the failure it +prevents, and often the count of live rows that motivated it. Several read as small +post-mortems (the sequence-number one, the delivery-status one). That is a real +engineering artefact, not decoration — it is what makes the codebase maintainable +by someone who was not there. + +--- + +## Flags before publishing any of this + +### Security issues found while reading + +1. **A Firebase service-account JSON key is committed to the repository** and + `COPY`'d into the Docker image by the Dockerfile. That is a live private key in + version control. Rotate it and move it to a secret/volume mount. +2. **`.env` was tracked until 2026-08-03.** The `.gitignore` says so itself and + notes the database credentials are still in history and the password should be + rotated. That does not appear to have happened. +3. **SQL injection in `tenantRepository.CheckTenantByNo`** — the contact number is + string-concatenated into raw SQL. Everything else in the codebase + parameterises; this one does not. +4. **Passwords are stored and compared in plaintext platform-wide.** There is an + explicit `TODO` acknowledging it, correctly noting a POS token minted off a + plaintext password is only as good as that column. The comparison is at least + constant-time. +5. **The main web/mobile API has no authentication at all.** + `SECURITY_HANDOFF.md` documents this: identity comes from client-supplied query + params, so requesting another tenant's id returns their data. Eight IDOR + endpoints were patched with controller-level scoping guards, but the doc is + clear that the root fix has not started. +6. **`POS_AUTH_REQUIRED` defaults to false**, so the POS surface is open unless + explicitly enabled — intentional and documented, but worth confirming whether it + has been flipped in production. +7. **The `/web/tenants/*` and `/mob/tenants/*` staff routes mint till credentials + on unauthenticated requests.** The route file flags this itself. +8. **CORS is `AllowOrigins: "*"` with `AllowCredentials: true`** — that combination + is rejected by browsers and is a smell either way. + +### Commercially sensitive — genericise before this goes public + +- **Named brand partners** in the catalogue allowlist (six FMCG brands, some of + them major). That is a partnership roster. +- **The production hostname** and the deployed API base path, which appear in the + proof scripts under `scratch/`. +- **Live customer/tenant identifiers** — the scratch scripts and doc examples + contain real tenant ids, location ids, store names and at least one real email + address. All of them have been kept out of this document. +- **Data-quality statistics about the live estate** (row counts, how many products + share a SKU, how many accounts use a given PIN, duplicate-order-id counts, + desynced-delivery counts). These make the writeup much more convincing, but they + are an unflattering audit of a client's production data. Keep the reasoning and + drop or round the numbers — "thousands of products shared a single SKU value" + carries the point without publishing the audit. Exact figures have been rounded + or removed here already. +- **The terminal sync contract itself** (topic structure, ack semantics, revision + format). It is the interface between two of their products; the *techniques* are + portfolio-safe, the exact wire protocol less so. + +### Inferred rather than read directly + +Deployment as a Kubernetes StatefulSet (from the ordinal election logic, not from +manifests); the existence and behaviour of the Flutter till app, the React console, +the consumer/rider apps and the Express backend (from docs and comments — none are +in this repo); and that the catalogue database uses pgvector (asserted in comments, +but nothing in this repo issues a vector query). diff --git a/docs/SCAN_TO_ORDER.md b/docs/SCAN_TO_ORDER.md new file mode 100644 index 0000000..ba74af6 --- /dev/null +++ b/docs/SCAN_TO_ORDER.md @@ -0,0 +1,261 @@ +# Scan-to-order — mobile integration + +A customer photographs a product. Google Lens (on the phone) turns the photo +into a label — `"Milk Bikis"`, `"Dabur Honey 500g"`. The app sends that label +here and gets back: what the product is, which of the customer's stores sell +it, in which sizes, with live stock, nearest first, and which store we +recommend. When the customer taps a store and a size, a second call confirms +the shelf still has it — and if it does not, names the next-nearest store +that does. + +Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope +`{ code, status, message, details }`; the shapes below are `details`. + +## The flow + +``` +photo ──Lens──▶ label + │ + ▼ + POST /lookup ───▶ match + stores[] (recommended first) + │ + customer taps a store + a size + │ + ▼ + POST /confirm ───▶ ok:true → add to basket with existing order APIs + ok:false + alternative → offer the other store +``` + +`GET /stores` is for the "choose another shop" sheet: the customer's +registered stores, nearest first, independent of any product. + +## `POST /lookup` + +```json +{ + "customerid": 5123, + "label": "Milk Bikis", + "latitude": 11.0290, // phone fix; optional — saved address is used without it + "longitude": 77.0290, + "tenantids": [1135, 1140], // optional: what the app THINKS the customer joined + "limit": 0 // optional: max stores, 0 = all +} +``` + +`tenantids` is verified, never trusted: the server intersects it with the +`tenantcustomers` table. Ids the customer is not actually registered with +come back in `unregistered_tenantids` — treat that as "refresh the local +list". A list that matches nothing at all is treated as stale and all +registered stores are used. + +Response: + +```json +{ + "label": "Milk Bikis", + "match": { + "brand": "britannia", "catalogueid": 7, "imageid": "britannia_milk_bikis_100g", + "product_name": "Milk Bikis", "size": "100 g", "variant_key": "milk_bikis", + "image": "https://…", "score": 0.94, "method": "vector+text" + }, + "catalogue_variants": [ { "…same shape…": "100 g" }, { "…": "200 g" } ], + "confidence": 0.94, + "available": true, + "recommended_locationid": 20, + "stores": [ + { + "tenantid": 2, "tenantname": "R Mart", "locationid": 20, "locationname": "Hopes", + "latitude": 11.01, "longitude": 77.0, "distance_km": 3.8, "open": true, + "deliveryradius": 5, "deliverymins": 30, + "recommended": true, "available": true, + "options": [ + { "productid": 200, "productname": "Milk Bikis 100g", "size": "100 g", "price": 12, "stock": 6, + "available": true, "is_variant": false, "matched_by": "imageid", "image": "…" }, + { "productid": 201, "productname": "Milk Bikis 200g", "size": "200 g", "price": 22, "stock": 3, + "available": true, "is_variant": true, "variantname": "200 g", "matched_by": "variant-of:200" } + ] + }, + { "locationid": 10, "locationname": "Peelamedu", "distance_km": 0.9, "available": false, "recommended": false, + "options": [ { "productid": 100, "stock": 0, "available": false, "…": "…" } ] } + ], + "unregistered_tenantids": [], + "message": "Available at 1 of your stores." +} +``` + +How to read it: + +- `match == null` → nothing recognised; show `message` and let them retry. + `confidence` below ~0.5 → recognised but unsure; confirm the name with the + customer before showing prices. `method: "text"` means no embedding model + was involved (not configured, or it timed out) — be a little more cautious. +- `stores` is ordered **in-stock first, then nearest**. Exactly one store has + `recommended: true` — the nearest with stock — and only when `available` + is true. Stores that sell it but have nothing on the shelf are still listed + (so the customer understands why they are not recommended); stores that do + not sell it are not. +- `options` are the things that can actually go in a basket at that store — + the matched product and each of its sizes — each a real product with its + own `productid`, price and live `stock`. Use `productid` in the existing + cart/order calls exactly as you would from the catalogue screen. +- `distance_km: -1` means the distance is unknown (no fix from the phone and + no saved address, or the store has no coordinates). Do not render it as 0. + +## `POST /confirm` + +Sent when the customer taps a store and an option. Re-reads live stock — +nothing is cached on this path. + +```json +{ "customerid": 5123, "tenantid": 1, "locationid": 10, "productid": 100, "quantity": 2, + "latitude": 11.029, "longitude": 77.029 } +``` + +```json +{ + "ok": false, + "reason": "out_of_stock", // in_stock | insufficient_stock | out_of_stock | not_sold_here | store_not_registered + "store": { "…the store they tapped…" }, + "option": { "productid": 100, "stock": 0, "…": "…" }, + "requested": 2, + "alternative": { // absent when nobody has enough + "locationid": 20, "locationname": "Hopes", "distance_km": 3.8, "recommended": true, "available": true, + "options": [ { "productid": 200, "stock": 6, "price": 12, "…": "…" } ] + }, + "message": "Out of stock at Peelamedu. Hopes has it (3.8 km away)." +} +``` + +`ok: true` → proceed to the basket. `ok: false` → show `message`; if +`alternative` is present offer it as a one-tap switch (it is the **same +product**, not another size — the customer chose a size and we do not +substitute). These are HTTP 200s: they are answers, not errors. + +## `GET /stores?customerid=5123&latitude=11.029&longitude=77.029` + +The customer's registered stores, nearest first, `distance_km: -1` last. +Same `ScanStore` shape as inside `stores[]` above, without options. + +## Errors (HTTP status ≠ 200) + +| Status | When | +|---|---| +| 400 | Missing `customerid`/`label`/ids, or a body that is not JSON. `message` says which. | +| 404 | `customerid` does not exist. | +| 503 | The catalogue database is not reachable. Retry later; the rest of the app is unaffected. | +| 500 | Anything else. Logged server-side. | + +## Behind the curtain (for whoever operates it) + +- **Recognition** = pgvector cosine search over every `brand_*` table in the + catalogue (each with its own index, merged), plus a word match on + `product_name`/`title`/`search_query` that settles near-ties and works on + its own when no embedding model is configured. The model is set by + `EMBEDDING_PROVIDER/MODEL/API_KEY` and **must** be the one that indexed + the catalogue — the first search checks the vector width and refuses a + mismatch by name. +- **The catalogue's model** (verified 2026-09-15 by cosine against a stored + row: 1.0000): `all-MiniLM-L6-v2`, 384-d, unit-normalised, embedding the + `search_query` column (brand + name + category + blurb + price range). + Ollama ships it as `all-minilm`; the cluster's `ollama.krow` service serves + it, so production is: + ``` + EMBEDDING_PROVIDER=openai + EMBEDDING_BASE_URL=http://ollama.krow.svc.cluster.local:11434/v1 + EMBEDDING_MODEL=all-minilm + EMBEDDING_API_KEY=ollama # any non-empty value; Ollama ignores it + EMBEDDING_DIMENSIONS=384 + ``` + A bare label ("Milk Bikis") scores ~0.92 against its product's stored + vector and ~0.23 against an unrelated one, which is what the 0.30 floor in + `scanService.go` is set against. If the catalogue team ever re-embeds + with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here and + nothing else. +- **Speed**: the label's vector (7 days) and the ranked catalogue hits + (30 min) are cached in Redis and in-process, so a popular product costs + one model call platform-wide. Customer, stores and catalogue are read + concurrently; the whole lookup is capped at 5 s and a slow model degrades + to a text answer instead of a spinner. Live stock is one indexed query and + is never cached. +- **Availability** is the same rule the app's catalogue screen uses: + `products.approve = 1`, `productlocations.publishedat IS NOT NULL`, stock = + live `SUM(in) − SUM(out)` of `productstocks` at that outlet, price = the + outlet's own price else the tenant's retail price. +- **No reservation.** Confirm re-reads the ledger; a hold would give the + same answer with a timer to babysit. If contention becomes real, a + Redis-backed short hold slots in at `Confirm` without changing the API. +- **Identity** is the `customerid` in the body, like every other mobile + endpoint here — there is no auth layer yet (see `SECURITY_HANDOFF.md`). + +## For backend developers + +### Where the code is + +| File | Holds | +|---|---| +| `models/scan.go` | request/response shapes (`ScanLookupRequest`, `ScanStoreOffer`, `ScanOption`, …) | +| `repositories/scanRepository.go` | all SQL: registered stores, live options, vector + text search, the two-tier cache | +| `services/scanService.go` | the pipeline: parallel reads, scoring, family grouping, ranking, confirm fallback | +| `controllers/scanController.go` | the three handlers and the error → status mapping | +| `routes/scanroutes.go` | `/v1/mob/scan/*` | +| `utils/embedding.go` | `Embedder` interface, OpenAI-compatible and Gemini clients | +| `utils/geo.go` | coordinate parsing, haversine, opening hours, label tokenising | +| `config/config.go` | `EmbeddingConfig` and its validation | +| `scratch/cataloguedims` | read-only check of the catalogue's embedding width / fill | + +### Try it locally + +```sh +go run . # with the local compose stack; EMBEDDING_* unset → text-only, still works +curl -s localhost:1122/live/api/v1/mob/scan/lookup -H 'Content-Type: application/json' \ + -d '{"customerid":1,"label":"Milk Bikis","latitude":11.03,"longitude":77.03}' | jq .details +``` + +To exercise the vector path locally, run Ollama on your Mac +(`ollama pull all-minilm`) and set `EMBEDDING_PROVIDER=openai`, +`EMBEDDING_BASE_URL=http://localhost:11434/v1`, `EMBEDDING_MODEL=all-minilm`, +`EMBEDDING_API_KEY=ollama`, `EMBEDDING_DIMENSIONS=384` in `.env.local`. The +local catalogue must carry vectors from the same model for results to mean +anything; a schema-only dump does not. + +### Tests + +`go test ./services -run 'Lookup|Confirm|Stores|CatalogueFamily'` drives +the whole pipeline through a fake repository (`services/scan_test.go`); no +database. `go test ./utils` covers both HTTP clients against `httptest` +servers, and the geo helpers. Add a case to `scan_test.go`'s fixture when +you change ranking — it is the spec. + +### Knobs (constants in `scanService.go`) + +| Constant | Default | Effect | +|---|---|---| +| `scanLookupTimeout` | 5 s | whole lookup, including the model call | +| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge | +| `scanMinScore` | 0.30 | below this the best hit is not shown as a match | +| `embedTimeout` (`utils/embedding.go`) | 4 s | one model call | +| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes | + +Scores: vector = `1 − cosine distance`; text = 0.95 for the whole label +inside the name, else `0.8 × (label words found / label words)`; combined = +`max(vector, text) + 0.10` when both hit, capped at 1. + +### Changing the embedding model + +1. The catalogue team re-embeds `search_query` with the new model. +2. Serve it (Ollama pull, or a hosted key). +3. Change `EMBEDDING_MODEL` / `EMBEDDING_DIMENSIONS` (and provider/URL if + needed) in the cluster; roll the pods. +4. Flush the hit cache if you cannot wait 30 min: keys are + `scan:hits:v1:*` and `scan:emb:v1:*` in Redis (they are also keyed by + model name, so old entries simply stop being read). + +Nothing in Go changes. A width mismatch fails the first search with an error +naming both numbers. + +### Adding a provider + +Implement `utils.Embedder` (`Embed(ctx, text) ([]float32, error)` and +`Model() string`), add a case to `NewEmbedder`, and add the provider name to +the allow-list in `config.validate`. Keep the HTTP client timeout: the +customer is holding a phone. diff --git a/facade/container.go b/facade/container.go index fba786e..058eace 100644 --- a/facade/container.go +++ b/facade/container.go @@ -4,6 +4,7 @@ import ( "nearle/controllers" "nearle/repositories" "nearle/services" + "nearle/utils" "gorm.io/gorm" ) @@ -22,6 +23,7 @@ type Facade struct { PosController *controllers.PosController LiveController *controllers.LiveController CatalogueUploadController *controllers.CatalogueUploadController + ScanController *controllers.ScanController // Held so the NATS consumer can reach the ingest without going through // HTTP. Unexported: everything else should use the controller. @@ -32,7 +34,8 @@ type Facade struct { // catalogueDB is a separate connection to the pgvector catalogue database; // it may be nil if catalogue env vars are not configured, in which case // catalogue endpoints will error at query time rather than at startup. -func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade { +// embedder may be nil too: scan-to-order then matches on words alone. +func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Facade { // User Module userRepo := repositories.NewUserRepository(db) @@ -109,6 +112,13 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade { catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo) catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService) + // Scan Module — a label from the customer's camera to "buy it here". + // Reads both databases: the catalogue to recognise the product, nearledb + // for who the customer is and what their outlets have on the shelf. + scanRepo := repositories.NewScanRepository(db, catalogueDB) + scanService := services.NewScanService(scanRepo, embedder) + scanController := controllers.NewScanController(scanService) + return &Facade{ UserController: userController, ProductController: productController, @@ -123,6 +133,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade { PosController: posController, LiveController: liveController, CatalogueUploadController: catalogueUploadController, + ScanController: scanController, posService: posService, } } diff --git a/go.mod b/go.mod index 5e00842..c103f1d 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/gofiber/fiber v1.14.6 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.18.0 + github.com/valyala/fasthttp v1.50.0 golang.org/x/oauth2 v0.12.0 google.golang.org/api v0.143.0 gorm.io/driver/postgres v1.6.0 @@ -42,8 +43,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect github.com/aws/smithy-go v1.27.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gofiber/utils v0.0.10 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect @@ -54,7 +55,6 @@ require ( github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/gorilla/schema v1.1.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -62,28 +62,17 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.19.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect - github.com/sagikazarmark/locafero v0.3.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.10.0 // indirect - github.com/spf13/cast v1.5.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.50.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.31.0 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/time v0.3.0 // indirect @@ -94,15 +83,11 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect google.golang.org/grpc v1.58.2 // indirect google.golang.org/protobuf v1.31.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect ) require ( github.com/gofiber/fiber/v2 v2.50.0 github.com/jinzhu/copier v0.4.0 - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/spf13/viper v1.17.0 golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8e90749..fba16bf 100644 --- a/go.sum +++ b/go.sum @@ -1,59 +1,21 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o= cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.13.0 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk= cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8= cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y= cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= @@ -100,13 +62,8 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -118,16 +75,7 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= -github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o= github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM= github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw= @@ -135,67 +83,34 @@ github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmh github.com/gofiber/utils v0.0.10 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U= github.com/gofiber/utils v0.0.10/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -203,21 +118,12 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ= github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= -github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -234,24 +140,11 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -261,12 +154,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -276,37 +163,16 @@ github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= -github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= -github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= -github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= -github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= -github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= @@ -315,302 +181,74 @@ github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.143.0 h1:o8cekTkqhywkbZT6p1UHJPZ9+9uuCAJs/KYomxZB8fA= google.golang.org/api v0.143.0/go.mod h1:FoX9DO9hT7DLNn97OuoZAGSDuNAXdJRuGK98rSUgurk= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA= google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI= @@ -618,21 +256,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM= google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I= google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -643,20 +270,12 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -665,12 +284,4 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/main.go b/main.go index bfaf000..290b1a7 100644 --- a/main.go +++ b/main.go @@ -3,12 +3,14 @@ package main import ( "fmt" "log" + "nearle/config" "nearle/db" "nearle/facade" "nearle/messaging" "nearle/models" "nearle/repositories" "nearle/routes" + "nearle/utils" "os" "os/signal" "strings" @@ -18,15 +20,14 @@ import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/joho/godotenv" "gorm.io/gorm" ) -func init() { - godotenv.Load() -} - func main() { + // Loads `.env.` (default `.env.local`) and `.env`, then checks + // every required setting at once. Nothing below runs against a half + // configured environment — see config/config.go for the precedence rules. + cfg := config.MustLoad() app := fiber.New() @@ -38,13 +39,13 @@ func main() { })) fmt.Println("🌐 Connecting to databases...") - db.Connect() + db.Connect(cfg) fmt.Println("✅ Database connections established!") // Shared with the express backend. POS terminal presence lives here under a // TTL; optional, because losing the health board is an inconvenience and // losing a sale is not. - db.InitRedis() + db.InitRedis(cfg.Redis) // Ensure schema is updated db.DB.AutoMigrate(&models.StockRequest{}) @@ -263,7 +264,19 @@ func main() { log.Fatal("could not add catalogueuploads.sheetrows:", err) } - f := facade.NewFacade(db.DB, db.CatalogueDB) + // The model behind scan-to-order. Optional: without EMBEDDING_PROVIDER the + // search matches on words, which works but ranks less well. + embedder, err := utils.NewEmbedder(cfg.Embedding) + if err != nil { + log.Fatal("embedding provider:", err) + } + if embedder == nil { + log.Println("scan: EMBEDDING_PROVIDER not set, product search is text-only") + } else { + log.Printf("scan: product search uses %s/%s", cfg.Embedding.Provider, cfg.Embedding.Model) + } + + f := facade.NewFacade(db.DB, db.CatalogueDB, embedder) routes.RegisterRoutes(app, f) @@ -293,17 +306,10 @@ func main() { repositories.SetCatalogueNotifier(posMqtt) } - // Start server - // - // The port comes from APP_PORT, defaulting to the 1122 this has always - // served on. It was hardcoded, which left `config.Load()`'s Port field - // dead and the Dockerfile's `EXPOSE 1009` describing a port nothing - // listened on — and made running a second copy locally, on a free port, - // impossible without editing this line. - port := os.Getenv("APP_PORT") - if port == "" { - port = "1122" - } + // Start server on APP_PORT (1122 locally, 1009 in production — see the + // env files). Running a second copy beside something else is a one-line + // change there rather than here. + port := cfg.Port go func() { log.Printf("🚀 listening on :%s", port) if err := app.Listen(":" + port); err != nil { diff --git a/messaging/livehub.go b/messaging/livehub.go index 695e04e..076ba19 100644 --- a/messaging/livehub.go +++ b/messaging/livehub.go @@ -119,7 +119,15 @@ func StartLiveHub() *LiveHub { SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise. SetOrderMatters(false) - if user := strings.TrimSpace(os.Getenv("MQTT_USERNAME")); user != "" { + // MQTT_USER is the name the ingest consumer and the env files use. This + // read MQTT_USERNAME for a while, so the live stream connected to the + // production broker with no credentials at all; MQTT_USERNAME is still + // honoured for any deployment that set it. + user := strings.TrimSpace(os.Getenv("MQTT_USER")) + if user == "" { + user = strings.TrimSpace(os.Getenv("MQTT_USERNAME")) + } + if user != "" { opts.SetUsername(user) opts.SetPassword(os.Getenv("MQTT_PASSWORD")) } diff --git a/models/scan.go b/models/scan.go new file mode 100644 index 0000000..fc61af2 --- /dev/null +++ b/models/scan.go @@ -0,0 +1,141 @@ +package models + +// Scan-to-order. +// +// A customer points the app at a packet, Google Lens (on the phone) reads a +// label off it, and the app asks: "which of MY shops has this, in what sizes, +// and which one should I buy from?" These are the shapes on both sides of +// that conversation. + +// ScanLookupRequest is what the app sends once Lens has produced a label. +type ScanLookupRequest struct { + Customerid int `json:"customerid"` + // What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed + // and capped by the service. + Label string `json:"label"` + // Where the customer is right now. Optional: without it the customer's + // saved primary address is used, and without that stores are listed in + // registration order with no distance. + Latitude FlexibleString `json:"latitude"` + Longitude FlexibleString `json:"longitude"` + // The tenants the app believes the customer has scanned into. Optional and + // never trusted on its own: the server intersects it with the + // tenantcustomers table and reports anything it dropped. + Tenantids []int `json:"tenantids"` + // How many stores to return. 0 = all registered stores that stock it. + Limit int `json:"limit"` +} + +// ScanStore is one of the customer's registered outlets. +type ScanStore struct { + Tenantid int `json:"tenantid"` + Tenantname string `json:"tenantname"` + Locationid int `json:"locationid"` + Locationname string `json:"locationname"` + Address string `json:"address,omitempty"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + // Kilometres from the customer, or -1 when either side has no usable + // coordinates. Never omitted: a missing number is easy to misread as 0. + DistanceKm float64 `json:"distance_km"` + // Delivery reach in the outlet's own units, straight from tenantlocations. + Deliveryradius int `json:"deliveryradius"` + Deliverymins int `json:"deliverymins"` + Open bool `json:"open"` +} + +// ScanOption is one thing the customer can actually put in the basket at one +// store: the matched product itself, or one of its sizes. Each is a real +// product row with its own price and stock, which is why they are flat. +type ScanOption struct { + Productid int `json:"productid"` + Productname string `json:"productname"` + Size string `json:"size"` // "500 g", "1 kg" — unitvalue + productunit + Price float64 `json:"price"` + Stock int `json:"stock"` + Available bool `json:"available"` + Image string `json:"image,omitempty"` + // Is this the product that matched, or a size hanging under it? + IsVariant bool `json:"is_variant"` + Variantname string `json:"variantname,omitempty"` + // How the row was tied back to the catalogue: "imageid", + // "brand+catalogueid", "name" or "variant-of:". + MatchedBy string `json:"matched_by"` +} + +// ScanStoreOffer is one store and what it can sell. +type ScanStoreOffer struct { + ScanStore + // Nearest store with at least one option in stock. Exactly one offer + // carries this, and only when something is in stock somewhere. + Recommended bool `json:"recommended"` + // Any option in stock here. + Available bool `json:"available"` + Options []ScanOption `json:"options"` +} + +// ScanCatalogueMatch is what the catalogue search settled on. +type ScanCatalogueMatch struct { + Brand string `json:"brand"` + Catalogueid int64 `json:"catalogueid"` + Imageid string `json:"imageid,omitempty"` + ProductName string `json:"product_name"` + Title string `json:"title,omitempty"` + Category string `json:"category,omitempty"` + Size string `json:"size,omitempty"` + VariantKey string `json:"variant_key,omitempty"` + Image string `json:"image,omitempty"` + Score float64 `json:"score"` + // "vector", "vector+text" or "text" — how the score was produced. The app + // can be more cautious with a text-only match. + Method string `json:"method"` +} + +// ScanLookupResponse is the answer to a scan. +type ScanLookupResponse struct { + Label string `json:"label"` + // The best catalogue product for the label, and the sizes of it the + // catalogue knows about (each a separate catalogue row). + Match *ScanCatalogueMatch `json:"match"` + Variants []ScanCatalogueMatch `json:"catalogue_variants"` + // 0..1. Below ~0.5 the app should confirm with the customer before + // showing prices. + Confidence float64 `json:"confidence"` + // Registered stores that stock the product, nearest first, in-stock + // first. Empty with Available=false when none does. + Stores []ScanStoreOffer `json:"stores"` + Available bool `json:"available"` + // The locationid of the store marked Recommended, or 0. + RecommendedLocationid int `json:"recommended_locationid"` + // Tenant ids the app sent that the customer is not actually registered + // with. Empty normally; non-empty means the app's local list is stale. + UnregisteredTenantids []int `json:"unregistered_tenantids,omitempty"` + Message string `json:"message"` +} + +// ScanConfirmRequest is sent when the customer taps a store and a size. +type ScanConfirmRequest struct { + Customerid int `json:"customerid"` + Tenantid int `json:"tenantid"` + Locationid int `json:"locationid"` + Productid int `json:"productid"` + Quantity int `json:"quantity"` + Latitude FlexibleString `json:"latitude"` + Longitude FlexibleString `json:"longitude"` +} + +// ScanConfirmResponse says whether the pick still holds, and where to go if +// it does not. +type ScanConfirmResponse struct { + Ok bool `json:"ok"` + // "in_stock", "insufficient_stock", "out_of_stock", "not_sold_here", + // "store_not_registered". + Reason string `json:"reason"` + Store *ScanStore `json:"store,omitempty"` + Option *ScanOption `json:"option,omitempty"` + Requested int `json:"requested"` + // The next-nearest registered store with enough of the same product, when + // the chosen one has run out. Nil when there is none. + Alternative *ScanStoreOffer `json:"alternative,omitempty"` + Message string `json:"message"` +} diff --git a/repositories/scanRepository.go b/repositories/scanRepository.go new file mode 100644 index 0000000..7e52cba --- /dev/null +++ b/repositories/scanRepository.go @@ -0,0 +1,633 @@ +package repositories + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "nearle/db" + "nearle/models" + "nearle/utils" + "sort" + "strings" + "sync" + "time" + + "gorm.io/gorm" +) + +/* +Scan-to-order reads from three places and this file is the only one that +knows which is which: + + - the catalogue database (pgvector, one table per brand) — to turn a label + into a catalogue product; + - nearledb — who the customer is, which outlets they scanned into, and what + those outlets have on the shelf right now; + - Redis — a cache for the expensive and stable half (the label's vector and + its catalogue hits). Live stock is never cached. + +Two connections are held rather than one because the catalogue must never be +reachable through the nearledb handle: the comment on db.CatalogueDB is +explicit about that and every catalogue reader in this package honours it. +*/ + +// CatalogueKey is how a tenant's product row points back at the catalogue. +// Imageid is the stable one; brand+catalogueid is kept for rows imported +// before imageid existed (see models.Products.Imageid). +type CatalogueKey struct { + Brand string + Catalogueid int64 + Imageid string +} + +// CatalogueHit is one catalogue row the search considered. +type CatalogueHit struct { + Brand string + ID int64 + ProductName string + Title string + Category string + Size string + VariantKey string + ImageID string + ImageURL string + // Cosine distance from pgvector (0 = identical); -1 for a text-only hit. + Distance float64 +} + +// StoreOptionRow is one sellable product at one outlet, with its live stock. +type StoreOptionRow struct { + Tenantid int + Locationid int + Productid int + Productname string + Productbrand string + Catalogueid int64 + Imageid string + Productimage string + Productunit string + Unitvalue string + Price float64 + Stock int + // For a size row: the product it hangs under and the label given to it. + Parentid int + Variantname string +} + +type ScanRepository interface { + // nearledb + CustomerExists(ctx context.Context, customerid int) (bool, error) + CustomerHome(ctx context.Context, customerid int) (lat, lng float64, ok bool, err error) + RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error) + // StoreOptions finds, at the given outlets, every published product tied + // to one of the catalogue keys (or, for hand-made products, one of the + // names) — and every size hanging under those products. + StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error) + // ProductAt is one product at one outlet with its live stock, or nil. + ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error) + + // catalogue + VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error) + TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error) + VectorSearchAvailable() bool + + // cache + CachedVector(ctx context.Context, model, label string) ([]float32, bool) + CacheVector(ctx context.Context, model, label string, v []float32) + CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool) + CacheHits(ctx context.Context, method, label string, hits []CatalogueHit) +} + +type scanRepository struct { + db *gorm.DB + catalogue *gorm.DB + + // Catalogue tables and their columns, discovered once and refreshed on a + // timer — the catalogue pipeline adds brands without telling anyone. + tablesMu sync.Mutex + tables map[string]map[string]bool // table -> column set + tablesAt time.Time + embeddingDim int + + // Process-local cache in front of Redis, bounded, so a hot label costs + // nothing even when Redis is not configured. + memMu sync.Mutex + memVecs map[string][]float32 + memHits map[string][]CatalogueHit +} + +const ( + scanTablesTTL = 10 * time.Minute + scanVectorTTL = 7 * 24 * time.Hour // a label's vector never changes for a given model + scanHitsTTL = 30 * time.Minute // the catalogue is rebuilt by scrape; not for long + scanMemCacheMax = 2000 +) + +func NewScanRepository(nearle, catalogue *gorm.DB) ScanRepository { + return &scanRepository{ + db: nearle, + catalogue: catalogue, + memVecs: make(map[string][]float32), + memHits: make(map[string][]CatalogueHit), + } +} + +// ── nearledb ──────────────────────────────────────────────────────────────── + +func (r *scanRepository) CustomerExists(ctx context.Context, customerid int) (bool, error) { + var n int64 + err := r.db.WithContext(ctx).Raw( + `SELECT COUNT(1) FROM customers WHERE customerid = ?`, customerid).Scan(&n).Error + return n > 0, err +} + +// CustomerHome is the saved primary address, falling back to the customers +// row itself. Either may be blank or unparsable — a customer created from a +// phone number alone has neither — and that is reported as ok=false rather +// than as (0, 0), which is a real place in the Gulf of Guinea. +func (r *scanRepository) CustomerHome(ctx context.Context, customerid int) (float64, float64, bool, error) { + var row struct { + Lat string + Lng string + } + err := r.db.WithContext(ctx).Raw(` + SELECT COALESCE(NULLIF(l.latitude, ''), c.latitude, '') AS lat, + COALESCE(NULLIF(l.longitude, ''), c.longitude, '') AS lng + FROM customers c + LEFT JOIN customerlocations l ON l.customerid = c.customerid AND l.primaryaddress = 1 + WHERE c.customerid = ? + LIMIT 1`, customerid).Scan(&row).Error + if err != nil { + return 0, 0, false, err + } + lat, lng, ok := utils.ParseLatLng(row.Lat, row.Lng) + return lat, lng, ok, nil +} + +// RegisteredStores is every active outlet of every tenant the customer has +// scanned into. A tenantcustomers row with locationid 0 means "the tenant", +// i.e. all of its outlets; a non-zero one pins a single outlet. +func (r *scanRepository) RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error) { + var rows []struct { + Tenantid int + Tenantname string + Locationid int + Locationname string + Address string + Latitude string + Longitude string + Deliveryradius int + Deliverymins int + Opentime string + Closetime string + } + err := r.db.WithContext(ctx).Raw(` + SELECT DISTINCT + tl.tenantid, COALESCE(t.tenantname, '') AS tenantname, + tl.locationid, COALESCE(tl.locationname, '') AS locationname, + COALESCE(tl.address, '') AS address, + COALESCE(tl.latitude, '') AS latitude, COALESCE(tl.longitude, '') AS longitude, + COALESCE(tl.deliveryradius, 0) AS deliveryradius, COALESCE(tl.deliverymins, 0) AS deliverymins, + COALESCE(tl.opentime, '') AS opentime, COALESCE(tl.closetime, '') AS closetime + FROM tenantcustomers tc + INNER JOIN tenantlocations tl + ON tl.tenantid = tc.tenantid + AND (COALESCE(tc.locationid, 0) = 0 OR tc.locationid = tl.locationid) + LEFT JOIN tenants t ON t.tenantid = tl.tenantid + WHERE tc.customerid = ? + AND LOWER(COALESCE(tl.status, 'active')) <> 'inactive' + ORDER BY tl.tenantid, tl.locationid`, customerid).Scan(&rows).Error + if err != nil { + return nil, err + } + + now := time.Now() + stores := make([]models.ScanStore, 0, len(rows)) + for _, row := range rows { + lat, lng, _ := utils.ParseLatLng(row.Latitude, row.Longitude) + stores = append(stores, models.ScanStore{ + Tenantid: row.Tenantid, + Tenantname: row.Tenantname, + Locationid: row.Locationid, + Locationname: row.Locationname, + Address: row.Address, + Latitude: lat, + Longitude: lng, + DistanceKm: -1, + Deliveryradius: row.Deliveryradius, + Deliverymins: row.Deliverymins, + Open: utils.OpenNow(row.Opentime, row.Closetime, now), + }) + } + return stores, nil +} + +// storeOptionSelect is the projection every outlet read shares, so the +// price and stock rules cannot differ between the lookup and the confirm. +// +// Price: the outlet's own price when it set one, else the tenant's retail +// price — the same rule GetProducts applies. Stock: the live IN−OUT balance +// of the ledger at that outlet, the same expression the app displays, so a +// product can never be offered here and show 0 on the next screen. +const storeOptionSelect = ` + SELECT a.tenantid, b.locationid, a.productid, + COALESCE(a.productname, '') AS productname, + LOWER(COALESCE(a.productbrand, '')) AS productbrand, + COALESCE(a.catalogueid, 0) AS catalogueid, + COALESCE(a.imageid, '') AS imageid, + COALESCE(a.productimage, '') AS productimage, + COALESCE(a.productunit, '') AS productunit, + COALESCE(a.unitvalue, '') AS unitvalue, + CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price, + COALESCE(( + SELECT SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity + WHEN LOWER(c.stocktype) = 'out' THEN -c.quantity + ELSE 0 END) + FROM productstocks c + WHERE c.productid = a.productid AND c.locationid = b.locationid AND c.tenantid = a.tenantid + ), 0) AS stock, + COALESCE(v.productid, 0) AS parentid, + COALESCE(v.variantname, '') AS variantname + FROM products a + INNER JOIN productlocations b ON b.productid = a.productid AND b.tenantid = a.tenantid + LEFT JOIN productvariants v ON v.variantproductid = a.productid AND v.tenantid = a.tenantid + AND LOWER(COALESCE(v.status, 'active')) <> 'inactive'` + +func (r *scanRepository) StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error) { + if len(locationids) == 0 || (len(keys) == 0 && len(names) == 0) { + return nil, nil + } + + // The products that ARE the catalogue match, at these outlets. + var matchConds []string + var args []interface{} + args = append(args, locationids) + for _, k := range keys { + if k.Imageid != "" { + matchConds = append(matchConds, "a.imageid = ?") + args = append(args, k.Imageid) + } + if k.Brand != "" && k.Catalogueid > 0 { + matchConds = append(matchConds, "(LOWER(a.productbrand) = ? AND a.catalogueid = ?)") + args = append(args, strings.ToLower(k.Brand), k.Catalogueid) + } + } + for _, n := range names { + if n = strings.ToLower(strings.TrimSpace(n)); n != "" { + matchConds = append(matchConds, "LOWER(a.productname) = ?") + args = append(args, n) + } + } + if len(matchConds) == 0 { + return nil, nil + } + + // Two reads rather than one recursive query: the second is keyed on the + // first's product ids, and a variant of a variant is not a thing here. + query := storeOptionSelect + ` + WHERE a.approve = 1 AND b.publishedat IS NOT NULL + AND b.locationid IN (?) + AND (` + strings.Join(matchConds, " OR ") + `)` + + var parents []StoreOptionRow + if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&parents).Error; err != nil { + return nil, err + } + if len(parents) == 0 { + return nil, nil + } + + parentIDs := make([]int, 0, len(parents)) + for _, p := range parents { + parentIDs = append(parentIDs, p.Productid) + } + + // The sizes hanging under those products, at the same outlets. Only the + // rows whose parent is one of ours — the LEFT JOIN in the select can + // attach any parent, so it is pinned here. + var sizes []StoreOptionRow + err := r.db.WithContext(ctx).Raw(storeOptionSelect+` + WHERE a.approve = 1 AND b.publishedat IS NOT NULL + AND b.locationid IN (?) + AND v.productid IN (?)`, locationids, parentIDs).Scan(&sizes).Error + if err != nil { + return nil, err + } + + return append(parents, sizes...), nil +} + +func (r *scanRepository) ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error) { + var rows []StoreOptionRow + err := r.db.WithContext(ctx).Raw(storeOptionSelect+` + WHERE a.approve = 1 AND b.publishedat IS NOT NULL + AND a.tenantid = ? AND b.locationid = ? AND a.productid = ? + LIMIT 1`, tenantid, locationid, productid).Scan(&rows).Error + if err != nil || len(rows) == 0 { + return nil, err + } + return &rows[0], nil +} + +// ── catalogue ─────────────────────────────────────────────────────────────── + +// brandTables is every `brand_*` table and its columns, cached briefly. +func (r *scanRepository) brandTables(ctx context.Context) (map[string]map[string]bool, error) { + if r.catalogue == nil { + return nil, ErrCatalogueDBUnavailable + } + r.tablesMu.Lock() + defer r.tablesMu.Unlock() + if r.tables != nil && time.Since(r.tablesAt) < scanTablesTTL { + return r.tables, nil + } + + var rows []struct { + TableName string + ColumnName string + } + err := r.catalogue.WithContext(ctx).Raw(` + SELECT c.table_name, c.column_name + FROM information_schema.columns c + WHERE c.table_schema = 'public' AND c.table_name LIKE 'brand\_%'`).Scan(&rows).Error + if err != nil { + return nil, err + } + tables := make(map[string]map[string]bool) + for _, row := range rows { + if tables[row.TableName] == nil { + tables[row.TableName] = make(map[string]bool) + } + tables[row.TableName][row.ColumnName] = true + } + for name, cols := range tables { + if !cols["id"] || !cols["product_name"] { + delete(tables, name) + } + } + + // The vector width, read from the first embedding column found. pgvector + // stores it as the type modifier, so a mismatch with the model can be + // named in the error instead of surfacing as a bare "different vector + // dimensions" from the driver. + if r.embeddingDim == 0 { + for name, cols := range tables { + if !cols["embedding"] { + continue + } + var dim int + r.catalogue.WithContext(ctx).Raw(` + SELECT a.atttypmod FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + WHERE c.relname = ? AND a.attname = 'embedding'`, name).Scan(&dim) + if dim > 0 { + r.embeddingDim = dim + } + break + } + } + + r.tables, r.tablesAt = tables, time.Now() + return tables, nil +} + +// VectorSearchAvailable is whether any catalogue table carries a vector. +func (r *scanRepository) VectorSearchAvailable() bool { + tables, err := r.brandTables(context.Background()) + if err != nil { + return false + } + for _, cols := range tables { + if cols["embedding"] { + return true + } + } + return false +} + +// hitColumns is the projection each search returns, with NULL stand-ins for +// columns a particular brand table lacks — the same tolerance +// catalogueRepository applies, for the same reason: a newer table missing +// one enrichment column is still a perfectly good catalogue of products. +func hitColumns(brand string, cols map[string]bool) string { + opt := func(name string) string { + if cols[name] { + return "COALESCE(" + name + ", '') AS " + name + } + return "'' AS " + name + } + return fmt.Sprintf(`'%s' AS brand, id, COALESCE(product_name, '') AS product_name, %s, %s, %s, %s, %s, %s`, + brand, opt("title"), opt("category"), opt("size"), opt("variant_key"), opt("image_id"), opt("image_url")) +} + +// VectorSearch ranks every brand table by cosine distance to the label's +// vector and merges the top of each. +// +// One branch per table, each with its own ORDER BY and LIMIT inside +// parentheses, so Postgres can use the per-table vector index instead of +// scanning the union. The literal is bound as a parameter and cast — never +// concatenated — and table names come from information_schema, never from +// the request. +func (r *scanRepository) VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error) { + tables, err := r.brandTables(ctx) + if err != nil { + return nil, err + } + if r.embeddingDim > 0 && len(vector) != r.embeddingDim { + return nil, fmt.Errorf("embedding is %d wide but the catalogue's embedding column is %d: EMBEDDING_MODEL/EMBEDDING_DIMENSIONS do not match the model that indexed the catalogue", len(vector), r.embeddingDim) + } + + literal := utils.VectorLiteral(vector) + var branches []string + var args []interface{} + for _, table := range sortedKeys(tables) { + cols := tables[table] + if !cols["embedding"] { + continue + } + brand := strings.TrimPrefix(table, "brand_") + branches = append(branches, fmt.Sprintf( + `(SELECT %s, (embedding <=> ?::vector) AS distance FROM %s WHERE embedding IS NOT NULL ORDER BY embedding <=> ?::vector LIMIT %d)`, + hitColumns(brand, cols), table, limit)) + args = append(args, literal, literal) + } + if len(branches) == 0 { + return nil, errors.New("no catalogue table has an embedding column") + } + + query := strings.Join(branches, " UNION ALL ") + fmt.Sprintf(" ORDER BY distance LIMIT %d", limit) + var hits []CatalogueHit + if err := r.catalogue.WithContext(ctx).Raw(query, args...).Scan(&hits).Error; err != nil { + return nil, err + } + return hits, nil +} + +// TextSearch is the fallback when there is no embedder, and the tie-breaker +// beside it when there is: rows whose name or title contains the label, or +// contains every word of it. +func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error) { + tables, err := r.brandTables(ctx) + if err != nil { + return nil, err + } + label = strings.ToLower(strings.TrimSpace(label)) + tokens := utils.SearchTokens(label) + if label == "" || len(tokens) == 0 { + return nil, nil + } + + var branches []string + var args []interface{} + for _, table := range sortedKeys(tables) { + cols := tables[table] + brand := strings.TrimPrefix(table, "brand_") + + hay := "LOWER(COALESCE(product_name, ''))" + if cols["title"] { + hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, ''))" + } + if cols["search_query"] { + hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, '') || ' ' || COALESCE(search_query, ''))" + } + + conds := []string{hay + " LIKE ?"} + args = append(args, "%"+label+"%") + all := make([]string, 0, len(tokens)) + for _, tok := range tokens { + all = append(all, hay+" LIKE ?") + args = append(args, "%"+tok+"%") + } + conds = append(conds, "("+strings.Join(all, " AND ")+")") + + branches = append(branches, fmt.Sprintf( + `(SELECT %s, -1::float8 AS distance FROM %s WHERE %s LIMIT %d)`, + hitColumns(brand, cols), table, strings.Join(conds, " OR "), limit)) + } + if len(branches) == 0 { + return nil, nil + } + + var hits []CatalogueHit + if err := r.catalogue.WithContext(ctx).Raw(strings.Join(branches, " UNION ALL "), args...).Scan(&hits).Error; err != nil { + return nil, err + } + return hits, nil +} + +func sortedKeys(m map[string]map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// ── cache ─────────────────────────────────────────────────────────────────── +// +// Two tiers. Redis is shared across replicas and survives a restart; the +// in-process map is there so the request after a cache hit costs no network +// round trip at all, and so a deployment without Redis still gets the +// benefit within one process. Neither tier ever holds stock. + +func scanCacheKey(kind, scope, label string) string { + sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(label)))) + return "scan:" + kind + ":v1:" + scope + ":" + hex.EncodeToString(sum[:16]) +} + +func (r *scanRepository) CachedVector(ctx context.Context, model, label string) ([]float32, bool) { + key := scanCacheKey("emb", model, label) + + r.memMu.Lock() + v, ok := r.memVecs[key] + r.memMu.Unlock() + if ok { + return v, true + } + + if db.Rdb == nil { + return nil, false + } + raw, err := db.Rdb.Get(ctx, key).Bytes() + if err != nil { + return nil, false + } + if json.Unmarshal(raw, &v) != nil || len(v) == 0 { + return nil, false + } + r.remember(key, v, nil) + return v, true +} + +func (r *scanRepository) CacheVector(ctx context.Context, model, label string, v []float32) { + key := scanCacheKey("emb", model, label) + r.remember(key, v, nil) + if db.Rdb == nil { + return + } + if raw, err := json.Marshal(v); err == nil { + if err := db.Rdb.Set(ctx, key, raw, scanVectorTTL).Err(); err != nil { + log.Printf("scan: could not cache vector: %v", err) + } + } +} + +func (r *scanRepository) CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool) { + key := scanCacheKey("hits", method, label) + + r.memMu.Lock() + h, ok := r.memHits[key] + r.memMu.Unlock() + if ok { + return h, true + } + + if db.Rdb == nil { + return nil, false + } + raw, err := db.Rdb.Get(ctx, key).Bytes() + if err != nil { + return nil, false + } + if json.Unmarshal(raw, &h) != nil { + return nil, false + } + r.remember(key, nil, h) + return h, true +} + +func (r *scanRepository) CacheHits(ctx context.Context, method, label string, hits []CatalogueHit) { + key := scanCacheKey("hits", method, label) + r.remember(key, nil, hits) + if db.Rdb == nil { + return + } + if raw, err := json.Marshal(hits); err == nil { + if err := db.Rdb.Set(ctx, key, raw, scanHitsTTL).Err(); err != nil { + log.Printf("scan: could not cache hits: %v", err) + } + } +} + +// remember writes one entry into the process-local tier. Eviction is the +// simplest thing that bounds memory: when full, drop everything. Labels are +// short-lived popularity, not a working set worth an LRU. +func (r *scanRepository) remember(key string, v []float32, h []CatalogueHit) { + r.memMu.Lock() + defer r.memMu.Unlock() + if len(r.memVecs)+len(r.memHits) >= scanMemCacheMax { + r.memVecs = make(map[string][]float32) + r.memHits = make(map[string][]CatalogueHit) + } + if v != nil { + r.memVecs[key] = v + } + if h != nil { + r.memHits[key] = h + } +} diff --git a/repositories/userRepository.go b/repositories/userRepository.go index 764bcee..60a4cf7 100644 --- a/repositories/userRepository.go +++ b/repositories/userRepository.go @@ -1,6 +1,8 @@ package repositories import ( + "database/sql" + "errors" "fmt" "strings" @@ -15,13 +17,11 @@ type UserRepository interface { Login(user models.User) (models.UserInfo, error) FindUserID(authname, contactno string, configid int) (int, error) UpdateStaff(user models.User) error - GetUserByAuthname(authname string, configid int) (int, string, string) - GetUserByContactNo(contactno string, configid int) (int, string, string) UpdateFCMToken(userid int, token string) error GetTenantUserById(userid int) models.TenantUserInfo CreateUser(user models.User) (int, error) GetUserById(uid int) (models.UserInfo, error) - GetUserLogin(field, value string, configid int) (int, string, string, int) + GetUserLogin(field, value string, configid int) (int, string, string, int, error) UpdateUserFcmToken(uid int, token string) error GetLocationStatus(locationid int) string DeleteUser(userid int) error @@ -164,7 +164,6 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) { return userInfo, nil } - func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) { var uid int var query string @@ -187,39 +186,10 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i return uid, nil } - - func (r *userRepository) UpdateStaff(user models.User) error { return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error } -// A till account is not a Nearle Daily user. The two products share this table -// and nothing else, so every way into the application excludes roles 7 and 8 in -// the lookup itself: a cashier is not "refused", they are simply not found. -// -// Doing it in the query rather than after it is deliberate. A check bolted on -// afterwards has to be repeated at each of these call sites and is one edit away -// from being forgotten at one of them, and that one would be the hole. -func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) { - var uid int - var password, status string - query := `SELECT userid, password, status FROM app_users - WHERE authname = ? AND configid = ? - AND COALESCE(roleid, 0) NOT IN (7, 8)` - r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status) - return uid, password, status -} - -func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) { - var uid int - var password, status string - query := `SELECT userid, password, status FROM app_users - WHERE contactno = ? AND configid = ? - AND COALESCE(roleid, 0) NOT IN (7, 8)` - r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status) - return uid, password, status -} - func (r *userRepository) UpdateFCMToken(userid int, token string) error { query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?` return r.db.Exec(query, token, userid).Error @@ -329,9 +299,40 @@ func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) { return user, nil } -func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int) { - var uid, roleid int - var password, status string +// GetUserLogin is the one sign-in lookup, for the app and the console alike. +// +// `field` is the column matched — "authname" or "contactno", nothing else is +// accepted — and it is interpolated, so the whitelist is what keeps this from +// being an injection point. +// +// A till account is not a Nearle Daily user. The two products share this table +// and nothing else, so the lookup itself excludes roles 7 and 8: a cashier is +// not "refused", they are simply not found. Doing it in the query rather than +// after it is deliberate — a check bolted on afterwards has to be repeated at +// every call site and is one edit away from being forgotten at one of them. +// +// Three outcomes, and the caller must tell them apart: +// +// - found: uid > 0, err == nil +// - not found: uid == 0, err == nil +// - failed: err != nil — the database could not answer at all +// +// The third used to be invisible. `Row().Scan`'s error was discarded, so a +// database that was down, a connection pool that was exhausted or a +// misconfigured `configid` all came back as uid 0 — which the service then +// reported as "Invalid Email". On 2026-07-20 the deployment lost its +// ConfigMaps/Secrets and every user on the platform was told their email was +// wrong, and nothing in the logs said otherwise. +func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int, error) { + switch field { + case "authname", "contactno": + default: + return 0, "", "", 0, fmt.Errorf("login: %q is not a sign-in field", field) + } + + var uid int + var password, status sql.NullString + var roleid sql.NullInt64 query := fmt.Sprintf(` SELECT userid, password, status, roleid @@ -339,9 +340,16 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s WHERE %s = ? AND configid = ? AND COALESCE(roleid, 0) NOT IN (7, 8)`, field) - r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid) - - return uid, password, status, roleid + err := r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid) + if errors.Is(err, sql.ErrNoRows) { + return 0, "", "", 0, nil + } + if err != nil { + return 0, "", "", 0, err + } + // Nullable columns scanned through sql.Null* so that a NULL password or + // role — both exist on real rows — does not itself read as a failed query. + return uid, password.String, status.String, int(roleid.Int64), nil } func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error { @@ -359,5 +367,3 @@ func (r *userRepository) GetLocationStatus(locationid int) string { func (r *userRepository) DeleteUser(userid int) error { return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error } - - diff --git a/routes/routes.go b/routes/routes.go index 688fe5c..e7c8e57 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -21,4 +21,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) { RegisterCatalogueRoutes(api, f) RegisterPosRoutes(api, f) RegisterUploadRoutes(api, f) + RegisterScanRoutes(api, f) } diff --git a/routes/scanroutes.go b/routes/scanroutes.go new file mode 100644 index 0000000..8277ba2 --- /dev/null +++ b/routes/scanroutes.go @@ -0,0 +1,17 @@ +package routes + +import ( + "nearle/facade" + + "github.com/gofiber/fiber/v2" +) + +// Scan-to-order, customer app only. See controllers/scanController.go for +// the three calls and services/scanService.go for the pipeline behind them. +func RegisterScanRoutes(api fiber.Router, f *facade.Facade) { + scan := api.Group("/v1/mob/scan") + + scan.Post("/lookup", f.ScanController.Lookup) + scan.Post("/confirm", f.ScanController.Confirm) + scan.Get("/stores", f.ScanController.Stores) +} diff --git a/scratch/cataloguedims/main.go b/scratch/cataloguedims/main.go new file mode 100644 index 0000000..1b39c1f --- /dev/null +++ b/scratch/cataloguedims/main.go @@ -0,0 +1,89 @@ +// Reports how the catalogue's `embedding` columns are shaped — width, how +// many rows are filled, and a sample norm — so the embedding model Fiesta +// calls can be matched to the one that indexed the catalogue. Metadata and +// counts only, on a read-only transaction; it never writes. +// +// go run ./scratch/cataloguedims # reads CATALOGUE_DB_* from .env.production +package main + +import ( + "flag" + "fmt" + "log" + "net/url" + "os" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func main() { + sample := flag.String("sample", "", "print one row's texts and stored vector from this table, to check which model produced it") + flag.Parse() + _ = godotenv.Load(".env.production") + dsn := url.URL{ + Scheme: "postgres", + User: url.UserPassword(os.Getenv("CATALOGUE_DB_USER"), os.Getenv("CATALOGUE_DB_PASSWORD")), + Host: os.Getenv("CATALOGUE_DB_HOST") + ":" + os.Getenv("CATALOGUE_DB_PORT"), + Path: "/" + os.Getenv("CATALOGUE_DB_NAME"), + } + q := dsn.Query() + q.Set("sslmode", "disable") + q.Set("default_transaction_read_only", "on") + dsn.RawQuery = q.Encode() + + db, err := gorm.Open(postgres.Open(dsn.String()), &gorm.Config{}) + if err != nil { + log.Fatal(err) + } + + if *sample != "" { + var row struct { + ProductName string + Title string + SearchQuery string + Embedding string + } + db.Raw(fmt.Sprintf(`SELECT product_name, COALESCE(title, '') AS title, COALESCE(search_query, '') AS search_query, + embedding::text AS embedding FROM %s WHERE embedding IS NOT NULL ORDER BY id LIMIT 1`, *sample)).Scan(&row) + fmt.Printf("product_name: %s\ntitle: %s\nsearch_query: %s\nembedding: %s\n", row.ProductName, row.Title, row.SearchQuery, row.Embedding) + return + } + + var cols []struct { + Relname string + Typname string + Atttypmod int + } + if err := db.Raw(` + SELECT c.relname, t.typname, a.atttypmod + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_type t ON t.oid = a.atttypid + WHERE a.attname = 'embedding' AND c.relname LIKE 'brand\_%' + ORDER BY c.relname`).Scan(&cols).Error; err != nil { + log.Fatal(err) + } + if len(cols) == 0 { + fmt.Println("no brand_* table has an embedding column") + return + } + fmt.Printf("%-28s %-8s %5s %5s %5s\n", "table", "type", "dims", "rows", "embd") + for _, c := range cols { + var total, filled int64 + db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s`, c.Relname)).Scan(&total) + db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s WHERE embedding IS NOT NULL`, c.Relname)).Scan(&filled) + fmt.Printf("%-28s %-8s %5d %5d %5d\n", c.Relname, c.Typname, c.Atttypmod, total, filled) + } + + // nomic/bge emit unit vectors; a norm far from 1 means another pipeline. + for _, c := range cols { + var norm float64 + db.Raw(fmt.Sprintf(`SELECT vector_norm(embedding) FROM %s WHERE embedding IS NOT NULL LIMIT 1`, c.Relname)).Scan(&norm) + if norm > 0 { + fmt.Printf("sample vector norm (%s): %.4f\n", c.Relname, norm) + break + } + } +} diff --git a/scratch/poslivecheck/main.go b/scratch/poslivecheck/main.go new file mode 100644 index 0000000..751b471 --- /dev/null +++ b/scratch/poslivecheck/main.go @@ -0,0 +1,267 @@ +// Does the mobile-number-and-PIN sign-in actually work against the live data? +// +// Picks a supervisor and a cashier that already have both halves of the +// credential, prints them so they can be typed into a terminal, then runs the +// real repository and service — the same code path the HTTP handler calls — and +// reports what came back. +// +// Read-only. PosLogin issues SELECTs and mints a token in memory; nothing here +// writes, and the token is not persisted anywhere by design. +// +// Numbers and PINs are masked unless -show is passed. They belong to real +// people at real shops, and the default should not be to print them into +// whatever is capturing this program's output. +// +// go run ./scratch/poslivecheck # picks a ready pair, masked +// go run ./scratch/poslivecheck -show # prints the credentials +// go run ./scratch/poslivecheck -show 1087 1137 # ...at a named outlet +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strconv" + "strings" + + "nearle/models" + "nearle/repositories" + "nearle/services" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type account struct { + Userid int + Tenantid int + Locationid int + Roleid int + Fullname string + Contactno string + Pin int64 +} + +func main() { + _ = godotenv.Load() + + if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" { + log.Fatal("POS_TOKEN_SECRET is not set; sign-in mints a token and will fail without it") + } + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME")) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + log.Fatal(err) + } + + // Only accounts holding both halves are candidates. An account missing + // either cannot sign in at all, and picking one would prove nothing except + // that the rejection works. + where := `COALESCE(roleid,0) = ? + AND COALESCE(pin,0) BETWEEN 1000 AND 9999 + AND TRIM(COALESCE(contactno,'')) <> '' + AND LOWER(COALESCE(status,'active')) <> 'inactive'` + args := []interface{}{} + + // -show opts into printing the credentials themselves. + show := false + rest := []string{} + for _, arg := range os.Args[1:] { + if arg == "-show" || arg == "--show" { + show = true + continue + } + rest = append(rest, arg) + } + + if len(rest) > 1 { + tenantID, _ := strconv.Atoi(rest[0]) + locationID, _ := strconv.Atoi(rest[1]) + where += ` AND tenantid = ? AND locationid = ?` + args = append(args, tenantID, locationID) + } + + pick := func(roleID int) *account { + var a account + params := append([]interface{}{roleID}, args...) + err := db.Raw(` + SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid, + COALESCE(roleid,0) AS roleid, + TRIM(CONCAT(COALESCE(firstname,''),' ',COALESCE(lastname,''))) AS fullname, + COALESCE(contactno,'') AS contactno, COALESCE(pin,0) AS pin + FROM app_users WHERE `+where+` ORDER BY userid LIMIT 1`, params...).Scan(&a).Error + if err != nil || a.Userid == 0 { + return nil + } + return &a + } + + supervisor := pick(models.PosRoleSupervisor) + cashier := pick(models.PosRoleCashier) + + fmt.Println("Accounts that can sign in today") + fmt.Println(strings.Repeat("-", 78)) + for _, pair := range []struct { + label string + a *account + }{{"supervisor", supervisor}, {"cashier", cashier}} { + a := pair.a + if a == nil { + fmt.Printf(" %-11s none — no account of this role has both a number and a PIN\n", pair.label) + continue + } + fmt.Printf(" %-11s userid %-6d tenant %-6d outlet %-6d %s\n", + pair.label, a.Userid, a.Tenantid, a.Locationid, a.Fullname) + fmt.Printf(" %-11s mobile %s PIN %s\n\n", "", + mask(a.Contactno, show), maskPin(a.Pin, show)) + } + if !show { + fmt.Println(" (masked — re-run with -show to print them)") + } + + if supervisor == nil && cashier == nil { + log.Fatal("nothing to test with") + } + + repo := repositories.NewPosRepository(db) + svc := services.NewPosService(repo, nil) + + fmt.Println("\nSigning in (real service, live data)") + fmt.Println(strings.Repeat("-", 78)) + + pass, fail := 0, 0 + check := func(name string, ok bool, detail string) { + if ok { + pass++ + fmt.Printf(" PASS %-46s %s\n", name, detail) + return + } + fail++ + fmt.Printf(" FAIL %-46s %s\n", name, detail) + } + + signIn := func(label string, a *account) *models.PosSession { + if a == nil { + return nil + } + session, err := svc.Login(models.PosLoginRequest{ + Contactno: a.Contactno, + Pin: strconv.FormatInt(a.Pin, 10), + }) + if err != nil { + check(label+" signs in", false, err.Error()) + return nil + } + check(label+" signs in", true, fmt.Sprintf( + "%s at %s (outlet %d), can_manage_staff=%v", + session.Role, session.Locationname, session.Locationid, session.Canmanagestaff)) + check(label+" gets a token", session.Token != "", + fmt.Sprintf("%d chars, expires %s", len(session.Token), session.Expiresat)) + return session + } + + supSession := signIn("supervisor", supervisor) + cashSession := signIn("cashier", cashier) + + if supSession != nil { + check("supervisor can manage staff", supSession.Canmanagestaff, "role 7 grants it") + } + if cashSession != nil { + check("cashier cannot manage staff", !cashSession.Canmanagestaff, "role 8 does not") + } + + // The response must not carry anyone's PIN — the whole point of the change + // that removed staff[].pin. + // + // Checked against the serialised JSON, not the Go struct. PosStaffMember.Pin + // is still *populated* — the query needs it to drop two people sharing a PIN + // — and is kept off the wire by `json:"-"`. Asserting on the struct field + // tests the wrong layer and fails a correct implementation. + if supSession != nil { + encoded, merr := json.Marshal(supSession) + check("session serialises", merr == nil, errText(merr)) + if merr == nil { + check("no PIN travels in the session", !strings.Contains(string(encoded), `"pin"`), + fmt.Sprintf("%d staff listed, %d bytes of JSON", len(supSession.Staff), len(encoded))) + } + } + + // A number typed the way a person actually types it. + if supervisor != nil && len(supervisor.Contactno) == 10 { + for label, typed := range map[string]string{ + "+91 with spaces": "+91 " + supervisor.Contactno[:5] + " " + supervisor.Contactno[5:], + "leading zero": "0" + supervisor.Contactno, + "bare ten digits": supervisor.Contactno, + } { + _, err := svc.Login(models.PosLoginRequest{ + Contactno: typed, Pin: strconv.FormatInt(supervisor.Pin, 10), + }) + check("number accepted as typed", err == nil, label) + } + } + + // And the refusals. + if supervisor != nil { + wrong := supervisor.Pin + 1 + if wrong > 9999 { + wrong = 1000 + } + _, err := svc.Login(models.PosLoginRequest{ + Contactno: supervisor.Contactno, Pin: strconv.FormatInt(wrong, 10), + }) + check("a wrong PIN is refused", err != nil, errText(err)) + } + + _, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"}) + check("an unknown number is refused", err != nil, errText(err)) + + // Switching operator at an open terminal, which is what the till does when + // a colleague takes over. + if supSession != nil && cashier != nil && cashier.Locationid == supSession.Locationid { + switched, err := repo.PosLoginByPin(supSession.Tenantid, supSession.Locationid, + strconv.FormatInt(cashier.Pin, 10)) + if err != nil { + check("operator switch by PIN", false, err.Error()) + } else { + check("operator switch by PIN", true, + fmt.Sprintf("now %s, can_manage_staff=%v", switched.Role, switched.Canmanagestaff)) + } + } + + fmt.Printf("\n%d passed, %d failed\n", pass, fail) + if fail > 0 { + os.Exit(1) + } +} + +// mask shows enough of a number to recognise the account, not enough to sign in +// as it. +func mask(number string, show bool) string { + if show { + return number + } + if len(number) != 10 { + return strings.Repeat("*", len(number)) + } + return number[:2] + "******" + number[8:] +} + +func maskPin(pin int64, show bool) string { + if show { + return strconv.FormatInt(pin, 10) + } + return "****" +} + +func errText(err error) string { + if err == nil { + return "no error" + } + return err.Error() +} diff --git a/scratch/posloginready/main.go b/scratch/posloginready/main.go new file mode 100644 index 0000000..d51e265 --- /dev/null +++ b/scratch/posloginready/main.go @@ -0,0 +1,166 @@ +// Can the accounts that may open a till actually complete the new sign-in? +// +// Read-only, and deliberately prints no numbers and no PINs — only whether each +// account has one and whether the login would find it. +// +// The question this answers is narrower than "does it have a number". The login +// matches `LOWER(TRIM(contactno)) = LOWER(TRIM($1))` where $1 has already been +// reduced to ten digits, so the comparison is exact: a row holding +// "+91 98765 43210" is invisible to somebody typing the same number, because +// only one side of the comparison gets normalised. +// +// go run ./scratch/posloginready +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "nearle/models" + + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type row struct { + Userid int + Tenantid int + Locationid int + Roleid int + Contactno string + Pin int64 + Status string +} + +// normalise mirrors repositories.normalisePosPhone, which is unexported. +func normalise(raw string) string { + digits := strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' { + return r + } + return -1 + }, raw) + if len(digits) == 12 && strings.HasPrefix(digits, "91") { + digits = digits[2:] + } else if len(digits) == 11 && strings.HasPrefix(digits, "0") { + digits = digits[1:] + } + if len(digits) != 10 { + return "" + } + return digits +} + +func main() { + _ = godotenv.Load() + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), + os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME")) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + log.Fatal(err) + } + + var rows []row + if err := db.Raw(` + SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid, + COALESCE(roleid,0) AS roleid, COALESCE(contactno,'') AS contactno, + COALESCE(pin,0) AS pin, COALESCE(status,'') AS status + FROM app_users + WHERE COALESCE(roleid,0) IN (?, ?) + ORDER BY tenantid, locationid, userid`, + models.PosRoleSupervisor, models.PosRoleCashier).Scan(&rows).Error; err != nil { + log.Fatal(err) + } + + // What the login would see. Only active till accounts are candidates, and a + // number matching more than one of them is refused outright. + byPhone := map[string][]int{} + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Status), "inactive") { + continue + } + if stored := strings.TrimSpace(r.Contactno); stored != "" { + byPhone[strings.ToLower(stored)] = append(byPhone[strings.ToLower(stored)], r.Userid) + } + } + + fmt.Printf("till accounts (roleid %d/%d): %d\n\n", models.PosRoleSupervisor, models.PosRoleCashier, len(rows)) + fmt.Printf("%-8s %-8s %-9s %-5s %-9s %-8s %-7s %s\n", + "userid", "tenant", "location", "role", "status", "number", "pin", "can sign in?") + fmt.Println(strings.Repeat("-", 86)) + + var ready, noPhone, unnormalised, noPin, ambiguous, inactive int + + for _, r := range rows { + stored := strings.TrimSpace(r.Contactno) + norm := normalise(stored) + + phoneState := "missing" + switch { + case stored == "": + phoneState = "missing" + case norm == "": + phoneState = "unusable" + case norm != stored: + phoneState = "STORED RAW" + default: + phoneState = "ok" + } + + pinState := "missing" + if r.Pin >= 1000 && r.Pin <= 9999 { + pinState = "ok" + } else if r.Pin != 0 { + pinState = "unusable" + } + + verdict := "yes" + switch { + case strings.EqualFold(r.Status, "inactive"): + verdict, inactive = "no — inactive", inactive+1 + case stored == "": + verdict, noPhone = "no — no number", noPhone+1 + case norm == "": + verdict, noPhone = "no — number unusable", noPhone+1 + case norm != stored: + // The one that looks fine in the console and fails at the counter. + verdict, unnormalised = "NO — number stored unnormalised", unnormalised+1 + case pinState != "ok": + verdict, noPin = "no — no usable PIN", noPin+1 + case len(byPhone[strings.ToLower(stored)]) > 1: + verdict, ambiguous = "NO — number shared with another till account", ambiguous+1 + default: + ready++ + } + + fmt.Printf("%-8d %-8d %-9d %-5d %-9s %-8s %-7s %s\n", + r.Userid, r.Tenantid, r.Locationid, r.Roleid, r.Status, phoneState, pinState, verdict) + } + + fmt.Printf("\nready to sign in with number + PIN : %d of %d\n", ready, len(rows)) + fmt.Printf(" blocked, no/unusable number : %d\n", noPhone) + fmt.Printf(" blocked, number stored raw : %d\n", unnormalised) + fmt.Printf(" blocked, no usable PIN : %d\n", noPin) + fmt.Printf(" blocked, number not unique : %d\n", ambiguous) + fmt.Printf(" inactive : %d\n", inactive) + + // Cross-tenant collisions are the failure creation cannot prevent: + // posPhoneTaken scopes uniqueness to one tenant, the login does not scope at + // all, so two tenants may each hold a number that neither can then use. + fmt.Println("\nnumbers shared by more than one active till account:") + found := false + for _, users := range byPhone { + if len(users) > 1 { + found = true + fmt.Printf(" userids %v — all refused with \"more than one account uses these sign-in details\"\n", users) + } + } + if !found { + fmt.Println(" none") + } +} diff --git a/scratch/posseparation/main.go b/scratch/posseparation/main.go index 6495f39..183d9ea 100644 --- a/scratch/posseparation/main.go +++ b/scratch/posseparation/main.go @@ -137,15 +137,11 @@ func main() { } fmt.Println("\n3. a till account cannot reach the Nearle Daily application") - uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid) - check("applogin lookup does not find the supervisor", - uid == 0, - fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid)) - - uid2, _, _, _ := users.GetUserLogin("authname", sup.Authname, sup.Configid) - check("tenant web login does not find the supervisor", - uid2 == 0, - fmt.Sprintf("GetUserLogin(%s) -> userid %d", sup.Authname, uid2)) + // Both the app and the console sign-in now go through GetUserLogin. + uid2, _, _, _, lookupErr := users.GetUserLogin("authname", sup.Authname, sup.Configid) + check("app and tenant web login do not find the supervisor", + uid2 == 0 && lookupErr == nil, + fmt.Sprintf("GetUserLogin(%s) -> userid %d err=%v", sup.Authname, uid2, lookupErr)) uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid) check("password-setup lookup does not find the supervisor", diff --git a/scratch/productimages/main.go b/scratch/productimages/main.go index c0cb51f..e07d8d3 100644 --- a/scratch/productimages/main.go +++ b/scratch/productimages/main.go @@ -27,9 +27,8 @@ import ( "log" "strings" + "nearle/config" "nearle/db" - - "github.com/joho/godotenv" ) type row struct { @@ -44,8 +43,7 @@ func main() { tenant := flag.Int("tenant", 0, "restrict to one tenant") flag.Parse() - _ = godotenv.Load() - db.Connect() + 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") diff --git a/services/scanService.go b/services/scanService.go new file mode 100644 index 0000000..4695212 --- /dev/null +++ b/services/scanService.go @@ -0,0 +1,721 @@ +package services + +import ( + "context" + "errors" + "fmt" + "log" + "math" + "nearle/models" + "nearle/repositories" + "nearle/utils" + "sort" + "strings" + "sync" + "time" +) + +/* +Scan-to-order: from a label Lens read off a packet to "buy it here". + +The pipeline, in the order it runs: + + 1. Who is asking, and where can they buy? The customer's registered outlets + (tenantcustomers → tenantlocations) and their position — the phone's + fix if it sent one, else the saved address. + 2. What did they scan? The label goes to the catalogue: embedded and ranked + by pgvector when a model is configured, matched on words when it is not, + and both when it is (the text match settles near-ties). The vector and + the hits are cached; a second person scanning the same packet today does + not pay for the model call. + 3. Which of THEIR outlets sell it, in which sizes, with how many on the + shelf right now? One read of nearledb keyed on the catalogue links every + imported product carries. Live stock is never cached. + 4. Rank: in-stock outlets first, nearest first, and the first of those is + the recommendation. The customer may still tap any other. + +Steps 1 and 2 touch different databases and run concurrently; the whole +lookup is bounded by scanLookupTimeout so a slow model degrades to a text +answer rather than a spinner. + +What this deliberately does not do: reserve stock. The confirm call +re-reads the ledger and, if the shelf emptied in between, points at the next +outlet — the same answer a hold would give, without a hold to expire. +*/ + +const ( + scanLookupTimeout = 5 * time.Second + scanMaxLabelLen = 200 + scanCatalogueTopK = 15 + // Below this the best hit is not shown as a match at all. + scanMinScore = 0.30 +) + +// ScanErrors the controller maps to statuses. Everything else is a 500. +var ( + ErrScanBadRequest = errors.New("scan: bad request") + ErrScanCustomerNotFound = errors.New("scan: customer not found") + ErrScanCatalogueDown = errors.New("scan: catalogue unavailable") +) + +type ScanService interface { + Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) + Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) + Stores(ctx context.Context, customerid int, lat, lng models.FlexibleString) ([]models.ScanStore, error) +} + +type scanService struct { + repo repositories.ScanRepository + embedder utils.Embedder // nil → text matching only +} + +func NewScanService(repo repositories.ScanRepository, embedder utils.Embedder) ScanService { + return &scanService{repo: repo, embedder: embedder} +} + +// ── Lookup ────────────────────────────────────────────────────────────────── + +func (s *scanService) Lookup(ctx context.Context, req models.ScanLookupRequest) (*models.ScanLookupResponse, error) { + label := strings.TrimSpace(req.Label) + if req.Customerid <= 0 { + return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest) + } + if label == "" { + return nil, fmt.Errorf("%w: label is required", ErrScanBadRequest) + } + if len(label) > scanMaxLabelLen { + label = label[:scanMaxLabelLen] + } + + ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout) + defer cancel() + + // Steps 1 and 2 in parallel — different databases, no dependency. + var ( + wg sync.WaitGroup + stores []models.ScanStore + exists bool + homeLat float64 + homeLng float64 + homeOK bool + custErr error + hits []scoredHit + method string + matchErr error + ) + wg.Add(2) + go func() { + defer wg.Done() + exists, custErr = s.repo.CustomerExists(ctx, req.Customerid) + if custErr != nil || !exists { + return + } + stores, custErr = s.repo.RegisteredStores(ctx, req.Customerid) + if custErr != nil { + return + } + // Only read the saved address when the phone sent nothing usable. + if _, _, ok := utils.ParseLatLng(string(req.Latitude), string(req.Longitude)); !ok { + homeLat, homeLng, homeOK, custErr = s.repo.CustomerHome(ctx, req.Customerid) + } + }() + go func() { + defer wg.Done() + hits, method, matchErr = s.searchCatalogue(ctx, label) + }() + wg.Wait() + + if custErr != nil { + return nil, custErr + } + if !exists { + return nil, ErrScanCustomerNotFound + } + if matchErr != nil { + return nil, matchErr + } + + lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude)) + if !hasPos && homeOK { + lat, lng, hasPos = homeLat, homeLng, true + } + + resp := &models.ScanLookupResponse{ + Label: label, + Stores: []models.ScanStoreOffer{}, + Variants: []models.ScanCatalogueMatch{}, + } + + // Verify the app's idea of the customer's tenants against the truth. + stores, resp.UnregisteredTenantids = restrictToTenants(stores, req.Tenantids) + + if len(hits) == 0 || hits[0].score < scanMinScore { + resp.Message = "We couldn't recognise that product. Try a clearer photo of the front of the pack." + return resp, nil + } + + best := hits[0] + family := catalogueFamily(hits) + resp.Match = ptr(best.toMatch(method)) + resp.Confidence = round3(best.score) + for _, h := range family { + resp.Variants = append(resp.Variants, h.toMatch(method)) + } + + if len(stores) == 0 { + resp.Message = "You haven't joined a store yet. Scan a store's QR code in the app to shop from it." + return resp, nil + } + + // Step 3: what those outlets have. + keys := make([]repositories.CatalogueKey, 0, len(family)) + names := make([]string, 0, len(family)) + for _, h := range family { + keys = append(keys, repositories.CatalogueKey{Brand: h.Brand, Catalogueid: h.ID, Imageid: h.ImageID}) + names = append(names, h.ProductName) + } + locationids := make([]int, 0, len(stores)) + for _, st := range stores { + locationids = append(locationids, st.Locationid) + } + rows, err := s.repo.StoreOptions(ctx, locationids, keys, names) + if err != nil { + return nil, err + } + + // Step 4: rank. + offers := buildOffers(stores, rows, keys, lat, lng, hasPos) + if req.Limit > 0 && len(offers) > req.Limit { + offers = offers[:req.Limit] + } + resp.Stores = offers + for i := range offers { + if offers[i].Recommended { + resp.Available = true + resp.RecommendedLocationid = offers[i].Locationid + break + } + } + + switch { + case resp.Available: + resp.Message = fmt.Sprintf("Available at %d of your stores.", countAvailable(offers)) + case len(offers) > 0: + resp.Message = "Your stores sell this but it's out of stock right now." + default: + resp.Message = "None of your stores sell this product yet." + } + return resp, nil +} + +// ── Confirm ───────────────────────────────────────────────────────────────── + +func (s *scanService) Confirm(ctx context.Context, req models.ScanConfirmRequest) (*models.ScanConfirmResponse, error) { + if req.Customerid <= 0 || req.Tenantid <= 0 || req.Locationid <= 0 || req.Productid <= 0 { + return nil, fmt.Errorf("%w: customerid, tenantid, locationid and productid are required", ErrScanBadRequest) + } + if req.Quantity <= 0 { + req.Quantity = 1 + } + + ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout) + defer cancel() + + stores, err := s.repo.RegisteredStores(ctx, req.Customerid) + if err != nil { + return nil, err + } + resp := &models.ScanConfirmResponse{Requested: req.Quantity} + + var chosen *models.ScanStore + for i := range stores { + if stores[i].Tenantid == req.Tenantid && stores[i].Locationid == req.Locationid { + chosen = &stores[i] + break + } + } + if chosen == nil { + resp.Reason = "store_not_registered" + resp.Message = "You're not registered with that store. Scan its QR code first." + return resp, nil + } + resp.Store = chosen + + row, err := s.repo.ProductAt(ctx, req.Tenantid, req.Locationid, req.Productid) + if err != nil { + return nil, err + } + if row == nil { + resp.Reason = "not_sold_here" + resp.Message = "That store doesn't sell this product." + return resp, nil + } + opt := optionFromRow(*row, nil) + resp.Option = &opt + + if row.Stock >= req.Quantity { + resp.Ok = true + resp.Reason = "in_stock" + resp.Message = "In stock." + return resp, nil + } + if row.Stock > 0 { + resp.Reason = "insufficient_stock" + resp.Message = fmt.Sprintf("Only %d left at %s.", row.Stock, chosen.Locationname) + } else { + resp.Reason = "out_of_stock" + resp.Message = fmt.Sprintf("Out of stock at %s.", chosen.Locationname) + } + + // The same product elsewhere, nearest first, with enough of it. + lat, lng, hasPos := utils.ParseLatLng(string(req.Latitude), string(req.Longitude)) + if !hasPos { + if hl, hg, ok, err := s.repo.CustomerHome(ctx, req.Customerid); err == nil && ok { + lat, lng, hasPos = hl, hg, true + } + } + others := make([]models.ScanStore, 0, len(stores)) + locationids := make([]int, 0, len(stores)) + for _, st := range stores { + if st.Locationid == req.Locationid { + continue + } + others = append(others, st) + locationids = append(locationids, st.Locationid) + } + if len(others) == 0 { + return resp, nil + } + + key := repositories.CatalogueKey{Brand: row.Productbrand, Catalogueid: row.Catalogueid, Imageid: row.Imageid} + rows, err := s.repo.StoreOptions(ctx, locationids, []repositories.CatalogueKey{key}, []string{row.Productname}) + if err != nil { + return nil, err + } + // Only the product itself — not its other sizes: the customer chose a + // size and a different one is not a substitute they asked for. + var same []repositories.StoreOptionRow + for _, r := range rows { + if r.Stock >= req.Quantity && sameStoreProduct(*row, r) { + same = append(same, r) + } + } + offers := buildOffers(others, same, []repositories.CatalogueKey{key}, lat, lng, hasPos) + for i := range offers { + if offers[i].Available { + offers[i].Recommended = true + resp.Alternative = &offers[i] + resp.Message += fmt.Sprintf(" %s has it", offers[i].Locationname) + if offers[i].DistanceKm >= 0 { + resp.Message += fmt.Sprintf(" (%.1f km away)", offers[i].DistanceKm) + } + resp.Message += "." + break + } + } + return resp, nil +} + +// ── Stores ────────────────────────────────────────────────────────────────── + +func (s *scanService) Stores(ctx context.Context, customerid int, latStr, lngStr models.FlexibleString) ([]models.ScanStore, error) { + if customerid <= 0 { + return nil, fmt.Errorf("%w: customerid is required", ErrScanBadRequest) + } + ctx, cancel := context.WithTimeout(ctx, scanLookupTimeout) + defer cancel() + + exists, err := s.repo.CustomerExists(ctx, customerid) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrScanCustomerNotFound + } + stores, err := s.repo.RegisteredStores(ctx, customerid) + if err != nil { + return nil, err + } + lat, lng, hasPos := utils.ParseLatLng(string(latStr), string(lngStr)) + if !hasPos { + if hl, hg, ok, err := s.repo.CustomerHome(ctx, customerid); err == nil && ok { + lat, lng, hasPos = hl, hg, true + } + } + for i := range stores { + stores[i].DistanceKm = distanceKm(stores[i], lat, lng, hasPos) + } + sort.SliceStable(stores, func(i, j int) bool { return nearer(stores[i].DistanceKm, stores[j].DistanceKm) }) + if stores == nil { + stores = []models.ScanStore{} + } + return stores, nil +} + +// ── Catalogue search ──────────────────────────────────────────────────────── + +type scoredHit struct { + repositories.CatalogueHit + score float64 +} + +func (h scoredHit) toMatch(method string) models.ScanCatalogueMatch { + return models.ScanCatalogueMatch{ + Brand: h.Brand, + Catalogueid: h.ID, + Imageid: h.ImageID, + ProductName: h.ProductName, + Title: h.Title, + Category: h.Category, + Size: h.Size, + VariantKey: h.VariantKey, + Image: h.ImageURL, + Score: round3(h.score), + Method: method, + } +} + +// searchCatalogue returns hits best-first and the method that produced them. +// +// Vector and text are combined as max(vector, text) with a small bonus when +// both agree. Max rather than a weighted sum so that a text-only hit — the +// exact product name typed on the pack — is never dragged below a vaguely +// similar vector neighbour, and a vector hit is never punished for a label +// Lens spelled slightly differently from the catalogue. +func (s *scanService) searchCatalogue(ctx context.Context, label string) ([]scoredHit, string, error) { + method := "text" + useVector := s.embedder != nil && s.repo.VectorSearchAvailable() + if useVector { + method = "vector+text" + } + + if cached, ok := s.repo.CachedHits(ctx, method+":"+s.modelName(), label); ok { + return scoreCachedHits(cached), method, nil + } + + tokens := utils.SearchTokens(label) + byKey := make(map[string]*scoredHit) + keyOf := func(h repositories.CatalogueHit) string { return h.Brand + "#" + fmt.Sprint(h.ID) } + + if useVector { + vec, err := s.embed(ctx, label) + if err != nil { + // Degrade, loudly in the log and quietly to the customer: a text + // answer now beats a vector answer never. + log.Printf("scan: embedding %q failed, falling back to text: %v", label, err) + method = "text" + } else { + vhits, err := s.repo.VectorSearch(ctx, vec, scanCatalogueTopK) + if err != nil { + log.Printf("scan: vector search failed, falling back to text: %v", err) + method = "text" + } + for _, h := range vhits { + sim := 1 - h.Distance // cosine distance → similarity + if sim < 0 { + sim = 0 + } + byKey[keyOf(h)] = &scoredHit{CatalogueHit: h, score: sim} + } + } + } + + thits, err := s.repo.TextSearch(ctx, label, scanCatalogueTopK) + if err != nil { + if errors.Is(err, repositories.ErrCatalogueDBUnavailable) { + return nil, method, ErrScanCatalogueDown + } + if len(byKey) == 0 { + return nil, method, err + } + log.Printf("scan: text search failed, vector only: %v", err) + } + for _, h := range thits { + ts := textScore(h, label, tokens) + if existing, ok := byKey[keyOf(h)]; ok { + existing.score = math.Min(1, math.Max(existing.score, ts)+0.10) + continue + } + byKey[keyOf(h)] = &scoredHit{CatalogueHit: h, score: ts} + } + + hits := make([]scoredHit, 0, len(byKey)) + for _, h := range byKey { + hits = append(hits, *h) + } + sortHits(hits) + + // Remember the ranked rows with their score folded into Distance, so the + // cache does not need a second shape. + toCache := make([]repositories.CatalogueHit, 0, len(hits)) + for _, h := range hits { + c := h.CatalogueHit + c.Distance = 1 - h.score + toCache = append(toCache, c) + } + s.repo.CacheHits(ctx, method+":"+s.modelName(), label, toCache) + + return hits, method, nil +} + +func scoreCachedHits(cached []repositories.CatalogueHit) []scoredHit { + hits := make([]scoredHit, 0, len(cached)) + for _, c := range cached { + hits = append(hits, scoredHit{CatalogueHit: c, score: 1 - c.Distance}) + } + sortHits(hits) + return hits +} + +func sortHits(hits []scoredHit) { + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + return hits[i].ProductName < hits[j].ProductName + }) +} + +func (s *scanService) modelName() string { + if s.embedder == nil { + return "none" + } + return s.embedder.Model() +} + +func (s *scanService) embed(ctx context.Context, label string) ([]float32, error) { + if v, ok := s.repo.CachedVector(ctx, s.embedder.Model(), label); ok { + return v, nil + } + v, err := s.embedder.Embed(ctx, label) + if err != nil { + return nil, err + } + s.repo.CacheVector(ctx, s.embedder.Model(), label, v) + return v, nil +} + +// textScore is how well a catalogue row's name matches the words Lens read. +// The whole label as a substring of the name is near-certain; otherwise the +// share of label words found in name+title, scaled so that "all of them" +// stops short of the substring case. +func textScore(h repositories.CatalogueHit, label string, tokens []string) float64 { + name := strings.ToLower(h.ProductName) + hay := name + " " + strings.ToLower(h.Title) + label = strings.ToLower(strings.TrimSpace(label)) + if label != "" && strings.Contains(name, label) { + return 0.95 + } + if len(tokens) == 0 { + return 0 + } + found := 0 + for _, t := range tokens { + if strings.Contains(hay, t) { + found++ + } + } + return 0.8 * float64(found) / float64(len(tokens)) +} + +// catalogueFamily is the best hit and its other pack sizes: same brand, and +// the same variant_key when the catalogue assigned one, else the same name. +// Every member is a separate catalogue row a shop may have imported. +func catalogueFamily(hits []scoredHit) []scoredHit { + if len(hits) == 0 { + return nil + } + best := hits[0] + family := []scoredHit{best} + for _, h := range hits[1:] { + if h.Brand != best.Brand { + continue + } + switch { + case best.VariantKey != "" && h.VariantKey != "": + if h.VariantKey == best.VariantKey { + family = append(family, h) + } + case strings.EqualFold(strings.TrimSpace(h.ProductName), strings.TrimSpace(best.ProductName)): + family = append(family, h) + } + } + return family +} + +// ── Offers ────────────────────────────────────────────────────────────────── + +// buildOffers turns outlet rows into ranked offers: one per outlet that had +// any row, in-stock outlets first, nearest first, the first in-stock one +// recommended. +func buildOffers(stores []models.ScanStore, rows []repositories.StoreOptionRow, keys []repositories.CatalogueKey, lat, lng float64, hasPos bool) []models.ScanStoreOffer { + byLocation := make(map[int][]repositories.StoreOptionRow) + for _, r := range rows { + byLocation[r.Locationid] = append(byLocation[r.Locationid], r) + } + + offers := make([]models.ScanStoreOffer, 0, len(byLocation)) + for _, st := range stores { + rs := byLocation[st.Locationid] + if len(rs) == 0 { + continue + } + st.DistanceKm = distanceKm(st, lat, lng, hasPos) + offer := models.ScanStoreOffer{ScanStore: st, Options: []models.ScanOption{}} + + // A product can arrive twice — as a direct match and as a size of + // another match. Keep the direct one; it carries the better label. + seen := make(map[int]int) + for _, r := range rs { + opt := optionFromRow(r, keys) + if idx, dup := seen[r.Productid]; dup { + if offer.Options[idx].IsVariant && !opt.IsVariant { + offer.Options[idx] = opt + } + continue + } + seen[r.Productid] = len(offer.Options) + offer.Options = append(offer.Options, opt) + if opt.Available { + offer.Available = true + } + } + // Direct matches first, then sizes; in stock before out. + sort.SliceStable(offer.Options, func(i, j int) bool { + a, b := offer.Options[i], offer.Options[j] + if a.Available != b.Available { + return a.Available + } + if a.IsVariant != b.IsVariant { + return !a.IsVariant + } + return a.Productname < b.Productname + }) + offers = append(offers, offer) + } + + sort.SliceStable(offers, func(i, j int) bool { + a, b := offers[i], offers[j] + if a.Available != b.Available { + return a.Available + } + if a.DistanceKm != b.DistanceKm { + return nearer(a.DistanceKm, b.DistanceKm) + } + return a.Locationname < b.Locationname + }) + for i := range offers { + if offers[i].Available { + offers[i].Recommended = true + break + } + } + return offers +} + +func optionFromRow(r repositories.StoreOptionRow, keys []repositories.CatalogueKey) models.ScanOption { + opt := models.ScanOption{ + Productid: r.Productid, + Productname: r.Productname, + Size: strings.TrimSpace(r.Unitvalue + " " + r.Productunit), + Price: r.Price, + Stock: r.Stock, + Available: r.Stock > 0, + Image: r.Productimage, + IsVariant: r.Parentid > 0, + Variantname: r.Variantname, + MatchedBy: "name", + } + if r.Parentid > 0 { + opt.MatchedBy = fmt.Sprintf("variant-of:%d", r.Parentid) + return opt + } + for _, k := range keys { + if k.Imageid != "" && k.Imageid == r.Imageid { + opt.MatchedBy = "imageid" + return opt + } + if k.Brand != "" && strings.EqualFold(k.Brand, r.Productbrand) && k.Catalogueid == r.Catalogueid && k.Catalogueid > 0 { + opt.MatchedBy = "brand+catalogueid" + return opt + } + } + return opt +} + +// sameStoreProduct is the cross-tenant identity of a product: the catalogue key +// when both rows carry one, the name otherwise. +func sameStoreProduct(a, b repositories.StoreOptionRow) bool { + if a.Imageid != "" && b.Imageid != "" { + return a.Imageid == b.Imageid + } + if a.Catalogueid > 0 && b.Catalogueid > 0 && a.Productbrand != "" { + return a.Catalogueid == b.Catalogueid && strings.EqualFold(a.Productbrand, b.Productbrand) + } + return strings.EqualFold(strings.TrimSpace(a.Productname), strings.TrimSpace(b.Productname)) +} + +// restrictToTenants keeps the outlets whose tenant the app named, and +// reports the names it got wrong. An app list that matches nothing is +// treated as stale rather than as "no stores": all registered outlets are +// used and every id it sent is reported. +func restrictToTenants(stores []models.ScanStore, tenantids []int) ([]models.ScanStore, []int) { + if len(tenantids) == 0 { + return stores, nil + } + registered := make(map[int]bool) + for _, st := range stores { + registered[st.Tenantid] = true + } + wanted := make(map[int]bool) + var unregistered []int + for _, id := range tenantids { + if registered[id] { + wanted[id] = true + } else { + unregistered = append(unregistered, id) + } + } + if len(wanted) == 0 { + return stores, unregistered + } + kept := make([]models.ScanStore, 0, len(stores)) + for _, st := range stores { + if wanted[st.Tenantid] { + kept = append(kept, st) + } + } + return kept, unregistered +} + +func distanceKm(st models.ScanStore, lat, lng float64, hasPos bool) float64 { + if !hasPos || (st.Latitude == 0 && st.Longitude == 0) { + return -1 + } + return math.Round(utils.HaversineKm(lat, lng, st.Latitude, st.Longitude)*100) / 100 +} + +// nearer orders distances with -1 (unknown) last. +func nearer(a, b float64) bool { + if a < 0 { + return false + } + if b < 0 { + return true + } + return a < b +} + +func countAvailable(offers []models.ScanStoreOffer) int { + n := 0 + for _, o := range offers { + if o.Available { + n++ + } + } + return n +} + +func round3(f float64) float64 { return math.Round(f*1000) / 1000 } + +func ptr[T any](v T) *T { return &v } diff --git a/services/scan_test.go b/services/scan_test.go new file mode 100644 index 0000000..6c5fe98 --- /dev/null +++ b/services/scan_test.go @@ -0,0 +1,438 @@ +package services + +import ( + "context" + "errors" + "testing" + + "nearle/models" + "nearle/repositories" +) + +/* +The scan pipeline has three decisions worth defending: which catalogue rows +count as "the product", which of the customer's outlets get shown and in what +order, and what happens when the outlet they tapped has run out. Everything +below drives those through a fake repository; the SQL itself is exercised +against a real database in scratch/ when there is one. +*/ + +// fakeScanRepo answers from fixtures and records what it was asked. +type fakeScanRepo struct { + exists bool + homeLat float64 + homeLng float64 + homeOK bool + stores []models.ScanStore + text []repositories.CatalogueHit + vector []repositories.CatalogueHit + hasVec bool + options []repositories.StoreOptionRow + at map[int]*repositories.StoreOptionRow // productid → row + + askedKeys []repositories.CatalogueKey + askedNames []string + askedLocs []int + cachedHits map[string][]repositories.CatalogueHit +} + +func (f *fakeScanRepo) CustomerExists(context.Context, int) (bool, error) { return f.exists, nil } +func (f *fakeScanRepo) CustomerHome(context.Context, int) (float64, float64, bool, error) { + return f.homeLat, f.homeLng, f.homeOK, nil +} +func (f *fakeScanRepo) RegisteredStores(context.Context, int) ([]models.ScanStore, error) { + out := make([]models.ScanStore, len(f.stores)) + copy(out, f.stores) + return out, nil +} +func (f *fakeScanRepo) StoreOptions(_ context.Context, locs []int, keys []repositories.CatalogueKey, names []string) ([]repositories.StoreOptionRow, error) { + f.askedLocs, f.askedKeys, f.askedNames = locs, keys, names + allowed := make(map[int]bool) + for _, l := range locs { + allowed[l] = true + } + var out []repositories.StoreOptionRow + for _, o := range f.options { + if allowed[o.Locationid] { + out = append(out, o) + } + } + return out, nil +} +func (f *fakeScanRepo) ProductAt(_ context.Context, _, _, productid int) (*repositories.StoreOptionRow, error) { + return f.at[productid], nil +} +func (f *fakeScanRepo) VectorSearch(context.Context, []float32, int) ([]repositories.CatalogueHit, error) { + return f.vector, nil +} +func (f *fakeScanRepo) TextSearch(context.Context, string, int) ([]repositories.CatalogueHit, error) { + return f.text, nil +} +func (f *fakeScanRepo) VectorSearchAvailable() bool { return f.hasVec } +func (f *fakeScanRepo) CachedVector(context.Context, string, string) ([]float32, bool) { + return nil, false +} +func (f *fakeScanRepo) CacheVector(context.Context, string, string, []float32) {} +func (f *fakeScanRepo) CachedHits(_ context.Context, method, label string) ([]repositories.CatalogueHit, bool) { + h, ok := f.cachedHits[method+"|"+label] + return h, ok +} +func (f *fakeScanRepo) CacheHits(_ context.Context, method, label string, hits []repositories.CatalogueHit) { + if f.cachedHits == nil { + f.cachedHits = make(map[string][]repositories.CatalogueHit) + } + f.cachedHits[method+"|"+label] = hits +} + +type fakeEmbedder struct { + vec []float32 + err error +} + +func (e fakeEmbedder) Embed(context.Context, string) ([]float32, error) { return e.vec, e.err } +func (e fakeEmbedder) Model() string { return "fake-model" } + +// A customer in Peelamedu with three outlets: one 1 km away, one 4 km away, +// one across town with no coordinates on file. +func fixtureStores() []models.ScanStore { + return []models.ScanStore{ + {Tenantid: 1, Tenantname: "Suriya Store", Locationid: 10, Locationname: "Peelamedu", Latitude: 11.030, Longitude: 77.030}, + {Tenantid: 2, Tenantname: "R Mart", Locationid: 20, Locationname: "Hopes", Latitude: 11.010, Longitude: 77.000}, + {Tenantid: 3, Tenantname: "Daily Needs", Locationid: 30, Locationname: "Gandhipuram"}, + } +} + +var milkBikis = repositories.CatalogueHit{Brand: "britannia", ID: 7, ProductName: "Milk Bikis", Size: "100 g", VariantKey: "milk_bikis", ImageID: "britannia_milk_bikis_100g", Distance: 0.05} +var milkBikis200 = repositories.CatalogueHit{Brand: "britannia", ID: 8, ProductName: "Milk Bikis", Size: "200 g", VariantKey: "milk_bikis", ImageID: "britannia_milk_bikis_200g", Distance: 0.12} +var goodDay = repositories.CatalogueHit{Brand: "britannia", ID: 9, ProductName: "Good Day Butter", VariantKey: "good_day", ImageID: "britannia_good_day", Distance: 0.40} + +func newLookupFixture() *fakeScanRepo { + return &fakeScanRepo{ + exists: true, + stores: fixtureStores(), + hasVec: true, + vector: []repositories.CatalogueHit{milkBikis, milkBikis200, goodDay}, + text: []repositories.CatalogueHit{milkBikis}, + options: []repositories.StoreOptionRow{ + // Nearest outlet: sells it, but the shelf is empty. + {Tenantid: 1, Locationid: 10, Productid: 100, Productname: "Milk Bikis 100g", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Unitvalue: "100", Productunit: "g", Price: 10, Stock: 0}, + // 4 km away: has both sizes. + {Tenantid: 2, Locationid: 20, Productid: 200, Productname: "Milk Bikis 100g", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Unitvalue: "100", Productunit: "g", Price: 12, Stock: 6}, + {Tenantid: 2, Locationid: 20, Productid: 201, Productname: "Milk Bikis 200g", Productbrand: "britannia", Catalogueid: 8, Imageid: "britannia_milk_bikis_200g", Unitvalue: "200", Productunit: "g", Price: 22, Stock: 3}, + // Same product listed again as a size under the 100g row — must not + // appear twice. + {Tenantid: 2, Locationid: 20, Productid: 201, Productname: "Milk Bikis 200g", Productbrand: "britannia", Catalogueid: 8, Imageid: "britannia_milk_bikis_200g", Unitvalue: "200", Productunit: "g", Price: 22, Stock: 3, Parentid: 200, Variantname: "200 g"}, + // Unknown distance, in stock. + {Tenantid: 3, Locationid: 30, Productid: 300, Productname: "Milk Bikis", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Price: 11, Stock: 2}, + }, + } +} + +func TestLookupRecommendsTheNearestOutletWithStock(t *testing.T) { + repo := newLookupFixture() + svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1, 0.2}}) + + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{ + Customerid: 5, Label: "Milk Bikis", Latitude: "11.035", Longitude: "77.035", + }) + if err != nil { + t.Fatal(err) + } + if resp.Match == nil || resp.Match.ProductName != "Milk Bikis" || resp.Match.Catalogueid != 7 { + t.Fatalf("expected Milk Bikis 100 g as the match, got %+v", resp.Match) + } + if resp.Match.Method != "vector+text" { + t.Errorf("method = %q, want vector+text", resp.Match.Method) + } + if len(resp.Variants) != 2 { + t.Errorf("the catalogue family should be the two Milk Bikis sizes, got %d: %+v", len(resp.Variants), resp.Variants) + } + if !resp.Available || resp.RecommendedLocationid != 20 { + t.Fatalf("Hopes (4 km, in stock) should be recommended over Peelamedu (1 km, empty); got available=%v recommended=%d", resp.Available, resp.RecommendedLocationid) + } + + // Order: in stock first (Hopes, then Gandhipuram with no distance), then + // the empty nearest outlet. + var order []int + for _, o := range resp.Stores { + order = append(order, o.Locationid) + } + if len(order) != 3 || order[0] != 20 || order[1] != 30 || order[2] != 10 { + t.Fatalf("store order = %v, want [20 30 10]", order) + } + if !resp.Stores[0].Recommended || resp.Stores[1].Recommended || resp.Stores[2].Recommended { + t.Error("exactly the first in-stock offer should be recommended") + } + if resp.Stores[2].Available { + t.Error("Peelamedu has no stock and must not be available") + } + if resp.Stores[1].DistanceKm != -1 { + t.Errorf("an outlet with no coordinates reports distance -1, got %v", resp.Stores[1].DistanceKm) + } + if resp.Stores[0].DistanceKm <= 0 || resp.Stores[0].DistanceKm > 10 { + t.Errorf("Hopes should be a few km away, got %v", resp.Stores[0].DistanceKm) + } + + hopes := resp.Stores[0] + if len(hopes.Options) != 2 { + t.Fatalf("Hopes should offer two sizes once, got %d: %+v", len(hopes.Options), hopes.Options) + } + if hopes.Options[0].Productid != 200 || hopes.Options[0].MatchedBy != "imageid" || hopes.Options[0].Size != "100 g" { + t.Errorf("first option should be the direct 100 g match by imageid, got %+v", hopes.Options[0]) + } + if hopes.Options[1].Productid != 201 || hopes.Options[1].IsVariant { + t.Errorf("the 200 g row seen both directly and as a size keeps the direct form, got %+v", hopes.Options[1]) + } + + // Every catalogue size was asked for at every registered outlet. + if len(repo.askedKeys) != 2 || len(repo.askedLocs) != 3 { + t.Errorf("asked keys=%d locs=%d, want 2 and 3", len(repo.askedKeys), len(repo.askedLocs)) + } +} + +func TestLookupFallsBackToTextWhenTheModelFails(t *testing.T) { + repo := newLookupFixture() + svc := NewScanService(repo, fakeEmbedder{err: errors.New("429 rate limited")}) + + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"}) + if err != nil { + t.Fatal(err) + } + if resp.Match == nil || resp.Match.Method != "text" || resp.Match.Catalogueid != 7 { + t.Fatalf("expected a text-only match on Milk Bikis, got %+v", resp.Match) + } + if resp.Confidence < 0.9 { + t.Errorf("the label is the whole product name; confidence should be high, got %v", resp.Confidence) + } +} + +func TestLookupWithoutAnEmbedderIsTextOnly(t *testing.T) { + repo := newLookupFixture() + repo.hasVec = false + svc := NewScanService(repo, nil) + + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "milk bikis"}) + if err != nil { + t.Fatal(err) + } + if resp.Match == nil || resp.Match.Method != "text" { + t.Fatalf("expected text method, got %+v", resp.Match) + } +} + +func TestLookupUsesTheCachedRankingOnASecondScan(t *testing.T) { + repo := newLookupFixture() + svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}}) + + if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"}); err != nil { + t.Fatal(err) + } + // Take the catalogue away: the second scan must be served from cache. + repo.vector, repo.text = nil, nil + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"}) + if err != nil { + t.Fatal(err) + } + if resp.Match == nil || resp.Match.Catalogueid != 7 { + t.Fatalf("second scan should hit the cache, got %+v", resp.Match) + } +} + +func TestLookupRefusesAWeakMatch(t *testing.T) { + repo := newLookupFixture() + repo.vector = []repositories.CatalogueHit{{Brand: "x", ID: 1, ProductName: "Something Else", Distance: 0.9}} + repo.text = nil + svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}}) + + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "zzz"}) + if err != nil { + t.Fatal(err) + } + if resp.Match != nil || resp.Available || len(resp.Stores) != 0 { + t.Fatalf("a 0.1 similarity is not a match; got %+v", resp) + } + if repo.askedLocs != nil { + t.Error("no outlet should be queried without a match") + } +} + +func TestLookupVerifiesTheAppsTenantList(t *testing.T) { + repo := newLookupFixture() + svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}}) + + // The app says tenant 2 and tenant 99; 99 is not registered. + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{ + Customerid: 5, Label: "Milk Bikis", Tenantids: []int{2, 99}, + }) + if err != nil { + t.Fatal(err) + } + if len(resp.UnregisteredTenantids) != 1 || resp.UnregisteredTenantids[0] != 99 { + t.Errorf("99 should be reported as unregistered, got %v", resp.UnregisteredTenantids) + } + if len(resp.Stores) != 1 || resp.Stores[0].Tenantid != 2 { + t.Errorf("only tenant 2's outlet should be offered, got %+v", resp.Stores) + } + + // A list that matches nothing is stale, not a request for nothing. + resp, _ = svc.Lookup(context.Background(), models.ScanLookupRequest{ + Customerid: 5, Label: "Milk Bikis", Tenantids: []int{98, 99}, + }) + if len(resp.Stores) != 3 || len(resp.UnregisteredTenantids) != 2 { + t.Errorf("a wholly stale list falls back to every registered outlet, got %d stores / %v", len(resp.Stores), resp.UnregisteredTenantids) + } +} + +func TestLookupWithNoStoresStillReturnsTheMatch(t *testing.T) { + repo := newLookupFixture() + repo.stores = nil + svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}}) + + resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"}) + if err != nil { + t.Fatal(err) + } + if resp.Match == nil || resp.Available || len(resp.Stores) != 0 { + t.Fatalf("match without stores, got %+v", resp) + } +} + +func TestLookupRejectsBadInput(t *testing.T) { + svc := NewScanService(newLookupFixture(), nil) + if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Label: "x"}); !errors.Is(err, ErrScanBadRequest) { + t.Errorf("missing customerid: %v", err) + } + if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 1, Label: " "}); !errors.Is(err, ErrScanBadRequest) { + t.Errorf("blank label: %v", err) + } + repo := newLookupFixture() + repo.exists = false + if _, err := NewScanService(repo, nil).Lookup(context.Background(), models.ScanLookupRequest{Customerid: 1, Label: "x"}); !errors.Is(err, ErrScanCustomerNotFound) { + t.Errorf("unknown customer: %v", err) + } +} + +func TestConfirmHoldsWhenStockIsThere(t *testing.T) { + repo := newLookupFixture() + repo.at = map[int]*repositories.StoreOptionRow{200: &repo.options[1]} + svc := NewScanService(repo, nil) + + resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{ + Customerid: 5, Tenantid: 2, Locationid: 20, Productid: 200, Quantity: 4, + }) + if err != nil { + t.Fatal(err) + } + if !resp.Ok || resp.Reason != "in_stock" || resp.Option == nil || resp.Option.Stock != 6 { + t.Fatalf("4 of 6 should be fine, got %+v", resp) + } +} + +func TestConfirmPointsAtTheNextOutletWhenTheShelfIsEmpty(t *testing.T) { + repo := newLookupFixture() + repo.at = map[int]*repositories.StoreOptionRow{100: &repo.options[0]} // Peelamedu, stock 0 + svc := NewScanService(repo, nil) + + resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{ + Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 100, Quantity: 1, + Latitude: "11.035", Longitude: "77.035", + }) + if err != nil { + t.Fatal(err) + } + if resp.Ok || resp.Reason != "out_of_stock" { + t.Fatalf("expected out_of_stock, got %+v", resp) + } + if resp.Alternative == nil || resp.Alternative.Locationid != 20 { + t.Fatalf("Hopes is the nearest outlet with the same 100 g product, got %+v", resp.Alternative) + } + if len(resp.Alternative.Options) != 1 || resp.Alternative.Options[0].Productid != 200 { + t.Errorf("the alternative carries the same product, not its other sizes: %+v", resp.Alternative.Options) + } + if !resp.Alternative.Recommended { + t.Error("the alternative is the recommendation") + } + + // Asking for more than anyone has: no alternative, honest reason. + resp, _ = svc.Confirm(context.Background(), models.ScanConfirmRequest{ + Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 100, Quantity: 50, + }) + if resp.Alternative != nil { + t.Errorf("nobody has 50; got alternative %+v", resp.Alternative) + } +} + +func TestConfirmReportsInsufficientRatherThanOut(t *testing.T) { + repo := newLookupFixture() + repo.at = map[int]*repositories.StoreOptionRow{300: &repo.options[4]} // Gandhipuram, stock 2 + svc := NewScanService(repo, nil) + + resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{ + Customerid: 5, Tenantid: 3, Locationid: 30, Productid: 300, Quantity: 5, + }) + if err != nil { + t.Fatal(err) + } + if resp.Ok || resp.Reason != "insufficient_stock" { + t.Fatalf("2 in stock, 5 asked: want insufficient_stock, got %+v", resp) + } + if resp.Alternative == nil || resp.Alternative.Locationid != 20 { + t.Errorf("Hopes has 6 of the same product, got %+v", resp.Alternative) + } +} + +func TestConfirmRefusesAnUnregisteredStoreAndAnUnsoldProduct(t *testing.T) { + repo := newLookupFixture() + repo.at = map[int]*repositories.StoreOptionRow{} + svc := NewScanService(repo, nil) + + resp, _ := svc.Confirm(context.Background(), models.ScanConfirmRequest{Customerid: 5, Tenantid: 9, Locationid: 90, Productid: 1}) + if resp.Ok || resp.Reason != "store_not_registered" { + t.Errorf("got %+v", resp) + } + resp, _ = svc.Confirm(context.Background(), models.ScanConfirmRequest{Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 424242}) + if resp.Ok || resp.Reason != "not_sold_here" { + t.Errorf("got %+v", resp) + } +} + +func TestStoresAreNearestFirstWithUnknownLast(t *testing.T) { + repo := newLookupFixture() + svc := NewScanService(repo, nil) + + stores, err := svc.Stores(context.Background(), 5, "11.035", "77.035") + if err != nil { + t.Fatal(err) + } + if len(stores) != 3 || stores[0].Locationid != 10 || stores[1].Locationid != 20 || stores[2].Locationid != 30 { + t.Fatalf("want [10 20 30], got %+v", stores) + } + + // No fix from the phone, saved address used instead. + repo.homeLat, repo.homeLng, repo.homeOK = 11.012, 77.001, true + stores, _ = svc.Stores(context.Background(), 5, "", "") + if stores[0].Locationid != 20 { + t.Errorf("from the saved address Hopes is nearest, got %+v", stores[0]) + } +} + +func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) { + hits := []scoredHit{ + {CatalogueHit: milkBikis, score: 0.95}, + {CatalogueHit: goodDay, score: 0.6}, + {CatalogueHit: milkBikis200, score: 0.88}, + {CatalogueHit: repositories.CatalogueHit{Brand: "parle", ProductName: "Milk Bikis", VariantKey: "milk_bikis"}, score: 0.5}, + } + family := catalogueFamily(hits) + if len(family) != 2 || family[1].ID != 8 { + t.Fatalf("family should be the two britannia sizes, got %+v", family) + } + + // No variant keys: fall back to the name. + a := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 1, ProductName: "Honey"}} + b := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 2, ProductName: "honey "}} + c := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 3, ProductName: "Honey Lite"}} + if family := catalogueFamily([]scoredHit{a, b, c}); len(family) != 2 { + t.Errorf("name match should join 1 and 2 only, got %+v", family) + } +} diff --git a/services/userLogin_test.go b/services/userLogin_test.go new file mode 100644 index 0000000..83cecad --- /dev/null +++ b/services/userLogin_test.go @@ -0,0 +1,139 @@ +package services + +import ( + "errors" + "testing" + + "nearle/models" + "nearle/repositories" +) + +/* +"Invalid Email" has to mean one thing: the sign-in query ran and matched +nobody. + +It used to also mean "the database did not answer". The repository discarded +the Scan error, so an outage, an exhausted pool or a deployment that had lost +its environment all surfaced as uid 0 — and every user on the platform was told +their email was wrong (2026-07-20). The console makes it worse: it reads a 409 +as "this address is not registered" and sends the person to sign-up. +*/ + +// loginRepo is a UserRepository that answers only the sign-in path. Anything +// else panics on the nil embedded interface, which is the point: these tests +// must not reach further than the lookup. +type loginRepo struct { + repositories.UserRepository + uid int + password string + status string + roleid int + err error + + field, value string + configid int +} + +func (r *loginRepo) GetUserLogin(field, value string, configid int) (int, string, string, int, error) { + r.field, r.value, r.configid = field, value, configid + if r.err != nil { + return 0, "", "", 0, r.err + } + return r.uid, r.password, r.status, r.roleid, nil +} + +func (r *loginRepo) UpdateFCMToken(int, string) error { return nil } +func (r *loginRepo) UpdateUserFcmToken(int, string) error { return nil } +func (r *loginRepo) GetTenantUserById(uid int) models.TenantUserInfo { + return models.TenantUserInfo{Userid: uid} +} + +func TestADatabaseFailureIsNotAnInvalidEmail(t *testing.T) { + repo := &loginRepo{err: errors.New("dial tcp 10.0.0.5:5433: connection refused")} + svc := NewUserService(repo) + user := models.User{Authname: "owner@shop.example", Password: "pw", Configid: 1} + + t.Run("app login", func(t *testing.T) { + _, resp, err := svc.AppLogin(user) + if err == nil { + t.Fatal("a failed lookup must be an error to the controller") + } + if resp["code"] != 500 || resp["status"] != false { + t.Fatalf("want a 500/false envelope, got %v", resp) + } + if resp["message"] == "Invalid Email" { + t.Fatal("the database being down was reported as a wrong email") + } + }) + + t.Run("tenant web login", func(t *testing.T) { + _, resp := svc.TenantWebLogin(user) + if resp["code"] != 500 || resp["status"] != false { + t.Fatalf("want a 500/false envelope, got %v", resp) + } + if resp["message"] == "Invalid Email" { + t.Fatal("the database being down was reported as a wrong email") + } + }) +} + +// The console depends on this exact shape — code 409 — to tell "not +// registered" from every other failure, so it must survive the refactor. +func TestNobodyMatchingIsStillInvalidEmail(t *testing.T) { + repo := &loginRepo{} // uid 0, no error: the query ran and found no row + svc := NewUserService(repo) + user := models.User{Authname: "nobody@shop.example", Password: "pw", Configid: 1} + + _, resp, err := svc.AppLogin(user) + if err == nil || resp["code"] != 409 || resp["message"] != "Invalid Email" { + t.Fatalf("app login: want 409 Invalid Email, got %v / %v", resp, err) + } + + _, wresp := svc.TenantWebLogin(user) + if wresp["code"] != 409 || wresp["message"] != "Invalid Email" { + t.Fatalf("web login: want 409 Invalid Email, got %v", wresp) + } +} + +func TestLookupPrefersAuthnameAndFallsBackToContactNo(t *testing.T) { + repo := &loginRepo{uid: 7, password: "pw", status: "Active"} + svc := NewUserService(repo) + + svc.AppLogin(models.User{Authname: "owner@shop.example", Contactno: "9999999999", Password: "pw", Configid: 1}) + if repo.field != "authname" || repo.value != "owner@shop.example" || repo.configid != 1 { + t.Fatalf("authname should win when both are sent, looked up %s=%q configid=%d", repo.field, repo.value, repo.configid) + } + + svc.AppLogin(models.User{Contactno: "9999999999", Password: "pw", Configid: 1}) + if repo.field != "contactno" || repo.value != "9999999999" { + t.Fatalf("contactno should be used when authname is blank, looked up %s=%q", repo.field, repo.value) + } +} + +func TestNeitherIdentifierIsRefusedBeforeTheLookup(t *testing.T) { + repo := &loginRepo{err: errors.New("must not be called")} + svc := NewUserService(repo) + + _, resp, err := svc.AppLogin(models.User{Password: "pw", Configid: 1}) + if err == nil || resp["code"] != 400 { + t.Fatalf("want 400, got %v / %v", resp, err) + } + if repo.field != "" { + t.Fatal("the repository was queried with nothing to match on") + } +} + +func TestAMatchedAccountStillSignsIn(t *testing.T) { + repo := &loginRepo{uid: 42, password: "secret", status: "Active", roleid: 2} + svc := NewUserService(repo) + + info, resp, err := svc.AppLogin(models.User{Authname: "owner@shop.example", Password: "secret", Configid: 1}) + if err != nil || resp["code"] != 200 || info.Userid != 42 { + t.Fatalf("app login: want success for userid 42, got %v / %v / %+v", resp, err, info) + } + + winfo, wresp := svc.TenantWebLogin(models.User{Authname: "owner@shop.example", Password: "secret", Configid: 1, Roleid: 2}) + if wresp["code"] != 200 || winfo.Userid != 42 { + t.Fatalf("web login: want success for userid 42, got %v / %+v", wresp, winfo) + } +} diff --git a/services/userService.go b/services/userService.go index dcd3a40..6a6922c 100644 --- a/services/userService.go +++ b/services/userService.go @@ -2,6 +2,7 @@ package services import ( "errors" + "log" "nearle/models" "nearle/repositories" "strings" @@ -9,6 +10,48 @@ import ( "github.com/gofiber/fiber" ) +// errLoginUnavailable is returned by lookupLogin when the database could not +// answer the sign-in query. It is deliberately not "invalid user": the account +// may well exist, and the person needs to be told to try again, not to check +// their spelling. +var errLoginUnavailable = errors.New("login lookup failed") + +// loginUnavailableResponse is the body for that case. 500 rather than 409, +// because the console reads `409` as "this email does not exist" (see +// daily_merchant_web/src/services/auth.ts) and would otherwise send a +// perfectly good account to the sign-up form while the database was down. +func loginUnavailableResponse() map[string]interface{} { + return map[string]interface{}{ + "status": false, + "code": 500, + "message": "Login is temporarily unavailable. Please try again in a moment.", + } +} + +// lookupLogin resolves who is signing in, by authname first and contact number +// second, and separates "not found" from "could not look". +// +// Both login paths used to run their own copy of this and both discarded the +// repository's error, so a database that was down came back as uid 0 and was +// reported as "Invalid Email". That message now means exactly one thing: the +// query ran and matched nobody. +func (s *userService) lookupLogin(user models.User) (uid int, password, status string, roleid int, err error) { + field, value := "authname", user.Authname + if user.Authname == "" { + field, value = "contactno", user.Contactno + } + + uid, password, status, roleid, err = s.repo.GetUserLogin(field, value, user.Configid) + if err != nil { + // The value is what the caller typed — an email or a phone number — + // and is safe to log; the password never reaches this function's + // output. + log.Printf("login: lookup by %s=%q configid=%d failed: %v", field, value, user.Configid, err) + return 0, "", "", 0, errLoginUnavailable + } + return uid, password, status, roleid, nil +} + type UserService interface { GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) GetUserByID(uid int) (models.UserInfo, error) @@ -80,15 +123,7 @@ func (s *userService) UpdateStaff(user models.User) error { } func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error) { - var uid int - var status, dbPassword string - - // Get user by authname or contactno - if user.Authname != "" { - uid, dbPassword, status = s.repo.GetUserByAuthname(user.Authname, user.Configid) - } else if user.Contactno != "" { - uid, dbPassword, status = s.repo.GetUserByContactNo(user.Contactno, user.Configid) - } else { + if user.Authname == "" && user.Contactno == "" { resp := fiber.Map{ "code": 400, "status": false, @@ -97,7 +132,12 @@ func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.M return models.TenantUserInfo{}, resp, errors.New("missing authname or contactno") } - // Invalid user + uid, dbPassword, status, _, err := s.lookupLogin(user) + if err != nil { + return models.TenantUserInfo{}, fiber.Map(loginUnavailableResponse()), err + } + + // Nobody matched. This is the only way to reach "Invalid Email" now. if uid == 0 { resp := fiber.Map{ "status": false, @@ -212,14 +252,8 @@ func (s *userService) CreateUser(user models.User) (models.UserInfo, error) { func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) { tenantFormExists := true - uid, dbPassword, status, roleid := 0, "", "", 0 - // Step 1: Login by authname or contactno - if user.Authname != "" { - uid, dbPassword, status, roleid = s.repo.GetUserLogin("authname", user.Authname, user.Configid) - } else if user.Contactno != "" { - uid, dbPassword, status, roleid = s.repo.GetUserLogin("contactno", user.Contactno, user.Configid) - } else { + if user.Authname == "" && user.Contactno == "" { return models.TenantUserInfo{}, map[string]interface{}{ "status": true, "code": 400, @@ -227,7 +261,13 @@ func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, m } } - // Step 2: Validate user + uid, dbPassword, status, roleid, err := s.lookupLogin(user) + if err != nil { + return models.TenantUserInfo{}, loginUnavailableResponse() + } + + // Step 2: Validate user. Nobody matched — the only way to reach + // "Invalid Email" now; a database that could not answer is a 500 above. if uid == 0 { return models.TenantUserInfo{}, map[string]interface{}{ "status": false, diff --git a/utils/config.go b/utils/config.go deleted file mode 100644 index f4a3471..0000000 --- a/utils/config.go +++ /dev/null @@ -1,51 +0,0 @@ -package utils - -import ( - "fmt" - "os" - - "github.com/spf13/viper" -) - -func DevConfig() (Port, DBname, Password, Username, Host, key, secret string) { - - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - - err := viper.ReadInConfig() - if err != nil { - fmt.Println("fatal error config file: default \n", err) - os.Exit(1) - } - - dbname := viper.GetString("DEV.DATABASE_NAME") - dbpassword := viper.GetString("DEV.DATABASE_PASSWORD") - dbusername := viper.GetString("DEV.DATABASE_USERNAME") - dbport := viper.GetString("DEV.DATABASE_PORT") - dbhost := viper.GetString("DEV.DATABASE_SERVER_HOST") - contextkey := viper.GetString("DEV.USER_CONTEXT_KEY") - jwtkey := viper.GetString("DEV.JWT_SECRET_KEY") - return dbport, dbname, dbpassword, dbusername, dbhost, contextkey, jwtkey -} - -func LiveConfig() (Port, DBname, Password, Username, Host, key, secret string) { - - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - - err := viper.ReadInConfig() - if err != nil { - fmt.Println("fatal error config file: default \n", err) - os.Exit(1) - } - dbname := viper.GetString("APP.DATABASE_NAME") - dbpassword := viper.GetString("APP.DATABASE_PASSWORD") - dbusername := viper.GetString("APP.DATABASE_USERNAME") - dbport := viper.GetString("APP.DATABASE_PORT") - dbhost := viper.GetString("APP.DATABASE_SERVER_HOST") - contextkey := viper.GetString("APP.USER_CONTEXT_KEY") - jwtkey := viper.GetString("APP.JWT_SECRET_KEY") - return dbport, dbname, dbpassword, dbusername, dbhost, contextkey, jwtkey -} diff --git a/utils/embedding.go b/utils/embedding.go new file mode 100644 index 0000000..7d5a5fc --- /dev/null +++ b/utils/embedding.go @@ -0,0 +1,225 @@ +package utils + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "nearle/config" +) + +// Embedder turns a short piece of text — what Google Lens read off a packet — +// into the vector the catalogue was indexed with. +// +// One method on purpose. The scan pipeline needs exactly one thing from the +// model and nothing about which model it is; the provider is an environment +// decision (config.EmbeddingConfig) and the tests supply a fake. +type Embedder interface { + // Embed returns the vector for text. It must be the same length as the + // catalogue's `embedding` column or pgvector refuses the comparison. + Embed(ctx context.Context, text string) ([]float32, error) + // Model names what produced the vector, so a cache key can include it: a + // vector cached under one model must never be served for another. + Model() string +} + +// ErrEmbedderNotConfigured is what the scan search sees when no provider is +// set. It falls back to text matching rather than failing the request. +var ErrEmbedderNotConfigured = errors.New("embedding provider is not configured") + +// embedTimeout bounds one call to the provider. The scan endpoint has a +// customer waiting with a phone in their hand; a slow model is worse than a +// text-only answer, and the caller falls back on error. +const embedTimeout = 4 * time.Second + +// NewEmbedder builds the provider named in the config, or returns nil when +// none is configured. A nil Embedder is a supported state everywhere it is +// used: the search degrades to text matching and says so in the response. +func NewEmbedder(cfg config.EmbeddingConfig) (Embedder, error) { + if !cfg.Enabled() { + return nil, nil + } + client := &http.Client{Timeout: embedTimeout} + switch cfg.Provider { + case "openai": + base := strings.TrimRight(cfg.BaseURL, "/") + if base == "" { + base = "https://api.openai.com/v1" + } + return &openAIEmbedder{cfg: cfg, base: base, client: client}, nil + case "gemini": + base := strings.TrimRight(cfg.BaseURL, "/") + if base == "" { + base = "https://generativelanguage.googleapis.com/v1beta" + } + return &geminiEmbedder{cfg: cfg, base: base, client: client}, nil + } + return nil, fmt.Errorf("embedding provider %q is not supported", cfg.Provider) +} + +// ── OpenAI-compatible ─────────────────────────────────────────────────────── +// +// POST {base}/embeddings — the shape OpenAI, Azure OpenAI (with a base URL), +// Ollama, vLLM, LM Studio and most hosted models all accept. + +type openAIEmbedder struct { + cfg config.EmbeddingConfig + base string + client *http.Client +} + +func (e *openAIEmbedder) Model() string { return e.cfg.Model } + +func (e *openAIEmbedder) Embed(ctx context.Context, text string) ([]float32, error) { + body := map[string]interface{}{ + "model": e.cfg.Model, + "input": text, + } + if e.cfg.Dimensions > 0 { + body["dimensions"] = e.cfg.Dimensions + } + + var out struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := postJSON(ctx, e.client, e.base+"/embeddings", "Bearer "+e.cfg.APIKey, body, &out); err != nil { + return nil, err + } + if out.Error != nil { + return nil, fmt.Errorf("embedding: %s", out.Error.Message) + } + if len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 { + return nil, errors.New("embedding: provider returned no vector") + } + return out.Data[0].Embedding, nil +} + +// ── Gemini ────────────────────────────────────────────────────────────────── +// +// POST {base}/models/{model}:embedContent with the key as a header. + +type geminiEmbedder struct { + cfg config.EmbeddingConfig + base string + client *http.Client +} + +func (e *geminiEmbedder) Model() string { return e.cfg.Model } + +func (e *geminiEmbedder) Embed(ctx context.Context, text string) ([]float32, error) { + model := e.cfg.Model + if !strings.HasPrefix(model, "models/") { + model = "models/" + model + } + body := map[string]interface{}{ + "model": model, + "content": map[string]interface{}{"parts": []map[string]string{{"text": text}}}, + "taskType": "RETRIEVAL_QUERY", + } + if e.cfg.Dimensions > 0 { + body["outputDimensionality"] = e.cfg.Dimensions + } + + var out struct { + Embedding struct { + Values []float32 `json:"values"` + } `json:"embedding"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + url := fmt.Sprintf("%s/%s:embedContent", e.base, model) + if err := postJSON(ctx, e.client, url, "", body, &out, "x-goog-api-key", e.cfg.APIKey); err != nil { + return nil, err + } + if out.Error != nil { + return nil, fmt.Errorf("embedding: %s", out.Error.Message) + } + if len(out.Embedding.Values) == 0 { + return nil, errors.New("embedding: provider returned no vector") + } + return out.Embedding.Values, nil +} + +// postJSON is the one HTTP call both providers make. Extra header pairs +// follow the body; `auth` is sent as Authorization when non-empty. +func postJSON(ctx context.Context, client *http.Client, url, auth string, body, out interface{}, headers ...string) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if auth != "" { + req.Header.Set("Authorization", auth) + } + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("embedding: %w", err) + } + defer resp.Body.Close() + + // Bounded: an error page from a misconfigured proxy should not be read to + // the end of the internet. + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return fmt.Errorf("embedding: %w", err) + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("embedding: HTTP %d, unreadable body: %w", resp.StatusCode, err) + } + if resp.StatusCode/100 != 2 { + // The decoded body carries the provider's message where there is one; + // this is the fallback for a bare status. + if msg := extractMessage(raw); msg != "" { + return fmt.Errorf("embedding: HTTP %d: %s", resp.StatusCode, msg) + } + return fmt.Errorf("embedding: HTTP %d", resp.StatusCode) + } + return nil +} + +func extractMessage(raw []byte) string { + var e struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(raw, &e) == nil { + return e.Error.Message + } + return "" +} + +// VectorLiteral renders a vector the way pgvector reads one: `[0.1,0.2,...]`. +func VectorLiteral(v []float32) string { + var b strings.Builder + b.Grow(len(v)*10 + 2) + b.WriteByte('[') + for i, f := range v { + if i > 0 { + b.WriteByte(',') + } + fmt.Fprintf(&b, "%g", f) + } + b.WriteByte(']') + return b.String() +} diff --git a/utils/embedding_test.go b/utils/embedding_test.go new file mode 100644 index 0000000..0a7653a --- /dev/null +++ b/utils/embedding_test.go @@ -0,0 +1,106 @@ +package utils + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "nearle/config" +) + +func TestOpenAIEmbedderSendsTheRequestTheAPIExpects(t *testing.T) { + var got map[string]interface{} + var auth, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth, path = r.Header.Get("Authorization"), r.URL.Path + json.NewDecoder(r.Body).Decode(&got) + w.Write([]byte(`{"data":[{"embedding":[0.1,0.2,0.3]}]}`)) + })) + defer srv.Close() + + e, err := NewEmbedder(config.EmbeddingConfig{Provider: "openai", Model: "text-embedding-3-small", APIKey: "sk-test", BaseURL: srv.URL + "/v1/", Dimensions: 3}) + if err != nil { + t.Fatal(err) + } + vec, err := e.Embed(context.Background(), "Milk Bikis") + if err != nil { + t.Fatal(err) + } + if len(vec) != 3 || vec[2] != 0.3 { + t.Errorf("vector = %v", vec) + } + if path != "/v1/embeddings" || auth != "Bearer sk-test" { + t.Errorf("path=%s auth=%s", path, auth) + } + if got["model"] != "text-embedding-3-small" || got["input"] != "Milk Bikis" || got["dimensions"] != float64(3) { + t.Errorf("body = %v", got) + } + if e.Model() != "text-embedding-3-small" { + t.Errorf("Model() = %q", e.Model()) + } +} + +func TestGeminiEmbedderSendsTheRequestTheAPIExpects(t *testing.T) { + var got map[string]interface{} + var key, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key, path = r.Header.Get("x-goog-api-key"), r.URL.Path + json.NewDecoder(r.Body).Decode(&got) + w.Write([]byte(`{"embedding":{"values":[0.5,0.6]}}`)) + })) + defer srv.Close() + + e, err := NewEmbedder(config.EmbeddingConfig{Provider: "gemini", Model: "gemini-embedding-001", APIKey: "g-test", BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + vec, err := e.Embed(context.Background(), "Milk Bikis") + if err != nil { + t.Fatal(err) + } + if len(vec) != 2 || vec[0] != 0.5 { + t.Errorf("vector = %v", vec) + } + if path != "/models/gemini-embedding-001:embedContent" || key != "g-test" { + t.Errorf("path=%s key=%s", path, key) + } + if got["model"] != "models/gemini-embedding-001" || got["taskType"] != "RETRIEVAL_QUERY" { + t.Errorf("body = %v", got) + } +} + +func TestEmbedderSurfacesProviderErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(429) + w.Write([]byte(`{"error":{"message":"Rate limit reached"}}`)) + })) + defer srv.Close() + + e, _ := NewEmbedder(config.EmbeddingConfig{Provider: "openai", Model: "m", APIKey: "k", BaseURL: srv.URL}) + _, err := e.Embed(context.Background(), "x") + if err == nil || !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "Rate limit") { + t.Fatalf("want a 429 with the provider's message, got %v", err) + } +} + +func TestNoProviderMeansNoEmbedder(t *testing.T) { + e, err := NewEmbedder(config.EmbeddingConfig{}) + if err != nil || e != nil { + t.Fatalf("got %v / %v", e, err) + } + if _, err := NewEmbedder(config.EmbeddingConfig{Provider: "cohere", Model: "m", APIKey: "k"}); err == nil { + t.Fatal("an unknown provider must be refused") + } +} + +func TestVectorLiteral(t *testing.T) { + if got := VectorLiteral([]float32{0.1, -2, 3.5}); got != "[0.1,-2,3.5]" { + t.Errorf("got %q", got) + } + if got := VectorLiteral(nil); got != "[]" { + t.Errorf("got %q", got) + } +} diff --git a/utils/geo.go b/utils/geo.go new file mode 100644 index 0000000..cc0a44c --- /dev/null +++ b/utils/geo.go @@ -0,0 +1,104 @@ +package utils + +import ( + "math" + "strconv" + "strings" + "time" +) + +// ParseLatLng reads the coordinate strings this schema stores — customers, +// customerlocations and tenantlocations all hold latitude/longitude as text +// — and says whether they name a real place. +// +// "0,0" is rejected along with blanks: nothing on this platform is in the +// Gulf of Guinea, and it is what an empty map picker saves. +func ParseLatLng(lat, lng string) (float64, float64, bool) { + la, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64) + lo, err2 := strconv.ParseFloat(strings.TrimSpace(lng), 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + if la < -90 || la > 90 || lo < -180 || lo > 180 || (la == 0 && lo == 0) { + return 0, 0, false + } + return la, lo, true +} + +// HaversineKm is the great-circle distance between two points. +func HaversineKm(lat1, lng1, lat2, lng2 float64) float64 { + const earthRadiusKm = 6371.0 + toRad := func(d float64) float64 { return d * math.Pi / 180 } + + dLat := toRad(lat2 - lat1) + dLng := toRad(lng2 - lng1) + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2) + return 2 * earthRadiusKm * math.Asin(math.Sqrt(a)) +} + +// OpenNow reads tenantlocations.opentime/closetime ("09:00", "21:30", +// "9:00 AM") against the wall clock. Unparsable or blank hours are treated as +// open: a shop that never filled the field in should not vanish from the +// list, and the ordering flow re-checks at checkout anyway. +func OpenNow(open, closeAt string, now time.Time) bool { + o, ok1 := parseClock(open) + c, ok2 := parseClock(closeAt) + if !ok1 || !ok2 || o == c { + return true + } + cur := now.Hour()*60 + now.Minute() + if o < c { + return cur >= o && cur < c + } + // Past midnight: "20:00" – "02:00". + return cur >= o || cur < c +} + +func parseClock(s string) (int, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + for _, layout := range []string{"15:04", "15:04:05", "3:04 PM", "3:04PM", "03:04 PM", "15.04"} { + if t, err := time.Parse(layout, strings.ToUpper(s)); err == nil { + return t.Hour()*60 + t.Minute(), true + } + } + return 0, false +} + +// SearchTokens splits a label into the words worth matching on: lowercased, +// punctuation stripped, single characters and pack-size noise dropped. "Milk +// Bikis 100g" → ["milk", "bikis"]; the size is matched separately, if at all. +func SearchTokens(label string) []string { + var tokens []string + seen := make(map[string]bool) + for _, raw := range strings.FieldsFunc(strings.ToLower(label), func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9') + }) { + if len(raw) < 2 || isPackSize(raw) || seen[raw] { + continue + } + seen[raw] = true + tokens = append(tokens, raw) + } + return tokens +} + +// isPackSize is "100g", "1kg", "500ml", "2l", "250gm" — a number with a unit +// glued on, or a bare number. +func isPackSize(tok string) bool { + digits := 0 + for digits < len(tok) && tok[digits] >= '0' && tok[digits] <= '9' { + digits++ + } + if digits == 0 { + return false + } + switch tok[digits:] { + case "", "g", "gm", "gms", "kg", "ml", "l", "ltr", "pcs", "pc", "x", "n": + return true + } + return false +} diff --git a/utils/geo_test.go b/utils/geo_test.go new file mode 100644 index 0000000..eb7c012 --- /dev/null +++ b/utils/geo_test.go @@ -0,0 +1,65 @@ +package utils + +import ( + "testing" + "time" +) + +func TestParseLatLng(t *testing.T) { + if _, _, ok := ParseLatLng("", ""); ok { + t.Error("blank must not parse") + } + if _, _, ok := ParseLatLng("0", "0"); ok { + t.Error("0,0 is an empty map picker, not a place") + } + if _, _, ok := ParseLatLng("91", "10"); ok { + t.Error("out of range") + } + lat, lng, ok := ParseLatLng(" 11.0168 ", "76.9558") + if !ok || lat != 11.0168 || lng != 76.9558 { + t.Errorf("got %v %v %v", lat, lng, ok) + } +} + +func TestHaversineKm(t *testing.T) { + // Coimbatore railway station to Peelamedu, roughly 8 km. + d := HaversineKm(11.0018, 76.9660, 11.0290, 77.0290) + if d < 7 || d > 9 { + t.Errorf("got %.2f km", d) + } + if HaversineKm(1, 1, 1, 1) != 0 { + t.Error("same point") + } +} + +func TestOpenNow(t *testing.T) { + at := func(h, m int) time.Time { return time.Date(2026, 9, 15, h, m, 0, 0, time.UTC) } + if !OpenNow("09:00", "21:00", at(12, 0)) || OpenNow("09:00", "21:00", at(22, 0)) { + t.Error("plain hours") + } + if !OpenNow("20:00", "02:00", at(1, 0)) || OpenNow("20:00", "02:00", at(12, 0)) { + t.Error("past midnight") + } + if !OpenNow("9:00 AM", "9:30 PM", at(21, 0)) || OpenNow("9:00 AM", "9:30 PM", at(21, 45)) { + t.Error("12-hour clock") + } + if !OpenNow("", "", at(3, 0)) || !OpenNow("always", "", at(3, 0)) { + t.Error("unknown hours mean open") + } +} + +func TestSearchTokens(t *testing.T) { + got := SearchTokens("Milk Bikis 100g, Britannia (2 x 50gm)") + want := []string{"milk", "bikis", "britannia"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } + if len(SearchTokens("500ml 1kg 2")) != 0 { + t.Error("pack sizes alone are not searchable") + } +}