Merge pull request 'feat/env-login-scan-to-order' (#4) from feat/env-login-scan-to-order into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-09-15 11:38:16 +00:00
41 changed files with 5420 additions and 715 deletions

19
.dockerignore Normal file
View File

@@ -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

23
.env
View File

@@ -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.<APP_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=

View File

@@ -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=

View File

@@ -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=

View File

@@ -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

View File

@@ -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"]

138
README.md Normal file
View File

@@ -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.<APP_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/<name>`
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.

View File

@@ -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.<APP_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.<APP_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.")
}

251
config/config_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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,
})
}

View File

@@ -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")
}

View File

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

View File

@@ -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

View File

@@ -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

103
docs/ENVIRONMENT.md Normal file
View File

@@ -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.<APP_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.

778
docs/PORTFOLIO.md Normal file
View File

@@ -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
`"<tenantid>-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:<billno>"` 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_<name> 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).

261
docs/SCAN_TO_ORDER.md Normal file
View File

@@ -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.

View File

@@ -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,
}
}

23
go.mod
View File

@@ -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
)

389
go.sum
View File

@@ -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=

44
main.go
View File

@@ -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.<APP_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 {

View File

@@ -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"))
}

141
models/scan.go Normal file
View File

@@ -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:<productid>".
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"`
}

View File

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

View File

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

View File

@@ -21,4 +21,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
RegisterCatalogueRoutes(api, f)
RegisterPosRoutes(api, f)
RegisterUploadRoutes(api, f)
RegisterScanRoutes(api, f)
}

17
routes/scanroutes.go Normal file
View File

@@ -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)
}

View File

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

View File

@@ -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()
}

View File

@@ -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")
}
}

View File

@@ -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",

View File

@@ -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")

721
services/scanService.go Normal file
View File

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

438
services/scan_test.go Normal file
View File

@@ -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)
}
}

139
services/userLogin_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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,

View File

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

225
utils/embedding.go Normal file
View File

@@ -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()
}

106
utils/embedding_test.go Normal file
View File

@@ -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)
}
}

104
utils/geo.go Normal file
View File

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

65
utils/geo_test.go Normal file
View File

@@ -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")
}
}