POST /v1/mob/scan/lookup label + customer → catalogue match, sizes, and
every registered store that sells it with live
stock, in-stock first / nearest first, one
recommended
POST /v1/mob/scan/confirm chosen store + size + qty → re-read the ledger;
ok, or the next-nearest store with enough of the
same product
GET /v1/mob/scan/stores registered stores nearest first
Recognition is pgvector cosine search over every brand_* table (each
with its own index, merged) plus a word match that settles near-ties
and works alone when no model is configured. The embedder is chosen by
EMBEDDING_PROVIDER (OpenAI-compatible or Gemini) and must be the model
that indexed the catalogue: verified 2026-09-15 as all-MiniLM-L6-v2 over
search_query, served by the cluster's Ollama as `all-minilm`; the first
search refuses a width mismatch by name.
Customer, stores and catalogue are read concurrently under a 5 s cap; a
slow model degrades to a text answer. Vectors and ranked hits are cached
in Redis and in-process; live stock never is. Availability uses the same
rules as the customer catalogue (approve, publishedat, ledger balance,
outlet price else retail). No stock reservation: confirm re-reads.
scratch/cataloguedims reports the catalogue's embedding width and fill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GetUserByAuthname / GetUserByContactNo / GetUserLogin discarded the
Scan error, so a database that could not answer — down, pool exhausted,
or booted without its config (2026-07-20) — came back as uid 0 and every
user was told their email was wrong.
One lookup, GetUserLogin, now returns an error; sql.ErrNoRows is "not
found" and anything else reaches the service, which answers 500 "Login
is temporarily unavailable" and logs the cause. 409 "Invalid Email" is
unchanged for a genuine no-match: the console reads that exact shape as
"not registered". NULL password/role columns scan through sql.Null* so
they do not become 500s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`main.go` only ever loaded `.env`; the `APP_ENV` switch described in
`.env.local` / `.env.production` did not exist, and a missing variable
surfaced one restart at a time as a log.Fatalf inside db.Connect.
config.Load now picks `.env.<APP_ENV>` (default local) then `.env`, with
real environment winning, reads every setting into one typed Config and
reports everything missing in one message. Production insists on a POS
signing secret; local warns when DB_HOST is not a local address. db,
redis and the image store take the Config instead of reading env
themselves.
Also:
- livehub read MQTT_USERNAME while everything else uses MQTT_USER, so the
console stream connected to the broker unauthenticated. Both accepted.
- .dockerignore: `COPY . .` was baking .env.production into the image.
Dockerfile sets APP_ENV=production.
- Drop utils/config.go (dead viper loader) and create_table.go (unused,
hardcoded production DSN); go mod tidy removes viper.
- .env.example lists every variable the code reads; docs/ENVIRONMENT.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A till signs in with a mobile number and a PIN, but createposuser still
accepted an account without a number. Such an account cannot reach the
sign-in screen at all, and the failure surfaces at a counter in front of
a queue rather than at the point of creation. Enforced in CreatePosUser,
so both doors are covered: POST /pos/users from the terminal and
createposuser from the console share that path.
Two checks, not one. normalisePosPhone answers ("", nil) rather than an
error for a value holding no digits, so "abc" would have passed an
emptiness check, then been written as a blank and skipped the uniqueness
check below it — which is the hole this closes.
Scope is new accounts only. The column stays nullable and UpdatePosUser
still reads an empty contactno as "leave alone", so the accounts that
predate the number keep working through the backfill and cannot have
theirs cleared. The PIN stays optional at creation.
Also in this change:
- docs: correct both phone-login handovers, which claimed creation
already required a number. The sequencing note in the PIN handover
said step 2 was a backfill that could never be finished; it now is
one, and POS_LOGIN.md says which half of the pair creation enforces.
- docs: remove credentials from the examples. POS_PHONE_LOGIN_HANDOVER
carried a real-looking back-office pair and a generated till password,
and POS_LOGIN.md a second one.
- posController.Staff: the comment justified scoping by token because
"the answer carries PINs". It has not since the PIN left the wire. The
scoping is still right for a different reason, which the comment now
gives.
- scratch/posstaffsetup: takes both mobile numbers as arguments and
refuses to run without them. Generating stand-ins would have produced
exactly what this change prevents. Validated before the database is
opened, in plan mode too, so a dry run cannot print a plan that apply
would reject halfway through and leave half a shop set up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>