feat/env-login-scan-to-order #4
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
23
.env
@@ -1,16 +1,18 @@
|
||||
# Fiesta configuration.
|
||||
# Fiesta configuration — the shared base file.
|
||||
#
|
||||
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one
|
||||
# exception is this file) — and they are gitignored for a reason: this
|
||||
# repository's history already contains a committed `.env` from before
|
||||
# 2026-08-03, so those database credentials are in the history and should be
|
||||
# rotated. Do not add another.
|
||||
# Loaded AFTER `.env.<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=
|
||||
|
||||
109
.env.example
109
.env.example
@@ -1,30 +1,41 @@
|
||||
# Fiesta configuration.
|
||||
# Fiesta configuration — the complete list of settings, with local values.
|
||||
#
|
||||
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one
|
||||
# exception is this file) — and they are gitignored for a reason: this
|
||||
# repository's history already contains a committed `.env` from before
|
||||
# 2026-08-03, so those database credentials are in the history and should be
|
||||
# rotated. Do not add another.
|
||||
# How the files are picked (config/config.go):
|
||||
#
|
||||
# `godotenv.Load()` in main.go reads `.env` from the working directory, so
|
||||
# `go run .` from this folder picks it up with no flags.
|
||||
# APP_ENV unset / local → .env.local then .env
|
||||
# APP_ENV=production → .env.production then .env
|
||||
#
|
||||
# A real environment variable always wins over a file, and no file has to
|
||||
# exist: on the deployed host the values come from the platform's environment
|
||||
# settings (Dokploy / Kubernetes), not from a file. Anything added here must be
|
||||
# added there too — a variable in this file and not in the platform is a
|
||||
# variable that is unset in production.
|
||||
#
|
||||
# Startup checks every required setting and prints everything that is missing
|
||||
# in one go, before any connection is attempted.
|
||||
#
|
||||
# The credentials in the committed .env.production have to be treated as
|
||||
# public: rotate them, and keep new values out of git.
|
||||
|
||||
# ── Environment ─────────────────────────────────────────────────────────────
|
||||
# local | production. Production requires POS_TOKEN_SECRET and never falls
|
||||
# back to localhost defaults. Set in the real environment, not in a file: a
|
||||
# file cannot decide which file gets loaded.
|
||||
#APP_ENV=local
|
||||
|
||||
# ── Where it listens ────────────────────────────────────────────────────────
|
||||
# 1122 is what production serves on. Change it locally to run a second copy
|
||||
# beside something else; the console then points at the same number.
|
||||
# 1122 locally; production serves on 1009 (matching the Dockerfile's EXPOSE).
|
||||
APP_PORT=1122
|
||||
|
||||
ENV=development
|
||||
|
||||
# ── The main database (nearledb) ────────────────────────────────────────────
|
||||
#
|
||||
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
|
||||
#
|
||||
# There is no "local mode" that protects you: `go run .` against the live host
|
||||
# creates real tenants, real logins and real stock movements, and main.go runs
|
||||
# schema migrations on boot. If the point of running locally is to try a change
|
||||
# before it is deployed, a local Postgres with a dump restored into it is the
|
||||
# only version that actually does that.
|
||||
# schema migrations on boot. Startup warns when APP_ENV=local and DB_HOST is
|
||||
# not a local address, but it does not stop you.
|
||||
#
|
||||
# These match docker-compose.local.yml, so `docker compose -f
|
||||
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
|
||||
DB_HOST=localhost
|
||||
@@ -36,10 +47,9 @@ DB_PASSWORD=localdev
|
||||
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
||||
#
|
||||
# A separate connection on purpose, so catalogue work never touches nearledb.
|
||||
# Leave blank to start without it: catalogue endpoints then fail at query time
|
||||
# rather than at boot, which is fine for testing anything else.
|
||||
# 5434, not 5432: a developer machine usually has something on 5432 already,
|
||||
# and a silent connection to the wrong database is worse than a refused one.
|
||||
# Leave CATALOGUE_DB_HOST blank to start without it: catalogue endpoints then
|
||||
# fail at query time rather than at boot. With a host set, the other four are
|
||||
# required. 5434, not 5432: a developer machine usually has something on 5432.
|
||||
CATALOGUE_DB_HOST=localhost
|
||||
CATALOGUE_DB_PORT=5434
|
||||
CATALOGUE_DB_NAME=cataloguedb
|
||||
@@ -48,12 +58,65 @@ CATALOGUE_DB_PASSWORD=localdev
|
||||
|
||||
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
|
||||
#
|
||||
# Optional. Losing the health board is an inconvenience; losing a sale is not,
|
||||
# so the API runs without it.
|
||||
# Optional: leave REDIS_HOST blank to run without it. Losing the health board
|
||||
# is an inconvenience; losing a sale is not, so the API runs without it.
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=
|
||||
REDIS_USER=default
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# ── DigitalOcean Spaces (S3-compatible) — catalogue product images ──────────
|
||||
#
|
||||
# Optional locally. With USE_S3=true every S3_* value below is required.
|
||||
USE_S3=false
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_ENDPOINT=
|
||||
S3_BUCKET=
|
||||
S3_REGION=
|
||||
|
||||
# ── POS terminals — the MQTT broker the in-store tills publish to ───────────
|
||||
#
|
||||
# Optional locally: with MQTT_URL blank the ingest and the console live stream
|
||||
# stay quiet and the HTTP endpoints still work. In production a blank MQTT_URL
|
||||
# means every till queues its bills silently — on startup you should see
|
||||
# three lines reading "pos: subscribed to nearle/pos/+/+/...".
|
||||
MQTT_URL=
|
||||
MQTT_USER=
|
||||
MQTT_PASSWORD=
|
||||
# Unique per replica: a second connection with the same id evicts the first.
|
||||
# Defaults to HOSTNAME (the pod name) when unset.
|
||||
MQTT_CLIENT_ID=
|
||||
# always | never — otherwise a StatefulSet pod ending in "-0" is elected and
|
||||
# anything else consumes. See messaging/posmqtt.go.
|
||||
#POS_MQTT_CONSUMER=
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||
# Signs POS terminal sessions; at least 16 characters. Falls back to
|
||||
# JWT_SECRET_KEY when unset. Required in production.
|
||||
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||
JWT_SECRET_KEY=
|
||||
USER_CONTEXT_KEY=
|
||||
USER_CONTEXT_KEY=nearle
|
||||
# true to make the POS routes require a terminal session.
|
||||
#POS_AUTH_REQUIRED=false
|
||||
|
||||
# ── Scan-to-order — the embedding model behind product recognition ─────────
|
||||
#
|
||||
# MUST be the model that filled the catalogue's `embedding` column: vectors
|
||||
# from two models are not comparable and pgvector will rank garbage without
|
||||
# complaint. The first search reads the column's width and refuses a mismatch
|
||||
# with an error that names both numbers.
|
||||
# Optional: with no provider the search matches on words alone (works, ranks
|
||||
# worse). openai = any OpenAI-compatible /embeddings endpoint (set
|
||||
# EMBEDDING_BASE_URL for Azure, Ollama, vLLM...); gemini = Google AI Studio.
|
||||
EMBEDDING_PROVIDER=
|
||||
EMBEDDING_MODEL=
|
||||
EMBEDDING_API_KEY=
|
||||
EMBEDDING_BASE_URL=
|
||||
# 0 = the model's default width.
|
||||
EMBEDDING_DIMENSIONS=0
|
||||
|
||||
# ── Geocoding ───────────────────────────────────────────────────────────────
|
||||
# Google Geocoding when set; OpenStreetMap's Nominatim otherwise.
|
||||
GEOCODER_API_KEY=
|
||||
|
||||
14
.env.local
14
.env.local
@@ -7,13 +7,10 @@
|
||||
# Keep every host here pointing at localhost. The whole point of the split is
|
||||
# that running the server locally cannot reach live data by accident.
|
||||
#
|
||||
# `.env`, `.env.local` and `.env.production` are all gitignored (`.env.example`
|
||||
# is the one exception) — and for a reason: this repository's history already
|
||||
# contains a committed `.env` from before 2026-08-03, so those credentials are
|
||||
# in the history and should be rotated. Do not add another.
|
||||
#
|
||||
# `loadEnv()` in main.go reads `.env.$APP_ENV` from the working directory, so
|
||||
# `go run .` from this folder picks this file up with no flags.
|
||||
# `config.Load()` reads `.env.$APP_ENV` and then `.env` from the working
|
||||
# directory, so `go run .` from this folder picks this file up with no flags.
|
||||
# A real environment variable always wins over either file. The full list of
|
||||
# settings is in `.env.example`.
|
||||
|
||||
# ── Where it listens ────────────────────────────────────────────────────────
|
||||
# Production serves on 1009 (see .env.production). Change this locally to run
|
||||
@@ -64,5 +61,8 @@ REDIS_USER=
|
||||
REDIS_DB=0
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
|
||||
# sign in against the local stack; production sets its own in the platform.
|
||||
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||
JWT_SECRET_KEY=
|
||||
USER_CONTEXT_KEY=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
138
README.md
Normal 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.
|
||||
360
config/config.go
360
config/config.go
@@ -1,49 +1,361 @@
|
||||
// Package config is the one place the process reads its environment.
|
||||
//
|
||||
// Two jobs, in order:
|
||||
//
|
||||
// 1. Pick the right `.env` file for the environment we are in and load it.
|
||||
// 2. Read every setting into a typed Config and refuse to start if anything
|
||||
// required is missing — all of it, in one message, before a single
|
||||
// connection is attempted.
|
||||
//
|
||||
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
|
||||
// `.env.production` described an `APP_ENV` switch that did not exist, so the
|
||||
// only way to run against production was to overwrite `.env` by hand, and the
|
||||
// only way to find out a variable was missing was a `log.Fatalf` from inside
|
||||
// `db.Connect()` — one variable per restart.
|
||||
//
|
||||
// # Which file loads
|
||||
//
|
||||
// `APP_ENV` names the environment and defaults to "local":
|
||||
//
|
||||
// go run . → .env.local, then .env
|
||||
// APP_ENV=production go run . → .env.production, then .env
|
||||
//
|
||||
// `.env` is a shared base loaded after the environment file. godotenv never
|
||||
// overwrites a variable that is already set, so the order of precedence is:
|
||||
//
|
||||
// real environment > .env.<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
251
config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
101
controllers/scanController.go
Normal file
101
controllers/scanController.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
19
db/redis.go
19
db/redis.go
@@ -3,9 +3,7 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"nearle/config"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -31,23 +29,18 @@ var RedisCtx = context.Background()
|
||||
// Redis is optional here: without it the POS health board goes dark, but bills
|
||||
// still arrive and commit. That is the right failure — losing presence is an
|
||||
// inconvenience, losing a sale is not — so this never aborts startup.
|
||||
func InitRedis() {
|
||||
host := strings.TrimSpace(os.Getenv("REDIS_HOST"))
|
||||
if host == "" {
|
||||
func InitRedis(c config.RedisConfig) {
|
||||
if !c.Enabled() {
|
||||
log.Println("redis: REDIS_HOST not set, POS presence disabled")
|
||||
return
|
||||
}
|
||||
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0"))
|
||||
if err != nil {
|
||||
dbIndex = 0
|
||||
}
|
||||
host, port, dbIndex := c.Host, c.Port, c.DB
|
||||
|
||||
Rdb = redis.NewClient(&redis.Options{
|
||||
Addr: host + ":" + port,
|
||||
Username: getEnv("REDIS_USER", "default"),
|
||||
Password: os.Getenv("REDIS_PASSWORD"),
|
||||
Username: c.User,
|
||||
Password: c.Password,
|
||||
DB: dbIndex,
|
||||
|
||||
// Short on purpose. A degraded Redis must fail fast rather than tie up
|
||||
|
||||
103
docs/ENVIRONMENT.md
Normal file
103
docs/ENVIRONMENT.md
Normal 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
778
docs/PORTFOLIO.md
Normal 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
261
docs/SCAN_TO_ORDER.md
Normal 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.
|
||||
@@ -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
23
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
github.com/gofiber/fiber v1.14.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/valyala/fasthttp v1.50.0
|
||||
golang.org/x/oauth2 v0.12.0
|
||||
google.golang.org/api v0.143.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
@@ -42,8 +43,8 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||
github.com/aws/smithy-go v1.27.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gofiber/utils v0.0.10 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
@@ -54,7 +55,6 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/gorilla/schema v1.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -62,28 +62,17 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.3.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.10.0 // indirect
|
||||
github.com/spf13/cast v1.5.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/stretchr/testify v1.8.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.50.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
@@ -94,15 +83,11 @@ require (
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect
|
||||
google.golang.org/grpc v1.58.2 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.50.0
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/spf13/viper v1.17.0
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
389
go.sum
389
go.sum
@@ -1,59 +1,21 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
||||
cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
|
||||
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
|
||||
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.13.0 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk=
|
||||
cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8=
|
||||
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
|
||||
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
|
||||
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
|
||||
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
@@ -100,13 +62,8 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -118,16 +75,7 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
|
||||
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
|
||||
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
|
||||
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
|
||||
@@ -135,67 +83,34 @@ github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmh
|
||||
github.com/gofiber/utils v0.0.10 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U=
|
||||
github.com/gofiber/utils v0.0.10/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -203,21 +118,12 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -234,24 +140,11 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -261,12 +154,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -276,37 +163,16 @@ github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQ
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
||||
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
|
||||
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
|
||||
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
|
||||
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
|
||||
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
|
||||
@@ -315,302 +181,74 @@ github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE
|
||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
|
||||
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.143.0 h1:o8cekTkqhywkbZT6p1UHJPZ9+9uuCAJs/KYomxZB8fA=
|
||||
google.golang.org/api v0.143.0/go.mod h1:FoX9DO9hT7DLNn97OuoZAGSDuNAXdJRuGK98rSUgurk=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA=
|
||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI=
|
||||
@@ -618,21 +256,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I=
|
||||
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
@@ -643,20 +270,12 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -665,12 +284,4 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
|
||||
44
main.go
44
main.go
@@ -3,12 +3,14 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/config"
|
||||
"nearle/db"
|
||||
"nearle/facade"
|
||||
"nearle/messaging"
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
"nearle/routes"
|
||||
"nearle/utils"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -18,15 +20,14 @@ import (
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
godotenv.Load()
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Loads `.env.<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 {
|
||||
|
||||
@@ -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
141
models/scan.go
Normal 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"`
|
||||
}
|
||||
633
repositories/scanRepository.go
Normal file
633
repositories/scanRepository.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
17
routes/scanroutes.go
Normal 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)
|
||||
}
|
||||
89
scratch/cataloguedims/main.go
Normal file
89
scratch/cataloguedims/main.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
267
scratch/poslivecheck/main.go
Normal file
267
scratch/poslivecheck/main.go
Normal 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()
|
||||
}
|
||||
166
scratch/posloginready/main.go
Normal file
166
scratch/posloginready/main.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
721
services/scanService.go
Normal 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
438
services/scan_test.go
Normal 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
139
services/userLogin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
225
utils/embedding.go
Normal 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
106
utils/embedding_test.go
Normal 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
104
utils/geo.go
Normal 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
65
utils/geo_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user