Compare commits

42 Commits

Author SHA1 Message Date
ed32a2620e delivery slot updated in orders and deliveries fix 2026-10-06 20:42:31 +05:30
a30763d323 delivery slot updated in orders and deliveries 2026-10-06 19:37:08 +05:30
03ae7d310b delivery slot api creation 2026-10-06 17:35:51 +05:30
f67cbad79a api for health score toggle test reverse 2026-10-06 14:58:17 +05:30
fc2caffcd1 api for health score toggle 2026-10-05 12:07:49 +05:30
97f277f424 pos gap fix 2026-09-30 18:01:59 +05:30
ea90b95cdc lookup endpoint updated 2026-09-30 16:13:25 +05:30
d0804ae84f nutrition docker file fix 2026-09-30 14:58:30 +05:30
c49f5372a5 nutrition: do not cache a lookup made under an unresolved brand
Brand case decides whether the catalogue-intelligence service answers at all.
Measured 30 Sep 2026:

    /nutrition/Balaji/balaji_..._135g   -> health_score 65.3, 545 kcal
    /nutrition/balaji/...  (our spelling) -> every field null

The brand list resolves ours to theirs, and /brands has slowed to 0.2-2.3s,
which exceeded the 3s client timeout on a cold start. The fallback then asked
under our own spelling, received a well-formed empty record, and cached it as
"no nutrition" for six hours -- so one slow moment silently removed nutrition
and health scores from every product of every brand, looking exactly like data
the agent team had not supplied.

Two changes:

  - a result reached without a resolved brand is no longer cached, so the next
    request retries rather than inheriting a wrong answer for six hours. A
    genuine miss on a resolved brand is still cached, which is the case that
    matters for traffic.
  - the brand list is warmed in the background at startup, so no shopper is
    ever in the path of that call.

Also logs which state the feature is in at boot, the way mail does. With
NUTRITION_BASE unset the endpoint simply omits `nutrition` and `healthscore`,
which is indistinguishable from an unscored product -- this deploy went out
without the variable set and had to be diagnosed by probing the API from
outside.

scratch/nutritionlive prints the exact response for any product by running this
code against the live product row and the live service.

NUTRITION_BASE=https://mcp.nearle.ai.in/api must be set in the deployment
environment. Unset, nothing changes and no product carries either key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 11:48:27 +05:30
fb859ecda1 health score 2026-09-29 22:40:36 +05:30
f9fb405974 nutrition field 2026-09-29 17:27:48 +05:30
b46902f51b auto mail generation 2026-09-29 16:53:21 +05:30
b18080d429 nearle admin agent 2026-09-28 11:26:18 +05:30
090e9c0c2f shifts 2026-09-25 16:18:53 +05:30
7fdcc92528 env fix 2026-09-25 12:39:38 +05:30
d562691f42 buddy fix 2026-09-25 11:55:50 +05:30
276e12beb9 login fix 2026-09-25 10:36:38 +05:30
db84a9a752 login 2026-09-25 09:53:16 +05:30
00317a00d8 secert updated 2026-09-24 17:20:04 +05:30
299871b820 shifts 2026-09-24 15:51:47 +05:30
cf3e4ea159 env fix 2026-09-24 13:15:47 +05:30
bb14445e21 agent fix 2026-09-24 12:36:33 +05:30
294fb8ab93 cors fixed 2026-09-24 11:38:46 +05:30
9698de32d5 api key integration 2026-09-24 11:01:16 +05:30
697b77f8c1 agent 2026-09-23 17:26:13 +05:30
8e1549764b Nearle Buddy answers a typed question
Phase 2: the loop and the model gateway. The composer in the console has
said "Not connected yet" since it was built, because there was no
assistant endpoint anywhere. There is one now.

- utils/chat.go   the gateway, a sibling of embedding.go: one small
                  interface, a provider switch, the shared postJSON, no
                  framework. Agents name a TIER (fast/balanced/deep) and
                  config maps tier to model, so changing provider does not
                  touch an agent.
- services/assistantService.go  one loop for every agent. An agent is a
                  name, a tier, a prompt and an allow-list — data, not a
                  class — so a sixth is config rather than a subclass.
- the endpoint under /v1/web, inheriting middleware.WebAuth along with
  every other console route. The assistant reads the same data the console
  does and must read it as the same person.

What the model does not get to decide:

  whose data      the caller is built from the verified session in the
                  controller, never from the request body — there is no
                  tenant field to fill in. A test scripts the model calling
                  a tool with {"tenantid": 916} and asserts it ran for 1147.
  which tools     the registry enforces the agent's allow-list; a test
                  scripts a call to a tool the agent lacks and asserts the
                  handler never ran.
  when to stop    steps and tool calls are counted here. A model that keeps
                  calling tools is stopped by arithmetic, not by being
                  asked nicely.

Two quiet failures have tests of their own. A finish_reason of "length"
means the provider cut the reply off mid-sentence, which reads exactly
like a complete answer unless it is flagged. And a truncated tool result
reaches the model in words it will repeat — otherwise it describes a
capped list and an empty one identically.

A refused tool goes back as a message, not an error: a model told "that
tool needs a tenant" can explain it, where a model handed nothing says
"something went wrong".

Optional, like the embedder. Without ASSISTANT_PROVIDER the endpoint
answers "not switched on here", the composer stays disabled, and the tools
still work — they are ordinary Go functions, and only turning a sentence
into a tool call needs a model.

14 tests, against a scripted model rather than a live provider: these are
about what the loop refuses to let a model do, and that has to hold for
any model, including one behaving badly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:13:20 +05:30
bb5f40926f Add the assistant tool registry and its first tool
Phase 1 of Nearle Buddy: an agent names a tool, and the registry decides
whether that is allowed, whether the arguments make sense, who is asking,
and what gets recorded — then runs a handler a person wrote and tested.

No agent gets raw table access. The usual argument for tools over
generated SQL is safety; here there is a harder one. The fields on this
backend do not mean what their names say, and it is measured:
orders.deliverystatus is an empty string on all 181 rows of tenant 1147,
orders.orderstatus never carries the six middle delivery stages,
deliveries.ridername holds statuses as often as names, deliverytype is
empty on every row in production. A model writing SQL gets each of those
wrong with no error — it reports a cancel rate from a column of empty
strings and nobody can tell. A model calling a tool cannot, because the
correction lives in the handler beside the measurement that justified it.

Call does five things in order: find the tool, check the agent's
allow-list, validate arguments, confirm the caller is scoped to
something, run the handler — writing exactly one audit row whatever
happens, refusals included. A trail of successes answers "did anything
try to read another tenant?" with silence, which reads the same as no.

The model has no say in whose data is read. stuck_orders has no tenantid
field on its schema — absent, not rejected — and the tenant comes from
the session claims added in the previous commit. Arguments the tool did
not declare are dropped rather than passed on, so a model sending a
`where` clause gets it discarded.

stuck_orders: deliveries a rider was given and has not accepted, ten
minutes for a look, twenty-five for somebody now. Derived from assigntime
and orderstatus, so it does not depend on anyone having been watching.
Carries the wait in minutes, what to do, where to check it, and what it
covered. A capped answer says so — an empty result and a truncated one
look identical to a model and it will call both "none".

The audit sink writes to the log for now; a database sink is phase 8.
Nothing calls the registry yet: the loop and the model gateway are phase 2.

37 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:22:57 +05:30
c516c224e5 Authenticate the console's /web surface
The /web endpoints have never had authentication. The console keeps its
login record in per-tab sessionStorage and sends no Authorization header,
so every endpoint under /v1/web read `tenantid` off the query string and
believed it — one number in a URL reached another merchant's orders,
stock, staff and takings. `createposuser` under /v1/web/tenants minted
till credentials on the strength of an unauthenticated request, which the
route file already flagged in as many words.

Closed the same way posauth.go closed it for the terminals, in the same
order: the caller holds a token this server signed, and the tenant they
name is the tenant inside that token.

- utils/webtoken.go   same HMAC construction as the POS token, 12h TTL,
                      a `w1.` prefix so the two kinds cannot verify as
                      each other
- middleware/webauth.go  verifies the token, pins the tenant, and checks
                      a named branch belongs to it; reads the tenant from
                      the query, the body, and inside a JSON array, since
                      createdeliveries posts one
- login now issues the token; the console sends it as Bearer

Platform access rides on issuperadmin and nothing else. Not the role —
app_roles calls roleid 1 "Super admin" and tenant onboarding wrote 1 for
every shop owner, so a role test would promote every merchant on the
platform. Not a zero tenant either, or a user row with the field unset
becomes the one session that reads everything. Both near-misses have
tests.

WEB_AUTH_REQUIRED defaults to off. The console in production does not
send a token yet, and enforcing before it does would lock every merchant
out of a working product. A token that IS sent is always verified, and
one naming the wrong tenant is always refused; the flag only decides what
happens to a request carrying none. This should be a short-lived state.

Still trusting the caller: partnerid, customerid and appuserid, which
some list endpoints also scope on. Noted in the middleware header.

25 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:22:20 +05:30
771d6a51cf Merge scan-to-order: ambiguous-label candidates and match method 2026-09-23 11:21:16 +05:30
24339a8b51 Merge origin/main: keep the substring rule, read its tie
main had moved on with retrieval work validated against real queries —
minTokenHits (the word match needs two thirds of the label, not all of
it), separator folding so "Parle G"/"Parle-G"/"ParleG" all reach Parle-G,
the floor at 0.50 after "Paracetamol" came back as "Paneer Makhni 500ml"
at 0.304, and ties broken on cosine distance instead of name. All of that
is kept exactly as it was.

The conflict was in textScore: this branch replaced the substring rule
with a coverage formula to stop a bare brand name resolving to one
arbitrary product. That is the wrong half to change. The substring rule
scores every product of a brand 0.95 IDENTICALLY, and that tie is not the
bug — it is the signal. isAmbiguous reads it, so the branch's coverage
rewrite is dropped and the ambiguity layer alone does the work:

  "britannia" → all 258 rows tie at 0.95 → ambiguous: true + candidates
  "Parle G"   → folding and the single-character token still land it
  a real name → runner-up far behind → match, unchanged

Dropped with it: scanSpecificEnough, the per-hit text score, and the
proportional confirmation bonus — the flat +0.10 is back. Simpler, and it
leaves main's tuning untouched.

TestTextScoreRewardsSpecificityNotJustOverlap tested the removed formula
and is replaced by TestABrandNameScoresItsProductsIdentically, which
guards the tie itself: a formula that broke it on name length or word
count would bring the bug back.

Docs carry both rationales, and now say plainly that confidence stays
high on the ambiguous path — gate on `ambiguous`, never on `confidence`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:05:03 +05:30
01bc89ab77 Ask instead of guessing when a label fits several products
`"britannia"` is a substring of all 258 Britannia product names, and
textScore returned 0.95 for any product whose name contained the label.
So every one of them tied, the tie broke alphabetically, and the customer
was shown one arbitrary biscuit with "confidence": 0.95 and a price. Lens
hands back a bare wordmark often — it is usually the biggest thing printed
on a packet — so this was the common case, not an edge one. Found via the
example request in the mobile team's own proposal.

Scoring now asks both questions. A hit carries `score` (ranks) and `text`
(how specifically the label names THIS product: the harmonic mean of how
much of the label the product explains and how much of the product's name
the label explains, pack sizes dropped from both sides). A brand name
scores its products ~0.33 equally instead of 0.95 arbitrarily. The
"vector and text agree" bonus is now proportional to the text score, so a
weak match can no longer inflate a whole brand.

isAmbiguous reads that: the leader is a guess if anything is level with it
(margin) or if the label names no one product (specificity), and then the
response carries `ambiguous: true` with `candidates` — distinct products,
not pack sizes, at most ten, each marked with whether one of the
customer's stores has it in stock, available ones first. `match` is nil
and `stores` empty on that path: no price for a product nobody chose.
Erring towards asking is deliberate — a tap versus the wrong biscuit.

To act on a pick, /lookup now accepts `brand` + `catalogueid` instead of a
label and skips recognition entirely (also serves deep links and re-order).
New: ScanRepository.CatalogueRef, resolving via the brand tables discovered
from information_schema, never a name built from the request.

Also: scratch/cataloguedims now reports every vector column, not just
`embedding` — which is how we learned the catalogue also carries
img_vector(1024), filled on 1885 of 2124 rows. SCAN_TO_ORDER.md records
why that column stays unread for now and what would change it, alongside
why the app is not asked to compute vectors on the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:56:02 +05:30
692c10e553 partner list 2026-09-17 11:04:37 +05:30
2f1501883b e2e changes 2026-09-16 17:09:04 +05:30
c06b029cb2 text search 2026-09-16 12:17:39 +05:30
28af3e05f2 image search test 2026-09-16 11:52:35 +05:30
42ea007fe7 image search 2026-09-16 11:34:18 +05:30
76bff883ec Merge pull request 'feat/env-login-scan-to-order' (#4) from feat/env-login-scan-to-order into main
Reviewed-on: #4
2026-09-15 11:38:16 +00:00
aaea1bfc00 README: a map of the service for new developers
What it is, how to run it, how configuration works, the module layout,
the seven steps to add an endpoint, the standing surprises, and a
Kubernetes cheat-sheet — each pointing at the detailed doc. Plus a
backend-developer section in SCAN_TO_ORDER.md: file map, local try-out,
tests, tuning knobs, and how to change the embedding model or add a
provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:06:42 +05:30
369e9fc7b4 Add pending POS scratch checks and portfolio notes
Untracked in the working tree before today's work; committed so the
branch carries everything on disk except a stray duplicate
(docs/MOBILE_ORDER_VERIFICATION copy.md).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:04:34 +05:30
72907dae74 Scan-to-order: label from the customer's camera to "buy it here"
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>
2026-09-15 17:04:34 +05:30
1633617dc4 Stop reporting a failed login lookup as "Invalid Email"
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>
2026-09-15 17:04:33 +05:30
4474479735 Load .env.<APP_ENV>, validate config at boot, keep secrets out of the image
`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>
2026-09-15 17:04:33 +05:30
185 changed files with 28211 additions and 1010 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
# 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.
#
# An exception for `.env.production` was added on 2026-09-25 so the container
# could read its own configuration, and the deploy came back 502 on every
# endpoint. Reverted. The committed file is a stale snapshot; letting it fill
# whatever the platform leaves unset is not a safe default.
.env*
.git
.claude
.DS_Store
docs
scratch
init
nearle
server
docker-compose.local.yml

49
.env
View File

@@ -1,16 +1,18 @@
# Fiesta configuration. # Fiesta configuration — the shared base file.
# #
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one # Loaded AFTER `.env.<APP_ENV>` (see config/config.go), and godotenv never
# exception is this file) — and they are gitignored for a reason: this # overwrites a value that is already set, so anything here is a fallback for
# repository's history already contains a committed `.env` from before # what the environment file and the real environment leave unset. Keep it
# 2026-08-03, so those database credentials are in the history and should be # local: with APP_ENV unset this is loaded straight after `.env.local`, and a
# rotated. Do not add another. # production value here would silently reach a local run.
# #
# `godotenv.Load()` in main.go reads `.env` from the working directory, so # go run . → .env.local, then this file
# `go run .` from this folder picks it up with no flags. # 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 ──────────────────────────────────────────────────────── # ── 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. # beside something else; the console then points at the same number.
APP_PORT=1122 APP_PORT=1122
@@ -59,5 +61,34 @@ REDIS_USER=
REDIS_DB=0 REDIS_DB=0
# ── Auth ──────────────────────────────────────────────────────────────────── # ── 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= JWT_SECRET_KEY=
USER_CONTEXT_KEY= USER_CONTEXT_KEY=
# ── Email ───────────────────────────────────────────────────────────────────
#
# The first-password invitation. See docs/MAIL_SETUP.md.
#
# MAIL_HOST IS DELIBERATELY BLANK HERE. This file is tracked and shared, and a
# host set here would mean any local run could email a real merchant a real
# password link. Blank is the documented off state: the server boots, onboarding
# works, and every create answers `invited: false` with the reason.
#
# Turn it on by putting the Google Workspace host and App Password in
# `.env.secrets`, which is read first and is the only one of these git ignores.
MAIL_HOST=
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
# On nearledaily.com because the link points at app.nearledaily.com — a password
# mail whose sender and destination are different domains reads as phishing.
MAIL_FROM=care@nearledaily.com
MAIL_FROM_NAME=Nearle
MAIL_CONSOLE_URL=https://app.nearledaily.com
# ── Nutrition ───────────────────────────────────────────────────────────────
# The catalogue-intelligence host behind the health score card. The customer
# app product screen reads its nutrition panel from here. Unset means no panel.
NUTRITION_BASE=https://mcp.nearle.ai.in/api

View File

@@ -1,30 +1,41 @@
# Fiesta configuration. # Fiesta configuration — the complete list of settings, with local values.
# #
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one # How the files are picked (config/config.go):
# 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.
# #
# `godotenv.Load()` in main.go reads `.env` from the working directory, so # APP_ENV unset / local → .env.local then .env
# `go run .` from this folder picks it up with no flags. # 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 ──────────────────────────────────────────────────────── # ── Where it listens ────────────────────────────────────────────────────────
# 1122 is what production serves on. Change it locally to run a second copy # 1122 locally; production serves on 1009 (matching the Dockerfile's EXPOSE).
# beside something else; the console then points at the same number.
APP_PORT=1122 APP_PORT=1122
ENV=development
# ── The main database (nearledb) ──────────────────────────────────────────── # ── The main database (nearledb) ────────────────────────────────────────────
# #
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION. # ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
# #
# There is no "local mode" that protects you: `go run .` against the live host # 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 # 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 # schema migrations on boot. Startup warns when APP_ENV=local and DB_HOST is
# before it is deployed, a local Postgres with a dump restored into it is the # not a local address, but it does not stop you.
# only version that actually does that. #
# These match docker-compose.local.yml, so `docker compose -f # These match docker-compose.local.yml, so `docker compose -f
# docker-compose.local.yml up -d` and `go run .` work together with no edits. # docker-compose.local.yml up -d` and `go run .` work together with no edits.
DB_HOST=localhost DB_HOST=localhost
@@ -36,10 +47,9 @@ DB_PASSWORD=localdev
# ── The catalogue database (pgvector) ─────────────────────────────────────── # ── The catalogue database (pgvector) ───────────────────────────────────────
# #
# A separate connection on purpose, so catalogue work never touches nearledb. # A separate connection on purpose, so catalogue work never touches nearledb.
# Leave blank to start without it: catalogue endpoints then fail at query time # Leave CATALOGUE_DB_HOST blank to start without it: catalogue endpoints then
# rather than at boot, which is fine for testing anything else. # fail at query time rather than at boot. With a host set, the other four are
# 5434, not 5432: a developer machine usually has something on 5432 already, # required. 5434, not 5432: a developer machine usually has something on 5432.
# and a silent connection to the wrong database is worse than a refused one.
CATALOGUE_DB_HOST=localhost CATALOGUE_DB_HOST=localhost
CATALOGUE_DB_PORT=5434 CATALOGUE_DB_PORT=5434
CATALOGUE_DB_NAME=cataloguedb CATALOGUE_DB_NAME=cataloguedb
@@ -48,12 +58,158 @@ CATALOGUE_DB_PASSWORD=localdev
# ── Redis — POS terminal presence, under a TTL ────────────────────────────── # ── Redis — POS terminal presence, under a TTL ──────────────────────────────
# #
# Optional. Losing the health board is an inconvenience; losing a sale is not, # Optional: leave REDIS_HOST blank to run without it. Losing the health board
# so the API runs without it. # is an inconvenience; losing a sale is not, so the API runs without it.
REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_USER= REDIS_USER=default
REDIS_PASSWORD=
REDIS_DB=0 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 ──────────────────────────────────────────────────────────────────── # ── 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= 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=
# ── Nutrition ───────────────────────────────────────────────────────────────
#
# The catalogue-intelligence service — the same host the console reads its
# health score card from. The customer app product screen gets its nutrition
# panel from here, through `getproductbyvariant`.
#
# Read server-side rather than by the app: the brand-spelling resolution below
# would otherwise have to be reimplemented in the app, and a wrong spelling
# returns a well-formed record with every figure null — indistinguishable from
# a product nobody has scored.
#
# Unset means product screens carry no nutrition panel and nothing else changes.
NUTRITION_BASE=https://mcp.nearle.ai.in/api
# ── Email ───────────────────────────────────────────────────────────────────
#
# Sending the first-password invitation a newly onboarded merchant receives.
# Without MAIL_HOST the server still boots and still onboards tenants — the
# create response comes back `invited: false` with the reason — but nobody is
# emailed, and the only way into a new account is a Nearle staff member using
# Resend invite.
#
# SMTP, because every provider speaks it. Any transactional service is the same
# five variables: its host, 587, the API key as MAIL_PASSWORD, and whatever
# username it documents.
#
# WE USE GOOGLE WORKSPACE SMTP, authenticating as care@nearledaily.com with a
# 16-character App Password — never the account's login password, because an App
# Password can be revoked on its own. See docs/MAIL_SETUP.md for the setup and
# for the DNS records, which are what actually decide whether the invitation
# reaches an inbox rather than a spam folder.
#
# Self-hosting (Postal) was the earlier plan and is the better answer at volume.
# At a few dozen invitations a month the work is not the software, it is IP
# reputation, rDNS and blocklists — so this buys the reputation instead.
#
# Google Workspace: smtp.gmail.com 587 an App Password
# via an SMTP relay: smtp-relay.gmail.com 587 if an admin sets one up
# Amazon SES: email-smtp.<region>.amazonaws.com 587
# SendGrid: smtp.sendgrid.net 587 username literally "apikey"
# Resend: smtp.resend.com 587 username literally "resend"
#
# Credentials belong in .env.secrets (git-ignored, read first), or in the
# deployment platform's own environment — NOT in this file and not in .env.
MAIL_HOST=
MAIL_PORT=587
# Optional. Leave both empty for a relay that authenticates by network rather
# than by credentials.
MAIL_USERNAME=
MAIL_PASSWORD=
# Who the invitation appears to come from. Separate from MAIL_USERNAME because
# most providers authenticate as one identity and send as another, and using
# the login as the From address is how mail lands in spam.
#
# ON NEARLEDAILY.COM, DELIBERATELY. The link in the mail points at
# app.nearledaily.com, and a password link arriving from a DIFFERENT domain than
# the one it sends you to is the exact shape of a phishing mail — to a filter
# and to the merchant reading it. Sender and link stay on one domain.
#
# `care@` rather than `no-reply@`, also deliberately: somebody who replies "I
# never got this" is the single most useful reply this system can receive, and
# it should reach a person.
MAIL_FROM=care@nearledaily.com
MAIL_FROM_NAME=Nearle
# Where the invitation link points — the MERCHANT console, always. A merchant
# sets their password there and nowhere else, so this is never the platform
# console's address.
MAIL_CONSOLE_URL=https://app.nearledaily.com
# ── Nearle Buddy ────────────────────────────────────────────────────────────
#
# ONE variable. The provider, endpoint and model are defaults in config.go
# (openai / api.groq.com / openai/gpt-oss-120b) because each has one right
# answer for this product — and three variables that must be typed correctly
# into a hosting platform are three ways for the assistant to sit silently off,
# which is how it spent its first week.
#
# The key is the only one that differs per deployment and the only one that
# cannot live in this repository. Locally it goes in `.env.secrets`, which git
# ignores; in production it is set on the platform.
ASSISTANT_API_KEY=
# Overrides, none of them needed for the shipped setup.
# Ollama on a laptop: ASSISTANT_BASE_URL=http://localhost:11434/v1 and
# ASSISTANT_MODEL=llama3 — a local endpoint needs no key.
ASSISTANT_PROVIDER=
ASSISTANT_BASE_URL=
ASSISTANT_MODEL=
# Per-tier overrides. ASSISTANT_MODEL alone sets all three.
ASSISTANT_MODEL_FAST=
ASSISTANT_MODEL_BALANCED=
ASSISTANT_MODEL_DEEP=

View File

@@ -7,13 +7,10 @@
# Keep every host here pointing at localhost. The whole point of the split is # 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. # that running the server locally cannot reach live data by accident.
# #
# `.env`, `.env.local` and `.env.production` are all gitignored (`.env.example` # `config.Load()` reads `.env.$APP_ENV` and then `.env` from the working
# is the one exception) — and for a reason: this repository's history already # directory, so `go run .` from this folder picks this file up with no flags.
# contains a committed `.env` from before 2026-08-03, so those credentials are # A real environment variable always wins over either file. The full list of
# in the history and should be rotated. Do not add another. # settings is in `.env.example`.
#
# `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.
# ── Where it listens ──────────────────────────────────────────────────────── # ── Where it listens ────────────────────────────────────────────────────────
# Production serves on 1009 (see .env.production). Change this locally to run # Production serves on 1009 (see .env.production). Change this locally to run
@@ -64,5 +61,25 @@ REDIS_USER=
REDIS_DB=0 REDIS_DB=0
# ── Auth ──────────────────────────────────────────────────────────────────── # ── 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= JWT_SECRET_KEY=
USER_CONTEXT_KEY= USER_CONTEXT_KEY=
# ── Nearle Buddy ────────────────────────────────────────────────────────────
#
# The model behind the assistant. Any OpenAI-compatible endpoint: Groq here,
# Ollama at http://localhost:11434/v1 with no key, or api.openai.com/v1.
#
# ASSISTANT_API_KEY is DELIBERATELY ABSENT. This file is tracked by git, so a
# key written here is a key pushed to the remote. Supply it from the real
# environment, which wins over both env files:
#
# ASSISTANT_API_KEY=gsk_... go run .
#
# On the deployed host there is no env file at all — every value comes from the
# platform's environment settings, which is where the key belongs.
ASSISTANT_PROVIDER=openai
ASSISTANT_BASE_URL=https://api.groq.com/openai/v1
ASSISTANT_MODEL=openai/gpt-oss-120b

View File

@@ -11,13 +11,15 @@
# run. If the point is to try a change before it ships, use `.env.local` with a # 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. # 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 # On the deployed host these values come from the platform's environment
# issue or a PR description: the credentials below have to be rotated if it # settings (Dokploy / Kubernetes), never from this file: the image is built
# leaves this machine. # 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 # This file is currently committed to git, which means every credential in it
# rather than from this file. Keep the two in step — a variable added here and # has to be treated as public: rotate them, and keep the new values out of
# not there is a variable that is unset in production. # the repository.
# ── Where it listens ──────────────────────────────────────────────────────── # ── Where it listens ────────────────────────────────────────────────────────
APP_PORT=1009 APP_PORT=1009
@@ -76,3 +78,7 @@ REDIS_DB=0
# ── Auth ──────────────────────────────────────────────────────────────────── # ── Auth ────────────────────────────────────────────────────────────────────
POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl
# ── Nearle Buddy ────────────────────────────────────────────────────────────
# One variable. Provider, endpoint and model are constants in config.go.
ASSISTANT_API_KEY=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

6
.gitignore vendored
View File

@@ -54,3 +54,9 @@ Thumbs.db
# that getting worse, but the existing history still has them and the password # that getting worse, but the existing history still has them and the password
# should be rotated. # should be rotated.
# Secrets, for local runs only. Never committed — the rule below is what makes
# that true, and it is why this file exists separately from .env.local, which
# IS tracked and therefore cannot hold a key.
.env.secrets

View File

@@ -4,7 +4,21 @@ FROM golang:1.24 AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server # Which commit this image is. Reported by GET /live/api/v1/health, so "I pushed
# it" and "it is running" stop being the same sentence — a redeploy can reuse a
# cached image, and there was no way to tell from outside.
#
# Passed by the platform as a build argument:
# docker build --build-arg BUILD_VERSION=$(git rev-parse --short HEAD) .
# In Dokploy this goes under the application's Build settings. Left unset it
# reads "unknown", which is itself worth seeing — it means nothing stamped it.
#
# `.git` is not in the build context (see .dockerignore), so the build cannot
# work this out for itself.
ARG BUILD_VERSION=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X nearle/controllers.Version=${BUILD_VERSION}" -o server
# ---------- Runtime Stage ---------- # ---------- Runtime Stage ----------
FROM alpine:latest FROM alpine:latest
@@ -14,6 +28,55 @@ WORKDIR /app
COPY --from=builder /app/server /app COPY --from=builder /app/server /app
COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json . COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json .
# Nearle Buddy's credential, as ONE container variable.
#
# Not an env file. `COPY .env.production .` was tried on 2026-09-25 and took the
# backend down with 502 on every endpoint: that file declares twenty-three
# variables, and godotenv fills any the platform leaves unset, so a stale
# committed DB or Redis value replaced a live one and the process died at boot.
# Twenty-three variables shipped to deliver one.
#
# A single ENV cannot do that — it sets this name and no other. A value set on
# the platform still wins, because `docker run -e` overrides a Dockerfile ENV,
# so this is a default rather than an override.
#
# Provider, endpoint and model are constants in config.go, so this is the only
# thing the assistant needs to come up.
ENV ASSISTANT_API_KEY=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
# Where the nutrition panel and health score come from.
#
# A PUBLIC URL, not a secret — it is the catalogue-intelligence service the
# console already reads its health score card from, and the same value is in
# .env.example. So it is a build-time default rather than a platform setting,
# for the same reason ASSISTANT_API_KEY is: one variable, set in one place,
# that cannot be missed on a deploy.
#
# It has been missed twice. Unset, `getproductbyvariant` simply omits
# `nutrition` and `healthscore`, which is indistinguishable from a product the
# service has not scored — so the feature ships switched off and looks broken
# rather than absent. The startup log now names which state it is in.
#
# A value set on the platform still wins: `docker run -e` overrides a Dockerfile
# ENV, so this is a default and not a lock-in.
ENV NUTRITION_BASE=https://mcp.nearle.ai.in/api
# 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 other real value comes from the platform's
# environment settings, exactly as before.
# The clock every business rule is decided on.
#
# tzdata was already installed and nothing set TZ, so the container ran UTC.
# That was invisible while time.Now() only ever stamped records — nothing
# compared a stored time of day against the current one. Delivery windows are
# the first rule that does: a shop setting morning as 08:00-10:00 would have had
# it close at 10:00 UTC, which is 15:30 where the shop is standing.
ENV TZ=Asia/Kolkata
ENV APP_ENV=production
# Must match APP_PORT in the platform's environment (1009 in production).
EXPOSE 1009 EXPOSE 1009
CMD ["/app/server"] CMD ["/app/server"]

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# 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`, and
most are read-only. Two are not — `termbackfill` and
`cataloguefactsbackfill` repair rows that no endpoint can reach. Both
default to a dry run that prints every change and write only when passed
`apply`, and both print the SQL to undo themselves afterwards. A new tool
that writes follows that shape or it does not write.
## 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.

261
config/assistant_test.go Normal file
View File

@@ -0,0 +1,261 @@
package config
import (
"os"
"strings"
"testing"
)
// Why the assistant is off.
//
// "Off" was the same answer for four different mistakes, and the only symptom
// was a disabled composer. Nobody could tell "we have not switched it on" from
// "somebody misspelled a variable" — which is how it stayed off for days with
// both of us guessing.
func TestAFullyConfiguredAssistantIsOn(t *testing.T) {
cfg := AssistantConfig{
Provider: "openai", BaseURL: "https://api.groq.com/openai/v1",
APIKey: "k", Balanced: "openai/gpt-oss-120b",
}
if !cfg.Enabled() {
t.Fatalf("a complete config was refused: %s", cfg.Why())
}
if cfg.Why() != "" {
t.Fatalf("an enabled assistant gave a reason: %q", cfg.Why())
}
}
func TestEachMissingPieceNamesItself(t *testing.T) {
for name, tc := range map[string]struct {
cfg AssistantConfig
says string
}{
// Nothing set at all names the MODEL, not the provider. The provider is
// derived from the model now, so an empty one is a consequence rather
// than a cause — and sending an operator to set ASSISTANT_PROVIDER, a
// variable they no longer need, while the one they actually missed goes
// unmentioned, is the same "off for four reasons" problem in new words.
"nothing set at all": {AssistantConfig{}, "ASSISTANT_MODEL"},
"no provider": {
AssistantConfig{Balanced: "m", APIKey: "k"}, "ASSISTANT_PROVIDER"},
"unknown provider": {
AssistantConfig{Provider: "anthropik", Balanced: "m", APIKey: "k"}, "not one this server speaks"},
"no model": {AssistantConfig{Provider: "openai", APIKey: "k"}, "ASSISTANT_MODEL"},
"no api key": {AssistantConfig{Provider: "openai", Balanced: "m", BaseURL: "https://api.groq.com/openai/v1"}, "ASSISTANT_API_KEY"},
} {
why := tc.cfg.Why()
if why == "" {
t.Fatalf("%s: reported as working", name)
}
if !strings.Contains(why, tc.says) {
t.Fatalf("%s: does not name the problem: %q", name, why)
}
}
}
func TestALocalModelNeedsNoKey(t *testing.T) {
// Ollama and LM Studio need no credential, and demanding one would refuse
// the setup a developer is most likely to have on their own machine.
for _, base := range []string{
"http://localhost:11434/v1",
"http://127.0.0.1:1234/v1",
"http://host.docker.internal:11434/v1",
} {
cfg := AssistantConfig{Provider: "openai", BaseURL: base, Balanced: "llama3"}
if !cfg.Enabled() {
t.Fatalf("%s was refused without a key: %s", base, cfg.Why())
}
}
}
func TestAHostedModelWithoutAKeyIsRefusedBeforeItFailsAtRuntime(t *testing.T) {
// Otherwise the first question a shopkeeper asks comes back as a 401 from
// the provider, which reads as the assistant being broken rather than as a
// variable nobody set.
cfg := AssistantConfig{Provider: "openai", BaseURL: "https://api.groq.com/openai/v1", Balanced: "m"}
if cfg.Enabled() {
t.Fatal("a hosted provider with no key reported as ready")
}
}
func TestTheTierFallbackDoesNotHideAMissingModel(t *testing.T) {
// `fast` and `deep` fall back to balanced, so a config with only those two
// set has no model at all for the default tier.
cfg := AssistantConfig{Provider: "openai", APIKey: "k", Fast: "small", Deep: "big"}
if cfg.Enabled() {
t.Fatal("an assistant with no balanced model reported as ready")
}
if cfg.ModelFor("fast") != "" && cfg.ModelFor("balanced") != "" {
t.Fatal("balanced resolved to something despite being unset")
}
}
// Where a secret is allowed to live.
//
// `.env`, `.env.local` and `.env.production` are all tracked by git, so a key
// written to any of them is a key published. There was nowhere else, and the
// standing instruction was to export it in the shell on every run — which is
// the kind of instruction people route around by editing a tracked file.
func TestASecretsFileIsReadBeforeAnyTrackedEnvFile(t *testing.T) {
order := envFileOrder("local")
if len(order) == 0 || order[0] != ".env.secrets" {
t.Fatalf(".env.secrets is not read first, so a tracked file wins: %v", order)
}
// godotenv does not overwrite, so being first IS what makes it authoritative.
// Being merely present would let .env.local decide the key instead.
for _, tracked := range []string{".env.local", ".env"} {
for i, name := range order {
if name == tracked && i == 0 {
t.Fatalf("%s is read first; a secret there would be committed", tracked)
}
}
}
}
func TestTheEnvironmentsOwnFileBeatsTheSharedOne(t *testing.T) {
// `.env.production` must be consulted before the shared `.env`, or a
// production deployment silently takes the local defaults.
order := envFileOrder("production")
var production, shared int = -1, -1
for i, name := range order {
switch name {
case ".env.production":
production = i
case ".env":
shared = i
}
}
if production < 0 || shared < 0 || production > shared {
t.Fatalf("the environment's own file does not take precedence: %v", order)
}
}
// One variable, not four.
//
// The assistant sat switched off for days because `ASSISTANT_PROVIDER` had not
// been typed into a hosting platform's environment tab — a variable whose only
// correct value is "openai", because every endpoint this server speaks is
// OpenAI-compatible. The base URL and the model had one right answer too.
//
// So three of the four are constants now. The key is the only one that varies
// between deployments and the only one that cannot live in the repository.
func TestTheKeyAloneSwitchesTheAssistantOn(t *testing.T) {
for _, name := range []string{
"ASSISTANT_PROVIDER", "ASSISTANT_BASE_URL", "ASSISTANT_MODEL",
"ASSISTANT_MODEL_BALANCED", "ASSISTANT_MODEL_FAST", "ASSISTANT_MODEL_DEEP",
} {
t.Setenv(name, "")
}
t.Setenv("ASSISTANT_API_KEY", "gsk_not-a-real-key")
cfg := AssistantConfig{
Provider: assistantProvider(),
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
APIKey: env("ASSISTANT_API_KEY", ""),
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
}
if !cfg.Enabled() {
t.Fatalf("the key alone did not switch it on: %s", cfg.Why())
}
if cfg.Provider != "openai" {
t.Fatalf("provider defaulted to %q", cfg.Provider)
}
if cfg.ModelFor("fast") != defaultAssistantModel {
t.Fatalf("the fast tier fell through to %q", cfg.ModelFor("fast"))
}
}
func TestNoKeyIsStillOffAndSaysWhich(t *testing.T) {
// The defaults must not make an unconfigured deployment look ready. Without
// a key every question would reach Groq and come back 401, which reads as
// the assistant being broken rather than as not being set up.
cfg := AssistantConfig{
Provider: defaultAssistantProvider,
BaseURL: defaultAssistantBaseURL,
Balanced: defaultAssistantModel,
}
if cfg.Enabled() {
t.Fatal("reported ready with no key")
}
if !strings.Contains(cfg.Why(), "ASSISTANT_API_KEY") {
t.Fatalf("did not name the one variable left to set: %q", cfg.Why())
}
}
func TestEachDefaultIsStillOverridable(t *testing.T) {
// Running against Ollama on a laptop must not need a code change.
t.Setenv("ASSISTANT_PROVIDER", "ollama")
t.Setenv("ASSISTANT_BASE_URL", "http://localhost:11434/v1")
t.Setenv("ASSISTANT_MODEL", "llama3")
cfg := AssistantConfig{
Provider: assistantProvider(),
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
APIKey: env("ASSISTANT_API_KEY", ""),
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
}
if cfg.Provider != "ollama" || cfg.Balanced != "llama3" {
t.Fatalf("an override was ignored: %+v", cfg)
}
// Local endpoints need no key, so this must be on without one.
if !cfg.Enabled() {
t.Fatalf("a local model was refused: %s", cfg.Why())
}
}
// The container reads its own configuration from a file beside the binary.
//
// The Dockerfile copies `.env.production` into the runtime image and sets
// APP_ENV=production, so `loadEnvFiles` reads it on boot. This asserts the
// mechanism rather than the Dockerfile — a COPY line is easy to check by eye
// and easy to believe wrongly, and the failure it produces is a server that
// starts fine with a variable silently unset.
func TestTheEnvironmentFileBesideTheBinaryIsRead(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(".env.production",
[]byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil {
t.Fatalf("writing the fixture: %v", err)
}
t.Setenv("APP_ENV", "production")
// Registered with t.Setenv first so it is restored on return, then removed:
// godotenv does not overwrite a variable that is PRESENT, and an empty
// string is present. Setting it to "" would have tested nothing.
t.Setenv("ASSISTANT_API_KEY", "placeholder")
os.Unsetenv("ASSISTANT_API_KEY")
loadEnvFiles()
if os.Getenv("ASSISTANT_API_KEY") != "from-the-file" {
t.Fatal("the environment file beside the binary was not read")
}
}
func TestThePlatformStillWinsOverTheFile(t *testing.T) {
// godotenv never overwrites a variable already in the environment, so a
// value set on the hosting platform overrides the committed file without
// the file having to change. Both mechanisms work; neither fights the other.
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(".env.production",
[]byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil {
t.Fatalf("writing the fixture: %v", err)
}
t.Setenv("APP_ENV", "production")
t.Setenv("ASSISTANT_API_KEY", "from-the-platform")
loadEnvFiles()
if got := os.Getenv("ASSISTANT_API_KEY"); got != "from-the-platform" {
t.Fatalf("the file overrode the platform: ASSISTANT_API_KEY=%q", got)
}
}

View File

@@ -1,49 +1,537 @@
// 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 package config
import ( import (
"errors"
"fmt"
"log" "log"
"os" "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 { type Config struct {
Env string // AppEnv is the value of APP_ENV: "local" or "production".
Port string AppEnv string
DBName string // Port the API listens on. APP_PORT, default 1122 (production sets 1009).
DBUser string Port string
DBPassword string
DBPort string DB DBConfig
DBHost string Catalogue DBConfig // Host empty → catalogue endpoints disabled.
UserContextKey string Redis RedisConfig
S3 S3Config
MQTT MQTTConfig
Embedding EmbeddingConfig
// Assistant is the model behind Nearle Buddy. Empty provider = no typed
// questions; the tools still work.
Assistant AssistantConfig
// Mail. Optional: a deployment without it still onboards tenants and reports
// the invitation as unsent.
Mail MailConfig
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
// unset, matching utils/postoken.go.
POSTokenSecret string
JWTSecret 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 != "" }
// AssistantConfig is the model behind Nearle Buddy.
//
// Optional, like the embedder. With no provider the assistant refuses typed
// questions and says so — the tools still work and still answer correctly,
// because they are ordinary Go functions; only the part that turns a sentence
// into a tool call is missing.
//
// ── Why three models and not one ────────────────────────────────────────────
//
// An agent names a TIER, never a model. "Which branch is underperforming?"
// and "why is the cancel rate high?" want different amounts of thinking, and
// wiring a model name into an agent means changing every agent to change
// provider. The tiers are the stable vocabulary; this map is the only place a
// model name appears.
//
// `ASSISTANT_MODEL` alone sets all three, which is the sane default for a
// deployment that has not thought about it yet.
type AssistantConfig struct {
Provider string // "openai" — any OpenAI-compatible endpoint
BaseURL string // default https://api.openai.com/v1
APIKey string
// Tier → model name. Empty falls back to Balanced, which falls back to
// ASSISTANT_MODEL.
Fast string
Balanced string
Deep string
}
func (a AssistantConfig) Enabled() bool { return a.Why() == "" }
// Why says what is missing, or "" when the assistant can run.
//
// A sentence rather than a bool, because "off" is the same answer for four
// different mistakes: no provider, no model, no key, a provider nobody
// recognises. Without this the only symptom is a disabled composer, and the
// difference between "we have not switched it on" and "somebody misspelled a
// variable" is invisible from the outside — which is exactly where this was
// stuck.
// The model is reported before the provider, and that order matters. Since
// `assistantProvider` derives the provider from the model, an empty provider
// means the model is empty too — and naming ASSISTANT_PROVIDER first would send
// an operator to set a variable they no longer need, while the one they
// actually missed went unmentioned.
func (a AssistantConfig) Why() string {
if a.Balanced == "" {
return "ASSISTANT_MODEL is not set; give it the provider's model name, " +
"for example openai/gpt-oss-120b"
}
switch a.Provider {
case "openai", "groq", "ollama", "together", "compatible":
case "":
// Not reachable through Load, which derives it. Reachable when
// something builds this struct by hand, and silence would be worse.
return "ASSISTANT_PROVIDER is not set and could not be derived"
default:
return "ASSISTANT_PROVIDER is " + a.Provider + ", which is not one this server speaks"
}
// A local provider needs no credential; a hosted one always does, and a
// missing key otherwise surfaces as a 401 from the provider on the first
// question rather than as a configuration problem.
if a.APIKey == "" && !isLocalEndpoint(a.BaseURL) {
where := a.BaseURL
if where == "" {
// Empty means the OpenAI default, which is emphatically not local.
// "and is not a local endpoint" is how that read before.
where = "the default https://api.openai.com/v1"
}
return "ASSISTANT_API_KEY is not set, and " + where + " is not a local endpoint"
}
return ""
}
// isLocalEndpoint reports whether a base URL is something running beside us.
//
// Ollama and LM Studio need no key, and demanding one would refuse the setup a
// developer is most likely to have on their own machine.
func isLocalEndpoint(baseURL string) bool {
url := strings.ToLower(baseURL)
return strings.Contains(url, "localhost") ||
strings.Contains(url, "127.0.0.1") ||
strings.Contains(url, "host.docker.internal")
}
// ModelFor resolves a tier to a model name, falling back rather than failing.
//
// A missing `fast` model should answer a cheap question with the balanced one,
// not refuse it. A deployment that sets one model gets one model everywhere.
func (a AssistantConfig) ModelFor(tier string) string {
switch tier {
case "fast":
if a.Fast != "" {
return a.Fast
}
case "deep":
if a.Deep != "" {
return a.Deep
}
}
return a.Balanced
}
// What Nearle Buddy runs on unless a deployment says otherwise.
//
// These are in the code rather than in the environment because they are not
// secrets and not deployment-specific — they are what this product uses. Every
// variable that has one right answer is a variable somebody has to remember,
// get past a platform's UI, and then re-enter on the next environment; three of
// the four were exactly that, and the assistant sat switched off for days
// because one of them had not been typed.
//
// The API key is the one that genuinely varies and genuinely cannot live here.
const (
defaultAssistantProvider = "openai"
defaultAssistantBaseURL = "https://api.groq.com/openai/v1"
defaultAssistantModel = "openai/gpt-oss-120b"
)
// assistantProvider reads the provider, defaulting to the one shape this
// server speaks.
//
// Every endpoint here is OpenAI-compatible — Groq, Ollama, Together and OpenAI
// itself — so the base URL is what actually distinguishes them. Naming a
// protocol you have no choice about is a variable that exists only to be
// forgotten.
func assistantProvider() string {
if named := strings.ToLower(strings.TrimSpace(env("ASSISTANT_PROVIDER", ""))); named != "" {
return named
}
return defaultAssistantProvider
}
// AssistantFromEnv reads the assistant's settings, defaults and all.
//
// Exported and used by `Load` rather than written inline there, because the
// live tests need the SAME reading. They used to build this struct by hand from
// `os.Getenv`, which meant they skipped silently the moment a default was
// introduced — they were testing a configuration production no longer uses,
// and the one time that mattered was the day the provider stopped being
// required and nothing noticed.
//
// Three of the four fields have one right answer and come from the constants
// above. The key varies between deployments and is the only one that cannot
// live in this repository.
func AssistantFromEnv() AssistantConfig {
return AssistantConfig{
Provider: assistantProvider(),
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
APIKey: env("ASSISTANT_API_KEY", ""),
Fast: env("ASSISTANT_MODEL_FAST", ""),
// ASSISTANT_MODEL alone still sets every tier, for a deployment that
// wants one model everywhere but not this one.
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
Deep: env("ASSISTANT_MODEL_DEEP", ""),
}
}
// 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{ cfg := &Config{
Env: getEnv("ENV", "production"), AppEnv: env("APP_ENV", EnvLocal),
Port: getEnv("APP_PORT", "1009"), Port: env("APP_PORT", "1122"),
// ✅ STANDARDIZED DB ENV KEYS DB: DBConfig{
DBName: getEnv("DB_NAME", ""), Host: env("DB_HOST", ""),
DBUser: getEnv("DB_USER", ""), Port: env("DB_PORT", "5433"),
DBPassword: getEnv("DB_PASSWORD", ""), Name: env("DB_NAME", ""),
DBPort: getEnv("DB_PORT", "5432"), User: env("DB_USER", ""),
DBHost: getEnv("DB_HOST", "localhost"), 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"), Embedding: EmbeddingConfig{
JWTSecret: getEnv("JWT_SECRET_KEY", ""), Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
Model: env("EMBEDDING_MODEL", ""),
APIKey: env("EMBEDDING_API_KEY", ""),
BaseURL: env("EMBEDDING_BASE_URL", ""),
},
Assistant: AssistantFromEnv(),
Mail: MailFromEnv(),
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
JWTSecret: env("JWT_SECRET_KEY", ""),
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
} }
// ✅ Correct validation if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
if cfg.DBPassword == "" { cfg.Redis.DB = db
log.Println("Warning: DB_PASSWORD is not set") } 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 return cfg
} }
func getEnv(key, fallback string) string { // validate collects every problem rather than stopping at the first, so one
if v := os.Getenv(key); v != "" { // 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.
// `.env.secrets` is read FIRST and is the only one of these git does not track.
// godotenv never overwrites a value already set, so first read wins — which is
// what makes this file the place a key belongs. Every other file here is in the
// repository, so a secret written to one is a secret published; there was
// previously nowhere to put a key at all, and the answer was "export it in your
// shell every time", which is the kind of instruction people route around.
// envFileOrder is the read order, and the order is the rule: godotenv never
// overwrites a value already set, so whichever file names a variable first is
// the one that decides it.
func envFileOrder(appEnv string) []string {
return []string{".env.secrets", ".env." + appEnv, ".env"}
}
func loadEnvFiles() {
for _, name := range envFileOrder(env("APP_ENV", EnvLocal)) {
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 v
} }
return fallback return fallback
} }
func isLocalHost(host string) bool {
switch strings.ToLower(host) {
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
return true
}
return strings.HasPrefix(host, "127.")
}

251
config/config_test.go Normal file
View File

@@ -0,0 +1,251 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Every key Load reads, so a test starts from nothing rather than from
// whatever the developer's shell happens to export.
var allKeys = []string{
"APP_ENV", "APP_PORT",
"DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD",
"CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_NAME", "CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD",
"REDIS_HOST", "REDIS_PORT", "REDIS_USER", "REDIS_PASSWORD", "REDIS_DB",
"USE_S3", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",
"MQTT_URL", "MQTT_USER", "MQTT_USERNAME", "MQTT_PASSWORD", "MQTT_CLIENT_ID",
"POS_TOKEN_SECRET", "JWT_SECRET_KEY", "USER_CONTEXT_KEY", "GEOCODER_API_KEY",
"EMBEDDING_PROVIDER", "EMBEDDING_MODEL", "EMBEDDING_API_KEY", "EMBEDDING_BASE_URL", "EMBEDDING_DIMENSIONS",
}
// cleanEnv clears every setting and moves into an empty directory so no
// `.env` file is picked up by accident. t.Setenv registers the restore; the
// Unsetenv after it matters because godotenv treats a variable that is present
// but empty as set and will not fill it from a file.
func cleanEnv(t *testing.T) string {
t.Helper()
unsetAll(t)
dir := t.TempDir()
t.Chdir(dir)
return dir
}
func unsetAll(t *testing.T) {
t.Helper()
for _, k := range allKeys {
t.Setenv(k, "")
os.Unsetenv(k)
}
}
func setMainDB(t *testing.T) {
t.Helper()
t.Setenv("DB_HOST", "localhost")
t.Setenv("DB_USER", "nearle")
t.Setenv("DB_PASSWORD", "localdev")
t.Setenv("DB_NAME", "nearledb")
}
func write(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoadReportsEveryMissingRequiredKeyAtOnce(t *testing.T) {
cleanEnv(t)
_, err := Load()
if err == nil {
t.Fatal("expected an error with no database configured")
}
for _, key := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
if !strings.Contains(err.Error(), key) {
t.Errorf("error should name %s, got:\n%s", key, err)
}
}
}
func TestLoadDefaults(t *testing.T) {
cleanEnv(t)
setMainDB(t)
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.AppEnv != EnvLocal {
t.Errorf("AppEnv = %q, want local", cfg.AppEnv)
}
if cfg.IsProduction() {
t.Error("IsProduction should be false by default")
}
if cfg.Port != "1122" {
t.Errorf("Port = %q, want 1122", cfg.Port)
}
if cfg.DB.Port != "5433" {
t.Errorf("DB.Port = %q, want 5433 (the port production and the local compose share)", cfg.DB.Port)
}
if cfg.Catalogue.Enabled() || cfg.Redis.Enabled() || cfg.S3.Enabled || cfg.MQTT.Enabled() {
t.Error("optional subsystems should be off when unset")
}
if cfg.Redis.User != "default" || cfg.Redis.Port != "6379" || cfg.Redis.DB != 0 {
t.Errorf("redis defaults wrong: %+v", cfg.Redis)
}
}
func TestAppEnvSelectsTheEnvFile(t *testing.T) {
dir := cleanEnv(t)
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=local\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1122\n")
write(t, dir, ".env.production", "DB_HOST=db.internal\nDB_USER=prod\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1009\nPOS_TOKEN_SECRET=0123456789abcdef\n")
// The shared base: only fills in what the environment file left unset.
write(t, dir, ".env", "DB_USER=base\nUSER_CONTEXT_KEY=from-base\n")
t.Run("default is local", func(t *testing.T) {
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.DB.User != "local" || cfg.Port != "1122" {
t.Errorf("expected .env.local values, got user=%s port=%s", cfg.DB.User, cfg.Port)
}
if cfg.UserContextKey != "from-base" {
t.Errorf(".env should fill in what .env.local left unset, got %q", cfg.UserContextKey)
}
})
t.Run("APP_ENV=production", func(t *testing.T) {
// Clears what godotenv loaded in the sibling above; restored on return.
unsetAll(t)
t.Setenv("APP_ENV", EnvProduction)
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if !cfg.IsProduction() || cfg.DB.User != "prod" || cfg.Port != "1009" {
t.Errorf("expected .env.production values, got env=%s user=%s port=%s", cfg.AppEnv, cfg.DB.User, cfg.Port)
}
})
}
func TestRealEnvironmentBeatsTheFile(t *testing.T) {
dir := cleanEnv(t)
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=file\nDB_PASSWORD=x\nDB_NAME=nearledb\n")
t.Setenv("DB_USER", "shell")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.DB.User != "shell" {
t.Errorf("a variable already in the environment must not be overwritten by the file, got %q", cfg.DB.User)
}
}
func TestProductionRequiresASigningSecret(t *testing.T) {
cleanEnv(t)
setMainDB(t)
t.Setenv("APP_ENV", EnvProduction)
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "POS_TOKEN_SECRET") {
t.Fatalf("production without a secret should fail naming POS_TOKEN_SECRET, got %v", err)
}
t.Setenv("POS_TOKEN_SECRET", "short")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "16 characters") {
t.Fatalf("a short secret should be refused, got %v", err)
}
t.Setenv("POS_TOKEN_SECRET", "")
t.Setenv("JWT_SECRET_KEY", "a-long-enough-fallback-secret")
if _, err := Load(); err != nil {
t.Fatalf("JWT_SECRET_KEY should be accepted as the fallback, got %v", err)
}
}
func TestHalfConfiguredSubsystemsAreRefused(t *testing.T) {
cleanEnv(t)
setMainDB(t)
t.Setenv("CATALOGUE_DB_HOST", "localhost")
t.Setenv("USE_S3", "true")
t.Setenv("S3_BUCKET", "nearle")
_, err := Load()
if err == nil {
t.Fatal("expected an error")
}
for _, want := range []string{"CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME", "S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should name %s, got:\n%s", want, err)
}
}
if strings.Contains(err.Error(), "S3_BUCKET") {
t.Error("S3_BUCKET was set and must not be reported")
}
}
func TestMQTTUsernameFallback(t *testing.T) {
cleanEnv(t)
setMainDB(t)
t.Setenv("MQTT_URL", "tcp://broker:1883")
t.Setenv("MQTT_USERNAME", "legacy")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.MQTT.User != "legacy" {
t.Errorf("MQTT_USERNAME should still be honoured, got %q", cfg.MQTT.User)
}
t.Setenv("MQTT_USER", "current")
cfg, err = Load()
if err != nil {
t.Fatal(err)
}
if cfg.MQTT.User != "current" {
t.Errorf("MQTT_USER should win over MQTT_USERNAME, got %q", cfg.MQTT.User)
}
}
func TestRedisDBMustBeNumeric(t *testing.T) {
cleanEnv(t)
setMainDB(t)
t.Setenv("REDIS_DB", "zero")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "REDIS_DB") {
t.Fatalf("expected REDIS_DB error, got %v", err)
}
}
func TestEmbeddingProviderNeedsModelAndKey(t *testing.T) {
cleanEnv(t)
setMainDB(t)
t.Setenv("EMBEDDING_PROVIDER", "openai")
_, err := Load()
if err == nil || !strings.Contains(err.Error(), "EMBEDDING_MODEL") || !strings.Contains(err.Error(), "EMBEDDING_API_KEY") {
t.Fatalf("a provider without model and key should be refused naming both, got %v", err)
}
t.Setenv("EMBEDDING_PROVIDER", "cohere")
t.Setenv("EMBEDDING_MODEL", "x")
t.Setenv("EMBEDDING_API_KEY", "y")
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "EMBEDDING_PROVIDER") {
t.Fatalf("an unknown provider should be refused, got %v", err)
}
t.Setenv("EMBEDDING_PROVIDER", "Gemini")
t.Setenv("EMBEDDING_DIMENSIONS", "768")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Embedding.Provider != "gemini" || cfg.Embedding.Dimensions != 768 || !cfg.Embedding.Enabled() {
t.Fatalf("unexpected embedding config: %+v", cfg.Embedding)
}
}

153
config/mail.go Normal file
View File

@@ -0,0 +1,153 @@
package config
import (
"fmt"
"strconv"
"strings"
"unicode"
)
// Sending email.
//
// ── Why this exists at all ──────────────────────────────────────────────────
//
// A newly onboarded merchant's admin account arrives with no password, and the
// only safe way to let them set one is a signed invitation sent to the primary
// email they gave us. Until this, the server could not send email: no library,
// no configuration, and `NotifyUser` is Firebase push rather than mail.
//
// ── Shaped like AssistantConfig, for the same reasons ───────────────────────
//
// Unconfigured is a deployment choice and not a fault, so `Enabled` reports it
// and `Why` says which variable is missing. A server with no mail still boots
// and still onboards tenants — the invitation is recorded as unsent rather than
// failing the creation, because a tenant that exists and cannot be reached is
// recoverable and a tenant that was rolled back by a mail outage is confusing.
type MailConfig struct {
// SMTP, because it is the one protocol every provider speaks. A transactional
// service (SES, SendGrid, Resend) is reached the same way, with its own host
// and an API key as the password — so choosing one later is configuration
// rather than code.
Host string
Port int
Username string
Password string
// Who the invitation appears to come from. Separate from the username
// because most providers authenticate as one identity and send as another,
// and using the login as the From address is how mail ends up in spam.
FromAddress string
FromName string
// Where the invitation link points. The merchant console, always — a
// merchant sets their password there and nowhere else — and a build
// variable rather than a constant because the site can move.
ConsoleURL string
}
func (m MailConfig) Enabled() bool { return m.Why() == "" }
// Why says what is missing, or "" when mail can be sent.
//
// A sentence rather than a bool. "Off" is the same answer for five different
// mistakes, and the difference between "we have not set this up" and "somebody
// misspelled a variable" is invisible from outside — which is exactly how the
// assistant sat switched off for two days.
func (m MailConfig) Why() string {
if strings.TrimSpace(m.Host) == "" {
return "MAIL_HOST is not set, so no invitation can be sent"
}
if m.Port <= 0 {
return "MAIL_PORT is not a usable port number"
}
if strings.TrimSpace(m.FromAddress) == "" {
return "MAIL_FROM is not set; an invitation needs a sender address"
}
// Username and password are deliberately NOT required. An internal relay
// that authenticates by network is a real deployment, and demanding
// credentials would refuse it.
if strings.TrimSpace(m.ConsoleURL) == "" {
return "MAIL_CONSOLE_URL is not set; the invitation would have nowhere to point"
}
return ""
}
// Address is host:port, as the SMTP client wants it.
func (m MailConfig) Address() string { return fmt.Sprintf("%s:%d", m.Host, m.Port) }
// InviteLink is where an invitation sends somebody.
//
// Built here rather than in the mailer so the shape is decided once, beside the
// console URL it depends on. The token is the whole credential, so it is the
// only thing in the query string — never an email address or a userid, which
// would put both halves of an account into a URL that lands in server logs,
// browser history and whatever proxy sits between.
func (m MailConfig) InviteLink(token string) string {
base := strings.TrimRight(strings.TrimSpace(m.ConsoleURL), "/")
return base + "/set-password?t=" + token
}
// MailFromEnv reads the mail settings.
func MailFromEnv() MailConfig {
port, err := strconv.Atoi(strings.TrimSpace(env("MAIL_PORT", "587")))
if err != nil {
// Zero rather than the default, so `Why` reports it instead of the
// server quietly dialling a port nobody asked for.
port = 0
}
return MailConfig{
Host: env("MAIL_HOST", ""),
Port: port,
Username: env("MAIL_USERNAME", ""),
Password: smtpPassword(env("MAIL_HOST", ""), env("MAIL_PASSWORD", "")),
// A name is optional; an address is not.
FromAddress: env("MAIL_FROM", ""),
FromName: env("MAIL_FROM_NAME", "Nearle"),
ConsoleURL: env("MAIL_CONSOLE_URL", "https://app.nearledaily.com"),
}
}
/*
smtpPassword takes the spaces out of a Google App Password.
Google shows a 16-character App Password formatted for reading — "abcd efgh
ijkl mnop" — and the spaces are presentation, not part of the secret. Pasted
verbatim they survive into the credential and Gmail refuses the login, which
Fiesta reports as "the mail server refused our credentials". That sends somebody
to revoke a perfectly good password and generate another one with the same four
spaces in it.
ONLY for Google's own SMTP hosts, and only when what is left is the 16
alphanumeric characters an App Password actually is. A password is a secret and
quietly editing one is normally the wrong thing: another relay's password may
legitimately contain a space, and stripping it there would turn a working
credential into a silent authentication failure — the exact bug this avoids,
pointed the other way.
*/
func smtpPassword(host, password string) string {
if !isGoogleSMTP(host) {
return password
}
stripped := strings.Join(strings.Fields(password), "")
if stripped == password || len(stripped) != googleAppPasswordLength {
return password
}
for _, r := range stripped {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
return password
}
}
return stripped
}
// googleAppPasswordLength is what Google issues: sixteen characters, shown in
// four groups of four.
const googleAppPasswordLength = 16
func isGoogleSMTP(host string) bool {
switch strings.ToLower(strings.TrimSpace(host)) {
case "smtp.gmail.com", "smtp-relay.gmail.com", "aspmx.l.google.com":
return true
}
return false
}

95
config/mail_test.go Normal file
View File

@@ -0,0 +1,95 @@
package config
import "testing"
// Confirms docs/MAIL_SETUP.md is telling the truth about the committed `.env`:
// a sender is set, a host is not, and the server therefore reports mail OFF with
// a reason naming the variable — rather than trying and failing to send.
func TestCommittedEnvLeavesMailOffWithAReason(t *testing.T) {
t.Setenv("MAIL_HOST", "")
t.Setenv("MAIL_PORT", "587")
t.Setenv("MAIL_FROM", "care@nearledaily.com")
t.Setenv("MAIL_FROM_NAME", "Nearle")
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
cfg := MailFromEnv()
if cfg.Enabled() {
t.Fatal("mail reported as enabled with no host")
}
if cfg.Why() == "" || cfg.Why()[:9] != "MAIL_HOST" {
t.Fatalf("the reason does not name the missing variable: %q", cfg.Why())
}
// And with the Postal host supplied from .env.secrets, it comes on and the
// link points at the MERCHANT console.
t.Setenv("MAIL_HOST", "postal.nearledaily.com")
on := MailFromEnv()
if !on.Enabled() {
t.Fatalf("still off with a host set: %s", on.Why())
}
if got := on.InviteLink("i1.abc.def"); got != "https://app.nearledaily.com/set-password?t=i1.abc.def" {
t.Fatalf("the invitation would point at %q", got)
}
if on.Address() != "postal.nearledaily.com:587" {
t.Fatalf("wrong SMTP address: %q", on.Address())
}
}
/* ── Google App Passwords ────────────────────────────────────────────────── */
func TestAGoogleAppPasswordSurvivesBeingPastedWithItsSpaces(t *testing.T) {
// Google shows it as "abcd efgh ijkl mnop". The spaces are presentation.
// Pasted verbatim they reach Gmail, which refuses the login — reported as
// "the mail server refused our credentials", sending somebody to revoke a
// password that was fine.
t.Setenv("MAIL_HOST", "smtp.gmail.com")
t.Setenv("MAIL_PORT", "587")
t.Setenv("MAIL_USERNAME", "care@nearledaily.com")
t.Setenv("MAIL_PASSWORD", "abcd efgh ijkl mnop")
t.Setenv("MAIL_FROM", "care@nearledaily.com")
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
t.Fatalf("password reached the relay as %q", got)
}
}
func TestAnAlreadyCleanAppPasswordIsUntouched(t *testing.T) {
t.Setenv("MAIL_HOST", "smtp.gmail.com")
t.Setenv("MAIL_PASSWORD", "abcdefghijklmnop")
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
t.Fatalf("got %q", got)
}
}
func TestAnotherRelaysPasswordIsNeverEdited(t *testing.T) {
// A secret is a secret. Another relay's password may legitimately contain a
// space, and stripping it there turns a working credential into a silent
// authentication failure — this bug pointed the other way.
for _, host := range []string{"smtp.sendgrid.net", "email-smtp.ap-south-1.amazonaws.com", "postal.nearledaily.com"} {
t.Setenv("MAIL_HOST", host)
t.Setenv("MAIL_PASSWORD", "two words here x")
if got := MailFromEnv().Password; got != "two words here x" {
t.Errorf("%s: password was edited to %q", host, got)
}
}
}
func TestSomethingThatIsNotAnAppPasswordIsLeftAlone(t *testing.T) {
// Only the exact shape Google issues — sixteen alphanumerics — is treated
// as display formatting. Anything else is somebody's real password.
t.Setenv("MAIL_HOST", "smtp.gmail.com")
for _, password := range []string{
"short one", // not 16 after stripping
"a much longer pass phrase here", // not 16
"abcd efgh ijkl mno!", // punctuation: not an App Password
} {
t.Setenv("MAIL_PASSWORD", password)
if got := MailFromEnv().Password; got != password {
t.Errorf("%q was rewritten to %q", password, got)
}
}
}

View File

@@ -0,0 +1,193 @@
package controllers
import (
"errors"
"net/http"
"strings"
"nearle/middleware"
"nearle/services"
"nearle/services/tools"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// Nearle Buddy's HTTP surface.
//
// POST /v1/web/assistant/ask a question → an answer, and what it ran
// POST /v1/web/assistant/approve a card the person pressed → the change, made
// GET /v1/web/assistant/status is this switched on here?
//
// ── Where the caller comes from ─────────────────────────────────────────────
//
// `middleware.WebAuth` parks the verified claims on the request, and this
// builds the tool caller from those and from nothing else. There is no tenant
// field on the request body — deliberately, so there is nothing for a model or
// a caller to fill in. The console asks "what is stuck?" and the server already
// knows whose shop that means.
type AssistantController struct {
assistant services.AssistantService
}
func NewAssistantController(assistant services.AssistantService) *AssistantController {
return &AssistantController{assistant: assistant}
}
type assistantApproveRequest struct {
Agent string `json:"agent"`
// The card exactly as it was handed out. Opaque to the console — it is
// signed, and anything the browser changed stops it verifying.
Card string `json:"card"`
}
type assistantAskRequest struct {
// Which agent to ask. The console sends the one matching the page the panel
// is sitting beside; empty means orders, the only one phase 2 ships.
Agent string `json:"agent"`
Question string `json:"question"`
}
// Status lets the console decide what to render before anybody types.
//
// The composer is disabled when this says no, which is the honest thing: a
// field that accepts text and then swallows it is worse than one that says it
// is not connected. The console has shown "Not connected yet" since it was
// built, and this is what finally answers that question at runtime rather than
// at build time.
func (ctl *AssistantController) Status(c *fiber.Ctx) error {
details := fiber.Map{"available": ctl.assistant.Available()}
// Named "reason" rather than "error": not having an assistant is a
// deployment choice, and the same field answers "we have not switched it
// on" and "somebody misspelled a variable" — which are the two states that
// looked identical from outside.
if why := ctl.assistant.Unavailable(); why != "" {
details["reason"] = why
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Success", "details": details,
})
}
func (ctl *AssistantController) Ask(c *fiber.Ctx) error {
var req assistantAskRequest
if err := c.BodyParser(&req); err != nil {
return assistantRefuse(c, http.StatusBadRequest, "Invalid request body")
}
caller, ok := callerFrom(c)
if !ok {
// Reachable only while WEB_AUTH_REQUIRED is off, where an untokened
// request still reaches handlers. Every other endpoint answers such a
// request; this one must not. Reading a shop's orders through a REST
// call takes knowing the endpoints and the fields; through an
// assistant it takes one sentence, so this surface holds the higher
// bar from its first day rather than inheriting the rollout's.
return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to use Nearle Buddy.")
}
agent := strings.TrimSpace(req.Agent)
if agent == "" {
agent = "orders"
}
ctx, cancel := services.WithTimeout(c.Context())
defer cancel()
answer, err := ctl.assistant.Ask(ctx, agent, req.Question, caller)
if err != nil {
// "Not switched on here" is a deployment fact, not a fault, and it gets
// its own status so the console can disable the composer rather than
// showing an error the person can do nothing about.
if errors.Is(err, utils.ErrChatNotConfigured) {
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "status": false,
"message": "Nearle Buddy is not switched on for this deployment.",
})
}
// Asking too fast gets its own status, so the console and whatever
// watches it can tell "you are going too quickly" apart from "that
// question was malformed". The message already says how long to wait.
var tooFast services.ErrTooFast
if errors.As(err, &tooFast) {
return assistantRefuse(c, http.StatusTooManyRequests, err.Error())
}
// The provider's quota, as opposed to our own limiter above. Same status
// for the same reason — it is not a bad question, it is a busy minute —
// and the message is ours rather than Groq's, which names our billing
// account and the tokens-per-minute arithmetic behind it.
if errors.Is(err, utils.ErrBusy) {
return assistantRefuse(c, http.StatusTooManyRequests, utils.ErrBusy.Error())
}
return assistantRefuse(c, http.StatusBadRequest, err.Error())
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Success", "details": answer,
})
}
// Approve performs a change the person pressed the button on.
//
// Its own endpoint, not a flag on /ask, because it is a different kind of act:
// no question, no model, no conversation. The card names the action and the
// session names the person, and the registry re-checks both against the live
// database before anything is written.
func (ctl *AssistantController) Approve(c *fiber.Ctx) error {
var req assistantApproveRequest
if err := c.BodyParser(&req); err != nil {
return assistantRefuse(c, http.StatusBadRequest, "Invalid request body")
}
if strings.TrimSpace(req.Card) == "" {
return assistantRefuse(c, http.StatusBadRequest, "Nothing to approve.")
}
caller, ok := callerFrom(c)
if !ok {
return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to approve this.")
}
agent := strings.TrimSpace(req.Agent)
if agent == "" {
agent = "orders"
}
ctx, cancel := services.WithTimeout(c.Context())
defer cancel()
answer, err := ctl.assistant.Approve(ctx, agent, req.Card, caller)
if err != nil {
// A refused approval is a business outcome, not a server fault: the card
// expired, somebody else already approved it, the request was withdrawn.
// The person needs the reason, and the console renders it beside the
// card rather than as an error page.
return assistantRefuse(c, http.StatusConflict, err.Error())
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Success", "details": answer,
})
}
// callerFrom turns a verified session into a tool caller.
//
// The one place the two vocabularies meet. Staff (`issuperadmin`) carry no
// tenant, and the registry lets them through — but a tool that reads a shop's
// data refuses them until they have picked one, because "every tenant at once"
// is not an answer to "what is stuck?".
func callerFrom(c *fiber.Ctx) (tools.Caller, bool) {
claims, ok := middleware.WebClaimsFrom(c)
if !ok {
return tools.Caller{}, false
}
return tools.Caller{
Userid: claims.Userid,
Tenantid: claims.Tenantid,
Locationid: claims.Locationid,
Superadmin: claims.Superadmin,
}, true
}
func assistantRefuse(c *fiber.Ctx, code int, message string) error {
return c.Status(code).JSON(fiber.Map{"code": code, "status": false, "message": message})
}

View File

@@ -0,0 +1,443 @@
package controllers
import (
"context"
"encoding/json"
"hash/crc32"
"io"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"nearle/config"
"nearle/middleware"
"nearle/models"
"nearle/services"
"nearle/services/tools"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// Nearle Buddy over HTTP, through the guard, as the console reaches it.
//
// Everything else tests one layer. The service tests call `Ask` directly with a
// caller already built; the live tests talk to a real model but never touch a
// route. Neither would notice the thing most likely to break on a deploy: the
// seam where a session token becomes a tool caller.
//
// That seam has four parts, and a mistake in any one of them produces a console
// showing an empty panel and a server logging nothing —
//
// the route sits under /v1/web, so WebAuth runs at all
// WebAuth verifies the token and parks the claims
// callerFrom reads those claims rather than the request body
// the answer comes back inside `details`, where the console's client looks
//
// No database: every tool is handed a fake, so this runs in CI beside the unit
// tests. The ones that need a model skip without a key.
const testSecret = "a-test-signing-secret-of-ample-length"
var testCaller = utils.WebClaims{Userid: 904, Tenantid: 1147}
// ── the shop these tests run against ────────────────────────────────────────
type fakeShop struct {
deliveries []models.Deliveryinfo
requests []models.StockRequest
// approved records what reached the write half, so the approval test can
// assert the change happened rather than that it was described.
approved []string
}
func (f *fakeShop) GetDeliveries(models.DeliveryQuery) []models.Deliveryinfo { return f.deliveries }
func (f *fakeShop) GetStockRequests(tenantID, _ int, status, _ string, _, _ int) ([]models.StockRequest, error) {
// Honours the tenant on purpose. A fake that returned rows to anybody would
// let an ownership bug pass this test.
if tenantID != testCaller.Tenantid || !strings.EqualFold(status, "Pending") {
return nil, nil
}
return f.requests, nil
}
func (f *fakeShop) UpdateStockRequest(requestID int, status string) error {
f.approved = append(f.approved, status+" #"+strconv.Itoa(requestID))
for i := range f.requests {
if f.requests[i].Requestid == requestID {
// Drops out of the pending list, as the real update does. Without
// this, approving the same card twice would succeed twice.
f.requests = append(f.requests[:i], f.requests[i+1:]...)
break
}
}
return nil
}
// The tools these tests do not exercise still have to exist, because the
// shipped agents name them and LoadAgents refuses an agent naming a tool that
// is absent. An empty answer is the honest fake: a shop with nothing to report.
func (f *fakeShop) GetLocationOrderSummary(int) ([]models.Ordersummarylocation, error) {
return nil, nil
}
func (f *fakeShop) GetProductStocks(string, string) ([]models.Productstocks, error) {
return nil, nil
}
func (f *fakeShop) LocationHealth(context.Context, string) ([]map[string]string, error) {
return nil, nil
}
func (f *fakeShop) GetRevenueSummary(int, int, string, string) (*models.TenantRevenueSummary, error) {
return &models.TenantRevenueSummary{}, nil
}
func (f *fakeShop) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
return &models.PosSalesSummary{}, nil
}
func newShop() *fakeShop {
now := time.Now()
stamp := func(minutesAgo int) string {
return now.Add(-time.Duration(minutesAgo) * time.Minute).Format("2006-01-02 15:04:05")
}
return &fakeShop{
deliveries: []models.Deliveryinfo{
{Deliveryid: 4412, Orderid: "ORD-4412", Orderstatus: "pending", Assigntime: stamp(41),
Ridername: "Varun", Locationname: "R Mart"},
{Deliveryid: 4421, Orderid: "ORD-4421", Orderstatus: "delivered", Assigntime: stamp(200)},
},
requests: []models.StockRequest{{
Requestid: 41, Productname: "Sona Masoori rice 25kg", Qty: 12,
Locationname: "R Mart", Status: "Pending", Created: now.Add(-36 * time.Hour),
}},
}
}
// ── the server, wired the way production wires it ───────────────────────────
func buildApp(t *testing.T, chat utils.Chat) (*fiber.App, *fakeShop) {
t.Helper()
t.Setenv("POS_TOKEN_SECRET", testSecret)
shop := newShop()
corpus, err := tools.LoadHelp()
if err != nil {
t.Fatalf("help corpus: %v", err)
}
registry := tools.New(tools.DiscardAudit{})
for _, tool := range []tools.Tool{
tools.StuckOrders(shop, nil),
tools.DeliveryProgress(shop),
tools.BranchPerformance(shop),
tools.PendingApprovals(shop, nil),
tools.LowStock(shop),
tools.TillsNotSyncing(shop),
tools.SalesByChannel(shop, shop, nil),
tools.Help(corpus),
tools.ApproveStockRequest(shop, shop),
} {
if err := registry.Register(tool); err != nil {
t.Fatalf("registering %s: %v", tool.Name, err)
}
}
// The shipped agent definitions, not a hand-built stand-in. A typo in
// agents/inventory.yaml should fail here rather than on deploy.
agents, err := services.LoadAgents("", registry.Has)
if err != nil {
t.Fatalf("agents: %v", err)
}
assistant := services.NewAssistantService(registry, chat, agents)
// Mirrors facade.NewFacade: with no model, the reason the config gives is
// threaded through to the service so /status can name the missing variable.
// Built the same way here, or this would assert a string production never
// produces.
if setter, ok := assistant.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
setter.SetUnavailableReason(config.AssistantConfig{}.Why())
}
controller := NewAssistantController(assistant)
app := fiber.New()
// nil is the branch-ownership checker, consulted only when a request names
// a branch. The assistant's body names none — that is the design — so
// nothing here can reach it.
app.Use(middleware.WebAuth(nil))
web := app.Group("/live/api/v1/web")
web.Get("/assistant/status", controller.Status)
web.Post("/assistant/ask", controller.Ask)
web.Post("/assistant/approve", controller.Approve)
return app, shop
}
// webSession mints a session for THIS test's own user.
//
// One user id across the file put every test in one rate-limit bucket — six
// questions and then 429 for ten seconds — so the suite passed test by test and
// failed when run together, which is the worst way round: green locally, red in
// CI, and the failure blamed on the model.
//
// A per-test user is also the truthful shape. The limiter is per person, and
// two tests are two people.
func webSession(t *testing.T) string {
t.Helper()
claims := testCaller
// Stable across runs and distinct per test, so a failure names the same
// user every time. The fakes key on tenant, never on this.
claims.Userid = testCaller.Userid + int(crc32.ChecksumIEEE([]byte(t.Name()))%10_000)
token, _, err := utils.MintWebToken(claims, time.Now())
if err != nil {
t.Fatalf("minting a session: %v", err)
}
return token
}
// envelope is the shape every Fiesta handler answers with, and the shape the
// console's client unwraps. Asserting on it rather than on the Go struct is the
// point: a controller returning the answer at the top level would pass a
// service-level test and hand the console `undefined`.
type envelope struct {
Code int `json:"code"`
Status bool `json:"status"`
Message string `json:"message"`
Details services.AssistantAnswer `json:"details"`
}
const (
statusPath = "/live/api/v1/web/assistant/status"
askPath = "/live/api/v1/web/assistant/ask"
approvePath = "/live/api/v1/web/assistant/approve"
)
func post(t *testing.T, app *fiber.App, path, token, body string) (int, envelope, string) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
raw, _ := io.ReadAll(resp.Body)
var out envelope
_ = json.Unmarshal(raw, &out)
return resp.StatusCode, out, string(raw)
}
func quote(s string) string {
out, _ := json.Marshal(s)
return string(out)
}
// ── the guard ───────────────────────────────────────────────────────────────
func TestAnUntokenedQuestionIsRefusedOverHTTP(t *testing.T) {
// WEB_AUTH_REQUIRED defaults on now, so the middleware turns this away
// before the controller sees it. Either refusal is correct; what must never
// happen is an answer.
app, _ := buildApp(t, nil)
status, _, body := post(t, app, askPath, "", `{"agent":"orders","question":"what is stuck?"}`)
if status == fiber.StatusOK {
t.Fatalf("an untokened question was answered: %s", body)
}
if status != fiber.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", status, body)
}
}
func TestATamperedTokenIsRefusedOverHTTP(t *testing.T) {
app, _ := buildApp(t, nil)
// Three characters at the end — the edit somebody would actually attempt.
broken := webSession(t)
broken = broken[:len(broken)-3] + "AAA"
status, _, body := post(t, app, askPath, broken, `{"question":"what is stuck?"}`)
if status != fiber.StatusUnauthorized {
t.Fatalf("a tampered session was not refused: %d %s", status, body)
}
}
func TestStatusNamesTheMissingVariable(t *testing.T) {
// Why the field exists: "available: false" alone is the same answer for "we
// have not switched it on" and "somebody misspelled a variable", and those
// need different actions from whoever is looking.
app, _ := buildApp(t, nil)
req := httptest.NewRequest("GET", statusPath, nil)
req.Header.Set("Authorization", "Bearer "+webSession(t))
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("status: %v", err)
}
raw, _ := io.ReadAll(resp.Body)
var out struct {
Details struct {
Available bool `json:"available"`
Reason string `json:"reason"`
} `json:"details"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("status is not the envelope the console unwraps: %s", raw)
}
if out.Details.Available {
t.Fatal("reported available with no model configured")
}
if out.Details.Reason == "" {
t.Fatalf("said no without saying why: %s", raw)
}
// Names the variable, not merely the symptom. "no assistant model is
// configured" is what the service says on its own, and it is the answer
// that left this switched off without anybody being able to tell which
// variable was wrong.
if !strings.Contains(out.Details.Reason, "ASSISTANT_") {
t.Fatalf("the reason names no variable to go and set: %q", out.Details.Reason)
}
t.Logf("reason: %s", out.Details.Reason)
}
// ── the live path ───────────────────────────────────────────────────────────
func liveHTTPChat(t *testing.T) utils.Chat {
t.Helper()
// Read exactly as production reads it, so this proves the shipped defaults
// work rather than quietly testing a configuration of its own.
cfg := config.AssistantFromEnv()
if !cfg.Enabled() {
t.Skipf("no model configured: %s", cfg.Why())
}
chat, err := utils.NewChat(cfg)
if err != nil || chat == nil {
t.Skipf("gateway not built: %v", err)
}
return chat
}
func TestLiveAQuestionAnswersThroughTheWholeStack(t *testing.T) {
app, _ := buildApp(t, liveHTTPChat(t))
status, out, body := post(t, app, askPath, webSession(t),
`{"agent":"orders","question":"Which orders are stuck?"}`)
if status != fiber.StatusOK {
t.Fatalf("HTTP %d: %s", status, body)
}
if !out.Status {
t.Fatalf("envelope says failure: %s", out.Message)
}
// Inside `details`, where the console's client reads. A correct answer at
// the top level is still a broken console.
if strings.TrimSpace(out.Details.Reply) == "" {
t.Fatalf("no reply in details: %s", body)
}
if len(out.Details.Used) == 0 {
t.Fatalf("answered without running a tool — it invented it: %s", out.Details.Reply)
}
t.Logf("used: %+v", out.Details.Used)
t.Logf("reply: %s", out.Details.Reply)
}
func TestLiveTheAnswerIsScopedToTheSessionsTenant(t *testing.T) {
// The claim the whole design rests on. The request body carries no tenant,
// so rows can only be reached through the token — and a session whose shop
// has nothing pending must not be handed a list.
app, shop := buildApp(t, liveHTTPChat(t))
shop.requests = nil
status, out, body := post(t, app, askPath, webSession(t),
`{"agent":"inventory","question":"What stock requests are waiting for approval?"}`)
if status != fiber.StatusOK {
t.Fatalf("HTTP %d: %s", status, body)
}
if strings.Contains(out.Details.Reply, "Sona Masoori") {
t.Fatalf("named a row this session cannot see: %s", out.Details.Reply)
}
t.Logf("reply: %s", out.Details.Reply)
}
// ── the approval card, end to end ───────────────────────────────────────────
func TestLiveAnApprovalCardRoundTripsAndWrites(t *testing.T) {
// The one path that has never run whole. The model proposes, the card comes
// back signed, the console sends it in unchanged, and only then does
// anything change. Each half has unit tests; this is the join.
app, shop := buildApp(t, liveHTTPChat(t))
token := webSession(t)
status, out, body := post(t, app, askPath, token,
`{"agent":"inventory","question":"Approve stock request 41."}`)
if status != fiber.StatusOK {
t.Fatalf("asking: HTTP %d: %s", status, body)
}
if out.Details.Awaiting == nil {
t.Fatalf("no approval card came back — nothing to press: %s", out.Details.Reply)
}
card := out.Details.Awaiting.Card
t.Logf("card: %s", out.Details.Awaiting.Summary)
// Nothing may have happened yet. A write at proposal time is the failure
// the whole two-step exists to prevent.
if len(shop.approved) != 0 {
t.Fatalf("the change was made before anybody agreed to it: %v", shop.approved)
}
status, done, body := post(t, app, approvePath, token,
`{"agent":"inventory","card":`+quote(card)+`}`)
if status != fiber.StatusOK {
t.Fatalf("approving: HTTP %d: %s", status, body)
}
if len(shop.approved) != 1 || shop.approved[0] != "Approved #41" {
t.Fatalf("the write did not reach the service: %v", shop.approved)
}
t.Logf("after approval: %s", done.Details.Reply)
// Pressing twice must not approve twice. The card still verifies; the row
// is no longer pending, and the re-check at execute time is what notices.
status, _, _ = post(t, app, approvePath, token,
`{"agent":"inventory","card":`+quote(card)+`}`)
if status == fiber.StatusOK {
t.Fatal("the same card approved the same request twice")
}
if len(shop.approved) != 1 {
t.Fatalf("a second write got through: %v", shop.approved)
}
}
func TestAForgedCardIsRefused(t *testing.T) {
// No model needed: a card that does not verify must be refused before
// anything reads what it claims.
app, shop := buildApp(t, nil)
status, _, body := post(t, app, approvePath, webSession(t),
`{"agent":"inventory","card":"w1.bm90LWEtcmVhbC1jYXJk.c2lnbmF0dXJl"}`)
if status == fiber.StatusOK {
t.Fatalf("a forged card was accepted: %s", body)
}
if len(shop.approved) != 0 {
t.Fatalf("a forged card changed something: %v", shop.approved)
}
}

View File

@@ -0,0 +1,146 @@
package controllers
import (
"net/http"
"strconv"
"github.com/gofiber/fiber/v2"
"nearle/models"
"nearle/services"
)
type DeliverySlotController struct {
service services.DeliverySlotService
}
func NewDeliverySlotController(service services.DeliverySlotService) *DeliverySlotController {
return &DeliverySlotController{service: service}
}
/*
GET /v1/web/deliveryslots?tenantid&locationid
Everything a branch has configured, active or not, for the console's editor.
A branch that has set nothing returns an empty list — see the note on Available
about why that is never an error.
*/
func (ctl *DeliverySlotController) ListDeliverySlots(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
locationID, _ := strconv.Atoi(c.Query("locationid"))
if tenantID <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
slots, err := ctl.service.ListForBranch(tenantID, locationID)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError,
"message": err.Error(),
"status": false,
})
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": slots,
})
}
/*
PUT /v1/web/deliveryslots
The branch's windows, all three together rather than one at a time: they are
edited as a set on one screen, and sending them together is what lets the
service reject the whole edit when one row is wrong instead of applying part of
it.
A business objection — an unreadable time, a window ending before it starts,
the same key twice — is 409 and not 500. It is an answer about the request, and
the message is written to be shown to the person who typed it.
*/
func (ctl *DeliverySlotController) SaveDeliverySlots(c *fiber.Ctx) error {
var req struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Slots []models.DeliverySlots `json:"slots"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "Invalid request body",
"status": false,
})
}
if req.Tenantid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
if err := ctl.service.Save(req.Tenantid, req.Locationid, req.Slots); err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict,
"message": err.Error(),
"status": false,
})
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"message": "Successfully Updated",
"status": true,
})
}
/*
GET /v1/mob/deliveryslots/available?tenantid&locationid
What the shopper may pick, already filtered and dated. The app renders this list
and does no time arithmetic of its own — see the service for why one clock has
to be authoritative.
An empty list is 200 with `details: []`, NOT an error. It means this branch has
set no windows, which is the state every shop is in today, and the app is
required to fall back to ordering without one. Returning 404 here would turn an
ordinary shop into a broken one.
*/
func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
locationID, _ := strconv.Atoi(c.Query("locationid"))
if tenantID <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
slots, err := ctl.service.Available(tenantID, locationID)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError,
"message": err.Error(),
"status": false,
})
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": slots,
})
}

View File

@@ -0,0 +1,113 @@
package controllers
import (
"net/http"
"runtime/debug"
"strings"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// What is running here, and is it wired up?
//
// ── Why this exists ─────────────────────────────────────────────────────────
//
// On 2026-09-24 the assistant sat switched off in production for most of a day,
// and neither of us could establish WHY from outside the container. Two
// questions had no answer:
//
// 1. which build is deployed? A redeploy can reuse a cached image, so
// "I pushed it" and "it is running" are different facts.
// 2. does the server have a model? `/assistant/status` knows, but it sits
// behind the session guard, and a 401 from `/v1/web` proves nothing —
// the middleware answers before routing, so a route that does not exist
// returns exactly the same 401 as one that does.
//
// Every diagnosis that day was guesswork for want of one request. Hours went
// into probing CORS headers and comparing nginx versions to infer a commit,
// which is what people do when a server will not simply say.
//
// ── What it deliberately does not say ───────────────────────────────────────
//
// Booleans, never values. "The assistant has a model" is operational; WHICH
// model, at which endpoint, under which key is not, and the reason string on
// `/assistant/status` names environment variables — that stays behind the
// guard. Nothing here distinguishes a tenant, so there is nothing to scope.
//
// Unauthenticated on purpose. A health check that needs a credential cannot be
// used by the person trying to work out why credentials are not working, and
// that is precisely when it is wanted.
type HealthController struct {
assistant services.AssistantService
// hasDatabase is a construction-time fact, not a live ping. A query per
// health check is a query per uptime probe, and "configured" is the thing
// that actually differs between a broken deployment and a working one.
hasDatabase bool
}
func NewHealthController(assistant services.AssistantService, hasDatabase bool) *HealthController {
return &HealthController{assistant: assistant, hasDatabase: hasDatabase}
}
// Version is stamped at build time:
//
// go build -ldflags "-X nearle/controllers.Version=$(git rev-parse --short HEAD)"
//
// Left as "unknown" when nothing stamps it, which is honest — and itself worth
// seeing, because it means the image was not built by the pipeline that does.
var Version = "unknown"
// buildVersion falls back to whatever the toolchain recorded.
//
// `debug.ReadBuildInfo` carries the VCS revision for a build made inside a git
// checkout, so even an image built by hand usually knows its own commit. The
// ldflag is preferred because a Docker build copies the tree without `.git`.
func buildVersion() string {
if Version != "unknown" && strings.TrimSpace(Version) != "" {
return Version
}
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" && setting.Value != "" {
if len(setting.Value) > 7 {
return setting.Value[:7]
}
return setting.Value
}
}
return "unknown"
}
func (ctl *HealthController) Health(c *fiber.Ctx) error {
assistant := false
if ctl.assistant != nil {
assistant = ctl.assistant.Available()
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Success",
"details": fiber.Map{
"version": buildVersion(),
// Can this server issue console sessions at all?
//
// `attachWebSession` logs a minting failure and lets the login
// succeed without a token, so a server with no signing secret hands
// out sessions that cannot authenticate: the console renders, and
// every request after it comes back 401 with no `authorization`
// header on it. False here is that, stated once, instead of found
// by reading request headers on a Friday morning.
"sessions": utils.WebTokenConfigured(),
// True when a model is configured and the assistant can answer. False
// is the answer to "I set the key and redeployed, did it take?" —
// which took a day to establish without it.
"assistant": assistant,
"database": ctl.hasDatabase,
},
})
}

194
controllers/health_test.go Normal file
View File

@@ -0,0 +1,194 @@
package controllers
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"nearle/services"
"nearle/services/tools"
"github.com/gofiber/fiber/v2"
)
/*
A server that can say what it is.
This exists because of a day spent unable to answer two questions about a
running deployment: which build is it, and does the assistant have a model. Both
were knowable inside the container and neither was reachable from outside —
`/assistant/status` sits behind the session guard, and a 401 from `/v1/web`
proves nothing, because the middleware answers before routing and a route that
does not exist returns the same 401 as one that does.
So the tests that matter here are about what it answers WITHOUT a session, and
about what it refuses to include.
*/
func healthApp(t *testing.T, assistantReady bool, hasDatabase bool) *fiber.App {
t.Helper()
app := fiber.New()
controller := NewHealthController(stubAssistant{ready: assistantReady}, hasDatabase)
app.Get("/live/api/v1/health", controller.Health)
return app
}
func readHealth(t *testing.T, app *fiber.App) (int, map[string]any, string) {
t.Helper()
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
if err != nil {
t.Fatalf("health: %v", err)
}
raw, _ := io.ReadAll(resp.Body)
var envelope struct {
Details map[string]any `json:"details"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("not the envelope the console unwraps: %s", raw)
}
return resp.StatusCode, envelope.Details, string(raw)
}
func TestHealthAnswersWithoutASession(t *testing.T) {
// The point. A health check that needs a credential cannot be used by the
// person working out why credentials are not working — which is exactly
// when somebody reaches for it.
status, details, body := readHealth(t, healthApp(t, true, true))
if status != fiber.StatusOK {
t.Fatalf("HTTP %d without a session: %s", status, body)
}
if details["version"] == nil {
t.Fatalf("no build id: %s", body)
}
}
func TestHealthSaysWhetherTheAssistantHasAModel(t *testing.T) {
// "I set the key and redeployed — did it take?" took a day to answer. This
// is that answer, in one unauthenticated request.
_, ready, _ := readHealth(t, healthApp(t, true, true))
if ready["assistant"] != true {
t.Fatalf("a configured assistant reported as %v", ready["assistant"])
}
_, off, body := readHealth(t, healthApp(t, false, true))
if off["assistant"] != false {
t.Fatalf("an unconfigured assistant reported as %v: %s", off["assistant"], body)
}
}
func TestHealthNeverLeaksTheConfiguration(t *testing.T) {
// Booleans, never values. WHICH model, at which endpoint, under which key is
// not operational information, and the `reason` string on /assistant/status
// names environment variables — that stays behind the guard.
_, _, body := readHealth(t, healthApp(t, false, true))
for _, secret := range []string{
"ASSISTANT_", "api.groq.com", "gsk_", "openai/gpt-oss", "POS_TOKEN", "password",
} {
if strings.Contains(strings.ToLower(body), strings.ToLower(secret)) {
t.Fatalf("%q is exposed on an unauthenticated endpoint: %s", secret, body)
}
}
}
func TestHealthSurvivesAServerWithNothingWiredUp(t *testing.T) {
// A deployment with no database and no model must still ANSWER. This is the
// state in which somebody is most likely to ask, and a 500 here would leave
// them exactly where they started.
app := fiber.New()
app.Get("/live/api/v1/health", NewHealthController(nil, false).Health)
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
if err != nil {
t.Fatalf("health: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("a bare server could not report its own health: HTTP %d", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
var envelope struct {
Details map[string]any `json:"details"`
}
_ = json.Unmarshal(raw, &envelope)
if envelope.Details["assistant"] != false || envelope.Details["database"] != false {
t.Fatalf("a bare server claimed to be wired up: %s", raw)
}
}
func TestAnUnstampedBuildSaysSoRatherThanGuessing(t *testing.T) {
// "unknown" is informative: it means nothing stamped the image, so the
// version cannot be trusted to date it. Inventing one would be worse than
// admitting it.
original := Version
Version = "unknown"
defer func() { Version = original }()
got := buildVersion()
// Either the toolchain recorded a revision, or it says unknown. What it must
// not do is return an empty string, which renders as a blank field and reads
// like the endpoint is broken.
if strings.TrimSpace(got) == "" {
t.Fatal("the build id is blank")
}
}
func TestAStampedBuildIsReported(t *testing.T) {
original := Version
Version = "abc1234"
defer func() { Version = original }()
_, details, body := readHealth(t, healthApp(t, true, true))
if details["version"] != "abc1234" {
t.Fatalf("the stamped build id was not reported: %s", body)
}
}
// stubAssistant is only ever asked one question.
type stubAssistant struct{ ready bool }
func (s stubAssistant) Available() bool { return s.ready }
func (s stubAssistant) Unavailable() string { return "" }
func (s stubAssistant) Ask(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) {
return services.AssistantAnswer{}, nil
}
func (s stubAssistant) Approve(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) {
return services.AssistantAnswer{}, nil
}
func TestHealthSaysWhetherSessionsCanBeIssued(t *testing.T) {
// The failure this exists for: `attachWebSession` logs a minting failure and
// lets the login succeed anyway, so a server with no signing secret issues
// sessions that cannot authenticate. The console renders, every request
// after it 401s with no `authorization` header, and nothing says why.
t.Setenv("POS_TOKEN_SECRET", "")
t.Setenv("JWT_SECRET_KEY", "")
_, broken, body := readHealth(t, healthApp(t, true, true))
if broken["sessions"] != false {
t.Fatalf("a server that cannot sign a session claimed it could: %s", body)
}
t.Setenv("POS_TOKEN_SECRET", "a-secret-of-quite-sufficient-length")
_, working, _ := readHealth(t, healthApp(t, true, true))
if working["sessions"] != true {
t.Fatal("a server with a signing secret reported it could not issue sessions")
}
}
func TestHealthDoesNotLeakTheSigningSecret(t *testing.T) {
// A boolean about the secret, never the secret.
t.Setenv("POS_TOKEN_SECRET", "correct-horse-battery-staple")
_, _, body := readHealth(t, healthApp(t, true, true))
if strings.Contains(body, "correct-horse") {
t.Fatalf("the signing secret is on an unauthenticated endpoint: %s", body)
}
}

View File

@@ -0,0 +1,270 @@
package controllers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"nearle/services"
"nearle/services/tools"
"github.com/gofiber/fiber/v2"
)
// The MCP door.
//
// A second way into the same registry. An outside client — Claude Desktop, an
// IDE, another service — speaks Model Context Protocol and reaches exactly the
// tools Nearle Buddy reaches, through exactly the same checks.
//
// ── Why it is a door and not a second implementation ────────────────────────
//
// `tools/list` is `Registry.Definitions`, and `tools/call` is `Registry.Call`.
// Nothing here knows what a tool does, what a tenant is, or how a scope is
// enforced. If this file grew its own idea of any of those, the two doors would
// drift and one of them would be the unguarded one — which is the usual way a
// system with two entrances ends up with one that skips the checks.
//
// ── The session is the same session ─────────────────────────────────────────
//
// Mounted under `/v1/web`, so `middleware.WebAuth` has already verified a
// console token and parked the claims before this runs. There is no second
// credential and no API key: whoever holds a console session gets exactly what
// that session gets, and somebody with no session gets nothing.
//
// ── Read-only, deliberately ─────────────────────────────────────────────────
//
// Write tools are filtered out of both `tools/list` and `tools/call`. A write
// resolves into an approval card, and the card is a thing a PERSON reads in the
// console — the quantity, the branch, the id — before pressing a button. An MCP
// client has no way to render that, and handing it a card to approve on its own
// would turn a human gate into a JSON field. So the door offers the reads and
// says plainly that changes happen in the console.
type MCPController struct {
registry *tools.Registry
agents map[string]services.Agent
assistant services.AssistantService
}
func NewMCPController(registry *tools.Registry, agents map[string]services.Agent) *MCPController {
return &MCPController{registry: registry, agents: agents}
}
// The protocol version this speaks. Sent back on initialize so a client that
// expects something else can say so rather than failing later on a shape it
// did not anticipate.
const mcpProtocolVersion = "2024-11-05"
/* ── JSON-RPC 2.0 ──────────────────────────────────────────────────────── */
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// The JSON-RPC codes this uses. Only the ones with a real meaning here — a
// server that returns -32603 for everything tells a client nothing.
const (
rpcParseError = -32700
rpcInvalidRequest = -32600
rpcMethodNotFound = -32601
rpcInvalidParams = -32602
rpcInternalError = -32603
)
func rpcOK(c *fiber.Ctx, id json.RawMessage, result any) error {
// HTTP 200 even for a JSON-RPC error, which is the protocol's own
// convention: the transport succeeded, and the error is in the envelope.
return c.Status(http.StatusOK).JSON(rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
}
func rpcFail(c *fiber.Ctx, id json.RawMessage, code int, message string) error {
return c.Status(http.StatusOK).JSON(rpcResponse{
JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: message},
})
}
/* ── The endpoint ──────────────────────────────────────────────────────── */
// Handle serves one JSON-RPC request.
func (ctl *MCPController) Handle(c *fiber.Ctx) error {
var req rpcRequest
if err := json.Unmarshal(c.Body(), &req); err != nil {
return rpcFail(c, nil, rpcParseError, "that is not valid JSON")
}
if req.Method == "" {
return rpcFail(c, req.ID, rpcInvalidRequest, "no method")
}
// A notification — a request with no id — expects no response at all.
// `initialized` is the one every client sends after the handshake, and
// answering it with a result is a protocol error on our side.
if len(req.ID) == 0 {
return c.SendStatus(http.StatusAccepted)
}
caller, ok := callerFrom(c)
if !ok {
return rpcFail(c, req.ID, rpcInvalidRequest,
"this door needs a console session; sign in to Nearle and use that token")
}
switch req.Method {
case "initialize":
return rpcOK(c, req.ID, fiber.Map{
"protocolVersion": mcpProtocolVersion,
// Tools only. No resources, no prompts, no sampling — claiming a
// capability this does not have makes a client fail on a call that
// looked supported.
"capabilities": fiber.Map{"tools": fiber.Map{}},
"serverInfo": fiber.Map{"name": "nearle", "version": "1"},
"instructions": "Read-only access to this merchant's own shop data. " +
"Changes are made in the Nearle console, where they are confirmed by a person.",
})
case "tools/list":
return rpcOK(c, req.ID, fiber.Map{"tools": ctl.list(c)})
case "tools/call":
return ctl.call(c, req, caller)
default:
return rpcFail(c, req.ID, rpcMethodNotFound, "this server does not do "+req.Method)
}
}
// list is Definitions, with writes removed and the key renamed.
//
// MCP spells it `inputSchema`; the registry speaks `input_schema` because that
// is what reads clearly and what the model gateway already converts from. The
// rename happens here rather than in the registry so neither door dictates the
// other's vocabulary.
func (ctl *MCPController) list(c *fiber.Ctx) []fiber.Map {
agent := ctl.agentFor(c)
defined := ctl.registry.Definitions(tools.Agent{Name: agent.Name, Tools: agent.Tools})
out := make([]fiber.Map, 0, len(defined))
for _, definition := range defined {
name, _ := definition["name"].(string)
// A write is not described at all, rather than described and refused.
// A client told about a tool it will always be denied reads that as the
// server malfunctioning.
if ctl.isWrite(name) {
continue
}
out = append(out, fiber.Map{
"name": definition["name"],
"description": definition["description"],
"inputSchema": definition["input_schema"],
})
}
return out
}
func (ctl *MCPController) call(c *fiber.Ctx, req rpcRequest, caller tools.Caller) error {
var params struct {
Name string `json:"name"`
Args map[string]any `json:"arguments"`
}
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &params); err != nil {
return rpcFail(c, req.ID, rpcInvalidParams, "arguments are not valid JSON")
}
}
if strings.TrimSpace(params.Name) == "" {
return rpcFail(c, req.ID, rpcInvalidParams, "no tool named")
}
// Checked before the registry sees it. The registry would refuse a write
// anyway — it returns a proposal rather than performing one — but a card
// handed to a client with nothing to render it is worse than a plain "not
// here", and this keeps the two doors' answers honest about why.
if ctl.isWrite(params.Name) {
return rpcFail(c, req.ID, rpcInvalidParams,
params.Name+" changes data, and changes are confirmed by a person in the Nearle console")
}
agent := ctl.agentFor(c)
ctx, cancel := services.WithTimeout(c.Context())
defer cancel()
result, err := ctl.registry.Call(ctx, tools.Agent{Name: agent.Name, Tools: agent.Tools},
params.Name, params.Args, caller)
if err != nil {
// A refusal is returned as a tool result with `isError`, not as a
// JSON-RPC error. The distinction is the protocol's: a transport fault
// is an RPC error, and "that tool needs a branch" is an answer the
// client should show its user.
if errors.Is(err, tools.ErrUnknownTool) || errors.Is(err, tools.ErrNotAllowed) {
return rpcFail(c, req.ID, rpcMethodNotFound, err.Error())
}
return rpcOK(c, req.ID, fiber.Map{
"isError": true,
"content": []fiber.Map{{"type": "text", "text": err.Error()}},
})
}
// The rows go back as JSON text, which is what MCP carries and what a model
// on the other end reads most reliably. `note` and `covers` ride alongside
// rather than inside, so an instruction about truncation cannot be mistaken
// for a row.
payload := fiber.Map{"rows": result.Rows, "count": result.Count}
if result.Scope != "" {
payload["covers"] = result.Scope
}
if result.Truncated {
payload["truncated"] = true
}
if result.Note != "" {
payload["note"] = result.Note
}
if result.Source != "" {
payload["see"] = result.Source
}
encoded, err := json.Marshal(payload)
if err != nil {
return rpcFail(c, req.ID, rpcInternalError, "the result could not be encoded")
}
return rpcOK(c, req.ID, fiber.Map{
"content": []fiber.Map{{"type": "text", "text": string(encoded)}},
})
}
// isWrite reports whether a tool changes anything.
func (ctl *MCPController) isWrite(name string) bool {
tool, ok := ctl.registry.Tool(name)
return ok && tool.Scope == tools.ScopeWrite
}
// agentFor picks which agent's allow-list applies.
//
// An MCP client has no page to sit beside, so there is no route to read one
// from. It gets `console` — the broadest of the read agents, matching what a
// person sees on the overview — and it is still an allow-list rather than
// "every tool": a door with no agent at all would be wider than any of the ones
// the console offers.
func (ctl *MCPController) agentFor(*fiber.Ctx) services.Agent {
if agent, ok := ctl.agents["console"]; ok {
return agent
}
// Named rather than defaulted to everything: a deployment whose agent files
// do not define `console` gets a door that lists nothing, which is visible,
// rather than one that offers the lot.
return services.Agent{Name: "mcp"}
}

306
controllers/mcp_test.go Normal file
View File

@@ -0,0 +1,306 @@
package controllers
import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"nearle/middleware"
"nearle/services"
"nearle/services/tools"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// The MCP door, held to the same rules as the console's.
//
// The point of these is not that JSON-RPC is spelled correctly — it is that a
// second entrance did not arrive with its own, looser idea of who may read what.
func readTool(name string) tools.Tool {
return tools.Tool{
Name: name,
Description: "a read tool with a description long enough to choose by, for testing",
Scope: tools.ScopeRead,
Schema: tools.Schema{Fields: []tools.Field{{
Name: "limit", Description: "how many", Kind: tools.KindInt, Min: 1, Max: 50, Default: 10,
}}},
Handler: func(_ context.Context, req tools.Request) (tools.Result, error) {
return tools.Result{
Rows: []map[string]any{{"id": 1}}, Count: 1,
Scope: "all branches", Source: "/admin/dispatch",
}, nil
},
}
}
func writeToolFor(t *testing.T, name string) tools.Tool {
t.Helper()
return tools.WriteTool(
tools.Tool{
Name: name,
Description: "a write tool with a description long enough to choose by, for testing",
Schema: tools.Schema{},
},
func(context.Context, tools.Request) (tools.Proposal, error) {
return tools.Proposal{Summary: "change something"}, nil
},
func(context.Context, tools.Request) (tools.Result, error) {
t.Fatal("a write executed through the MCP door")
return tools.Result{}, nil
})
}
// mcpApp mounts the door with a session already verified, as WebAuth would.
func mcpApp(t *testing.T, claims *utils.WebClaims, toolset ...tools.Tool) *fiber.App {
t.Helper()
registry := tools.New(nil)
names := make([]string, 0, len(toolset))
for _, tool := range toolset {
if err := registry.Register(tool); err != nil {
t.Fatalf("registering %s: %v", tool.Name, err)
}
names = append(names, tool.Name)
}
agents := map[string]services.Agent{"console": {Name: "console", Tools: names}}
ctl := NewMCPController(registry, agents)
app := fiber.New()
app.Post("/mcp", func(c *fiber.Ctx) error {
if claims != nil {
c.Locals(middleware.WebLocalsKey, *claims)
}
return ctl.Handle(c)
})
return app
}
func rpc(t *testing.T, app *fiber.App, body string) map[string]any {
t.Helper()
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("calling: %v", err)
}
if resp.StatusCode == fiber.StatusAccepted {
return nil
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decoding: %v", err)
}
return out
}
var session = &utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172}
/* ── The handshake ─────────────────────────────────────────────────────── */
func TestInitializeClaimsOnlyWhatItCanDo(t *testing.T) {
// Claiming a capability this does not have makes a client fail later, on a
// call that looked supported.
app := mcpApp(t, session, readTool("stuck"))
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"initialize"}`)
result, _ := out["result"].(map[string]any)
caps, _ := result["capabilities"].(map[string]any)
if _, ok := caps["tools"]; !ok {
t.Fatalf("tools not offered: %v", caps)
}
for _, unsupported := range []string{"resources", "prompts", "sampling"} {
if _, claimed := caps[unsupported]; claimed {
t.Fatalf("claimed %q, which this server does not do", unsupported)
}
}
}
func TestANotificationGetsNoResponse(t *testing.T) {
// `initialized` arrives with no id after every handshake. Answering it with
// a result is a protocol error on our side.
app := mcpApp(t, session, readTool("stuck"))
if out := rpc(t, app, `{"jsonrpc":"2.0","method":"notifications/initialized"}`); out != nil {
t.Fatalf("a notification was answered: %v", out)
}
}
func TestAnUnknownMethodIsRefusedByName(t *testing.T) {
app := mcpApp(t, session, readTool("stuck"))
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"resources/list"}`)
rpcErr, _ := out["error"].(map[string]any)
if rpcErr == nil {
t.Fatalf("an unsupported method succeeded: %v", out)
}
if !strings.Contains(rpcErr["message"].(string), "resources/list") {
t.Fatalf("the refusal does not say what was asked for: %v", rpcErr)
}
}
/* ── The same door, the same guard ─────────────────────────────────────── */
func TestNoSessionMeansNoTools(t *testing.T) {
// There is no API key and no second credential. Whoever holds a console
// session gets what that session gets; somebody with none gets nothing.
app := mcpApp(t, nil, readTool("stuck"))
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`)
if out["error"] == nil {
t.Fatalf("an unauthenticated call was answered: %v", out)
}
}
func TestTheDoorOffersOnlyTheAgentsAllowList(t *testing.T) {
// The registry's allow-list, not a second one written here.
registry := tools.New(nil)
_ = registry.Register(readTool("stuck"))
_ = registry.Register(readTool("secret"))
agents := map[string]services.Agent{"console": {Name: "console", Tools: []string{"stuck"}}}
ctl := NewMCPController(registry, agents)
app := fiber.New()
app.Post("/mcp", func(c *fiber.Ctx) error {
c.Locals(middleware.WebLocalsKey, *session)
return ctl.Handle(c)
})
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
result, _ := out["result"].(map[string]any)
listed, _ := result["tools"].([]any)
if len(listed) != 1 {
t.Fatalf("the door listed %d tools, not the agent's one", len(listed))
}
// And calling the one it did not list is refused.
denied := rpc(t, app, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"secret"}}`)
if denied["error"] == nil {
t.Fatalf("a tool off the allow-list was callable: %v", denied)
}
}
func TestTheCallerComesFromTheSessionNotTheRequest(t *testing.T) {
// Same property as the console door: the model, or whatever is driving this
// client, has no say in whose data is read.
var seen tools.Caller
tool := readTool("stuck")
tool.Handler = func(_ context.Context, req tools.Request) (tools.Result, error) {
seen = req.Caller
return tools.Result{Count: 0, Scope: "all branches"}, nil
}
app := mcpApp(t, session, tool)
rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"tenantid":916}}}`)
if seen.Tenantid != 1147 {
t.Fatalf("the tool ran for tenant %d", seen.Tenantid)
}
}
/* ── Read-only ─────────────────────────────────────────────────────────── */
func TestAWriteIsNotEvenListed(t *testing.T) {
// Described and then refused reads to a client as the server malfunctioning.
app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something"))
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
result, _ := out["result"].(map[string]any)
for _, listed := range result["tools"].([]any) {
entry, _ := listed.(map[string]any)
if entry["name"] == "change_something" {
t.Fatal("a write tool was offered over MCP")
}
}
}
func TestAWriteCannotBeCalledAndTheRefusalSaysWhere(t *testing.T) {
// The write's execute half fails the test if it runs. The refusal has to
// point somewhere useful, or a person is stuck.
app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something"))
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"change_something"}}`)
rpcErr, _ := out["error"].(map[string]any)
if rpcErr == nil {
t.Fatalf("a write was accepted over MCP: %v", out)
}
if !strings.Contains(rpcErr["message"].(string), "console") {
t.Fatalf("the refusal does not say where changes happen: %v", rpcErr)
}
}
/* ── Results ───────────────────────────────────────────────────────────── */
func TestAResultCarriesItsRowsAndItsCaveats(t *testing.T) {
tool := readTool("stuck")
tool.Handler = func(context.Context, tools.Request) (tools.Result, error) {
return tools.Result{
Rows: []map[string]any{{"id": 1}}, Count: 60, Truncated: true,
Note: "60 jobs are waiting; the 50 longest are listed.",
Scope: "all branches", Source: "/admin/dispatch",
}, nil
}
app := mcpApp(t, session, tool)
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`)
result, _ := out["result"].(map[string]any)
content, _ := result["content"].([]any)
first, _ := content[0].(map[string]any)
text, _ := first["text"].(string)
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
t.Fatalf("the content is not JSON: %v", err)
}
for _, want := range []string{"rows", "count", "covers", "truncated", "note", "see"} {
if _, ok := payload[want]; !ok {
t.Fatalf("the result dropped %q: %v", want, payload)
}
}
}
func TestARefusedToolIsAResultNotATransportError(t *testing.T) {
// The protocol's own distinction: a transport fault is an RPC error, and
// "that tool needs a branch" is an answer the client should show its user.
app := mcpApp(t, session, readTool("stuck"))
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"limit":999}}}`)
if out["error"] != nil {
t.Fatalf("a bad argument was reported as a transport fault: %v", out["error"])
}
result, _ := out["result"].(map[string]any)
if result["isError"] != true {
t.Fatalf("a refusal was reported as success: %v", result)
}
}
func TestTheSchemaIsSpelledTheWayMCPExpects(t *testing.T) {
// The registry says `input_schema`; MCP says `inputSchema`. The rename lives
// at the door so neither side dictates the other's vocabulary.
app := mcpApp(t, session, readTool("stuck"))
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
result, _ := out["result"].(map[string]any)
first, _ := result["tools"].([]any)[0].(map[string]any)
if _, ok := first["inputSchema"]; !ok {
t.Fatalf("no inputSchema on a listed tool: %v", first)
}
if _, stillSnake := first["input_schema"]; stillSnake {
t.Fatal("the registry's spelling leaked through the door")
}
}
func TestMalformedJSONIsRefusedWithoutPanicking(t *testing.T) {
app := mcpApp(t, session, readTool("stuck"))
for _, body := range []string{"", "{", "not json", `{"jsonrpc":"2.0","id":1}`} {
out := rpc(t, app, body)
if out != nil && out["error"] == nil && out["result"] == nil {
t.Fatalf("%q produced neither a result nor an error", body)
}
}
}

View File

@@ -16,117 +16,126 @@ import (
type OrderController struct { type OrderController struct {
orderService services.OrderService orderService services.OrderService
// Asked whether a chosen delivery window is still open. Held here rather
// than reimplemented, so the app's list and this check can never disagree
// about where a window ends.
deliverySlotService services.DeliverySlotService
} }
func NewOrderController(orderService services.OrderService) *OrderController { func NewOrderController(
return &OrderController{orderService: orderService} orderService services.OrderService,
deliverySlotService services.DeliverySlotService,
) *OrderController {
return &OrderController{
orderService: orderService,
deliverySlotService: deliverySlotService,
}
} }
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error { func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
tid, _ := strconv.Atoi(c.Query("tenantid")) tid, _ := strconv.Atoi(c.Query("tenantid"))
pid, _ := strconv.Atoi(c.Query("partnerid")) pid, _ := strconv.Atoi(c.Query("partnerid"))
cid, _ := strconv.Atoi(c.Query("customerid")) cid, _ := strconv.Atoi(c.Query("customerid"))
mid, _ := strconv.Atoi(c.Query("moduleid")) mid, _ := strconv.Atoi(c.Query("moduleid"))
aid, _ := strconv.Atoi(c.Query("applocationid")) aid, _ := strconv.Atoi(c.Query("applocationid"))
uid, _ := strconv.Atoi(c.Query("appuserid")) uid, _ := strconv.Atoi(c.Query("appuserid"))
lid, _ := strconv.Atoi(c.Query("locationid")) lid, _ := strconv.Atoi(c.Query("locationid"))
configid, _ := strconv.Atoi(c.Query("configid")) configid, _ := strconv.Atoi(c.Query("configid"))
stat := c.Query("status") stat := c.Query("status")
fdate := c.Query("fromdate") fdate := c.Query("fromdate")
tdate := c.Query("todate") tdate := c.Query("todate")
keyword := c.Query("keyword") keyword := c.Query("keyword")
pageno, _ := strconv.Atoi(c.Query("pageno")) pageno, _ := strconv.Atoi(c.Query("pageno"))
pagesize, _ := strconv.Atoi(c.Query("pagesize")) pagesize, _ := strconv.Atoi(c.Query("pagesize"))
if pageno <= 0 { if pageno <= 0 {
pageno = 1 pageno = 1
} }
if pagesize <= 0 { if pagesize <= 0 {
pagesize = 10 pagesize = 10
} }
// Build dynamic query struct // Build dynamic query struct
query := models.DeliveryQuery{ query := models.DeliveryQuery{
Tenantid: tid, Tenantid: tid,
Partnerid: pid, Partnerid: pid,
Customerid: cid, Customerid: cid,
Moduleid: mid, Moduleid: mid,
Applocationid: aid, Applocationid: aid,
Locationid: lid, Locationid: lid,
UserID: uid, UserID: uid,
Appuserid: uid, Appuserid: uid,
Configid: configid, Configid: configid,
Fromdate: fdate, Fromdate: fdate,
ToDate: tdate, ToDate: tdate,
Status: stat, Status: stat,
Keyword: keyword, Keyword: keyword,
Pageno: pageno, Pageno: pageno,
Pagesize: pagesize, Pagesize: pagesize,
} }
var ( var (
orders []models.OrderInfo orders []models.OrderInfo
err error err error
) )
// -------------------------- // --------------------------
// 🔥 DYNAMIC ROUTING LOGIC // 🔥 DYNAMIC ROUTING LOGIC
// -------------------------- // --------------------------
if tid != 0 && lid != 0 { if tid != 0 && lid != 0 {
// ⭐ Both tenant & location → special handler // ⭐ Both tenant & location → special handler
orders, err = ctl.orderService.GetTenantLocationOrders(query) orders, err = ctl.orderService.GetTenantLocationOrders(query)
} else if tid != 0 { } else if tid != 0 {
// Tenant only // Tenant only
orders, err = ctl.orderService.GetTenantOrders(query) orders, err = ctl.orderService.GetTenantOrders(query)
} else if pid != 0 { } else if pid != 0 {
// Partner // Partner
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword) orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
} else if cid != 0 { } else if cid != 0 {
// Customer // Customer
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword) orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
} else if aid != 0 { } else if aid != 0 {
// App-location orders // App-location orders
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword) orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
} else if uid != 0 { } else if uid != 0 {
// User orders // User orders
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword) orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
} else { } else {
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid). // No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
// Refuse instead of silently returning every order in the database. // Refuse instead of silently returning every order in the database.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{ return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"status": false, "status": false,
"code": http.StatusBadRequest, "code": http.StatusBadRequest,
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required", "message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
}) })
} }
if err != nil { if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"status": false, "status": false,
"code": http.StatusInternalServerError, "code": http.StatusInternalServerError,
"message": err.Error(), "message": err.Error(),
}) })
} }
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"status": true, "status": true,
"code": http.StatusOK, "code": http.StatusOK,
"message": "Success", "message": "Success",
"details": orders, "details": orders,
}) })
} }
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error { func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
tid, _ := strconv.Atoi(c.Query("tenantid")) tid, _ := strconv.Atoi(c.Query("tenantid"))
pid, _ := strconv.Atoi(c.Query("partnerid")) pid, _ := strconv.Atoi(c.Query("partnerid"))
@@ -160,7 +169,6 @@ func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
}) })
} }
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error { func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
tenantIDStr := c.Query("tenantid") tenantIDStr := c.Query("tenantid")
tenantID, _ := strconv.Atoi(tenantIDStr) tenantID, _ := strconv.Atoi(tenantIDStr)
@@ -350,6 +358,29 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05") data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
} }
// The chosen delivery window, re-decided here.
//
// The app sends back what /v1/mob/deliveryslots/available handed it, but
// that group carries NO SESSION — anything arriving is a claim, not a fact.
// The common case is innocent and still has to be caught: a shopper leaves
// the checkout screen open while the window closes, then taps pay.
//
// 409 rather than 400: the request was well formed and was true when it was
// built. The message is written to be shown to the shopper as-is.
//
// An order naming NO window passes straight through. That is every order
// placed before this shipped and every order from a branch that has set no
// windows, and it must stay ordinary.
if err := ctl.deliverySlotService.ValidateForOrder(
data.Tenantid, data.Locationid, data.Deliveryslotid, data.Deliveryslotdate,
); err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict,
"message": err.Error(),
"status": false,
})
}
// An order that does not state its config is an APP order, because that is // An order that does not state its config is an APP order, because that is
// the only kind this endpoint takes. // the only kind this endpoint takes.
// //
@@ -592,7 +623,7 @@ func (ctl *OrderController) GetTimeSeries(c *fiber.Ctx) error {
"status": false, "status": false,
}) })
} }
if granularity == "" { if granularity == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{ return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "code": http.StatusBadRequest,

View File

@@ -73,6 +73,40 @@ func (ctl *PartnerController) GetPartners(c *fiber.Ctx) error {
}) })
} }
// CreateRiderShift opens a working window in a delivery region.
//
// Riders cannot be hired without one, and until this existed the table could
// only be read — a region that shipped with no shift rows was a region no rider
// could ever be added to, with nothing in the product able to change that.
//
// The region comes from the body rather than the query because this is a write
// and the whole shift is one object; `getridershifts` beside it reads the same
// id from a param, which is the existing convention for reads here.
func (ctl *PartnerController) CreateRiderShift(c *fiber.Ctx) error {
var shift models.Ridershifts
if err := c.BodyParser(&shift); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"status": false, "code": http.StatusBadRequest, "message": "Invalid request body",
})
}
result, err := ctl.partnerService.CreateRiderShift(shift)
if err != nil {
// 400, not 500. Every failure here is something the person typed — a
// region that is not configured, a window that already exists, a time
// that is not a time — and each message says which.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"status": false, "code": http.StatusBadRequest, "message": err.Error(),
})
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"status": true, "code": http.StatusCreated,
"message": "Shift created", "details": result,
})
}
func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error { func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error {
aid, _ := strconv.Atoi(c.Query("applocationid")) aid, _ := strconv.Atoi(c.Query("applocationid"))

View File

@@ -771,6 +771,20 @@ func posClaimError(c *fiber.Ctx, err error) error {
// once the console can hold a session. // once the console can hold a session.
// posWebScope reads and checks the tenant and outlet a console request names. // posWebScope reads and checks the tenant and outlet a console request names.
// posTenantScope is the guard for things that belong to a whole business
// rather than to one of its shops — shift windows, so far.
//
// No ownership query, because there is nothing to own: `middleware.WebAuth`
// pins the tenant from the signed session and refuses a request naming another
// one, so reaching here with a tenant id at all means it is this caller's.
// Naming an outlet is what needs checking, and that is `posWebScope` below.
func (ctl *PosController) posTenantScope(tenantID int) error {
if tenantID <= 0 {
return fmt.Errorf("tenantid is required")
}
return nil
}
func (ctl *PosController) posWebScope(tenantID, locationID int) error { func (ctl *PosController) posWebScope(tenantID, locationID int) error {
if tenantID <= 0 { if tenantID <= 0 {
return fmt.Errorf("tenantid is required") return fmt.Errorf("tenantid is required")
@@ -921,9 +935,18 @@ func (ctl *PosController) WebListStaffShifts(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid"))) tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid"))) locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err := ctl.posWebScope(tenantID, locationID); err != nil { // Tenant-scoped, because a shift belongs to the business rather than to one
// of its shops. An outlet may still be named to narrow the list, and is
// checked for ownership when it is — omitting it is not a way to read
// somebody else's, because the tenant comes from the signed session.
if err := ctl.posTenantScope(tenantID); err != nil {
return posBadRequest(c, err) return posBadRequest(c, err)
} }
if locationID > 0 {
if err := ctl.posWebScope(tenantID, locationID); err != nil {
return posBadRequest(c, err)
}
}
shifts, err := ctl.posService.ListStaffShifts(tenantID, locationID, shifts, err := ctl.posService.ListStaffShifts(tenantID, locationID,
strings.EqualFold(c.Query("include_inactive"), "true")) strings.EqualFold(c.Query("include_inactive"), "true"))
@@ -944,9 +967,19 @@ func (ctl *PosController) WebCreateStaffShift(c *fiber.Ctx) error {
return posBadRequest(c, fmt.Errorf("invalid request body")) return posBadRequest(c, fmt.Errorf("invalid request body"))
} }
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil { // A shift with no outlet belongs to the tenant and every branch it owns,
// which is the ordinary case — a business that works 07:00–15:00 works
// those hours at every shop, and entering them per outlet is how the third
// branch quietly ends up on 07:00–15:30. An outlet is named only when one
// shop really does differ, and is checked for ownership then.
if err := ctl.posTenantScope(req.Tenantid); err != nil {
return posBadRequest(c, err) return posBadRequest(c, err)
} }
if req.Locationid > 0 {
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
return posBadRequest(c, err)
}
}
shift, err := ctl.posService.CreateStaffShift(req.Tenantid, req.Locationid, req) shift, err := ctl.posService.CreateStaffShift(req.Tenantid, req.Locationid, req)
if err != nil { if err != nil {
@@ -982,3 +1015,20 @@ func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
"message": "Shift updated", "details": shift, "message": "Shift updated", "details": shift,
}) })
} }
// AuthAdoption reports how much of the till fleet is carrying a session token.
//
// The answer to "is it safe to set POS_AUTH_REQUIRED=true yet". Every outlet it
// lists is a till that would stop being able to ring a bill the moment
// enforcement goes on.
//
// Behind the web session guard on purpose: that list is also a map of which
// shops are reachable without a credential today.
func (ctl *PosController) AuthAdoption(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"message": "Success",
"details": middleware.PosAdoptionReport(),
})
}

View File

@@ -191,7 +191,14 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
}) })
} }
if err := ctl.productService.CreateProduct(product); err != nil { // The created row, not the parsed body.
//
// This returned the struct it had just parsed off the request, which by
// definition carried `productid: 0` — the id is assigned by the database a
// moment later and was never read back. Every caller that needed the id
// went and looked the product up again by SKU.
created, err := ctl.productService.CreateProduct(product)
if err != nil {
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"code": http.StatusInternalServerError, "code": http.StatusInternalServerError,
"message": "Failed to create product", "message": "Failed to create product",
@@ -203,7 +210,7 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
"code": http.StatusCreated, "code": http.StatusCreated,
"message": "Product created successfully", "message": "Product created successfully",
"status": true, "status": true,
"data": product, "data": created,
}) })
} }
@@ -1001,3 +1008,44 @@ func (ctl *ProductController) RelinkCatalogue(c *fiber.Ctx) error {
} }
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report}) return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report})
} }
// SetShowHealthScore turns one product's health score on or off for one shop.
//
// Scoped twice over: `middleware.WebAuth` refuses a request naming a tenant the
// session does not own — it reads `tenantid` from the body as well as the query
// — and the repository's UPDATE carries the tenant in its WHERE clause. A write
// that changes what a shopper sees should not rest on one guard being mounted
// correctly.
func (ctl *ProductController) SetShowHealthScore(c *fiber.Ctx) error {
var req struct {
Tenantid int `json:"tenantid"`
Productid int `json:"productid"`
// A POINTER so a body that forgot the field is refused rather than read
// as "turn it off". The whole point of this endpoint is the difference
// between the two.
Showhealthscore *bool `json:"showhealthscore"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if req.Showhealthscore == nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "showhealthscore is required: send true or false.",
})
}
if err := ctl.productService.SetShowHealthScore(req.Tenantid, req.Productid, *req.Showhealthscore); err != nil {
// 409, not 500. "No such product for this business" is a fact the
// caller can act on, not a fault in the server.
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
})
}

View File

@@ -0,0 +1,210 @@
package controllers
import (
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"nearle/middleware"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
/*
Who may re-issue a first-password link, and for whom.
This endpoint mints a credential, so most of what matters is what it refuses.
The service layer refuses the business cases — an account that already has a
password, a tenant whose primary email matches no login — and those are covered
in `services/resendInvite_test.go`. This file is about the door: who gets
through it, and which account a request actually names.
*/
// resendService answers both resends and records which was called. Only the two
// methods under test are real; the rest of TenantService is embedded nil, which
// panics if anything else is reached — exactly the signal wanted.
type resendService struct {
services.TenantService
byTenant int
byUser int
outcome services.InviteOutcome
err error
}
func (s *resendService) ResendInvite(tenantID int) (services.InviteOutcome, error) {
s.byTenant = tenantID
return s.outcome, s.err
}
func (s *resendService) ResendInviteToUser(userID int) (services.InviteOutcome, error) {
s.byUser = userID
return s.outcome, s.err
}
func resendApp(t *testing.T, service *resendService) *fiber.App {
t.Helper()
t.Setenv("POS_TOKEN_SECRET", testSecret)
app := fiber.New()
// The real guard, mounted as routes.go mounts it: this endpoint sits behind
// the session, and the handler then requires a platform account on top.
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
app.Post("/live/api/v1/web/tenants/resendinvite", NewTenantController(service).ResendInvite)
return app
}
// staffToken is a signed session for a Nearle staff account.
//
// `Superadmin` is the signal, and it is minted from `app_users.issuperadmin` —
// not from the tenant being zero and not from a role id. Both of those look
// equivalent and are not: `app_roles` calls roleid 1 "Super admin" and
// onboarding wrote 1 for every shop owner, and a zero tenant is what an
// unfilled column looks like. See `utils.WebClaims`.
func staffToken(t *testing.T) string {
t.Helper()
token, _, err := utils.MintWebToken(utils.WebClaims{
Userid: 12, Roleid: 1, Configid: 1, Superadmin: true,
}, time.Now())
if err != nil {
t.Fatalf("mint: %v", err)
}
return token
}
// merchantToken is a signed session for a shop's own admin.
func merchantToken(t *testing.T) string {
t.Helper()
token, _, err := utils.MintWebToken(utils.WebClaims{
Userid: 904, Tenantid: 1147, Roleid: 3, Configid: 1,
}, time.Now())
if err != nil {
t.Fatalf("mint: %v", err)
}
return token
}
func postAs(t *testing.T, app *fiber.App, token, body string) (int, string) {
t.Helper()
req := httptest.NewRequest("POST", "/live/api/v1/web/tenants/resendinvite",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("resendinvite: %v", err)
}
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
func TestAMerchantCannotResendAnything(t *testing.T) {
// A merchant's session is pinned to their own tenant, so the worst they could
// do is re-invite themselves — and the service refuses that, because an
// account signing in to ask already has a password. Refusing here as well
// means the endpoint does not rely on two other checks to make the wrong case
// impossible.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, merchantToken(t), `{"tenantid":1147}`)
if status != 403 {
t.Fatalf("a merchant was let through: %d %s", status, body)
}
if service.byTenant != 0 || service.byUser != 0 {
t.Fatal("the service was reached by a caller who should have been refused")
}
}
func TestNearleStaffCanResendToATenantsOwner(t *testing.T) {
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
if status != 200 {
t.Fatalf("refused Nearle staff: %d %s", status, body)
}
if service.byTenant != 1147 {
t.Fatalf("resent for tenant %d, want 1147", service.byTenant)
}
}
func TestAUseridNamesOnePersonRatherThanTheOwner(t *testing.T) {
// The reason this parameter exists. Staff added after onboarding, and the
// login every branch spawns, are created with no password too — and a
// business has many of them, so "the tenant's invitation" cannot reach them.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{"userid":7781}`)
if status != 200 {
t.Fatalf("refused: %d %s", status, body)
}
if service.byUser != 7781 {
t.Fatalf("resent for user %d, want 7781", service.byUser)
}
if service.byTenant != 0 {
t.Fatal("emailed the owner when a person was named")
}
}
func TestAUseridWinsOverATenantid(t *testing.T) {
// A caller that sent a person's id meant that person. Falling back to the
// owner would be the wrong mailbox with nothing on the response to say so.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
if status, body := postAs(t, app, staffToken(t), `{"tenantid":1147,"userid":7781}`); status != 200 {
t.Fatalf("refused: %d %s", status, body)
}
if service.byUser != 7781 || service.byTenant != 0 {
t.Fatalf("resolved to the wrong account: user=%d tenant=%d", service.byUser, service.byTenant)
}
}
func TestAnEmptyBodyIsRefusedRatherThanSentToTenantZero(t *testing.T) {
// `{}` parses cleanly into two zeroes. Without this check it would reach the
// service as tenant 0 and come back "tenant 0 has no account matching its
// primary email address", which describes nothing the caller did.
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{}`)
if status != 400 {
t.Fatalf("an empty request was accepted: %d %s", status, body)
}
if service.byTenant != 0 || service.byUser != 0 {
t.Fatal("the service was called with nothing to act on")
}
if !strings.Contains(body, "tenantid") || !strings.Contains(body, "userid") {
t.Errorf("the refusal does not say what to send: %s", body)
}
}
func TestMailThatDidNotLeaveIsReportedAsAFailure(t *testing.T) {
// The operator pressed a button expecting an email to go. "Success" with no
// mail sent is the one answer they cannot act on.
service := &resendService{outcome: services.InviteOutcome{
Sent: false, Reason: "MAIL_HOST is not set",
}}
app := resendApp(t, service)
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
if status != 409 {
t.Fatalf("an unsent invitation was reported as sent: %d %s", status, body)
}
if !strings.Contains(body, "MAIL_HOST") {
t.Errorf("the reason was lost: %s", body)
}
}

View File

@@ -0,0 +1,101 @@
package controllers
import (
"errors"
"nearle/models"
"nearle/services"
"net/http"
"github.com/gofiber/fiber/v2"
)
// ScanController is the scan-to-order surface for the customer app:
//
// POST /v1/mob/scan/lookup a label → the product, and which of my stores has it
// POST /v1/mob/scan/confirm I picked a store and a size → still there? else where?
// GET /v1/mob/scan/stores my stores, nearest first
//
// Business outcomes ("out of stock", "not registered with that store") are
// 200s with a reason in the body: the app renders them, it does not retry
// them. HTTP errors are reserved for a request that cannot be served at all.
type ScanController struct {
scanService services.ScanService
}
func NewScanController(scanService services.ScanService) *ScanController {
return &ScanController{scanService: scanService}
}
func (ctl *ScanController) Lookup(c *fiber.Ctx) error {
var req models.ScanLookupRequest
if err := c.BodyParser(&req); err != nil {
return scanBadRequest(c, "Invalid request body")
}
resp, err := ctl.scanService.Lookup(c.Context(), req)
if err != nil {
return scanError(c, err, "Could not look up that product")
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"message": resp.Message,
"details": resp,
})
}
func (ctl *ScanController) Confirm(c *fiber.Ctx) error {
var req models.ScanConfirmRequest
if err := c.BodyParser(&req); err != nil {
return scanBadRequest(c, "Invalid request body")
}
resp, err := ctl.scanService.Confirm(c.Context(), req)
if err != nil {
return scanError(c, err, "Could not check that store")
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"message": resp.Message,
"details": resp,
})
}
func (ctl *ScanController) Stores(c *fiber.Ctx) error {
customerid, _ := c.QueryInt("customerid"), 0
stores, err := ctl.scanService.Stores(c.Context(), customerid,
models.FlexibleString(c.Query("latitude")), models.FlexibleString(c.Query("longitude")))
if err != nil {
return scanError(c, err, "Could not list your stores")
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"message": "Success",
"details": stores,
})
}
func scanBadRequest(c *fiber.Ctx, msg string) error {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": msg,
})
}
func scanError(c *fiber.Ctx, err error, fallback string) error {
code, msg := http.StatusInternalServerError, fallback
switch {
case errors.Is(err, services.ErrScanBadRequest):
code, msg = http.StatusBadRequest, err.Error()
case errors.Is(err, services.ErrScanCustomerNotFound):
code, msg = http.StatusNotFound, "Customer not found"
case errors.Is(err, services.ErrScanCatalogueDown):
code, msg = http.StatusServiceUnavailable, "Product search is temporarily unavailable"
}
return c.Status(code).JSON(fiber.Map{
"code": code,
"status": false,
"message": msg,
})
}

View File

@@ -0,0 +1,302 @@
package controllers
import (
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"nearle/middleware"
"nearle/models"
"nearle/services"
"nearle/utils"
fiberv1 "github.com/gofiber/fiber"
"github.com/gofiber/fiber/v2"
)
/*
Setting a first password, with no session and no way to get one.
A branch login created by `createtenantlocation` arrives with an empty password.
The console signs in, is told to set one, and does — and until now it did that
through `PUT /users/update`, which is behind the session guard. Once
WEB_AUTH_REQUIRED began defaulting on, that answered
401 "a session token is required; sign in again"
to somebody who could not sign in, because signing in needs the password they
were trying to set. Every such account was unusable, and the 401 read as an
authentication bug rather than a deadlock.
`publicWebPaths` had named `/users/setpassword` since the guard was written. The
path was reserved; the handler never existed, so it answered 404.
The tests that matter are about the two halves: it must be reachable WITHOUT a
session, and it must refuse everything except the one case it exists for.
*/
type fakePasswords struct {
// set records what reached the write, so a refusal can be shown to have
// refused rather than merely reported.
set []string
lastUserid int
refuseIt error
}
func (f *fakePasswords) SetInitialPassword(userid int, password string) error {
f.lastUserid = userid
if f.refuseIt != nil {
return f.refuseIt
}
f.set = append(f.set, password)
return nil
}
// The rest of UserService, unused here.
func (f *fakePasswords) GetAllUsers(int, int, int, int, string) ([]models.UserInfo, error) {
return nil, nil
}
func (f *fakePasswords) GetUserByID(int) (models.UserInfo, error) { return models.UserInfo{}, nil }
func (f *fakePasswords) Login(models.User) (models.UserInfo, error) {
return models.UserInfo{}, nil
}
func (f *fakePasswords) TenantLogin(models.User) (models.TenantUserInfo, error) {
return models.TenantUserInfo{}, nil
}
func (f *fakePasswords) UpdateStaff(models.User) error { return nil }
func (f *fakePasswords) AppLogin(models.User) (models.TenantUserInfo, fiberv1.Map, error) {
return models.TenantUserInfo{}, fiberv1.Map{}, nil
}
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, services.InviteOutcome, error) {
return models.UserInfo{}, services.InviteOutcome{}, nil
}
func passwordApp(t *testing.T, service *fakePasswords) *fiber.App {
t.Helper()
t.Setenv("POS_TOKEN_SECRET", testSecret)
app := fiber.New()
// The real guard, mounted exactly as routes.go mounts it. The point of this
// file is which side of it this endpoint lands on.
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
app.Post("/live/api/v1/web/users/setpassword", NewUserController(service).SetPassword)
app.Put("/live/api/v1/web/users/update", NewUserController(service).UpdateStaff)
return app
}
func send(t *testing.T, app *fiber.App, method, path, body string) (int, string) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
func TestAFirstPasswordCanBeSetWithoutASession(t *testing.T) {
// The whole point. There is no session to present and no way to obtain one.
service := &fakePasswords{}
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
if status == fiber.StatusUnauthorized {
t.Fatalf("the guard blocked the one call that cannot present a token: %s", body)
}
if status != fiber.StatusOK {
t.Fatalf("HTTP %d: %s", status, body)
}
if len(service.set) != 1 || service.set[0] != "opensesame" {
t.Fatalf("the password did not reach the service: %v", service.set)
}
}
func TestTheGeneralUpdateStaysBehindTheGuard(t *testing.T) {
// The reason this is a new endpoint rather than `/users/update` being
// opened up: that one writes whatever struct it is handed, so unauthenticated
// it would let anybody change any field of any user.
service := &fakePasswords{}
app := passwordApp(t, service)
status, body := send(t, app, "PUT", "/live/api/v1/web/users/update",
`{"userid":904,"roleid":1,"tenantid":9}`)
if status != fiber.StatusUnauthorized {
t.Fatalf("an untokened user update was not refused: %d %s", status, body)
}
}
func TestAnAccountThatAlreadyHasOneIsRefusedAsAConflict(t *testing.T) {
// 409, never 401. Nothing here is an authentication failure — the caller is
// not supposed to have a session — and a 401 would send the console into its
// sign-out-and-reload path on the one screen with nothing to sign out of.
service := &fakePasswords{refuseIt: errAlreadySet{}}
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
if status != fiber.StatusConflict {
t.Fatalf("expected 409, got %d: %s", status, body)
}
if len(service.set) != 0 {
t.Fatalf("a refused call still wrote: %v", service.set)
}
}
func TestTheRefusalDoesNotSayWhichAccountsExist(t *testing.T) {
// "No such user" and "already has a password" must read identically, or
// this becomes a way to ask whether a userid exists and whether it has been
// set up — unauthenticated, one request at a time.
service := &fakePasswords{refuseIt: errAlreadySet{}}
app := passwordApp(t, service)
_, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
for _, leak := range []string{"not found", "no such", "does not exist"} {
if strings.Contains(strings.ToLower(body), leak) {
t.Fatalf("the refusal distinguishes a missing account: %s", body)
}
}
}
func TestAMalformedBodyIsRefusedWithoutPanicking(t *testing.T) {
app := passwordApp(t, &fakePasswords{})
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"userid":`)
if status != fiber.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
func TestTheAnswerIsTheEnvelopeTheConsoleUnwraps(t *testing.T) {
// A handler answering at the top level passes a service test and hands the
// console `undefined`.
_, body := send(t, passwordApp(t, &fakePasswords{}), "POST",
"/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
var envelope struct {
Status bool `json:"status"`
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
t.Fatalf("not an envelope: %s", body)
}
if !envelope.Status || envelope.Code != fiber.StatusOK {
t.Fatalf("success did not read as success: %s", body)
}
}
type errAlreadySet struct{}
func (errAlreadySet) Error() string {
return "that account cannot have its password set here — it may already have one"
}
func (f *fakePasswords) TenantWebLogin(models.User) (models.TenantUserInfo, map[string]interface{}) {
return models.TenantUserInfo{}, map[string]interface{}{}
}
func (f *fakePasswords) DeleteUser(int) error { return nil }
// invite mints a real invitation for the test's account.
//
// A helper rather than a literal, because the token is signed: a hand-written
// string would test the refusal path and nothing else, and the point of these
// is what happens when a genuine invitation arrives.
func invite(t *testing.T, userid int) string {
t.Helper()
token, _, err := utils.MintInviteToken(utils.InviteClaims{Userid: userid, Tenantid: 1147}, time.Now())
if err != nil {
t.Fatalf("minting an invitation: %v", err)
}
return token
}
/*
The invitation replaced a userid, and that was a security fix rather than a
tidy-up.
`applogin` answers a POST carrying an email and no password with 409 and the
userid, for any account that has not set one. So the recipe was: know a
merchant's primary email — usually printed on their shopfront — POST it, receive
their userid, set their password, own the business's admin account. No guessing
at any step, and the empty-password check was no defence because an un-set-up
account is exactly what such an attacker wants.
*/
func TestAUseridIsNoLongerEnoughToSetAPassword(t *testing.T) {
// The hole, asserted closed. A body carrying a userid and no invitation
// must not set anything, whatever the userid is.
service := &fakePasswords{}
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"userid":904,"password":"opensesame"}`)
if status == fiber.StatusOK {
t.Fatalf("a bare userid still set a password: %s", body)
}
if len(service.set) != 0 {
t.Fatalf("a bare userid reached the service: %v", service.set)
}
}
func TestAnInvitationSetsThePasswordForTheAccountItNames(t *testing.T) {
service := &fakePasswords{}
app := passwordApp(t, service)
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
if status != fiber.StatusOK {
t.Fatalf("HTTP %d: %s", status, body)
}
if len(service.set) != 1 || service.set[0] != "opensesame" {
t.Fatalf("the password did not reach the service: %v", service.set)
}
}
func TestTheUseridComesFromTheSignatureNotTheRequest(t *testing.T) {
// An invitation for 904 with a `userid` field claiming 999 must set 904's
// password. If the body could override it, the token would be decoration.
service := &fakePasswords{}
app := passwordApp(t, service)
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+invite(t, 904)+`","userid":999,"password":"opensesame"}`)
if status != fiber.StatusOK {
t.Fatalf("a valid invitation was refused: %d", status)
}
if service.lastUserid != 904 {
t.Fatalf("the request's userid won: set the password for %d", service.lastUserid)
}
}
func TestAForgedInvitationIsRefused(t *testing.T) {
service := &fakePasswords{}
app := passwordApp(t, service)
for _, token := range []string{"", "i1.forged.signature", "not-a-token", "w1.a.b"} {
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
`{"token":"`+token+`","password":"opensesame"}`)
if status == fiber.StatusOK {
t.Fatalf("%q was accepted as an invitation", token)
}
}
if len(service.set) != 0 {
t.Fatalf("a forged invitation wrote: %v", service.set)
}
}

View File

@@ -3,6 +3,7 @@ package controllers
import ( import (
"fmt" "fmt"
"log" "log"
"nearle/middleware"
"nearle/models" "nearle/models"
"nearle/services" "nearle/services"
"net/http" "net/http"
@@ -44,6 +45,25 @@ func (ctl *TenantController) SearchTenant(c *fiber.Ctx) error {
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error { func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
pageno, _ := strconv.Atoi(c.Query("pageno")) pageno, _ := strconv.Atoi(c.Query("pageno"))
pagesize, _ := strconv.Atoi(c.Query("pagesize")) pagesize, _ := strconv.Atoi(c.Query("pagesize"))
// Paging is defaulted, not required.
//
// The repository builds LIMIT/OFFSET from these directly, so a caller that
// omitted either — or sent pageno=0 — got an empty result reported as
// `code 200, status true, message "Success"`. "There are no tenants on the
// platform" and "you forgot a query parameter" are very different answers
// and this endpoint gave the first for the second.
//
// Defaulted rather than rejected with a 400: every existing caller that
// works today keeps working, and a platform list with no paging asked for
// has an obvious right answer — the first page.
if pageno < 1 {
pageno = 1
}
if pagesize < 1 {
pagesize = 50
}
status := c.Query("status") status := c.Query("status")
aid, _ := strconv.Atoi(c.Query("applocationid")) aid, _ := strconv.Atoi(c.Query("applocationid"))
tenanttype := c.Query("tenanttype") tenanttype := c.Query("tenanttype")
@@ -327,7 +347,8 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
}) })
} }
if err := ctl.tenantService.CreateStaff(data); err != nil { invite, err := ctl.tenantService.CreateStaff(data)
if err != nil {
// A rejected PIN, a missing name, a role nobody set — these are things // A rejected PIN, a missing name, a role nobody set — these are things
// the person filling in the form can fix, so they come back as 400 with // the person filling in the form can fix, so they come back as 400 with
// the reason. This answered 500 with a body claiming 409, which told a // the reason. This answered 500 with a body claiming 409, which told a
@@ -339,10 +360,17 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
}) })
} }
// The person was hired either way. Whether they were emailed their
// first-password link is reported beside that rather than folded into
// `status`: this account is created with no password and the link is the only
// way in, so an operator who is not told cannot know they have added somebody
// who cannot sign in.
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"code": http.StatusCreated, "code": http.StatusCreated,
"message": "Staff created successfully", "message": "Staff created successfully",
"status": true, "status": true,
"invited": invite.Sent,
"invitereason": invite.Reason,
}) })
} }
@@ -417,7 +445,7 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
}) })
} }
result, err := ctl.tenantService.CreateTenantUser(data) result, invite, err := ctl.tenantService.CreateTenantUser(data)
if err != nil { if err != nil {
if err.Error() == "Tenant Already Exists" { if err.Error() == "Tenant Already Exists" {
return c.Status(http.StatusConflict).JSON(fiber.Map{ return c.Status(http.StatusConflict).JSON(fiber.Map{
@@ -434,11 +462,21 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
}) })
} }
// The tenant was created either way. The invitation is reported beside it
// rather than folded into `status`, because a merchant who exists and has
// not been emailed is a task for the operator — resend, or correct the
// address — and not a failed onboarding to be retried.
//
// `invited: false` with a reason is the state the platform console shows on
// the tenant, so it never has to guess whether the email went.
return c.Status(http.StatusCreated).JSON(fiber.Map{ return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": 201, "code": 201,
"status": true, "status": true,
"message": "Successfully Created", "message": "Successfully Created",
"details": result, "details": result,
"invited": invite.Sent,
// Omitted when it sent, so a successful onboarding carries no apology.
"invitereason": invite.Reason,
}) })
} }
@@ -757,3 +795,79 @@ func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
"code": http.StatusOK, "status": true, "message": "Successfully Updated", "code": http.StatusOK, "status": true, "message": "Successfully Updated",
}) })
} }
// ResendInvite re-issues a merchant's first-password link.
//
// ── Why this is platform staff only ─────────────────────────────────────────
//
// It mints a credential. `middleware.WebAuth` already pins a merchant's session
// to their own tenant, so a shop could at most re-invite itself — but the
// account it would be inviting is the one signing in to ask, which can only
// happen if that account already has a password, and the service refuses that
// case outright.
//
// So the only caller this is for is Nearle's own staff, chasing a merchant who
// never received the mail. Saying so explicitly is better than relying on two
// other checks to make the wrong case impossible.
func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
claims, ok := middleware.WebClaimsFrom(c)
if !ok || !claims.IsPlatformAccount() {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false,
"message": "Only Nearle staff can resend an invitation.",
})
}
// Either a tenant — meaning its owner, the one account onboarding created —
// or one named person. Staff added later and the login every branch spawns
// are created with no password too, and a business has many of them, so
// "the tenant's invitation" cannot reach them.
var req struct {
Tenantid int `json:"tenantid"`
Userid int `json:"userid"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if req.Tenantid <= 0 && req.Userid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "Send a tenantid to re-invite the owner, or a userid to re-invite one person.",
})
}
// `userid` wins when both arrive. It is the more specific of the two, and a
// caller that sent a person's id meant that person — silently emailing the
// owner instead would be the wrong mailbox with no sign anything was off.
var (
outcome services.InviteOutcome
err error
)
if req.Userid > 0 {
outcome, err = ctl.tenantService.ResendInviteToUser(req.Userid)
} else {
outcome, err = ctl.tenantService.ResendInvite(req.Tenantid)
}
if err != nil {
// 409, not 500. Every failure here is a business fact the operator can
// act on — no such tenant, an address that matches no login, a merchant
// already set up — rather than a fault in the server.
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "status": false, "message": err.Error(),
})
}
if !outcome.Sent {
// The tenant is fine and the mail did not go. Reported as a failure
// because the operator pressed a button expecting an email to leave,
// and the reason names what to fix.
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "status": false, "message": outcome.Reason,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
})
}

View File

@@ -1,16 +1,63 @@
package controllers package controllers
import ( import (
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"nearle/models" "nearle/models"
"nearle/services" "nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
// attachWebSession hands a signed-in console user their session token.
//
// Added to the login response rather than served from a second endpoint, so the
// console receives it on the call it already makes and nothing changes about
// when or how it signs in.
//
// The claims come from the user's own record, which is the whole point: until
// now the console asserted its tenant on every request and was believed, and
// sealing it under a signature here is what makes `middleware.WebAuth` able to
// refuse a request naming somebody else's.
//
// `Issuperadmin` is copied across as the ONLY source of cross-tenant access.
// Not the role — `app_roles` calls roleid 1 "Super admin" and tenant onboarding
// wrote 1 for every shop owner, so trusting the role would promote every
// merchant on the platform.
//
// A failure to mint is logged and swallowed, deliberately, while
// WEB_AUTH_REQUIRED is off: a deployment that has not set a signing key yet must
// still be able to sign in, or shipping this takes the console down everywhere
// the secret is missing. Once enforcement is on, no token means no session —
// which is then the correct and loud failure.
//
// The parameter is the underlying map type rather than `fiber.Map`, because the
// two login paths do not agree on which fiber that is: `AppLogin` returns the
// v1 package's `Map` and `TenantWebLogin` the v2 one. Both are
// `map[string]any`, so taking that accepts either without dragging the
// old import into this file.
func attachWebSession(resp map[string]any, info models.TenantUserInfo) {
token, expires, err := utils.MintWebToken(utils.WebClaims{
Userid: info.Userid,
Tenantid: info.Tenantid,
Locationid: info.Locationid,
Roleid: info.Roleid,
Configid: info.Configid,
Superadmin: info.Issuperadmin,
}, time.Now())
if err != nil {
log.Printf("login: could not issue a console session for user %d: %v", info.Userid, err)
return
}
resp["token"] = token
resp["tokenexpiresat"] = expires.Unix()
}
type UserController struct { type UserController struct {
userService services.UserService userService services.UserService
} }
@@ -179,7 +226,7 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
}) })
} }
_, resp, err := ctl.userService.AppLogin(user) info, resp, err := ctl.userService.AppLogin(user)
if err != nil { if err != nil {
// Use resp.Code if present, fallback to 409 // Use resp.Code if present, fallback to 409
code := http.StatusConflict code := http.StatusConflict
@@ -189,6 +236,8 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
return c.Status(code).JSON(resp) return c.Status(code).JSON(resp)
} }
attachWebSession(resp, info)
// ✅ Always return resp // ✅ Always return resp
return c.Status(http.StatusOK).JSON(resp) return c.Status(http.StatusOK).JSON(resp)
} }
@@ -206,7 +255,7 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
} }
// Call service // Call service
info, err := ctl.userService.CreateUser(user) info, invite, err := ctl.userService.CreateUser(user)
if err != nil { if err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{ return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "code": http.StatusConflict,
@@ -215,11 +264,17 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
}) })
} }
// The account was created either way. Whether its first-password invitation
// was emailed is reported beside it rather than folded into `status`: the
// account has no password and the link is the only way to set one, so an
// operator who is not told has hired somebody who cannot sign in.
return c.Status(http.StatusCreated).JSON(fiber.Map{ return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "code": http.StatusCreated,
"status": true, "status": true,
"message": "Success", "message": "Success",
"details": info, "details": info,
"invited": invite.Sent,
"invitereason": invite.Reason,
}) })
} }
@@ -244,6 +299,7 @@ func (ctl *UserController) TenantWebLogin(c *fiber.Ctx) error {
// Include tenant user info if login successful (code 200) // Include tenant user info if login successful (code 200)
if code == fiber.StatusOK { if code == fiber.StatusOK {
resp["details"] = info resp["details"] = info
attachWebSession(resp, info)
} }
return c.Status(code).JSON(resp) return c.Status(code).JSON(resp)
@@ -274,3 +330,72 @@ func (ctl *UserController) DeleteUser(c *fiber.Ctx) error {
}) })
} }
// SetPassword gives a never-used account its first password.
//
// ── Why this endpoint exists ────────────────────────────────────────────────
//
// Because the flow was impossible without it. A branch login created by
// `createtenantlocation` arrives with an empty password; the console signs in,
// is told to set one, and does so — through `PUT /users/update`, which sits
// behind the session guard. So the call answered "a session token is required;
// sign in again" to a person who could not sign in, because they had no
// password yet. Every such account was unusable.
//
// `publicWebPaths` has named `/users/setpassword` since the guard was written.
// The path was reserved and the handler never built, so it answered 404 and the
// console went on using the guarded one.
//
// ── Why not simply open up `/users/update` ──────────────────────────────────
//
// It writes whatever struct it is handed. Unauthenticated, it would let anybody
// change any field of any user — their email, their role, their tenant. This
// takes two fields and can only act on an account with no password, which is
// what makes it safe to leave open. See the repository for the rest.
func (ctl *UserController) SetPassword(c *fiber.Ctx) error {
var req struct {
// The invitation, exactly as it arrived in the emailed link. The userid
// is read out of the signature and never out of the request — see below.
Token string `json:"token"`
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"status": false, "code": http.StatusBadRequest, "message": "Invalid request body",
})
}
// ── Why this takes a token and no longer takes a userid ─────────────────
//
// It used to accept `{userid, password}`, and that was an account takeover
// waiting to be noticed. `applogin` answers a POST carrying an email and NO
// password with 409 and the userid, for any account that has not set one —
// which is how the console's own setup step learned it. So the whole recipe
// was: know a merchant's primary email, which is usually printed on their
// shopfront, POST it here, receive their userid, then set their password
// and own the business's admin account. No guessing at any step.
//
// The invitation closes it. It is signed with the deployment's key, names
// the account in a payload the server produced, and expires. Knowing an
// email is no longer enough, and neither is knowing a userid.
claims, err := utils.ParseInviteToken(req.Token, time.Now())
if err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"status": false, "code": http.StatusConflict, "message": err.Error(),
})
}
if err := ctl.userService.SetInitialPassword(claims.Userid, req.Password); err != nil {
// 409, not 401. Nothing about this is an authentication failure — the
// caller is not supposed to have a session — and answering 401 would
// send the console into its sign-out-and-reload path on the one screen
// where there is nothing to sign out of.
return c.Status(http.StatusConflict).JSON(fiber.Map{
"status": false, "code": http.StatusConflict, "message": err.Error(),
})
}
return c.JSON(fiber.Map{
"status": true, "code": http.StatusOK,
"message": "Password set. Sign in with it.",
})
}

View File

@@ -1,32 +0,0 @@
package main
import (
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func CreateStockRequestsTable() {
dsn := "host=66.116.207.225 user=admin password=Package@123# dbname=nearledb port=5433 sslmode=disable TimeZone=Asia/Kolkata"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
query := `CREATE TABLE IF NOT EXISTS stockrequests (
requestid SERIAL PRIMARY KEY,
tenantid INT NOT NULL,
locationid INT NOT NULL,
productid INT NOT NULL,
qty INT NOT NULL,
status VARCHAR(50) DEFAULT 'Pending',
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
if err := db.Exec(query).Error; err != nil {
log.Fatalf("failed to create table: %v", err)
}
fmt.Println("Table stockrequests created successfully")
}

View File

@@ -3,8 +3,8 @@ package db
import ( import (
"fmt" "fmt"
"log" "log"
"nearle/config"
"net/url" "net/url"
"os"
"time" "time"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
@@ -23,14 +23,18 @@ var (
// DATABASE CONNECTION // 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( dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata", "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
mustEnv("DB_HOST"), cfg.DB.Host,
mustEnv("DB_USER"), cfg.DB.User,
mustEnv("DB_PASSWORD"), cfg.DB.Password,
mustEnv("DB_NAME"), cfg.DB.Name,
getEnv("DB_PORT", "5433"), cfg.DB.Port,
) )
var err error var err error
@@ -42,17 +46,16 @@ func Connect() {
setupDB(DB) setupDB(DB)
fmt.Println("✅ Database connected") fmt.Println("✅ Database connected")
connectCatalogueDB() connectCatalogueDB(cfg.Catalogue)
connectImageStore() connectImageStore(cfg.S3)
} }
// connectCatalogueDB opens the read-only connection to the catalogue // connectCatalogueDB opens the read-only connection to the catalogue
// (pgvector) database. If its env vars are not set, catalogue endpoints // (pgvector) database. If its env vars are not set, catalogue endpoints
// are simply unavailable — this must never block startup of the main app. // are simply unavailable — this must never block startup of the main app.
func connectCatalogueDB() { func connectCatalogueDB(c config.DBConfig) {
host := getEnv("CATALOGUE_DB_HOST", "") if !c.Enabled() {
if host == "" { fmt.Println("⚠️ CATALOGUE_DB_HOST not set, skipping catalogue DB connection")
fmt.Println("⚠️ Catalogue DB env vars not set, skipping catalogue DB connection")
return return
} }
@@ -62,9 +65,9 @@ func connectCatalogueDB() {
// quoting/comment syntax. // quoting/comment syntax.
dsnURL := url.URL{ dsnURL := url.URL{
Scheme: "postgres", Scheme: "postgres",
User: url.UserPassword(mustEnv("CATALOGUE_DB_USER"), mustEnv("CATALOGUE_DB_PASSWORD")), User: url.UserPassword(c.User, c.Password),
Host: fmt.Sprintf("%s:%s", host, getEnv("CATALOGUE_DB_PORT", "5432")), Host: fmt.Sprintf("%s:%s", c.Host, c.Port),
Path: "/" + mustEnv("CATALOGUE_DB_NAME"), Path: "/" + c.Name,
} }
q := dsnURL.Query() q := dsnURL.Query()
q.Set("sslmode", "disable") q.Set("sslmode", "disable")
@@ -108,22 +111,3 @@ func CloseDB() {
} }
fmt.Println("Connection closed Successfully") fmt.Println("Connection closed Successfully")
} }
// --------------------
// ENV HELPERS
// --------------------
func mustEnv(key string) string {
val := os.Getenv(key)
if val == "" {
log.Fatalf("Missing required env variable: %s", key)
}
return val
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"nearle/config"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -33,23 +34,20 @@ var ImageStore *imageStore
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client // connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
// used to resolve catalogue product images. Like the catalogue DB, this must // 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 // never block or fail app startup — with USE_S3 unset, image URLs are simply
// are simply omitted from catalogue responses. // omitted from catalogue responses. (USE_S3=true with a key missing is caught
func connectImageStore() { // by config.Load before we get here.)
if getEnv("USE_S3", "") != "true" { func connectImageStore(c config.S3Config) {
fmt.Println("⚠️ S3 not enabled, skipping image store") if !c.Enabled {
fmt.Println("⚠️ USE_S3 not set, skipping image store")
return return
} }
endpoint := getEnv("S3_ENDPOINT", "") endpoint := c.Endpoint
bucket := getEnv("S3_BUCKET", "") bucket := c.Bucket
accessKey := getEnv("S3_ACCESS_KEY", "") accessKey := c.AccessKey
secretKey := getEnv("S3_SECRET_KEY", "") secretKey := c.SecretKey
region := getEnv("S3_REGION", "") region := c.Region
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
fmt.Println("⚠️ S3 env vars incomplete, skipping image store")
return
}
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com). // 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 // The SDK's virtual-hosted-style client re-prepends the bucket to whatever

View File

@@ -3,9 +3,7 @@ package db
import ( import (
"context" "context"
"log" "log"
"os" "nearle/config"
"strconv"
"strings"
"time" "time"
"github.com/redis/go-redis/v9" "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 // 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 // still arrive and commit. That is the right failure — losing presence is an
// inconvenience, losing a sale is not — so this never aborts startup. // inconvenience, losing a sale is not — so this never aborts startup.
func InitRedis() { func InitRedis(c config.RedisConfig) {
host := strings.TrimSpace(os.Getenv("REDIS_HOST")) if !c.Enabled() {
if host == "" {
log.Println("redis: REDIS_HOST not set, POS presence disabled") log.Println("redis: REDIS_HOST not set, POS presence disabled")
return return
} }
port := getEnv("REDIS_PORT", "6379") host, port, dbIndex := c.Host, c.Port, c.DB
dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0"))
if err != nil {
dbIndex = 0
}
Rdb = redis.NewClient(&redis.Options{ Rdb = redis.NewClient(&redis.Options{
Addr: host + ":" + port, Addr: host + ":" + port,
Username: getEnv("REDIS_USER", "default"), Username: c.User,
Password: os.Getenv("REDIS_PASSWORD"), Password: c.Password,
DB: dbIndex, DB: dbIndex,
// Short on purpose. A degraded Redis must fail fast rather than tie up // Short on purpose. A degraded Redis must fail fast rather than tie up

218
docs/DELIVERY_SLOTS_APP.md Normal file
View File

@@ -0,0 +1,218 @@
# Delivery windows — the app contract
Shoppers now choose when their order arrives: one of three windows a day —
morning, afternoon, evening — set per branch by the shop.
Two things to build: show the choice at checkout, and send it with the order.
Everything else is done.
---
## 1. What a shopper may pick
```
GET https://fiesta.nearle.app/live/api/v1/mob/deliveryslots/available?tenantid=&locationid=
```
No authentication. Call it at checkout, once the branch is known.
**Real response** (branch 1179, taken at 19:02 IST):
```json
{
"code": 200,
"message": "Success",
"status": true,
"details": [
{ "deliveryslotid": 3, "slotkey": "evening", "name": "Evening",
"starttime": "17:00", "endtime": "20:00",
"slotdate": "2026-10-06", "istomorrow": false },
{ "deliveryslotid": 1, "slotkey": "morning", "name": "Morning",
"starttime": "08:00", "endtime": "10:00",
"slotdate": "2026-10-07", "istomorrow": true },
{ "deliveryslotid": 2, "slotkey": "afternoon", "name": "Afternoon",
"starttime": "12:00", "endtime": "15:00",
"slotdate": "2026-10-07", "istomorrow": true },
{ "deliveryslotid": 3, "slotkey": "evening", "name": "Evening",
"starttime": "17:00", "endtime": "20:00",
"slotdate": "2026-10-07", "istomorrow": true }
]
}
```
Morning and afternoon are absent from today because both had ended by 19:02.
**That filtering is already done — render the list as given.**
### Do no time arithmetic
Do not compare `starttime`/`endtime` against the device clock to decide what to
show. The server owns that rule, and the device's clock, timezone and locale are
all things we do not control. If the app re-derives it, the two will disagree
and the shopper will be offered a window the server then rejects.
The fields are there to display — "Evening, 5–8pm" — not to filter on.
### Ordering
Already sorted: today's remaining windows first, then tomorrow's, each by start
time. Render in the order given.
`istomorrow` is there so you can put "Tomorrow" beside a name without comparing
dates yourself.
---
## 2. An empty list is normal
```json
{ "code": 200, "message": "Success", "status": true, "details": [] }
```
**This is not an error, and it is the common case today.** Most branches have
not set windows yet, and they are trading normally right now.
When `details` is empty:
- Do not show the window picker
- Do not show an error, a retry, or "this shop is closed"
- **Let the order go through with no window**, exactly as before this feature
The whole rollout depends on this. A branch with no windows is an ordinary
branch, and treating it as broken would take every shop on the platform offline.
It is always `[]`, never `null`.
---
## 3. Sending the choice
```
POST https://fiesta.nearle.app/live/api/v1/mob/orders/createorder
```
Two new **optional** fields on the existing body:
```json
{
"deliveryslotid": 3,
"deliveryslotdate": "2026-10-06"
}
```
Send both or neither. Copy them straight from the chosen entry — do not
recompute the date.
Omitting them creates an order with no window, which is valid and unchanged
from today's behaviour.
---
## 4. The one error to handle
A window takes orders **right up until it ends**, then stops. So a shopper who
opens checkout at 09:58 and pays at 10:02 has chosen a window that closed while
they were deciding.
The server re-checks on every order and answers:
```json
{
"code": 409,
"status": false,
"message": "the morning window has closed for today — please choose another"
}
```
**On 409:** re-fetch `available`, show the fresh list, ask again. The `message`
is written to be shown to the shopper as-is.
Other 409 messages from the same check, all safe to display:
- `that delivery window is not one this shop offers`
- `the evening window is not currently available` — the shop switched it off
- `that delivery window has already passed`
- `a delivery date is required with a delivery window`
This is worth handling properly rather than as a generic failure. It is the one
case that will happen to real people in normal use.
---
## 5. Reading it back
Orders carry what was chosen:
```json
{ "deliveryslotid": 3, "deliveryslotdate": "2026-10-06" }
```
Both absent or `0`/empty on orders placed without a window. Show the window on
the confirmation screen and in order history; treat absence as "no window was
asked for", never as missing data.
---
## 6. What the window means
**A preference, not a promise.**
- Every order is accepted. A window never fills up and never blocks a sale.
- There is no capacity limit, and no "slots remaining".
- It tells the shop when to group the drop, and the shopper roughly when to
expect it.
Please do not word it in the app as a guaranteed delivery time. "Arrives
between 5 and 8pm" is right; "Guaranteed by 8pm" is not something the backend
can honour.
---
## 7. Done on our side
| | |
|---|---|
| `deliveryslots` table, per branch | ✅ live |
| `GET /v1/mob/deliveryslots/available` | ✅ live, filtering and dating already applied |
| `orders.deliveryslotid` + `deliveryslotdate` | ✅ live |
| `createorder` accepts and validates both | ✅ live, 409 on a closed window |
| Server timezone (IST) | ✅ fixed — windows close on the shop's clock |
| Shops set their windows at onboarding | ✅ live in both consoles |
| Shops edit them later (Store profile → Settings) | ✅ live |
| Window shown on the order in the console | ✅ live |
Verified end to end on live data: saved through the console, served to the app
already filtered, with today's closed windows correctly absent.
**Not done:** grouping the dispatch queue by window. That is a console concern
and does not affect anything above.
---
## 8. A branch you can test against
**Tenant `1141`, branch `1179`** — three windows configured:
| | |
|---|---|
| Morning | 08:00–10:00 |
| Afternoon | 12:00–15:00 |
| Evening | 17:00–20:00 |
```
GET /live/api/v1/mob/deliveryslots/available?tenantid=1141&locationid=1179
```
Call it at different times of day and the list shortens as windows close —
that is the easiest way to see the rule working.
For the empty-list path, use any other branch: most have no windows set, which
is exactly the case you need to handle.
---
## Questions
The rule lives in one place server-side (`services/deliverySlotService.go`), so
if anything about open/closed looks wrong, it is one function and not a
disagreement between us. Ask rather than working around it in the app — a
workaround on the device is how the two clocks drift apart.

103
docs/ENVIRONMENT.md Normal file
View File

@@ -0,0 +1,103 @@
# Environment & configuration
Everything the API reads from its environment goes through `config/config.go`.
It loads the right `.env` file, reads every setting into one `Config`, and
refuses to start — listing *everything* that is wrong in one message — before
a single connection is attempted.
## Which file loads
`APP_ENV` names the environment. It defaults to `local`.
| Command | Files loaded, in order |
|----------------------------------|---------------------------------|
| `go run .` | `.env.local`, then `.env` |
| `APP_ENV=production go run .` | `.env.production`, then `.env` |
| `APP_ENV=staging go run .` | `.env.staging`, then `.env` |
Precedence, highest first:
```
real environment > .env.<APP_ENV> > .env
```
A variable that is already set is never overwritten by a file, and no file has
to exist. `APP_ENV` itself is read from the real environment before any file
is opened — a file cannot decide which file gets loaded.
| File | Role |
|-------------------|------------------------------------------------------------|
| `.env.example` | The complete list of settings, with comments. Start here. |
| `.env.local` | The docker-compose stack. Every host is `localhost`. |
| `.env.production` | The live hosts. Loaded only when asked for. |
| `.env` | Shared base: fallbacks for whatever the file above left out. Keep it local. |
## Running locally
```sh
docker compose -f docker-compose.local.yml up -d # postgres :5433, pgvector :5434, redis :6379
go run . # APP_ENV unset → .env.local
```
An empty database is not enough — `main.go` runs migrations that assume the
live schema. See `init/README.md` for loading a schema dump first.
Startup prints what it loaded and where it is pointed:
```
config: loaded .env.local
config: loaded .env
config: APP_ENV=local, listening on :1122, database nearle@localhost:5433/nearledb
```
If `DB_HOST` is not a local address under `APP_ENV=local`, it says so:
```
⚠️ APP_ENV=local but DB_HOST=66.116.x.x is not a local address — every write goes to that database for real
```
That is a warning, not a stop. There is no "local mode" that protects
production: `go run .` against the live host creates real tenants and real
logins, and runs schema migrations on boot.
## Running in production
The container gets **no `.env` file at all** — `.dockerignore` keeps every
`.env*` out of the image — and the `Dockerfile` sets `APP_ENV=production`.
Every value comes from the platform's environment settings (Dokploy today;
ConfigMaps/Secrets under Kubernetes).
Under `APP_ENV=production` startup additionally insists on:
- `POS_TOKEN_SECRET` (or `JWT_SECRET_KEY` as a fallback), at least 16 characters.
A variable added to `.env.production` and not to the platform is a variable
that is unset in production. Missing required ones stop the boot with the
full list; missing optional ones (`MQTT_URL`, `REDIS_HOST`, `USE_S3`,
`CATALOGUE_DB_HOST`) silently disable that subsystem — check the startup log
lines when something is "not working".
## What is required
| Always | Only when enabled |
|----------------------------------------------|---------------------------------------------------------|
| `DB_HOST` `DB_USER` `DB_PASSWORD` `DB_NAME` | `CATALOGUE_DB_HOST` set → `CATALOGUE_DB_USER/PASSWORD/NAME` |
| (production) `POS_TOKEN_SECRET` | `USE_S3=true` → `S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY/REGION` |
A half-configured subsystem is an error, not a warning: a warning reads as
"fine" in a log and turns into "why are there no images" a week later.
## The committed credentials
The three `.env` files, including `.env.production`, are currently tracked in
git (commit `be47435`), and an earlier `.env` was committed before
2026-08-03. Every credential in them has to be treated as public:
1. Rotate the database, catalogue, Spaces, MQTT and Redis credentials and the
POS signing secret, and update them in the platform.
2. Take the files back out of the index and restore the ignore rules:
```sh
git rm --cached .env .env.local .env.production
printf '.env\n.env.*\n!.env.example\n' >> .gitignore
```
The files stay on disk; they just stop being committed.

176
docs/MAIL_SETUP.md Normal file
View File

@@ -0,0 +1,176 @@
# Mail setup — Google Workspace SMTP, sending as care@nearledaily.com
What this is for: the first-password invitation. Every back-office account on
Fiesta is created with an empty password, and the link in this email is the only
way to set one — the sign-in screen no longer offers a form, because a public one
meant that knowing a merchant's email address was enough to claim their account.
So this is not newsletter plumbing. **If the mail lands in spam, a business that
was just onboarded cannot sign in**, and the first anyone hears of it is a phone
call. Step 3 is the one that decides that, and it is the one people skip.
---
## The decisions
| | | why |
|---|---|---|
| Relay | Google Workspace SMTP | no server to run, no IP to warm, no port 25 exception to beg for. At a few dozen invitations a month that is the whole argument |
| Sender | `care@nearledaily.com` | the link points at `app.nearledaily.com`; a password mail whose sender and destination are different domains is the shape of a phishing mail |
| `care@` not `no-reply@` | | somebody replying "I never got this" is the most useful reply this system can receive, and it should reach a person |
| Auth | an App Password, never the login password | it can be revoked on its own if it leaks |
This replaces an earlier plan to self-host Postal. Postal is the better answer at
volume; it is the wrong answer for tens of emails a month, because the work is
not the software — it is IP reputation, rDNS and blocklists.
---
## Step 1 — Google Workspace on nearledaily.com
1. Sign up at workspace.google.com with `nearledaily.com`. Business Starter is
enough.
2. Verify the domain with the TXT record Google gives you.
3. Create `care@nearledaily.com`. It is a real mailbox and **somebody has to read
it** — merchant replies and bounce notices both land there, and a bounce is how
you learn an invitation never arrived.
## Step 2 — DNS on nearledaily.com
| Record | Name | Value |
|---|---|---|
| MX | `nearledaily.com` | `smtp.google.com` (priority 1) |
| TXT (SPF) | `nearledaily.com` | `v=spf1 include:_spf.google.com ~all` |
| TXT (DKIM) | `google._domainkey` | the key from Step 3 |
| TXT (DMARC) | `_dmarc` | `v=DMARC1; p=none; rua=mailto:care@nearledaily.com` |
- **One SPF record only.** If the domain already has one, merge
`include:_spf.google.com` into it. Two SPF records is a permerror and fails
every check.
- **Remove old MX records** if the domain receives mail somewhere else today, or
that mail keeps going to the old place.
- **Keep DMARC at `p=none`** for a couple of weeks, read the reports, then move to
`p=quarantine`. Going straight to `p=reject` is how you find a misaligned
sender by losing its mail.
## Step 3 — DKIM (the step people skip)
Admin console → Apps → Google Workspace → Gmail → **Authenticate email**.
1. **Generate new record** (2048-bit), add the TXT record it prints to DNS.
2. Wait for DNS to propagate — minutes to hours.
3. Come back and click **Start authentication**.
Until you click that last button the mail is unsigned, and unsigned mail carrying
a password link goes to spam.
## Step 4 — An App Password for Fiesta
1. Sign in as `care@nearledaily.com` → Google Account → Security → turn on
**2-Step Verification**.
2. Security → **App passwords** → create one named `Fiesta`. You get 16
characters.
3. That is what Fiesta uses. Never the account's real password.
No App passwords option? An admin has to allow it, or set up Admin console →
Gmail → Routing → **SMTP relay service** with "require SMTP authentication" and
"require TLS". In that case `MAIL_HOST` becomes `smtp-relay.gmail.com`.
## Step 5 — Point Fiesta at it
Credentials go in **`.env.secrets`**, which is read first and is the only env
file git ignores. Never in `.env` — that one is tracked and shared.
```sh
MAIL_HOST=smtp.gmail.com
MAIL_USERNAME=care@nearledaily.com
MAIL_PASSWORD=<16-character app password>
```
Already set in `.env`:
```sh
MAIL_PORT=587
MAIL_FROM=care@nearledaily.com
MAIL_FROM_NAME=Nearle
MAIL_CONSOLE_URL=https://app.nearledaily.com
```
Google shows the App Password as four groups — `abcd efgh ijkl mnop`. Paste it
with or without the spaces; Fiesta strips them for Google SMTP hosts only, and
only when what remains is the sixteen alphanumerics an App Password actually is.
Another relay's password is never edited.
`MAIL_CONSOLE_URL` is the **merchant** console and never the platform one — a
merchant sets their password at `app.nearledaily.com/set-password` and nowhere
else.
Restart. The log says which state it is in:
```
mail: sending as care@nearledaily.com via smtp.gmail.com:587
mail: OFF — <reason naming the missing variable>
```
**On a hosted deployment these belong in the platform's own environment**
(Dokploy), not in a file in the repo. A `.env` committed to the repository is
overwritten at build time — that is how the nutrition service shipped switched
off.
### What Fiesta does with them
`utils/mail.go` upgrades to TLS with STARTTLS before authenticating, and
**refuses to send at all if a relay offers no encryption while credentials are
configured**. Go's own `smtp.PlainAuth` would decline to hand over the password
anyway, so nothing leaks either way — but it reports that as the server refusing
the credentials, which sends somebody to check the password when the problem is
the connection. It also closes a downgrade, where an attacker strips STARTTLS
from the greeting.
## Step 6 — Check the DNS
```sh
dig TXT nearledaily.com +short # SPF, with _spf.google.com
dig TXT google._domainkey.nearledaily.com +short # DKIM key
dig TXT _dmarc.nearledaily.com +short # DMARC
dig MX nearledaily.com +short # smtp.google.com
```
Google's Check MX tool at toolbox.googleapps.com does the same job.
## Step 7 — Prove it end to end
Not "the config looks right" — watch one arrive.
1. Send a test to a [mail-tester.com](https://mail-tester.com) address. Aim for
9/10 or better **before** a real merchant sees one.
2. Onboard a test merchant with an address you can read.
3. Confirm it is in the **inbox, not spam**. In Gmail, "Show original" should
show SPF, DKIM and DMARC all PASS.
4. Follow the link, set a password, sign in at `app.nearledaily.com`.
5. Press **Resend invite**. It must refuse, naming the business:
*"… has already set a password — send them to the sign-in page instead."*
That refusal is what stops this becoming a password reset.
---
## Worth knowing
- **Limit:** about 2,000 messages a day per user. Onboarding runs at a few dozen
a month, so this is not a constraint.
- **Bounces** arrive as "Delivery failed" in the `care@` inbox. Nothing in Fiesta
watches for them, so somebody has to read that mailbox after onboarding.
- **Not for bulk.** Google does not permit marketing sends through Workspace. If
newsletters are ever wanted, that is a separate provider — not this mailbox.
- **If the App Password leaks:** revoke it in Google Account → Security, issue a
new one, update `.env.secrets`. Nothing else has to change.
## What the merchant receives
Plain text, deliberately. A password link arriving as an image-heavy HTML
template is the shape of a phishing mail, and plain text renders identically
everywhere. The body names the business, puts the link on its own line, and says
it expires in seven days — because an invitation found three weeks later needs to
explain itself rather than look broken.
The wording is `inviteMessage` in `services/inviteService.go`.

115
docs/NUTRITION_API.md Normal file
View File

@@ -0,0 +1,115 @@
# Nutrition and health score — the app contract
`GET /live/api/v1/mob/products/getproductbyvariant?tenantid=&productid=&variantid=`
Each product in `details[]` may now carry two extra keys. Both come from the
catalogue-intelligence service (`mcp.nearle.ai.in`) — the same records behind the
health score card in the console — fetched server-side, so the app needs no
second host, no second failure mode, and no copy of the rules below.
---
## nutrition
```json
"nutrition": {
"per": "100g",
"servingsize": "1 mini (11 g)",
"items": [
{ "name": "Energy", "value": 545, "unit": "kcal" },
{ "name": "Protein", "value": 7.5, "unit": "g" }
]
}
```
- **Absent when unknown.** Not `null`, not `{}`. A missing key means "we do not
know", never "this food has no nutrition".
- `items` is never empty when `nutrition` is present.
- `per` is `"100g"` for everything the service returns today. `servingsize` is
often absent — show the basis only when it is there.
- `value` may be a decimal. `unit` is free text and may be absent.
- Rows appear only when the service stated them. A null field is omitted; a
stated zero is kept, because "no fibre" is a fact and a dash is not.
## healthscore
```json
"healthscore": {
"score": 65,
"band": "good",
"label": "Healthy",
"positives": ["Good source of protein (7.5 g per 100 g)."],
"cautions": ["High in saturated fat (14.4 g per 100 g)."],
"diettags": ["High Fiber", "Vegetarian"],
"allergens": [],
"allergensunconfirmed": true,
"caveat": "Matched to a reference product with 61% confidence — treat these figures as a guide.",
"source": { "label": "openfoodfacts", "url": "https://..." }
}
```
- **Absent when there is nothing safe to show** — unscored, not food, or no
record at all. All three read as "not rated yet".
- `band` is one of `excellent` | `good` | `fair` | `poor`, for styling. `label`
is what a shopper reads. Use the label; do not re-derive it.
- `score` is 0–100, already rounded.
- `positives`, `cautions` and `diettags` are sentences the service wrote for a
person. Render as given.
### Two rules the app MUST honour
**`caveat`, when present, has to be on screen.** It means the underlying source
match was weak — most are; the service matches down to 0.32 confidence. A
nutrition table presented as fact on a 61% match is a claim the data does not
support.
**`allergensunconfirmed: true` means an empty `allergens` list must NOT be
rendered as "contains none".** Say "not confirmed — check the packet". Silence
standing in for "none" is the one failure here that can put somebody in hospital.
A declared allergen is always sent and must always be shown.
---
## Why the judgement is server-side
The service returns a raw number and, from this endpoint, no band. Deciding which
band, whether the match is strong enough to state plainly, and whether the
product is even food is a set of rules that already exists in the console. Two
implementations would drift and disagree about the same product on two screens.
The edibility guard is the sharpest of them. The upstream per-product endpoint is
**not** gated for it: on 4 Sep 2026 it rated Godrej Hit insecticide 80/100 with
`data_status: "verified"`, and soap and shampoo both scored 37.5. Those records
now read "unavailable", and the guard stays — this tenant sells soap and
toothpaste beside its biscuits.
## Coverage today
Measured 29 Sep 2026 against tenant 1147: **6 of 15 catalogue-linked products
have nutrition**, and fewer have a score. The Patanjali ghee this work started
from has neither.
Test with **product 7101, Balaji Wafers Simply Salted** — a full panel and a
65/100 score.
```sh
go run ./scratch/nutritionproof # three real products
go run ./scratch/nutritionproof <brand> <image_id> # any product
```
## Known bad data upstream
Balaji Wafers reports `sodium_mg: 0.967` — under 1 mg per 100 g, for salted
crisps, where 500–900 mg is normal. The `Salt` figure of 0.002 g is wrong the
same way. It looks like a grams/milligrams mix-up at the source.
Fiesta passes the value through as given rather than scaling it: silently
"correcting" a food label is how wrong data becomes invisible. It will look wrong
in the app until the agent team fixes the unit.
## Configuration
`NUTRITION_BASE=https://mcp.nearle.ai.in/api`, in `.env`. Unset means neither key
is ever sent and nothing else changes. Lookups are cached six hours, capped at
three seconds, and every failure costs that product its panel rather than the
response.

778
docs/PORTFOLIO.md Normal file
View File

@@ -0,0 +1,778 @@
# Nearle "Fiesta" backend — what was built
A Go monolith that serves a multi-tenant retail platform: neighbourhood shops
(tenants) with one or more outlets, selling through a consumer app, a rider
delivery fleet, and — the newest and largest piece of work — **physical
point-of-sale terminals sitting on shop counters**.
There are really five distinct systems in here. They are ordered below by how
much original engineering they represent.
---
## 1. The POS terminal integration (the centrepiece)
### What it is
Retail tills in shops run a Flutter app with its own local SQLite database. They
keep selling with **no network at all** — a village shop's connection drops for
hours. When connectivity returns, each till uploads the bills it rang while
offline, pulls down an updated product catalogue, and reports its own health.
This backend is the other end of that conversation. It has to solve the classic
offline-first sync problem under a hard constraint: *a sale that has already been
paid for in cash must never be lost, and must never be counted twice.*
The problem it solves for the business: shops that were doing counter sales
entirely off-platform now have those sales in the same database as their app
orders, deducting from the same stock, appearing in the same revenue reports.
### How it's built
```
┌─────────────────────────────────────────────────────────┐
│ Till (Flutter + SQLite) — not in this repo │
│ sale committed locally first, sync_status = 0 │
└───────────────┬──────────────────────────┬──────────────┘
│ │
(transport A) MQTT (transport B) HTTPS
nearle/pos/{loc}/{term}/order POST /live/api/v1/pos/orders
nearle/pos/{loc}/{term}/customer POST .../customers
nearle/pos/{loc}/{term}/health POST .../health
│ │
▼ ▼
┌──────────────────────┐ ┌────────────────────────┐
│ messaging/posmqtt.go │ │ controllers/ │
│ • leader election │ │ posController.go │
│ • bounded worker │ │ • PosAuth middleware │
│ pools (ingest, │ │ verifies token + │
│ health) │ │ outlet ownership │
└──────────┬───────────┘ └───────────┬────────────┘
└────────────┬──────────────┘
▼
services.PosService ← one code path for both
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
posRepository posSalesRepository posPresence
(ingest, catalogue) (read-back) (Redis)
│ │ │
▼ ▼ ▼
┌──────────────────────────────┐ ┌──────────────┐
│ POSTGRES (nearledb) │ │ REDIS │
│ pos_orders │ │ pos:terminal:│
│ pos_order_items │ │ {id} HASH │
│ productstocks ← shared │ │ TTL 90s │
│ customers, app_users │ │ pos:location:│
└──────────────────────────────┘ │ {id} SET │
└──────────────┘
│
▼
ack → nearle/pos/{loc}/{term}/ack (or HTTP 200 body)
│
▼
Till marks bill synced, keeps its copy 7 more days
```
The storage split is deliberate and explained in the code:
- **Postgres `pos_orders` / `pos_order_items`** — the permanent record of counter
bills, kept *separate* from the app's `orders` table. A bill carries a cashier,
a terminal id, a rounding adjustment, promo campaigns, loyalty movement and a
payment split across several tenders; `orders` has nowhere to put any of that.
The stated cost of the split is that every revenue query has to union both —
which was done, in `orderRepository.posRevenue` and `posSalesTotals`.
- **Postgres `productstocks`** — stock is deliberately *not* split. A counter sale
writes the same "out" ledger rows an app order does, through the same helper, so
the catalogue pushed down to a till reflects the till's own trading.
- **Redis** — terminal presence only, with a 90-second TTL. Shared with a separate
Express backend (the rider app reads the board), namespaced `pos:*` so it cannot
collide with that service's `delivery:*` / `city:*` keys.
### How the problem was solved — the approach
Six interlocking decisions.
**(a) The acknowledgement protocol is the whole design.** Delivery is
at-least-once. The rule, stated in `models.PosAck`: *a till marks a record synced
if and only if its id appears in `accepted`.* Silence is not acceptance — an empty
ack, a dropped connection, or a 200 with no body all leave the record pending, and
it gets re-sent. `rejected` is a separate, deliberate verdict meaning "stop
retrying this one, fetch a human" — used for a malformed bill, never for "the
database is having a bad minute." That distinction is enforced right up in the
HTTP layer: `posIngestError` decides 4xx (permanent — the till halts and shows a
person) versus 5xx (unknown — the till keeps everything and backs off).
**(b) Idempotency: a duplicate is a success, not a failure.** Each bill carries a
UUID minted at the till, stored as `terminalorderid` with a unique index. On
arrival, `importPosOrder` opens a transaction, takes
`pg_advisory_xact_lock(hashtext('possale:' || id))`, then checks whether the bill
is already held. If it is, the transaction rolls back and the bill is
**accepted** — stock untouched. Calling a re-delivered bill a failure would strand
a day's takings on the till forever. The advisory lock turns what would otherwise
be a unique-constraint violation into an orderly "already held," and closes the
race where two redeliveries arrive simultaneously.
**(c) Ordering inside the transaction — locks, then availability, then writes.**
`stockLedger.go` holds the shared machinery. `lockStockRows` takes
`SELECT … FOR UPDATE` on every `(tenant, location, product)` the sale touches,
**sorted by (productid, locationid)** so two concurrent sales sharing products
always contend in the same sequence and block rather than deadlock. Only then does
`assertStockAvailable` read balances, and only then are rows written. This is the
same path an app order and a spreadsheet import take — the code was extracted
specifically so there aren't three implementations of the anti-overselling rule
drifting apart.
**(d) Line-item reconciliation.** A subtle one. The till has already apportioned
bill-level discounts across its lines to get tax right, but it sends each line at
its *pre-apportionment* value. Left alone, summing line items gives the subtotal
while the header carries the total, and two reports disagree. So the code computes
`amountFactor = (total − roundoff) / Σ line_total` and
`taxFactor = header_tax / Σ line_tax`, and scales each line onto what was actually
collected. The till stays authoritative for the bill as a whole; this only decides
attribution *within* it.
**(e) The catalogue downlink is a delta protocol with a safety invariant.**
`GET /pos/catalogue` answers either a full snapshot or a change set. The terminal
treats `is_delta: false` as a snapshot and **withdraws every product the response
does not mention** — so mislabelling a filtered result empties the shop's shelf.
The code therefore derives both the filter and the flag from one value:
`cutoff := posRevisionCutoff(...)`; zero cutoff ⇒ no filter ⇒ `is_delta: false`,
non-zero ⇒ filtered ⇒ `is_delta: true`. There is no path that filters without
setting the flag. Supporting details:
- The revision is an opaque token `loc{id}-{YYYYMMDDTHHMMSSZ}` the till stores and
hands back. If it is unreadable, malformed, or belongs to a *different outlet*,
the cutoff is zero and you get a full snapshot — failing toward "send
everything" is the only safe direction.
- **The revision only advances on the final page.** Mid-pagination it echoes back
whatever the till already had. A till that dies half way through a paginated
pull must not end up holding a revision claiming it saw pages it never received
— those products would be excluded from every future delta, silently, forever.
- The revision stamp is taken **one second in the past**, so a product written
during the same second the query ran cannot land on the wrong side of the next
cutoff. Costs one redundant row; cannot lose one.
- "Changed" is one predicate covering three things: the product row, its
per-location row (price/availability), or its stock ledger. Stock is in there
because a shop's count drifts on every sale rung at another counter.
- Acknowledged limitation, in a comment: a product *deleted* from
`productlocations` leaves no tombstone, so a delta cannot know to withdraw it.
Only a full pull collects those — hence "pull without a revision every morning."
**(f) Presence is a TTL, not a table.** A heartbeat is a fact with an expiry date.
In Postgres it would be ~288k writes/day across a hundred tills plus a reaper job
to mark them dead. A Redis hash with a 90s TTL (three missed 30s heartbeats — "two
would make a GPRS hiccup look like a dead till; five would take 2½ minutes to
notice a real one") ages out for free. The location→terminals SET has *no* TTL:
it is an index of what exists, not a claim anything is alive. A till whose hash
expired comes back as a stub marked `offline` with a reason, rather than being
omitted — because the missing till is exactly what someone is looking for. The
write also `HDEL`s fields the till stopped reporting, so a hash never lingers at a
stale battery reading.
#### Tricky cases the code explicitly handles
| Case | Handling |
|---|---|
| Bill with no id | Rejected — nothing to dedupe on, and it would double on every retry |
| Fractional quantity (1.5 kg onions) vs integer stock column | `roundStockQty` rounds **up** — conservative, never records more stock than is physically there. Flagged in-code as a workaround, not a fix |
| Timestamps without a timezone offset (older terminal builds) | Accepted, with a comment stating the instant will be wrong by the offset and is unrecoverable — but the *business date* is right, which is what daily figures use |
| `jsonb` columns left at Go's zero value | Forced through `posJSON`, which emits `"null"` — an empty string reaches Postgres as invalid JSON and takes the whole bill down |
| Terminal id present on the batch but not the bill | `posTerminalFor` falls back, trimming first so `" "` is not mistaken for a real code |
| Broker Last Will (`{"status":"offline"}`) | Arrives on the same handler and is recorded verbatim — exactly right for a till that lost power |
| Customer registrations replayed | Insert-if-absent, **never update** — a profile corrected at head office must not be reverted by a till replaying months-old data |
| Loyalty points on the uplink | Deliberately absent from the wire format. Points are derived from the bill stream (idempotent, sees every counter); accepting a till's local balance would make "last till to sync wins" |
#### Trade-offs, and what they buy
- Bills separate from `orders` → full fidelity, at the cost of every report needing
a union.
- Payment mode denormalised to "largest tender" for grouping, with the full split
kept verbatim in `paymentsjson` → fast reports, no lost reconciliation data.
- `businessdate` denormalised as a `YYYY-MM-DD` string → a day's takings is one
indexed equality match instead of a range scan with timezone arithmetic.
- Barcode generation: `products.productsku` cannot be trusted for the till's
*unique* barcode index (in live data, thousands of products share a single SKU
value), so a SKU is only used if it looks like a real EAN/UPC — 8–14 digits —
and otherwise the product id stands in. Scanning physical barcodes will not work
until real ones are populated; the comment says so plainly and notes it starts
working with no code change.
---
## 2. Transport, concurrency and delivery guarantees
### What it is
The same ingest, over two transports (MQTT and HTTP), running under multiple
replicas, with backpressure that reaches all the way back to the till.
### How it's built
`messaging/posmqtt.go` (broker client, topic routing, ack publishing) plus
`messaging/posworkers.go` (a bounded worker pool). Both hand off to the same
`PosService` the HTTP controller uses — the facade exposes `f.PosService()`
specifically so a bill cannot behave differently depending on how it arrived.
### The approach
**Leader election with no coordination service.** MQTT has no queue groups — every
subscriber gets every message, so three replicas would each commit the same bill
and publish three acks. The ingest is idempotent so nothing double-counts, but it
is 3× the database work. The fix: a StatefulSet gives pods stable ordinal names,
so **ordinal 0 is the elected consumer** — no lease, no lock, no extra dependency.
Overridable via `POS_MQTT_CONSUMER=always|never`; a non-ordinal hostname (bare
container, local dev) is elected, because "a single instance that refused to
consume would be a far more confusing failure."
**Backpressure by construction.** paho delivers on one goroutine, so naively every
bill commits serially — a bill is a full transaction (advisory lock, dedup, row
locks, availability, four inserts, commit) at ~10–30 ms, giving 30–100 bills/sec,
and a shop-wide backlog after an outage takes minutes. paho *can* call handlers
concurrently, but it spawns without limit — a storm would open a transaction per
message, exhaust the connection pool, and stall everything at once.
So: a fixed pool behind a bounded queue, and `submit` **blocks** when the queue is
full. That is the point — paho stops acking, the broker's in-flight window fills,
it stops sending, and the till holds its bills and retries. `SetOrderMatters(true)`
is kept on precisely because single-goroutine delivery is what makes that chain
work; concurrent delivery would let paho keep reading no matter how far behind the
workers were.
**Separate pools for bills and heartbeats.** A heartbeat is one Redis write; a bill
is a transaction. Sharing a queue would delay presence behind a bill backlog, and
every till would appear to go dark at the exact moment the system was busiest.
**The pool's shutdown race is handled explicitly.** A plain `select` over a
done-channel and the job channel is not enough — once both are ready Go picks at
random, and picking the send panics on a closed channel. So there is an `RWMutex`
held for *reading across the whole of `submit`*, and `stop` takes the write lock
before closing. The comment works through why this cannot deadlock: workers only
exit once the channel is closed, which happens under the write lock the in-flight
send is holding off. Post-close submissions run **inline** rather than being
dropped — discarding a bill that already reached you is worse than doing it slowly.
**Identity comes from the topic, never the body.** `topicIdentity` parses store and
terminal out of `nearle/pos/{store}/{terminal}/{kind}`. A till that could name its
own store in a payload could post sales into another shop's books. There is a test
named exactly that: `TestABodyCannotOverrideTheTopicIdentity`.
**Shutdown order is load-bearing.** `Close()` drains the worker pools *before*
disconnecting, so a bill mid-commit still gets its ack out. Disconnecting first
would strand it — committed here, unacknowledged there, re-sent on the till's next
attempt.
Payloads are copied in `wrapHandler` because paho reuses its buffer once the
handler returns, and the work now happens after that.
The test suite here is genuinely good: bounded concurrency, blocking-not-dropping,
drain-on-stop, idempotent stop, payload copying, ordinal election, and "a failed
presence write does not stop the till."
---
## 3. Terminal authentication and shop-managed staff
### What it is
Before this work, the POS surface was completely open: a till held a store id
typed into a Settings screen and a password compiled into the app, so
`store_id=1185` in a URL was enough to read another tenant's catalogue or post
bills into their books. One leaked build opened every tenant on the platform. This
subsystem replaces that with a real session, and adds shop-run staff management on
top.
### How it's built
```
POST /pos/login (the only unguarded route — it's where tokens come from)
│ authname|contactno + password [+ optional configid, location_id]
▼
posAuthRepository.PosLogin
│ reads the SAME app_users rows the web console authenticates against
│ resolves the outlet FROM the user's record — never from the wire
▼
posService.mint → utils.MintPosToken
│ base64url(payload) "." base64url(HMAC-SHA256)
│ claims: uid, tid, lid, rid, cid, trm, iat, exp TTL 30 days
▼
PosSession { token, expires_at, role, can_manage_staff,
tenant + GSTIN + address (for the printed invoice),
locations[] (picker for multi-outlet owners),
staff[] (so the till can trade immediately) }
│
▼
every other /pos/* route → middleware.PosAuth
1. verify signature 2. is the named outlet owned by the token's tenant?
```
### The approach
**A signed, self-describing token rather than a session table.** Reasoning given in
`utils/postoken.go`: a till is not a browser. It signs in when the shop opens and
bills for a whole day on a connection that comes and goes, so the credential must
survive reboot, network loss, and an hour in a drawer. A server-side session table
fails that (a till that cannot reach you must still be able to prove who it is when
it returns), and so does a short expiry.
**Deliberately not JWT.** One issuer, one audience, one algorithm — the header JWT
spends bytes negotiating is a constant. And `alg` is the source of JWT's
worst-known footgun (`alg: none`); a format with no algorithm field cannot have
that bug. The MAC is taken over the *encoded* payload so verification never
re-serialises anything.
**Verification order is deliberate:** signature first, *then* expiry. Reading `exp`
out of an unverified payload would mean taking the attacker's word for when their
own token runs out. Comparison is `hmac.Equal` (constant time). A token that
verifies but names no outlet is refused, so it cannot be mistaken for one that
authorises everything.
**The middleware's second check is the one that matters.** A valid token is not a
licence to name *any* outlet — it is a licence to name *your* outlets.
`requestedLocation` reads all three spellings the routes use (`store_id`,
`locationid`, `location_id`) rather than breaking terminals in the field by
normalising, and — crucially — **searches the JSON body, not just the query
string**, because the two routes that *write* carry `store_id` in the batch and
never in the URL. It handles the id being sent quoted or bare, since accepting only
one shape would silently skip the check, and "a skipped check reads exactly like a
passed one."
**A migration escape hatch, honestly labelled.** `POS_AUTH_REQUIRED` defaults to
**off**, because terminals are already in shops billing real customers against
unauthenticated endpoints and flipping enforcement at deploy would stop every one
of them mid-trade. While off, a token that *is* sent is still fully verified and a
wrong-tenant request is still refused — the flag only governs requests carrying
none.
**Two-tier sign-in: password opens the terminal, PIN switches the operator.**
- The session token is the security boundary. A four-digit PIN is not:
`POST /pos/login/pin` sits *behind* the guard, so guesses are confined to one
already-opened outlet's own staff.
- PIN login mints a **fresh** token rather than reusing the presented one, so a
cashier taking over from a supervisor drops the supervisor's permissions instead
of inheriting them.
- PINs travel in the clear over TLS, and the model file argues the case rather than
hiding it: four digits is brute-forceable in microseconds whatever it is wrapped
in, so hashing here buys the appearance of strength; meanwhile the terminal salts
every PIN with its own random salt, so a hash computed server-side could never be
verified there without inventing and maintaining a shared scheme across two
codebases. The honest framing: **a PIN is shift attribution, not a security
boundary.**
**Schema archaeology, handled rather than wished away:**
- `authname` is not unique in `app_users` — live data has the same address twice
under one config. Rather than `LIMIT 1` (which would let a stranger's account
shadow the one a person meant, and on a POS means billing into the wrong tenant),
multiple matches are **refused** with an actionable message.
- Inactive accounts are excluded from the *match*, not matched-then-refused, so a
deactivated leaver cannot make a live login ambiguous.
- `configid` (which tenant portal an account belongs to) is asked for by the web
console because the browser knows it — but a person at a counter has never seen
the number. So it is honoured when sent, inferred when not, and an ambiguous
inference is reported rather than guessed. `PosConfigidFor` infers it from
whichever value the tenant's existing accounts most commonly carry.
- Staff come from **two** sources unioned: the purpose-built `tenantstaffs` table
(a dozen rows on the entire platform) and `app_users.locationid` (where staff
actually ended up). Reading either alone returns the wrong answer.
- Duplicate PINs are dropped from the response, because live data has one PIN
shared across many accounts — a shared PIN would attribute a bill to whichever
row was read first.
- PINs are bounded 1000–9999 with **no leading zero**, because `app_users.pin` is a
`bigint`: "0451" stores as 451, and the cashier types four digits and is refused
forever. That costs 1000 of 10000 combinations and buys a PIN that means the same
thing in both directions. Obvious PINs (1234, 1111, …) are rejected.
- `userid` is left to Postgres's identity column, with a comment explaining the
earlier mistake: `information_schema.column_default` is empty for identity
columns, which reads like "no default," and a hand-rolled MAX+1 leaves two
allocators racing.
- Emails go through `NULLIF(?, '')` because a unique constraint means a second
PIN-only cashier would collide on the empty string, whereas NULLs do not collide
in Postgres.
**The inversion, applied to people.** `PosUserRequest` has no tenant and no
location field. A supervisor creating staff can only ever create them at their own
outlet, and *no field in the struct can say otherwise* — the same inversion that
stopped a till naming its own shop. Updates are scoped by tenant *and* location in
the `WHERE` clause rather than checked first, so a wrong user id updates zero rows
and is reported, instead of quietly editing another shop's staff. Deactivation is a
status change, never a delete, because bills carry the cashier's name. You cannot
deactivate the account you are signed in as, or the last supervisor could lock the
whole shop out with one tap.
The same service calls are exposed to the web console under `/web/tenants/*` and
`/mob/tenants/*` — deliberately the same code, not a parallel implementation,
"because two code paths writing one table is exactly how that stops being true."
The route file itself flags that this half is weaker: the console *asserts* its
outlet where a terminal *proves* it, and says these should move behind a session
guard as soon as the console can hold one.
---
## 4. The shared order + stock engine
### What it is
One transactional path that every sale in the platform goes through, regardless of
channel: an app order, a spreadsheet import of historical counter sales, or a POS
bill.
### How it's built
```
CreateOrder (app) UploadOfflineSales (spreadsheet) importPosOrder (till)
│ │ │
│ per-bill tx + advisory lock per-bill tx + advisory lock
│ + remarks-based dedup + terminalorderid dedup
▼ ▼ │
┌──────────────────────── createOrderTx ──────────────────────┐ │
│ 0. lockStockRows (FOR UPDATE, sorted, deduped) │ │
│ 1. assertStockAvailable (ledger balance, all lines first) │◄───────┤ (uses the same
│ 1b. priceOrderLines (fill unpriced lines from catalogue) │ │ stockLedger.go
│ 2. nextSequenceNo (UPDATE…RETURNING inside the tx) │ │ helpers directly)
│ 3. insert header, insert lines, recordStockOut per line │ │
│ → syncProductLocationStatus re-derives availability │ │
└─────────────────────────────────────────────────────────────┘ │
│ contract: on failure it has already rolled back; │
│ on success tx is left OPEN so the caller can │
│ include its own dedup guard in the same tx │
▼ ▼
COMMIT pos_orders + productstocks
```
### The approach
**Stock is derived, never stored.** Availability is `SUM(in) − SUM(out)` over
`productstocks`, computed under the row locks. `productlocations.status` is a
*derived cache* re-synced from that balance after every movement, in the same
transaction, by the same rule on both the sale side and the receiving side. A
commit message captures the earlier bug this replaced: receiving stock used to
overwrite `products.productstatus` — a per-product **lifecycle** column — with an
**availability** value, destroying the lifecycle state of well over a hundred
products. Availability is a per-outlet fact and a single column on `products`
cannot express it, since the same product can be stocked at one outlet and empty at
another.
**Order-number allocation.** `nextSequenceNo` does read-and-increment in a single
`UPDATE … RETURNING` inside the caller's transaction. The doc comment is a small
forensic report on the previous implementation: two separate calls on `r.db` (not
the transaction), so concurrent orders read the same value; a `NULL` counter made
`COALESCE(MAX(x)+1, 1)` evaluate `NULL+1 = NULL` and fall through to a hardcoded
`"<tenantid>-1"`, so a whole cohort of live orders share one id; tenants with
multiple sequence rows hit a `GROUP BY` where the read kept the first row and the
write updated all of them. The fix pins to `MIN(sequenceid)`, seeds a NULL from the
tenant's existing order count (guaranteed ≥ any id already issued, so recovery never
reissues), and creates the row on first use.
**Two rounding conventions, kept apart on purpose.** `legacyOrderQty` (truncate,
floor at 1) is preserved *exactly* for app orders — changing it would silently
alter stock deduction for every order in production. `roundStockQty` (ceil) is used
by the POS path. Both are named, tested, and flagged in a comment as something to
reconcile once someone owns the decision. That is the right call: the divergence is
documented rather than papered over.
**Deduplication without a natural key.** The spreadsheet importer dedupes on
`orders.remarks = "OFFLINE:<billno>"` under an advisory lock. When the sheet has no
bill number, an **FNV-1a hash of the bill's own contents** (date, mobile, payment
mode, and each line's product/qty/price) stands in — so re-uploading the same file
is a no-op rather than a double stock deduction. The trade-off is stated: two
genuinely separate identical baskets on the same day with no bill numbers will
collide, and the result *names* the collision rather than hiding it.
**Referential scaffolding.** The order-listing query INNER JOINs five tables, so an
imported order with a zero `applocationid` or `customerid` would be written
successfully and then be **invisible in every screen**.
`resolveOfflineLocationContext` is the single place where "this location belongs to
this tenant" is established (so editing a locationid in a spreadsheet reaches
nothing), and it fills the scaffolding from `tenantlocations` plus the most recent
real order at that outlet — because `tenantlocations` carries 0 for
`moduleid`/`partnerid` at outlets whose live orders use non-zero values. It refuses
outright rather than writing an order that will never be visible.
**Batch semantics.** Each bill is its own transaction, so one bad row cannot undo
the rest, and the response says exactly which landed, which were duplicates, and
which failed with why. Branch context and catalogue are memoised per outlet so a
workbook covering six branches does not re-run both queries per bill.
Also here: `priceOrderLines` fills lines the client sent unpriced from the
merchant's own catalogue, using arithmetic that *deliberately matches* the offline
importer exactly — gross, minus discount, tax extracted from the landing amount
because shelf prices are MRP (tax inside). One convention for both channels, so the
same basket rings up the same either way. This fixed a real bug where
catalogue-imported products had no per-store price, so real delivered orders
recorded zero revenue.
---
## 5. Brand catalogue bridge and the rest of the platform
### What it is
A **second, isolated Postgres database** holds a curated master catalogue of
packaged goods, organised one table per brand, with rich metadata (title,
description, nutrients, highlights, FSSAI licence, size, variant key, providers).
Shop owners browse it and "import" products into their own store catalogue rather
than typing them in. Product photography lives in S3-compatible object storage.
### How it's built
```
CatalogueDB (separate conn, may be nil) Object storage (S3-compatible)
brand_<name> tables daily/brands/{brand}/{image_id}/*
│ │
│ catalogueRepository │ db/imagestore.go
│ (table name from a fixed allowlist) │ full LIST → in-memory map,
│ │ swapped atomically, 30-min refresh
└──────────────┬───────────────────────────────┘
▼
productService.ImportCatalogueProduct
│ snapshot into tenant `products` (keyed brand+catalogueid)
│ + link via productlocations (price, stock, status)
▼
nearledb: products / productlocations / productstocks
▼
POS catalogue pull · customer app · order lines
```
### The approach
- **Isolation is enforced structurally.** `NewCatalogueRepository` takes the
catalogue connection and *never* `db.DB`. If the catalogue env vars are absent,
the connection is simply nil and catalogue endpoints return a normal error — it
must never block startup of the main app. Same rule for Redis and the image
store: optional dependencies degrade, they do not kill the process. The one
dependency that *is* fatal on misconfiguration is the MQTT broker, and the
comment says why: "coming up healthy while every till quietly queues is the worse
failure."
- **Table names cannot be parameterised in SQL**, so brand → table goes through a
fixed allowlist map. That is the correct pattern.
- The catalogue DSN is built as a URL and percent-encoded via `net/url` rather than
a `keyword=value` DSN, because that password contains characters the keyword
format would misparse as quoting/comment syntax.
- GORM's raw scan silently drops slice-kind destination fields, so `text[]` columns
are cast to text in SQL and parsed in Go.
- Cross-brand browsing merges and sorts in Go, with an explicit note that this is
fine at a few hundred rows and should become a `UNION ALL` if it grows.
- The image store caches a full object listing so no GET ever calls out to S3,
rebuilt from scratch every 30 minutes and swapped under a write lock. A nice
deployment detail: the endpoint is bucket-qualified, and the SDK's
virtual-hosted-style client re-prepends the bucket — so the client is pointed at
the bare region host or requests get addressed to `bucket.bucket.…`.
- Import is idempotent on `(tenant, brand, catalogueid)`: re-import updates pricing
rather than creating a duplicate product.
**The surrounding platform** — roughly two thirds of the file count — is a
conventional layered Fiber/GORM app: orders, deliveries (rider dispatch, status
lifecycle with mirrored timestamps, rider/report summaries), products and stock,
tenants and outlets, customers, partners, users, and FCM push. Most of it is CRUD
and reporting SQL. The parts worth noting are the *repairs*, which are documented
in-place with the evidence that motivated them:
- `UpdateDelivery` used to write the parent order with `WHERE orderheaderid = ?`
from a client-supplied field. Clients often omitted it, making it `= 0`, matching
nothing — and GORM reports no error for an update affecting zero rows, so the API
answered success while the order silently kept its old status. Hundreds of
deliveries were marked delivered against orders still reading pending. Fixed by
deriving the link from `deliveryid` (the one field every caller must send) and
failing loudly when the row is not there.
- The rider push-notification route had been commented out since the initial commit
— the handler, the model, the Firebase service account and the Dockerfile `COPY`
were all in place; only the route registration was missing. So every rider push
the admin console ever sent returned 404, and riders were assigned deliveries and
never told.
- New store outlets and their logins were being forced to `InActive`, which blocked
the spawned manager login before it could ever reach the password-setup screen.
- Tenant onboarding created admin users with `configid = 0`, which the web login
(which queries `configid = 1`) could never find — permanently unfindable accounts.
- Order line items were being silently dropped.
---
## The stack
**Language / runtime**
- Go 1.24
**Web / API**
- Fiber v2 (`gofiber/fiber/v2`), CORS middleware, custom `PosAuth` middleware
**Data**
- PostgreSQL (primary, `nearledb`) via GORM 1.25 + `pgx/v5` driver — heavily raw
SQL, GORM mostly as a connection/scan layer
- A second PostgreSQL instance for the brand catalogue (described in code as
pgvector)
- Redis 7-family via `go-redis/v9` — TTL-based presence, shared with a separate
Node/Express service
- Postgres features used directly: advisory locks (`pg_advisory_xact_lock`),
`SELECT … FOR UPDATE`, `UPDATE … RETURNING`, `jsonb`, identity columns
**Messaging**
- Eclipse Mosquitto over MQTT, `eclipse/paho.mqtt.golang` v1.5 — QoS 1, persistent
sessions, retained messages, Last Will
**Crypto / auth**
- `crypto/hmac` + SHA-256, custom compact token format (not JWT)
**Cloud / integrations**
- AWS SDK Go v2 S3 client pointed at an S3-compatible object store
- Firebase Cloud Messaging via `golang.org/x/oauth2` JWT service-account flow
(`firebase.google.com/go` present)
**Config / ops**
- `godotenv`, `spf13/viper`, `time/tzdata` (Asia/Kolkata baked in)
- Multi-stage Dockerfile → static binary on Alpine
- Kubernetes StatefulSet *(inferred — from the `HOSTNAME` ordinal election logic
and the `-0` convention, not from manifests in this repo)*
**Testing**
- Go stdlib `testing`, table-driven, with hand-rolled fakes for the MQTT client and
the POS service — no mocking framework
**Consumers of this API** *(not in this repo; described in docs and comments)*: a
Flutter POS terminal app with local SQLite, a React/TypeScript merchant console, a
consumer mobile app, a rider app, and a separate Node/Express backend sharing the
Redis instance.
---
## What's genuinely hard here
**1. The ack protocol and idempotency, together.** Anyone can write "insert if not
exists." What is hard is the discipline that follows from at-least-once delivery
when the payload is *money that has already changed hands*: a duplicate must be
reported as success, silence must never be interpreted as acceptance, "reject" must
be reserved for permanent faults, transport errors must produce *no* ack at all,
and the whole thing has to hold under a shutdown. The code gets all five right and
the reasoning is written down at each decision point. The
advisory-lock-then-check pattern — turning a constraint violation into an orderly
"already held" — is the specific move to point to.
**2. The delta/snapshot invariant in the catalogue pull.** The failure mode —
`is_delta: false` on a filtered response empties a real shop's shelf — is the kind
of bug that only shows up in a store, at a counter, with a queue. Deriving the
filter and the flag from a *single* value so no code path can set them
inconsistently is the right structural answer, not a defensive check. The
pagination detail (never advance the revision mid-pull) and the one-second cutoff
overlap are both real distributed-systems reasoning: each is a choice about which
direction to fail in, and each picks "redundant work" over "silent permanent data
loss."
**3. Backpressure that reaches the physical device.** The chain is: bounded queue
blocks → paho's single delivery goroutine stalls → broker's in-flight window fills
→ broker stops sending → till holds its bills. Every link is a deliberate
configuration choice (`SetOrderMatters(true)` exists solely to preserve link two).
The alternative designs are both worse in ways that are only obvious once you have
reasoned it through: unbounded goroutines exhaust the connection pool and stall
everything at once; dropping work loses a sale you had already accepted. Plus the
pool's close race — recognising that `select` over done-and-jobs picks randomly and
can panic on a closed channel, and solving it with a read-lock held *across the
send* — is a genuinely subtle piece of Go concurrency.
**4. Retrofitting authorisation onto a live, unauthenticated fleet.** The
intellectual move is small and correct: **invert the direction of the store id.**
It was an input (typed into Settings, believed on the wire); it becomes an output
(derived from the authenticated user's record, sealed under a signature).
Everything else follows — `PosUserRequest` having no tenant field, the middleware's
tenant-owns-outlet cross-check, the read-the-body-not-just-the-query detail that
closes the hole on the exact routes that write. The rollout strategy (ship the
endpoint, let the fleet adopt, then flip `POS_AUTH_REQUIRED`) is how you do this
without stopping a hundred shops trading, and the code is honest that the flag is a
temporary state and not a design.
**5. Sharing one transactional path across three channels without forking it.**
`createOrderTx`'s contract — *on failure it has already rolled back; on success the
transaction is left open so the caller can put its own dedup guard inside the same
transaction* — is unusual and slightly dangerous, but it is what allows an app
order, a spreadsheet import and a POS bill to share row-locking, availability
checks, ledger writes and sequence allocation. The alternative (three
implementations of the anti-overselling rule) is exactly the drift that produces
"empty in the database, full on the shelf."
**6. Making a hostile schema work without a migration.** This is unglamorous and it
is a lot of the actual difficulty. Non-unique `authname`; a `bigint` PIN column
that eats leading zeros; a `roleid` of 0 that is not a role; `app_roles` with six
rows for four roles and most accounts carrying an id that is not in it; a
`registrationno` column that is really the GSTIN; `configid` varying per tenant
with no way to look it up; a SKU column where thousands of products share one
value; tax rates in live data that include 3, 7, 15 and −1 when Indian GST only has
0/5/12/18/28. Each of these gets a handler *and a written justification measured
against the actual data*, rather than a schema change nobody has the appetite to
run. The negative-GST floor is a good example of why this matters: a negative rate
would put negative tax on a bill and a negative figure in a slab on a **filed tax
return**.
**7. The commenting itself.** Worth calling out explicitly. Nearly every non-obvious
decision carries a comment that states the alternative considered, the failure it
prevents, and often the count of live rows that motivated it. Several read as small
post-mortems (the sequence-number one, the delivery-status one). That is a real
engineering artefact, not decoration — it is what makes the codebase maintainable
by someone who was not there.
---
## Flags before publishing any of this
### Security issues found while reading
1. **A Firebase service-account JSON key is committed to the repository** and
`COPY`'d into the Docker image by the Dockerfile. That is a live private key in
version control. Rotate it and move it to a secret/volume mount.
2. **`.env` was tracked until 2026-08-03.** The `.gitignore` says so itself and
notes the database credentials are still in history and the password should be
rotated. That does not appear to have happened.
3. **SQL injection in `tenantRepository.CheckTenantByNo`** — the contact number is
string-concatenated into raw SQL. Everything else in the codebase
parameterises; this one does not.
4. **Passwords are stored and compared in plaintext platform-wide.** There is an
explicit `TODO` acknowledging it, correctly noting a POS token minted off a
plaintext password is only as good as that column. The comparison is at least
constant-time.
5. **The main web/mobile API has no authentication at all.**
`SECURITY_HANDOFF.md` documents this: identity comes from client-supplied query
params, so requesting another tenant's id returns their data. Eight IDOR
endpoints were patched with controller-level scoping guards, but the doc is
clear that the root fix has not started.
6. **`POS_AUTH_REQUIRED` defaults to false**, so the POS surface is open unless
explicitly enabled — intentional and documented, but worth confirming whether it
has been flipped in production.
7. **The `/web/tenants/*` and `/mob/tenants/*` staff routes mint till credentials
on unauthenticated requests.** The route file flags this itself.
8. **CORS is `AllowOrigins: "*"` with `AllowCredentials: true`** — that combination
is rejected by browsers and is a smell either way.
### Commercially sensitive — genericise before this goes public
- **Named brand partners** in the catalogue allowlist (six FMCG brands, some of
them major). That is a partnership roster.
- **The production hostname** and the deployed API base path, which appear in the
proof scripts under `scratch/`.
- **Live customer/tenant identifiers** — the scratch scripts and doc examples
contain real tenant ids, location ids, store names and at least one real email
address. All of them have been kept out of this document.
- **Data-quality statistics about the live estate** (row counts, how many products
share a SKU, how many accounts use a given PIN, duplicate-order-id counts,
desynced-delivery counts). These make the writeup much more convincing, but they
are an unflattering audit of a client's production data. Keep the reasoning and
drop or round the numbers — "thousands of products shared a single SKU value"
carries the point without publishing the audit. Exact figures have been rounded
or removed here already.
- **The terminal sync contract itself** (topic structure, ack semantics, revision
format). It is the interface between two of their products; the *techniques* are
portfolio-safe, the exact wire protocol less so.
### Inferred rather than read directly
Deployment as a Kubernetes StatefulSet (from the ordinal election logic, not from
manifests); the existence and behaviour of the Flutter till app, the React console,
the consumer/rider apps and the Express backend (from docs and comments — none are
in this repo); and that the catalogue database uses pgvector (asserted in comments,
but nothing in this repo issues a vector query).

441
docs/SCAN_TO_ORDER.md Normal file
View File

@@ -0,0 +1,441 @@
# 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.
When the label fits several products — `"britannia"` names 258 of them — it
answers with a short "did you mean?" list instead of picking one, because a
confident price on the wrong biscuit is worse than one extra tap.
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 ───▶ ambiguous:true + candidates[] "did you mean?"
│ │
│ customer taps one candidate
│ │
│ POST /lookup { brand, catalogueid }
│ │
└───▶ 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
```
**`/lookup` has two possible answers and the app must handle both.** A label
that names one product comes back with `match` + `stores`. A label that fits
several — a bare brand name like `"britannia"`, a generic word like
`"biscuits"` — comes back with `ambiguous: true` and `candidates`, and the
app asks the customer which one before any price is shown. Lens returns a
bare wordmark often, because it is usually the biggest thing printed on a
packet, so this is a normal path and not an error case.
`GET /stores` is for the "choose another shop" sheet: the customer's
registered stores, nearest first, independent of any product.
## `POST /lookup`
Note the `//` notes below are annotations, not JSON — strip them.
```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
// Instead of a label: name the product outright. This is how you resolve
// a candidate the customer tapped, and how a deep link or a "buy again"
// skips recognition. With both set, `label` is ignored.
// "brand": "britannia", "catalogueid": 7
}
```
`label` is required **unless** `brand` and `catalogueid` are both given.
`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 A — one product identified
`ambiguous: false`, `match` set, `candidates` empty.
```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" } ],
"ambiguous": false,
"candidates": [],
"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."
}
```
### Response B — several products fit, none clearly
`ambiguous: true`, `match: null`, `stores: []`. Show a "did you mean?" list.
```json
{
"label": "britannia",
"match": null,
"ambiguous": true,
"candidates": [
{ "brand": "britannia", "catalogueid": 23, "product_name": "Britannia Marie Gold",
"size": "250 g", "image": "https://…", "score": 0.95, "method": "text", "available": true },
{ "brand": "britannia", "catalogueid": 22, "product_name": "Britannia Good Day Butter Cookies",
"image": "https://…", "score": 0.95, "method": "text" },
{ "brand": "britannia", "catalogueid": 21, "product_name": "Britannia Good Day Cashew Cookies",
"image": "https://…", "score": 0.95, "method": "text" }
],
"confidence": 0.95,
"available": false,
"stores": [],
"catalogue_variants": [],
"message": "Which one is it? 1 of these 3 are in stock near you."
}
```
- **`confidence` is not low here, and that is not a bug.** "britannia" really
does appear in all three names, so relevance is high — what is missing is
*identification*. Gate on `ambiguous`, never on `confidence`: an app that
reads 0.95 as "sure enough to show a price" reintroduces the exact bug this
path exists to prevent.
- **`available` on a candidate** means at least one of the customer's
registered stores has it in stock right now. Candidates are ordered
available-first, so the list can show what is buyable before what is not
— and the field is absent (not `false`) when unavailable, so read it as
falsy, not as a required key.
- **To resolve a pick**, call `/lookup` again with that candidate's `brand`
and `catalogueid` and no label. You get Response A for that exact product,
with `method: "direct"` and `confidence: 1`.
- At most 10 candidates come back.
### How to read either response
- `match == null && !ambiguous` → nothing recognised; show `message` and let
them retry with a clearer photo.
- `ambiguous: true` → ask, do not guess. Never show a price on this path;
`stores` is deliberately empty.
- `confidence` below ~0.5 with a `match` → recognised but unsure; worth
confirming the name before showing prices. `method: "text"` means no
embedding model was involved (not configured, or it timed out) — be a
little more cautious. `method: "direct"` means the caller named the
product, so nothing was recognised at all.
- `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.
Send `latitude`/`longitude` on `/confirm` too if you display distance from
its reply: the saved address is only consulted there when the shelf is
empty and alternatives have to be ranked, so without a fix the store you
tapped comes back `-1`.
## `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…" }, // distance_km filled from the fix you send
"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 word match asks for most of the label, not all of it**
(`minTokenHits`: two thirds, rounded up, and both of a two-word label).
Requiring every word meant one word the catalogue does not use took the
right product out of the running entirely — "Dettol bottle pack" retrieved
no Dettol, "Parle G biscuit pack" retrieved no Parle-G — and the vector
search then answered alone, confidently and wrongly, at a score the floor
could not catch. Each brand's rows are ordered by how much of the label
they carry (the whole label as a substring outranks any number of loose
words) so that the per-brand `LIMIT` keeps the best rows and not merely the
first ones the planner reached. Packaging words — "pack", "bottle", "jar",
"sachet" and friends, see `utils.isPackaging` — are dropped before any of
this, like pack sizes, unless the label is nothing else.
- **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.50 floor in
`scanService.go` is set against — the middle of that split, not the edge of
the noise. It was 0.30 until a near-miss got through in production
("Paracetamol" → "Paneer Makhni 500ml", 0.304). 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`).
## Two decisions, and why
Both come from a proposal (2026-09-23) to have the app send vectors it
computed on the phone. Recorded here because the next person will ask.
### The app does not send `textvector`
An on-device MiniLM vector is only comparable to the catalogue's if the app
ships the identical model *and* tokenizer *and* pooling *and* normalisation;
a quantised tflite build usually drifts, and the failure is silent — the
ranking just gets worse. There is also nothing to gain: the server-side
embed is ~30 ms warm and the result is cached in Redis by label, so one
model call serves every customer who scans that product. A client-supplied
vector *defeats* that cache (the key would have to be the vector, not the
label), and 384 floats is ~5 KB of upload against ~12 bytes for
`"Milk Bikis"`. If the field ever arrives it can be accepted and validated,
but the app should not be asked to compute it.
**Send the full OCR text instead** if you want to give the server more to
work with — ~100 bytes, no model coupling, strictly more information than a
single label.
### The app does not send `imagevector` — yet
The catalogue *does* carry image vectors: every `brand_*` table has
`img_vector vector(1024)`, filled on 1885 of 2124 rows (empty in
`brand_haldirams`, `brand_kaleesuwari`, `brand_mdh`, `brand_zzsmoketest`).
That matches the proposed MobileNetV3-Small embedder, so the idea is
coherent and half-built — this flow simply does not read that column.
It stays unread for now because **Google Lens is already the image
recogniser, and a far better one**: photo → Lens → label is Google's product
recognition, trained on billions of images. Putting a 137M-parameter
ImageNet backbone searching 1885 vectors *behind* that adds little where
Lens succeeds, and MobileNetV3-Small — which struggles to tell one blue
biscuit wrapper from another — is unlikely to rescue the cases where Lens
fails. There is also an unverified dependency: the preprocessing the app
would use (BGR → centre crop → 224×224 INTER_AREA → RGB → `/255.0`) has to
match whatever the catalogue pipeline actually ran, or the search returns
confidently-ranked noise.
**What would change this:** the field data. Once live, count how often
`/lookup` returns `ambiguous: true` or nothing recognised. If Lens labels are
reliable, image search is polish; if that number is high, it becomes the
priority — and the first task is the cosine check (embed a known catalogue
product's image through the app's exact pipeline, compare with its stored
`img_vector`; ≈0.99 means the contract holds), not writing the query.
There is one non-recognition argument for it worth remembering: on-device
inference is free and needs no Google dependency, which matters if Cloud
Vision costs start to bite at volume. That is a business reason, not a
quality one.
## 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 every catalogue vector column's width and 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|Brand|Ambiguous|Specific|TextScore|Distinct|Naming'`
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, and
`newBrandLabelFixture` in particular is the regression guard for the
brand-name bug described under Scoring.
### 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.50 | below this the best hit is not shown as a match |
| `scanAmbiguityMargin` | 0.06 | how close the runner-up may be before the answer becomes a question |
| `scanMaxCandidates` | 10 | longest "did you mean?" list |
| `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. Ties are broken by
cosine distance — nearest first, a text-only row last — and only then by
name.
The label and the product name are both separator-folded before that
substring test (`utils.FoldSeparators`), and compared again with separators
removed (`utils.TightenLabel`, labels of 4+ characters), so the brand's own
punctuation does not decide the match: "Parle G", "Parle-G" and "ParleG" all
reach *Parle-G Original Glucose Biscuits*. A single-character token survives
tokenising when it follows a word, because it is often the whole name — the
"G" of Parle-G, the "K" of Special K. It is still dropped when it stands
alone or is a pack multiplier.
All three mattered at once: before this, "Parle G" tied with *Parle Monaco
Classic* at 0.9 (the "G" was dropped, so only "parle" matched either row),
and the name tie-break handed it to Monaco because a space precedes a hyphen
in ASCII. A confident, wrong answer — the kind no score floor can catch.
**When the substring rule ties, that tie is the answer.** A bare brand name
is a substring of every one of that brand's names, so all of them score 0.95
— identically, at a high score no floor would ever catch. Rather than
scoring around it, `isAmbiguous` reads it: if the runner-up is within
`scanAmbiguityMargin` of the leader, the reply becomes `ambiguous: true`
with `candidates` instead of a match (see Response B). Erring towards asking
is deliberate — one tap on a picture against the wrong biscuit. A label that
names one product leaves the runner-up far behind, so the common case is
untouched, and `services/scan_test.go`'s
`TestABrandNameScoresItsProductsIdentically` guards the tie itself: a
formula that broke it on name length or word count would bring the bug
back.
### Changing the embedding model
1. The catalogue team re-embeds `search_query` with the new model.
2. Serve it (Ollama pull, or a hosted key).
3. Change `EMBEDDING_MODEL` / `EMBEDDING_DIMENSIONS` (and provider/URL if
needed) in the cluster; roll the pods.
4. Flush the hit cache if you cannot wait 30 min: keys are
`scan:hits:v1:*` and `scan:emb:v1:*` in Redis (they are also keyed by
model name, so old entries simply stop being read).
Nothing in Go changes. A width mismatch fails the first search with an error
naming both numbers.
### Adding a provider
Implement `utils.Embedder` (`Embed(ctx, text) ([]float32, error)` and
`Model() string`), add a case to `NewEmbedder`, and add the provider name to
the allow-list in `config.validate`. Keep the HTTP client timeout: the
customer is holding a phone.

View File

@@ -1,9 +1,14 @@
package facade package facade
import ( import (
"log"
"nearle/config"
"nearle/controllers" "nearle/controllers"
"nearle/repositories" "nearle/repositories"
"nearle/services" "nearle/services"
"nearle/services/tools"
"nearle/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -22,6 +27,18 @@ type Facade struct {
PosController *controllers.PosController PosController *controllers.PosController
LiveController *controllers.LiveController LiveController *controllers.LiveController
CatalogueUploadController *controllers.CatalogueUploadController CatalogueUploadController *controllers.CatalogueUploadController
ScanController *controllers.ScanController
DeliverySlotController *controllers.DeliverySlotController
AssistantController *controllers.AssistantController
HealthController *controllers.HealthController
MCPController *controllers.MCPController
// Tools is what Nearle Buddy is allowed to do.
//
// Held on the facade because the assistant is not a module with a
// repository of its own — it is a door onto the services already built
// here, and every tool handler calls one of them rather than the database.
Tools *tools.Registry
// Held so the NATS consumer can reach the ingest without going through // Held so the NATS consumer can reach the ingest without going through
// HTTP. Unexported: everything else should use the controller. // HTTP. Unexported: everything else should use the controller.
@@ -32,11 +49,30 @@ type Facade struct {
// catalogueDB is a separate connection to the pgvector catalogue database; // catalogueDB is a separate connection to the pgvector catalogue database;
// it may be nil if catalogue env vars are not configured, in which case // 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. // 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, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig, nutritionBase string) *Facade {
// The invitation, built first because two modules need it.
//
// Every back-office account on this platform is created with NO password —
// the onboarded merchant, every person added to the directory, and the login
// each branch spawns — and since the sign-in screen stopped offering to set
// one, the emailed link is the only way in. So whichever module creates an
// account has to be able to send it.
//
// `mailer` may be nil: a deployment with no mail configured still creates
// everything, and each response says the invitation was not sent and names
// the variable, rather than failing the create.
//
// The tenant repository supplies the business name for the mail's first line
// (`services.TenantNamer`), which is why it is built here rather than in the
// tenant module below.
tenantRepo := repositories.NewTenantRepository(db)
inviteService := services.NewInviteService(mailer, mailCfg, tenantRepo)
// User Module // User Module
userRepo := repositories.NewUserRepository(db) userRepo := repositories.NewUserRepository(db)
userService := services.NewUserService(userRepo) userService := services.NewUserService(userRepo, inviteService)
userController := controllers.NewUserController(userService) userController := controllers.NewUserController(userService)
// Catalogue Module (separate pgvector DB — never the main `db`). Built // Catalogue Module (separate pgvector DB — never the main `db`). Built
@@ -47,14 +83,29 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
catalogueController := controllers.NewCatalogueController(catalogueService) catalogueController := controllers.NewCatalogueController(catalogueService)
// Product Module // Product Module
//
// The nutrition service is the catalogue-intelligence host — the same one
// behind the health score card in the console — read by the product screen
// for its nutrition panel. Nil when NUTRITION_BASE is unset, which serves
// every product screen exactly as before, without a panel.
productRepo := repositories.NewProductRepository(db) productRepo := repositories.NewProductRepository(db)
productService := services.NewProductService(productRepo, catalogueService) productService := services.NewProductService(
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
productController := controllers.NewProductController(productService) productController := controllers.NewProductController(productService)
// When each branch delivers.
//
// BEFORE the order controller, which takes it: order creation re-checks a
// chosen window against the same rule the app was shown, so the two cannot
// drift. No dependency the other way — this service knows nothing of orders.
deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService)
// Order Module // Order Module
orderRepo := repositories.NewOrderRepository(db) orderRepo := repositories.NewOrderRepository(db)
orderService := services.NewOrderService(orderRepo) orderService := services.NewOrderService(orderRepo)
orderController := controllers.NewOrderController(orderService) orderController := controllers.NewOrderController(orderService, deliverySlotService)
// Deliveries Module // Deliveries Module
deliveriesRepo := repositories.NewDeliveriesRepository(db) deliveriesRepo := repositories.NewDeliveriesRepository(db)
@@ -67,8 +118,11 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
utilsController := controllers.NewUtilsController(utilsService) utilsController := controllers.NewUtilsController(utilsService)
//Tenant Module //Tenant Module
tenantRepo := repositories.NewTenantRepository(db) //
tenantService := services.NewTenantService(tenantRepo) // Onboarding, adding a person and commissioning a branch all create an
// account with no password, so all three send an invitation. `tenantRepo` and
// `inviteService` are built above, where the reasoning is.
tenantService := services.NewTenantService(tenantRepo, inviteService)
tenantController := controllers.NewTenantController(tenantService) tenantController := controllers.NewTenantController(tenantService)
//Partner Module //Partner Module
@@ -109,6 +163,79 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo) catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService) 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)
// The assistant registry. Built last, because every tool it holds is a thin
// wrapper over a service constructed above.
//
// A registration error panics rather than being logged. A duplicate name or
// a tool with no description is a programming mistake, and a server that
// starts with a tool silently absent answers real questions with "I cannot
// do that" for a reason nobody can see from the outside.
// The help corpus, checked before it is registered. A passage carrying one
// shop's figures stops the server rather than reaching another shop's screen.
helpCorpus, err := tools.LoadHelp()
if err != nil {
panic("assistant help: " + err.Error())
}
// The audit trail goes to the database and to the log. See
// services/assistantAudit.go for why both.
auditRepo := repositories.NewAssistantAuditRepository(db)
toolRegistry := tools.New(services.NewDBAudit(auditRepo))
for _, tool := range []tools.Tool{
tools.StuckOrders(deliveriesService, nil),
tools.DeliveryProgress(deliveriesService),
tools.BranchPerformance(orderService),
tools.PendingApprovals(stockRequestService, nil),
tools.LowStock(productService),
tools.TillsNotSyncing(posService),
tools.SalesByChannel(orderService, posService, nil),
tools.Help(helpCorpus),
tools.ApproveStockRequest(stockRequestService, stockRequestService),
} {
if err := toolRegistry.Register(tool); err != nil {
panic("assistant tools: " + err.Error())
}
}
// Nearle Buddy. `chat` may be nil — a deployment with no model configured
// still gets the registry and the endpoint, and the endpoint answers "not
// switched on here" rather than a 500. The tools themselves are ordinary
// Go functions and work either way; only turning a sentence into a tool
// call needs a model.
// Agent definitions, validated against the registry above. A typo in a tool
// name stops the server rather than producing an agent that quietly cannot
// do one of the things it claims — which is invisible at runtime, because the
// model simply reports it could not look something up.
agents, err := services.LoadAgents(agentsDir, toolRegistry.Has)
if err != nil {
panic("assistant agents: " + err.Error())
}
log.Printf("assistant: %d agents loaded %v", len(agents), services.AgentNames(agents))
assistantService := services.NewAssistantService(toolRegistry, chat, agents)
// Why there is no model, if there is not. Passed through so /assistant/status
// can name the missing variable instead of just saying no.
if setter, ok := assistantService.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
setter.SetUnavailableReason(assistantWhy)
}
assistantController := controllers.NewAssistantController(assistantService)
// What is running here. Unauthenticated, booleans only — see healthController.go
// for why a server that cannot say which build it is costs a day.
healthController := controllers.NewHealthController(assistantService, db != nil)
// The second door. Same registry, same agents, same session — see
// controllers/mcpController.go for why it is a door rather than a service.
mcpController := controllers.NewMCPController(toolRegistry, agents)
return &Facade{ return &Facade{
UserController: userController, UserController: userController,
ProductController: productController, ProductController: productController,
@@ -123,6 +250,12 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
PosController: posController, PosController: posController,
LiveController: liveController, LiveController: liveController,
CatalogueUploadController: catalogueUploadController, CatalogueUploadController: catalogueUploadController,
ScanController: scanController,
DeliverySlotController: deliverySlotController,
AssistantController: assistantController,
HealthController: healthController,
MCPController: mcpController,
Tools: toolRegistry,
posService: posService, posService: posService,
} }
} }

24
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/gofiber/fiber v1.14.6 github.com/gofiber/fiber v1.14.6
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0 github.com/redis/go-redis/v9 v9.18.0
github.com/valyala/fasthttp v1.50.0
golang.org/x/oauth2 v0.12.0 golang.org/x/oauth2 v0.12.0
google.golang.org/api v0.143.0 google.golang.org/api v0.143.0
gorm.io/driver/postgres v1.6.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/aws-sdk-go-v2/service/sts v1.44.1 // indirect
github.com/aws/smithy-go v1.27.3 // indirect github.com/aws/smithy-go v1.27.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/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/gofiber/utils v0.0.10 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // 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/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/gorilla/schema v1.1.0 // indirect github.com/gorilla/schema v1.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // 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/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.19.0 // 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-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // 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/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/stretchr/testify v1.8.4 // 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/valyala/bytebufferpool v1.0.0 // 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 github.com/valyala/tcplisten v1.0.0 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.11.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/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/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
@@ -94,15 +83,12 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect
google.golang.org/grpc v1.58.2 // indirect google.golang.org/grpc v1.58.2 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require ( require (
github.com/gofiber/fiber/v2 v2.50.0 github.com/gofiber/fiber/v2 v2.50.0
github.com/jinzhu/copier v0.4.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/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

389
go.sum
View File

@@ -1,59 +1,21 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.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 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= 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 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= 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 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 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 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk=
cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8= 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 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= 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 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= 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 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= 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 h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= 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/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.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/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-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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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.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.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.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/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 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM= github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw= 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 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U=
github.com/gofiber/utils v0.0.10/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo= 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/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-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 h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 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.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.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.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.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/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.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.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-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.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 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.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 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 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 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.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.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.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.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.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.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.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= 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 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 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= 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/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 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 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 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= 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 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 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.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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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-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 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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.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.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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= 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 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 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 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 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 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 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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-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-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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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-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-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-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-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-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-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-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-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-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-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-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-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 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 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-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 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= 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-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-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-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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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-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-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-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-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-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-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-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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 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/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.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.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.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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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-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-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-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-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-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-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 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= 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 h1:o8cekTkqhywkbZT6p1UHJPZ9+9uuCAJs/KYomxZB8fA=
google.golang.org/api v0.143.0/go.mod h1:FoX9DO9hT7DLNn97OuoZAGSDuNAXdJRuGK98rSUgurk= 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.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.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 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 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-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-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-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 h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA=
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= 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= 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 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA= 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.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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 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.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.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 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I=
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= 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= 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.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.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.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.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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 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-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.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=

View File

@@ -39,11 +39,41 @@ inside a git repository. `.gitignore` excludes `*.sql` here for that reason.
## Getting something to test against ## Getting something to test against
An empty schema boots but has no tenants, so there is nothing to sign in as. `nearledb/02-seed.sql` is committed and applied automatically, so a fresh
Two options: volume already has a merchant to sign into. It invents one rather than copying
one, which is why it can live here at all.
- **Onboard a tenant through the console** once it is pointed at localhost. | Account | Password | Opens |
That exercises the real path and is usually what you want. |---|---|---|
- **Copy a few rows** you actually need — a tenant, its locations, its | `super@nearle.invalid` | `localdev` | Nearle Admin — the platform workspace |
app_users — with `pg_dump --data-only --table=...`. Check what you are | `admin@testmart.invalid` | `localdev` | Store Admin — all of Testmart's branches |
copying: `app_users.password` is stored in clear. | `main@testmart.invalid` | `localdev` | Store user — Testmart Main only |
It also seeds the role ladder, three aisles under category 2, and a second
merchant (`Halfmart`) deliberately left in the broken `categoryid = 0` shape as
a permanent regression fixture. The sequences are moved past the seeded ids at
the end, so the first row you create locally does not come back as id 1.
If you need something it does not cover:
- **Onboard a tenant through the console.** That exercises the real path and is
usually what you want.
- **Copy a few rows** you actually need with `pg_dump --data-only --table=...`.
Check what you are copying: `app_users.password` is stored in clear.
## The catalogue database
`cataloguedb/02-seed.sql` is committed too, and also entirely invented. The
real catalogue is another team's scrape of real retailers and a dump of it does
not belong on a laptop.
Without it the catalogue database exists but holds no catalogue: every
`brand_*` table is missing, `getbrands` answers 500, and the global catalogue
screen, the import flow and `importcatalogueproduct` cannot be exercised at
all. The seed gives you two brands:
- `brand_testbrand` — every column the reader knows about, four products, one
of them deliberately with no images.
- `brand_sparsebrand` — only `id`, `product_name` and a price, to keep the
degraded-but-still-listed path covered. Brands are discovered by table name,
so adding another is just another `brand_*` table.

View File

@@ -0,0 +1,109 @@
-- A synthetic global catalogue to develop against.
--
-- INVENTED DATA, exactly like `nearledb/02-seed.sql` and for the same reason:
-- the real catalogue is somebody else's scrape of real retailers, and a dump of
-- it does not belong on a laptop inside a git repository.
--
-- ── Why this file has to exist ──────────────────────────────────────────────
--
-- `init/cataloguedb/` was empty, so a local stack had a catalogue DATABASE with
-- no catalogue in it. Every `brand_*` table was missing, `getbrands` answered
-- 500, and the whole catalogue-import path — the global catalogue screen, the
-- import flow, `importcatalogueproduct` — could not be exercised locally at
-- all. It is a documented feature with its own integration doc and it had no
-- local coverage whatsoever.
--
-- ── The shape ───────────────────────────────────────────────────────────────
--
-- Brands are discovered from `information_schema` by table name, so a table
-- called `brand_<something>` IS a brand; there is no registry to add it to.
-- `catalogueCoreColumns` requires only `id` and `product_name` — everything
-- else is selected when present and replaced with NULL when absent, so a
-- partial table degrades rather than disappearing. These two are written full
-- so that the degraded path is a deliberate test, not the only thing available:
-- `brand_testbrand` has every column, and `brand_sparsebrand` deliberately has
-- only the core two plus a price, to exercise `columnsFor`.
--
-- `image_id` is the durable key across re-scrapes — catalogue ids are not
-- stable and `models.Products.Imageid` is what the import stores — so every
-- product here has one and they are distinct.
CREATE EXTENSION IF NOT EXISTS vector;
-- ── A brand with the full column set ────────────────────────────────────────
CREATE TABLE IF NOT EXISTS brand_testbrand (
id BIGSERIAL PRIMARY KEY,
product_name TEXT NOT NULL,
title TEXT,
description TEXT,
category TEXT,
image_id TEXT,
size TEXT,
variant_key TEXT,
product_sku TEXT,
sku_source TEXT,
-- A RANGE, not a price. The global catalogue carries what retailers were
-- seen charging; the shop sets its own price at import time, which is why
-- the console collects one before an import can be enabled.
price_range TEXT,
providers TEXT[],
fssai_license TEXT,
highlights TEXT[],
nutrients TEXT[],
search_query TEXT,
image_url TEXT,
image_urls TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO brand_testbrand
(product_name, title, description, category, image_id, size, variant_key,
product_sku, sku_source, price_range, providers, fssai_license,
highlights, nutrients, search_query, image_url, image_urls)
VALUES
('Testbrand Basmati Rice 5kg', 'Testbrand Basmati Rice', 'Long grain basmati, aged twelve months.',
'Rice & Grains', 'IMG-TB-RICE-5K', '5 kg', 'rice-5kg', 'TB-RICE-5K', 'scrape',
'380-420', ARRAY['bigbasket','amazon'], '12345678901234',
ARRAY['Aged 12 months','Extra long grain'], ARRAY['Energy 350kcal','Protein 7g'],
'basmati rice 5kg', 'https://placehold.co/300x300?text=Rice5kg',
ARRAY['https://placehold.co/300x300?text=Rice5kg','https://placehold.co/300x300?text=Rice5kg-back']),
('Testbrand Basmati Rice 1kg', 'Testbrand Basmati Rice', 'Long grain basmati, aged twelve months.',
'Rice & Grains', 'IMG-TB-RICE-1K', '1 kg', 'rice-1kg', 'TB-RICE-1K', 'scrape',
'85-99', ARRAY['bigbasket'], '12345678901234',
ARRAY['Aged 12 months'], ARRAY['Energy 350kcal','Protein 7g'],
'basmati rice 1kg', 'https://placehold.co/300x300?text=Rice1kg',
ARRAY['https://placehold.co/300x300?text=Rice1kg']),
('Testbrand Sunflower Oil 1L', 'Testbrand Sunflower Oil', 'Refined sunflower oil, light and neutral.',
'Oils & Ghee', 'IMG-TB-OIL-1L', '1 L', 'oil-1l', 'TB-OIL-1L', 'scrape',
'150-185', ARRAY['bigbasket','jiomart'], '99999999999999',
ARRAY['Vitamin E','Light frying'], ARRAY['Energy 900kcal','Fat 100g'],
'sunflower oil 1 litre', 'https://placehold.co/300x300?text=Oil1L',
ARRAY['https://placehold.co/300x300?text=Oil1L']),
-- No images at all. `ImportCatalogueProduct` only sets `productimages` when
-- the product has photos, so this row is the one that proves an import still
-- works when it does not — the case that used to hit the jsonb empty-string
-- failure in `products`.
('Testbrand Salt 1kg', 'Testbrand Iodised Salt', 'Free-flowing iodised salt.',
'Everyday', 'IMG-TB-SALT-1K', '1 kg', 'salt-1kg', 'TB-SALT-1K', 'scrape',
'20-28', ARRAY['jiomart'], NULL,
NULL, NULL, 'iodised salt 1kg', NULL, NULL);
-- ── A brand with only the core columns ──────────────────────────────────────
--
-- Discovery used to demand all eighteen columns, which made a table like this
-- INVISIBLE rather than merely thin — 16 of 35 live brands were unreachable
-- from this side for exactly that reason. Keeping one here means the
-- degraded-but-listed path is covered by the seed and stays covered.
CREATE TABLE IF NOT EXISTS brand_sparsebrand (
id BIGSERIAL PRIMARY KEY,
product_name TEXT NOT NULL,
price_range TEXT
);
INSERT INTO brand_sparsebrand (product_name, price_range) VALUES
('Sparsebrand Biscuits 100g', '20-30'),
('Sparsebrand Tea 250g', '110-140');

View File

@@ -161,4 +161,72 @@ INSERT INTO productstocks (
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active') (9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
ON CONFLICT (productstockid) DO NOTHING; ON CONFLICT (productstockid) DO NOTHING;
-- ── The platform operator ───────────────────────────────────────────────────
--
-- Without this there is nobody who can open the Nearle Admin workspace, which
-- is the one this console was built for first. `resolveRole` checks
-- `issuperadmin` BEFORE roleid — deliberately, because the flag is derived by
-- the server and a roleid is just a number in a row — so no amount of role 1
-- gets you in without it, and every local session landed in Store Admin
-- instead. The accounts above are one per role and this was the role they were
-- missing.
--
-- Not attached to either merchant in spirit, only in columns: a platform
-- operator has to carry a tenantid because the column is not nullable, and
-- nothing in the admin workspace reads it.
INSERT INTO app_users (
userid, authname, firstname, lastname, email, dialcode, contactno,
configid, roleid, password, tenantid, locationid, applocationid,
status, issuperadmin
) VALUES
(9299, 'super@nearle.invalid', 'Nearle', 'Operator', 'super@nearle.invalid',
'+91', '9000009999', 1, 1, 'localdev', 9001, 9101, 9001, 'Active', true)
ON CONFLICT (userid) DO NOTHING;
-- ── The role ladder ─────────────────────────────────────────────────────────
--
-- `getstaffs` LEFT JOINs app_roles for `rolename`, so an empty table is not an
-- error — every person on Users & access simply reads "—" where their role
-- should be. The ids are the ones the rest of the system already assumes:
-- 1 and 3 reach Store Admin, 4 is a branch manager, 7 and 8 are till accounts
-- and are excluded from every back-office query by the backend itself.
INSERT INTO app_roles (roleid, rolename, configid) VALUES
(1, 'Super admin', 1),
(3, 'Admin', 1),
(4, 'Manager', 1),
(7, 'Supervisor', 1),
(8, 'Cashier', 1)
ON CONFLICT (roleid) DO NOTHING;
-- ── Aisles under the category the customer app browses ──────────────────────
--
-- categoryid 2 is the only category the app lists, and the aisle a shopper
-- reads is the SUBCATEGORY. With none of these the sheet importer has nothing
-- to resolve a row's category against, so every imported product falls back to
-- subcategoryid 0 and lands under "Uncategorized".
INSERT INTO productsubcategories (subcatid, categoryid, tenantid, subcatname, status, sortorder)
VALUES
(9601, 2, 9001, 'Rice & Grains', 'Active', 1),
(9602, 2, 9001, 'Oils & Ghee', 'Active', 2),
(9603, 2, 9001, 'Snacks', 'Active', 3)
ON CONFLICT (subcatid) DO NOTHING;
-- ── Move the sequences past the seeded ids ──────────────────────────────────
--
-- Everything above inserts an explicit id, which does NOT advance the sequence
-- behind that column. So the first tenant, outlet or product created against a
-- fresh local database came back as id 1 — harmless here, but it means local
-- ids look nothing like the ones the same code produces in production, and a
-- seed that ever collides with a sequence value fails on a duplicate key.
--
-- `GREATEST(..., 1)` because setval refuses a value below the sequence minimum,
-- and a table the seed does not touch is legitimately empty.
SELECT setval('tenants_tenantid_seq', GREATEST((SELECT COALESCE(MAX(tenantid),0) FROM tenants), 1));
SELECT setval('tenantlocations_locationid_seq', GREATEST((SELECT COALESCE(MAX(locationid),0) FROM tenantlocations), 1));
SELECT setval('app_users_userid_seq', GREATEST((SELECT COALESCE(MAX(userid),0) FROM app_users), 1));
SELECT setval('products_productid_seq', GREATEST((SELECT COALESCE(MAX(productid),0) FROM products), 1));
SELECT setval('productlocations_productlocationid_seq', GREATEST((SELECT COALESCE(MAX(productlocationid),0) FROM productlocations), 1));
SELECT setval('productstocks_productstockid_seq', GREATEST((SELECT COALESCE(MAX(productstockid),0) FROM productstocks), 1));
SELECT setval('customers_customerid_seq', GREATEST((SELECT COALESCE(MAX(customerid),0) FROM customers), 1));
COMMIT; COMMIT;

341
main.go
View File

@@ -1,14 +1,18 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"nearle/config"
"nearle/db" "nearle/db"
"nearle/facade" "nearle/facade"
"nearle/messaging" "nearle/messaging"
"nearle/middleware"
"nearle/models" "nearle/models"
"nearle/repositories" "nearle/repositories"
"nearle/routes" "nearle/routes"
"nearle/utils"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
@@ -18,33 +22,77 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
"github.com/joho/godotenv"
"gorm.io/gorm" "gorm.io/gorm"
) )
func init() { // corsSettings is a function so it can be tested.
godotenv.Load() //
// Inline, it could only be checked by starting the server and pointing a real
// browser at it — which is how the missing Authorization header reached
// production in the first place.
func corsSettings() cors.Config {
return cors.Config{
AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Authorization",
AllowOrigins: "*",
AllowCredentials: false,
AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS",
}
} }
func main() { 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() app := fiber.New()
app.Use(cors.New(cors.Config{ // Cross-origin access.
AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Access-Control-Allow-Origin", //
AllowOrigins: "*", // The console is served from app.nearledaily.com and calls this host
AllowCredentials: true, // directly, so every request it makes is cross-origin and the browser
AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS", // decides whether to allow it from the headers below.
})) //
// ── Authorization has to be listed ──────────────────────────────────────
//
// It was not, and adding the session token to the console broke every call
// the moment it shipped. A request carrying `Authorization` is no longer a
// "simple" request, so the browser stops and asks permission first — and the
// answer has to name that header explicitly. It was never needed before
// because the console sent nothing but `Accept` and `Content-Type`.
//
// The failure is worth recognising again: the preflight returns 204 and
// looks healthy in a terminal, the server logs nothing, and only the browser
// refuses. `curl` cannot reproduce it, because curl does not enforce CORS.
//
// ── Credentials off, wildcard on ────────────────────────────────────────
//
// `AllowOrigins: "*"` with `AllowCredentials: true` is not a valid pair: a
// browser rejects a credentialed response that carries a wildcard origin.
// That combination was here already and was harmless only because nothing
// used credentials — it would have become a second, identical-looking bug
// the day anything did.
//
// Credentials means cookies and TLS client certs, and this backend uses
// neither: authentication is a Bearer token, which is an ordinary header and
// needs no credentialed mode. Nothing in the console, the app or the POS
// sets `credentials: 'include'`, so turning it off costs nothing and makes
// the pair legal.
//
// The wildcard itself is worth revisiting — it lets any site on the internet
// call this API from a browser, and the tenant guard is what stops that
// mattering. Narrowing it to the known console origins is a separate change,
// and one that breaks local development if the list is got wrong.
app.Use(cors.New(corsSettings()))
fmt.Println("🌐 Connecting to databases...") fmt.Println("🌐 Connecting to databases...")
db.Connect() db.Connect(cfg)
fmt.Println("✅ Database connections established!") fmt.Println("✅ Database connections established!")
// Shared with the express backend. POS terminal presence lives here under a // Shared with the express backend. POS terminal presence lives here under a
// TTL; optional, because losing the health board is an inconvenience and // TTL; optional, because losing the health board is an inconvenience and
// losing a sale is not. // losing a sale is not.
db.InitRedis() db.InitRedis(cfg.Redis)
// Ensure schema is updated // Ensure schema is updated
db.DB.AutoMigrate(&models.StockRequest{}) db.DB.AutoMigrate(&models.StockRequest{})
@@ -56,6 +104,24 @@ func main() {
log.Fatal("POS schema migration failed:", err) log.Fatal("POS schema migration failed:", err)
} }
// What Nearle Buddy did, and on whose behalf. Its own table: these rows are
// written on a different schedule from anything else and are the only record
// of an assistant acting for a merchant.
//
// Logged and carried on rather than fatal, unlike the migrations around it,
// and the difference is deliberate. Those create tables the product cannot
// trade without — a POS order has nowhere to land if its table is missing.
// This one serves an assistant that may not even be switched on, and taking
// the whole backend down over it would stop every shop taking orders to
// protect a log.
//
// The degradation is already built: `DBAudit` writes to the log as well as
// the table, and reports each failed insert as AUDIT ROW LOST. So a missing
// table costs the queryable trail and nothing else, loudly.
if err := db.DB.AutoMigrate(&models.AssistantAudit{}); err != nil {
log.Printf("assistant: audit table unavailable, the trail is log-only: %v", err)
}
// Shift windows for till staff. Additive — `app_users.shiftid` already // Shift windows for till staff. Additive — `app_users.shiftid` already
// existed and pointed at the rider table, so an account with no shift is // existed and pointed at the rider table, so an account with no shift is
// simply unassigned rather than broken. // simply unassigned rather than broken.
@@ -78,6 +144,113 @@ func main() {
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err) log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
} }
// What the global catalogue knew about this product, kept.
//
// The import copies eight of the catalogue's eighteen fields onto the
// tenant's product and left the other ten behind — among them the FSSAI
// licence, the nutrition lines, the highlights, the provider list, the
// price range and the variant key. The console needs exactly those to
// decide what to charge, so `ProductDrawer` went back to the catalogue for
// them on every open.
//
// That lookup is not a substitute for storing them. A tenant's product is a
// SNAPSHOT and outlives its source row: the catalogue is re-scraped, a
// variant is retired, and the licence number and the nutrition panel for a
// product the shop is still selling are gone with no way back. Measured
// locally by retiring one row — the product survived, everything the drawer
// shows about it did not.
//
// One jsonb column rather than six typed ones, and rather than the
// `productspecs` table that has sat unused since the schema was written.
// The value is a snapshot of somebody else's record, read as a whole and
// displayed as a whole — it is never joined, aggregated or filtered — and
// the catalogue grows fields faster than this side can add migrations.
// Postgres can still reach inside it (`cataloguefacts->>'fssai_license'`)
// on the day somebody needs to. `productimages` beside it made the same
// call for the same reason.
//
// Not fatal on failure, exactly like the column above: a product without
// its catalogue facts is the product we have today.
if err := db.DB.Exec(
`ALTER TABLE products ADD COLUMN IF NOT EXISTS cataloguefacts jsonb`).Error; err != nil {
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
}
// Whether this shop shows a health score for this product.
//
// The shopkeeper's call, not ours. The score comes from a third party that
// matches a reference product by name — often at under 60% confidence — so a
// merchant who knows the packet in front of them may reasonably decide the
// rating does not describe what they are selling, and should be able to take
// it off their own shelf without taking it off everybody's.
//
// DEFAULT TRUE, so every product already imported keeps showing exactly what
// it shows today. A new column defaulting to false would silently strip the
// health score from every shelf on the platform, which is a change nobody
// asked for dressed up as a migration.
//
// Only the score. `nutrition` is unaffected and always sent: the figures are
// what the packet says, while the score is somebody's judgement of them.
if err := db.DB.Exec(
`ALTER TABLE products ADD COLUMN IF NOT EXISTS showhealthscore boolean NOT NULL DEFAULT true`).Error; err != nil {
log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err)
}
// When a shop delivers, and which window an order chose.
//
// Three named windows a day per BRANCH — see models/deliveryslot.go for why
// the scope is the branch and not the company.
//
// ── A branch with no rows here still trades ─────────────────────────────
//
// Every tenant on the platform the day this ships has no slots, and all of
// them must keep taking orders exactly as before. No backfill, no defaults
// written here: absence means "order without a slot", and the app is
// required to treat an empty list as ordinary rather than as a closed shop.
// Seeding every existing branch with invented timings would have each one
// promising hours nobody agreed to.
if err := db.DB.Exec(`CREATE TABLE IF NOT EXISTS deliveryslots (
slotid SERIAL PRIMARY KEY,
tenantid INTEGER NOT NULL,
locationid INTEGER NOT NULL DEFAULT 0,
slotkey TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
starttime TEXT NOT NULL,
endtime TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created TIMESTAMPTZ NOT NULL DEFAULT now(),
updated TIMESTAMPTZ NOT NULL DEFAULT now()
)`).Error; err != nil {
log.Println("⚠️ could not create deliveryslots, delivery windows will be unavailable:", err)
}
// One row per key per branch. A shop has ONE morning, and a duplicate would
// show the shopper the same window twice with different hours — so the
// upsert that writes these leans on this constraint rather than on a
// read-then-write that two requests could interleave.
if err := db.DB.Exec(
`CREATE UNIQUE INDEX IF NOT EXISTS deliveryslots_branch_key
ON deliveryslots (tenantid, locationid, slotkey)`).Error; err != nil {
log.Println("⚠️ could not add the deliveryslots uniqueness index, a branch may end up with duplicate windows:", err)
}
// The window an order chose, and the day it falls on.
//
// Both nullable, and both stay empty for every order placed without a slot —
// which is every order today and every order from a branch that never sets
// timings. Nothing downstream may require them.
//
// The DATE is not redundant. "evening" cannot say tonight or tomorrow night,
// and an order placed after the last window closes is for the next day.
if err := db.DB.Exec(
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotid INTEGER`).Error; err != nil {
log.Println("⚠️ could not add orders.deliveryslotid, orders will not record a delivery window:", err)
}
if err := db.DB.Exec(
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotdate DATE`).Error; err != nil {
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
}
// When a product became visible to a store, and the only thing that decides // When a product became visible to a store, and the only thing that decides
// whether it is. // whether it is.
// //
@@ -172,6 +345,60 @@ func main() {
log.Println("productvariants.variantid given a key generator (one time)") log.Println("productvariants.variantid given a key generator (one time)")
} }
// Key generators for the two partner tables, for exactly the reason above.
//
// `partnerinfo.partnerid` and `partnerlocations.partnerlocationid` are both
// NOT NULL with no default and no identity, so GORM — which sends nothing
// for a key it expects the database to mint — had every insert refused with
// a not-null violation. `createpartner` therefore could not write a partner
// OR its regions: the endpoint exists, the form exists, and the row could
// never land. The five partners on the platform were all inserted by hand,
// which is the symptom rather than a choice.
//
// This matters more than one broken button. `GetPartners` now separates the
// partners registered through this console from the ones another product
// left in the shared `partnerinfo` by joining `partnerlocations` — and only
// a successful create writes that table. Without a key generator no partner
// can ever be registered, so nothing would ever have a link row and the
// Rider partners page would be empty forever.
//
// Both sequences start above the ids already there, so the hand-inserted
// rows keep theirs.
for _, key := range []struct{ table, column string }{
{"partnerinfo", "partnerid"},
{"partnerlocations", "partnerlocationid"},
} {
var keyed int64
if err := db.DB.Raw(`
SELECT COUNT(1) FROM information_schema.columns
WHERE table_name = ? AND column_name = ?
AND (column_default IS NOT NULL OR is_identity = 'YES')`,
key.table, key.column).Scan(&keyed).Error; err != nil {
log.Fatalf("could not check %s.%s: %v", key.table, key.column, err)
}
if keyed > 0 {
continue
}
seq := key.table + "_" + key.column + "_seq"
if err := db.DB.Exec(fmt.Sprintf(
`CREATE SEQUENCE IF NOT EXISTS %s START WITH 1 OWNED BY %s.%s`,
seq, key.table, key.column)).Error; err != nil {
log.Fatalf("could not create %s: %v", seq, err)
}
if err := db.DB.Exec(fmt.Sprintf(
`SELECT setval('%s', COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false)`,
seq, key.column, key.table)).Error; err != nil {
log.Fatalf("could not position %s: %v", seq, err)
}
if err := db.DB.Exec(fmt.Sprintf(
`ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')`,
key.table, key.column, seq)).Error; err != nil {
log.Fatalf("could not default %s.%s: %v", key.table, key.column, err)
}
log.Printf("%s.%s given a key generator (one time)", key.table, key.column)
}
// The catalogue's own stable key for an imported product. // The catalogue's own stable key for an imported product.
// //
// `catalogueid` was never able to be this. The catalogue is rebuilt by // `catalogueid` was never able to be this. The catalogue is rebuilt by
@@ -263,7 +490,74 @@ func main() {
log.Fatal("could not add catalogueuploads.sheetrows:", err) 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)
}
// The model behind Nearle Buddy. Optional in the same way: without
// ASSISTANT_PROVIDER the tools still work and the panel says the assistant
// is not switched on, rather than the console showing a field that accepts
// text and swallows it.
chat, err := utils.NewChat(cfg.Assistant)
if err != nil {
log.Fatal("assistant provider:", err)
}
if chat == nil {
log.Printf("assistant: OFF — %s", cfg.Assistant.Why())
} else {
log.Printf("assistant: %s, balanced tier is %s", cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
}
// ASSISTANT_AGENTS_DIR replaces the compiled-in agent definitions wholesale.
// Empty uses the embedded ones, so a deployment cannot be broken by a missing
// directory.
// Mail, for the invitation a newly onboarded merchant is sent.
//
// Optional in the same way as the model and the embedder: without it the
// server still boots and still onboards tenants, and the create response
// says the invitation was not sent and which variable is missing. Refusing
// to start would make a mail relay a hard dependency of creating a shop,
// which it is not.
mailer, err := utils.NewMailer(cfg.Mail)
if err != nil {
// A configured-but-invalid sender, as opposed to no mail at all. That
// fails every message, so it is worth stopping for rather than
// discovering one silent invitation at a time.
log.Fatal("mail:", err)
}
if mailer == nil {
log.Printf("mail: OFF — %s", cfg.Mail.Why())
} else {
log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address())
}
// NUTRITION_BASE is the catalogue-intelligence host — the same service the
// console reads its health score card from. Unset means product screens
// carry no nutrition panel, and nothing else changes.
//
// Logged for the same reason mail is, and learned the same way: with it
// unset, `getproductbyvariant` simply omits `nutrition` and `healthscore`,
// which is indistinguishable from a product the service has not scored.
// A deploy that silently does nothing is one somebody has to reverse
// engineer from the outside, and this line is the difference.
nutritionBase := strings.TrimSpace(os.Getenv("NUTRITION_BASE"))
if nutritionBase == "" {
log.Printf("nutrition: OFF — NUTRITION_BASE is not set, so no product carries a nutrition panel or health score")
} else {
log.Printf("nutrition: reading panels and scores from %s", nutritionBase)
}
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat,
os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail,
nutritionBase)
routes.RegisterRoutes(app, f) routes.RegisterRoutes(app, f)
@@ -293,17 +587,20 @@ func main() {
repositories.SetCatalogueNotifier(posMqtt) repositories.SetCatalogueNotifier(posMqtt)
} }
// Start server // How much of the till fleet is carrying a session token, in the log every
// half hour.
// //
// The port comes from APP_PORT, defaulting to the 1122 this has always // `POS_AUTH_REQUIRED` is off, and the only thing between here and switching
// served on. It was hardcoded, which left `config.Load()`'s Port field // it on is that number — nothing was recording it, so an untokened till was
// dead and the Dockerfile's `EXPOSE 1009` describing a port nothing // waved through in silence and the risk of flipping the flag could only be
// listened on — and made running a second copy locally, on a free port, // measured by flipping it. The same figures are on
// impossible without editing this line. // `GET /v1/web/pos/authadoption`, behind the session guard.
port := os.Getenv("APP_PORT") go middleware.LogPosAdoption(context.Background())
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() { go func() {
log.Printf("🚀 listening on :%s", port) log.Printf("🚀 listening on :%s", port)
if err := app.Listen(":" + port); err != nil { if err := app.Listen(":" + port); err != nil {

113
main_test.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
)
// Cross-origin access, checked the way a browser checks it.
//
// These exist because this went wrong in production and nothing caught it.
// Adding the session token to the console made every request non-simple, so
// browsers began asking permission first — and the answer did not name the
// `Authorization` header, so every call was blocked.
//
// The reason it reached production is worth keeping in mind while reading
// these: the preflight returns 204 and looks perfectly healthy from a terminal,
// the server logs nothing unusual, and `curl` cannot reproduce it because curl
// does not enforce CORS. The only thing that noticed was a browser.
// preflight asks the question a browser asks before a cross-origin request.
func preflight(t *testing.T, requestHeaders string) map[string]string {
t.Helper()
app := fiber.New()
app.Use(cors.New(corsSettings()))
app.Get("/probe", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
req := httptest.NewRequest("OPTIONS", "/probe", nil)
req.Header.Set("Origin", "https://app.nearledaily.com")
req.Header.Set("Access-Control-Request-Method", "GET")
if requestHeaders != "" {
req.Header.Set("Access-Control-Request-Headers", requestHeaders)
}
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("preflight: %v", err)
}
out := map[string]string{}
for _, name := range []string{
"Access-Control-Allow-Origin",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Credentials",
} {
out[name] = resp.Header.Get(name)
}
return out
}
func TestTheBrowserIsAllowedToSendTheSessionToken(t *testing.T) {
// The bug itself. Without `Authorization` in this list the console cannot
// make a single authenticated call, and the error surfaces only in a
// browser console as a CORS failure.
headers := preflight(t, "authorization")["Access-Control-Allow-Headers"]
if !strings.Contains(strings.ToLower(headers), "authorization") {
t.Fatalf("the console may not send its session token: %q", headers)
}
}
func TestTheHeadersTheConsoleAlreadySentStillWork(t *testing.T) {
// Adding one header must not quietly drop the others.
headers := strings.ToLower(preflight(t, "content-type")["Access-Control-Allow-Headers"])
for _, needed := range []string{"content-type", "accept", "origin"} {
if !strings.Contains(headers, needed) {
t.Fatalf("%q is no longer allowed: %q", needed, headers)
}
}
}
func TestAWildcardOriginIsNotPairedWithCredentials(t *testing.T) {
// Not a valid combination: a browser rejects a credentialed response
// carrying a wildcard origin. It was here already and was harmless only
// because nothing used credentials — it would have become a second bug
// that looked exactly like the first, the day anything did.
got := preflight(t, "authorization")
if got["Access-Control-Allow-Origin"] == "*" &&
strings.EqualFold(got["Access-Control-Allow-Credentials"], "true") {
t.Fatal("wildcard origin with credentials allowed — browsers reject this pair")
}
}
func TestEveryMethodTheConsoleUsesIsAllowed(t *testing.T) {
// The console writes with POST, PUT and DELETE. A missing one fails only
// on the screens that use it, which is the kind of gap that ships.
methods := strings.ToUpper(preflight(t, "authorization")["Access-Control-Allow-Methods"])
for _, method := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
if !strings.Contains(methods, method) {
t.Fatalf("%s is not allowed cross-origin: %q", method, methods)
}
}
}
func TestAResponseHeaderIsNotListedAsAnAllowedRequestHeader(t *testing.T) {
// `Access-Control-Allow-Origin` was in the allowed REQUEST headers, which is
// a category error: it is something the server sends back, never something a
// browser asks to send. Harmless, but it reads as though somebody added
// names until the error went away.
headers := strings.ToLower(preflight(t, "authorization")["Access-Control-Allow-Headers"])
if strings.Contains(headers, "access-control-allow-origin") {
t.Fatalf("a response header is listed as an allowed request header: %q", headers)
}
}

View File

@@ -119,7 +119,15 @@ func StartLiveHub() *LiveHub {
SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise. SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise.
SetOrderMatters(false) 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.SetUsername(user)
opts.SetPassword(os.Getenv("MQTT_PASSWORD")) opts.SetPassword(os.Getenv("MQTT_PASSWORD"))
} }

View File

@@ -55,6 +55,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
token := bearerToken(c) token := bearerToken(c)
if token == "" { if token == "" {
// Counted before anything else happens to it. This is the number
// that decides when POS_AUTH_REQUIRED can be switched on, and
// nothing else in the system was recording it — the request was
// simply waved through in silence. See posauthadoption.go.
recordPosUntokened(requestedLocation(c), c.Path())
if posAuthRequired() { if posAuthRequired() {
return posUnauthorized(c, "a session token is required; sign in at /pos/login") return posUnauthorized(c, "a session token is required; sign in at /pos/login")
} }
@@ -96,6 +102,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
} }
} }
// A till that has adopted the new sign-in. Counted only once the token
// has verified AND the outlet check has passed, so the figure means
// "requests this guard would still serve with enforcement on" rather
// than "requests that carried something token-shaped".
recordPosToken()
c.Locals(PosLocalsKey, claims) c.Locals(PosLocalsKey, claims)
return c.Next() return c.Next()
} }

View File

@@ -0,0 +1,249 @@
package middleware
import (
"context"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
)
/*
How much of the till fleet is carrying a session token.
── Why this exists ─────────────────────────────────────────────────────────
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
switching it on is a number nobody has: how many terminals still call the POS
routes with no token. Flipping the flag blind is the one action on this surface
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
bill is not a reversible inconvenience.
So this counts, and names the outlets that are still untokened, so the flag gets
flipped on evidence rather than on hope.
── What it deliberately is not ─────────────────────────────────────────────
Not persisted. It lives in memory and resets on restart, which is honest about
what it measures: adoption since this process started, not all time. A restart
mid-observation means starting the week again, and that is a smaller cost than a
migration and a table for a number that stops mattering the day the flag is on.
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
here can change whether a request is served.
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
name ten thousand outlets and grow this without bound. Past the cap new outlets
are counted in the totals and not listed individually, which keeps the answer
useful without making it a way to spend the server's memory.
*/
// posAdoptionCap is how many distinct untokened outlets are named individually.
// The real fleet is dozens; anything beyond this is noise or somebody probing.
const posAdoptionCap = 200
type posOutletSeen struct {
Locationid int
Requests int64
FirstSeen time.Time
LastSeen time.Time
}
var posAdoption = struct {
sync.Mutex
since time.Time
tokened int64
untokened int64
// Untokened requests by the outlet they named, and by the route they hit.
outlets map[int]*posOutletSeen
paths map[string]int64
// True once the cap was reached, so the report can say it is partial
// rather than quietly under-reporting.
truncated bool
}{
since: time.Now(),
outlets: map[int]*posOutletSeen{},
paths: map[string]int64{},
}
// recordPosToken notes one request that arrived with a usable token.
func recordPosToken() {
posAdoption.Lock()
posAdoption.tokened++
posAdoption.Unlock()
}
// recordPosUntokened notes one request that arrived with none, and where it
// claimed to be. `locationid` is 0 when the route named no outlet.
func recordPosUntokened(locationid int, path string) {
now := time.Now()
posAdoption.Lock()
defer posAdoption.Unlock()
posAdoption.untokened++
posAdoption.paths[path]++
if locationid <= 0 {
return
}
if seen, ok := posAdoption.outlets[locationid]; ok {
seen.Requests++
seen.LastSeen = now
return
}
if len(posAdoption.outlets) >= posAdoptionCap {
posAdoption.truncated = true
return
}
posAdoption.outlets[locationid] = &posOutletSeen{
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
}
}
// PosAdoptionOutlet is one outlet still calling without a token.
type PosAdoptionOutlet struct {
Locationid int `json:"locationid"`
Requests int64 `json:"requests"`
FirstSeen string `json:"firstseen"`
LastSeen string `json:"lastseen"`
}
// PosAdoptionPath is one route, and how often it was reached untokened.
type PosAdoptionPath struct {
Path string `json:"path"`
Requests int64 `json:"requests"`
}
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
type PosAdoption struct {
// Whether an untokened request is currently refused.
Enforced bool `json:"enforced"`
// When counting started — process start, not all time.
Since string `json:"since"`
// Requests seen on the POS surface since then.
Tokened int64 `json:"tokened"`
Untokened int64 `json:"untokened"`
// 0–100. 100 means every request in this window carried a token, which is
// the condition for flipping the flag.
AdoptedPercent float64 `json:"adoptedpercent"`
// The outlets still calling without one, busiest first. These are the tills
// that would stop working the moment enforcement is switched on.
Outlets []PosAdoptionOutlet `json:"outlets"`
// Which routes they are reaching, busiest first.
Paths []PosAdoptionPath `json:"paths"`
// True when more outlets were seen than are listed — see posAdoptionCap.
Truncated bool `json:"truncated"`
// Plain-language reading of the above, for whoever has to make the call.
Verdict string `json:"verdict"`
}
// PosAdoptionReport is the snapshot, safe to call at any time.
func PosAdoptionReport() PosAdoption {
posAdoption.Lock()
defer posAdoption.Unlock()
report := PosAdoption{
Enforced: posAuthRequired(),
Since: posAdoption.since.Format(time.RFC3339),
Tokened: posAdoption.tokened,
Untokened: posAdoption.untokened,
Truncated: posAdoption.truncated,
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
}
total := posAdoption.tokened + posAdoption.untokened
if total > 0 {
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
}
for _, seen := range posAdoption.outlets {
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
Locationid: seen.Locationid,
Requests: seen.Requests,
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
LastSeen: seen.LastSeen.Format(time.RFC3339),
})
}
// Busiest first: the outlet ringing the most bills is the one that hurts
// most if enforcement switches on before it has adopted.
sort.Slice(report.Outlets, func(i, j int) bool {
return report.Outlets[i].Requests > report.Outlets[j].Requests
})
for path, count := range posAdoption.paths {
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
}
sort.Slice(report.Paths, func(i, j int) bool {
return report.Paths[i].Requests > report.Paths[j].Requests
})
report.Verdict = posAdoptionVerdict(report)
return report
}
// posAdoptionVerdict says what the numbers mean, because the number on its own
// invites the wrong reading in both directions: a clean window that is only an
// hour long proves nothing, and one stubborn outlet is not a reason to leave
// the whole surface open.
func posAdoptionVerdict(r PosAdoption) string {
switch {
case r.Enforced:
return "Enforcement is already on: an untokened request is refused."
case r.Tokened+r.Untokened == 0:
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
case r.Untokened == 0:
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
case len(r.Outlets) == 0:
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
default:
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
}
}
// posAdoptionLogEvery is how often the summary reaches the log.
//
// Long, because this is a slow-moving fact — a fleet adopts over days, not
// minutes — and a log line nobody needs every minute is a log line people learn
// to scroll past.
const posAdoptionLogEvery = 30 * time.Minute
// LogPosAdoption prints the summary on a timer until ctx is done.
//
// In the log as well as on the endpoint because the two get used by different
// people at different moments: somebody already reading Dokploy's log because a
// till is misbehaving should not have to know an endpoint exists.
//
// Outlet ids only, never names or counts of takings — a log is the one place
// this data ends up somewhere nobody chose to put it.
func LogPosAdoption(ctx context.Context) {
ticker := time.NewTicker(posAdoptionLogEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
report := PosAdoptionReport()
if report.Tokened+report.Untokened == 0 {
continue // nothing happened; saying so every half hour is noise
}
if report.Untokened == 0 {
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
continue
}
outlets := make([]string, 0, len(report.Outlets))
for _, o := range report.Outlets {
outlets = append(outlets, strconv.Itoa(o.Locationid))
}
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
report.AdoptedPercent, report.Tokened+report.Untokened,
report.Untokened, strings.Join(outlets, ", "))
}
}
}

View File

@@ -0,0 +1,297 @@
package middleware
import (
"net/http/httptest"
"strings"
"testing"
"time"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
/*
Counting the till fleet's adoption of the session token.
This exists to answer one question — is it safe to set POS_AUTH_REQUIRED=true —
and the cost of answering it wrong is a cashier at a counter who cannot ring a
bill. So these are mostly about the figure being honest: not flattering, not
alarmist, and never able to change whether a request is served.
*/
// resetAdoption puts the counters back, since they are process-wide.
func resetAdoption(t *testing.T) {
t.Helper()
posAdoption.Lock()
posAdoption.since = time.Now()
posAdoption.tokened = 0
posAdoption.untokened = 0
posAdoption.outlets = map[int]*posOutletSeen{}
posAdoption.paths = map[string]int64{}
posAdoption.truncated = false
posAdoption.Unlock()
}
func TestAnUntokenedTillIsNamedByItsOutlet(t *testing.T) {
// The whole point. Without this list, switching enforcement on is a guess
// about which shops stop trading.
resetAdoption(t)
recordPosUntokened(1185, "/live/api/v1/pos/orders")
recordPosUntokened(1185, "/live/api/v1/pos/orders")
recordPosUntokened(1170, "/live/api/v1/pos/catalogue")
recordPosToken()
report := PosAdoptionReport()
if report.Untokened != 3 || report.Tokened != 1 {
t.Fatalf("counts wrong: %+v", report)
}
if len(report.Outlets) != 2 {
t.Fatalf("outlets: %+v", report.Outlets)
}
// Busiest first — the outlet ringing the most bills is the one that hurts
// most if enforcement goes on before it has adopted.
if report.Outlets[0].Locationid != 1185 || report.Outlets[0].Requests != 2 {
t.Errorf("not ordered by traffic: %+v", report.Outlets)
}
}
func TestTheAdoptedPercentageIsOfEverything(t *testing.T) {
resetAdoption(t)
for i := 0; i < 3; i++ {
recordPosToken()
}
recordPosUntokened(1185, "/pos/orders")
if got := PosAdoptionReport().AdoptedPercent; got != 75 {
t.Fatalf("adopted = %v%%, want 75", got)
}
}
func TestNoTrafficIsNotAHundredPercent(t *testing.T) {
// A fleet nobody has used is not a fleet that has adopted. Reporting 100%
// here is the single most dangerous rounding this file could do — it would
// green-light the flag on an empty window.
resetAdoption(t)
report := PosAdoptionReport()
if report.AdoptedPercent != 0 {
t.Fatalf("empty window reported as %v%%", report.AdoptedPercent)
}
if !strings.Contains(report.Verdict, "nothing to conclude") {
t.Errorf("verdict oversells an empty window: %q", report.Verdict)
}
}
func TestACleanWindowStillSaysToKeepWatching(t *testing.T) {
// Zero untokened requests in one hour is not an adopted fleet — a shop that
// is shut has no traffic either. The verdict has to say so, because the
// number on its own reads as permission.
resetAdoption(t)
recordPosToken()
verdict := PosAdoptionReport().Verdict
if !strings.Contains(verdict, "trading days") {
t.Errorf("a one-request window was treated as proof: %q", verdict)
}
}
func TestAnUntokenedFleetIsNotDescribedAsReady(t *testing.T) {
resetAdoption(t)
recordPosToken()
recordPosUntokened(1185, "/pos/orders")
verdict := PosAdoptionReport().Verdict
if !strings.Contains(verdict, "stop being able to trade") {
t.Errorf("the consequence is not stated: %q", verdict)
}
}
func TestARequestThatNamesNoOutletIsStillCounted(t *testing.T) {
// `/pos/staff` deliberately takes no location parameter. Such a request is
// still an untokened till, and dropping it would understate the problem.
resetAdoption(t)
recordPosUntokened(0, "/live/api/v1/pos/staff")
report := PosAdoptionReport()
if report.Untokened != 1 {
t.Fatalf("not counted: %+v", report)
}
if len(report.Outlets) != 0 {
t.Errorf("invented an outlet for a request that named none: %+v", report.Outlets)
}
if len(report.Paths) != 1 || report.Paths[0].Path != "/live/api/v1/pos/staff" {
t.Errorf("the route was lost: %+v", report.Paths)
}
if !strings.Contains(report.Verdict, "cannot be traced") {
t.Errorf("verdict does not explain the blind spot: %q", report.Verdict)
}
}
func TestOutletsCannotGrowWithoutBound(t *testing.T) {
// `store_id` comes off the wire. Without a cap an untokened caller could
// name ten thousand outlets and spend the server's memory doing it.
resetAdoption(t)
for i := 1; i <= posAdoptionCap+50; i++ {
recordPosUntokened(i, "/pos/orders")
}
report := PosAdoptionReport()
if len(report.Outlets) > posAdoptionCap {
t.Fatalf("listed %d outlets, cap is %d", len(report.Outlets), posAdoptionCap)
}
if report.Untokened != int64(posAdoptionCap+50) {
// The total must stay true even when the list is trimmed.
t.Errorf("total under-reported: %d", report.Untokened)
}
if !report.Truncated {
t.Error("a trimmed list was presented as complete")
}
}
func TestFirstAndLastSeenAreBothKept(t *testing.T) {
// "This till stopped calling untokened three days ago" and "it did so a
// minute ago" are different facts, and only one of them means it has been
// updated. So the first sighting must stick and the last must move.
//
// The clock is wound back rather than slept through: the report formats to
// RFC3339, which is second-precision, and a test that waits a second to
// prove an assignment is a second every run forever.
resetAdoption(t)
recordPosUntokened(1185, "/pos/orders")
posAdoption.Lock()
seen := posAdoption.outlets[1185]
seen.FirstSeen = seen.FirstSeen.Add(-48 * time.Hour)
seen.LastSeen = seen.LastSeen.Add(-48 * time.Hour)
posAdoption.Unlock()
recordPosUntokened(1185, "/pos/orders")
out := PosAdoptionReport().Outlets[0]
if out.FirstSeen == "" || out.LastSeen == "" {
t.Fatalf("timestamps missing: %+v", out)
}
if out.Requests != 2 {
t.Errorf("requests = %d, want 2", out.Requests)
}
if out.FirstSeen == out.LastSeen {
t.Errorf("last seen never moved: %+v", out)
}
if out.FirstSeen > out.LastSeen {
// RFC3339 sorts lexically, so this comparison is meaningful.
t.Errorf("first seen is after last seen: %+v", out)
}
}
func TestTheReportSaysWhetherEnforcementIsAlreadyOn(t *testing.T) {
resetAdoption(t)
t.Setenv("POS_AUTH_REQUIRED", "true")
report := PosAdoptionReport()
if !report.Enforced {
t.Fatal("enforcement is on and the report says otherwise")
}
if !strings.Contains(report.Verdict, "already on") {
t.Errorf("verdict ignores that the work is done: %q", report.Verdict)
}
}
/* ── The guard still behaves exactly as it did ───────────────────────────── */
func TestCountingNeverChangesWhetherARequestIsServed(t *testing.T) {
// This whole file is instrumentation. If it can refuse a request, or let
// one through that should have been refused, it has become the thing it was
// built to make safe.
//
// Both sides of the flag, against the real middleware.
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
for _, tc := range []struct {
name string
required string
want int
}{
{"off: an untokened till still trades", "", 200},
{"on: an untokened till is refused", "true", 401},
} {
t.Run(tc.name, func(t *testing.T) {
resetAdoption(t)
t.Setenv("POS_AUTH_REQUIRED", tc.required)
got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=1185", "")
if got != tc.want {
t.Fatalf("status %d, want %d", got, tc.want)
}
// Counted either way: the figure is about what the fleet is doing,
// not about what the flag currently allows.
if report := PosAdoptionReport(); report.Untokened != 1 {
t.Errorf("untokened = %d, want 1", report.Untokened)
}
})
}
}
func TestOnlyARequestThatWouldSurviveEnforcementCountsAsAdopted(t *testing.T) {
// A token that verifies but names somebody else's outlet is refused, and
// must NOT be counted as adopted — otherwise a misconfigured till inflates
// the very number used to decide the flag is safe to set.
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
t.Setenv("POS_AUTH_REQUIRED", "")
resetAdoption(t)
token := posTokenFor(t, 1147, 1185)
if got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=9999", token); got != 403 {
t.Fatalf("a token was allowed to name another tenant's outlet: %d", got)
}
if report := PosAdoptionReport(); report.Tokened != 0 {
t.Errorf("a refused request was counted as adopted: %+v", report)
}
}
/* ── Harness ─────────────────────────────────────────────────────────────── */
const posTestSecret = "a-pos-signing-secret-of-ample-length"
// posLocations answers the tenant-owns-outlet question without a database.
// Only LocationAllowed is real; anything else the guard touched would panic,
// which is the signal wanted.
type posLocations struct {
services.PosService
}
func (posLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
// Tenant 1147 owns 1185 and nothing else, which is all these tests need.
return tenantID == 1147 && locationID == 1185, nil
}
func posTokenFor(t *testing.T, tenantID, locationID int) string {
t.Helper()
token, _, err := utils.MintPosToken(utils.PosClaims{
Tenantid: tenantID, Locationid: locationID, Configid: 1,
}, time.Now())
if err != nil {
t.Fatalf("minting a terminal session: %v", err)
}
return token
}
func callPos(t *testing.T, method, target, token string) int {
t.Helper()
app := fiber.New()
app.Use("/live/api/v1/pos", PosAuth(posLocations{}))
app.All("/live/api/v1/pos/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
req := httptest.NewRequest(method, target, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("calling: %v", err)
}
return resp.StatusCode
}

332
middleware/webauth.go Normal file
View File

@@ -0,0 +1,332 @@
package middleware
import (
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"time"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// Authorisation for the console.
//
// The `/web` surface has never had any. The console keeps its login record in
// per-tab `sessionStorage` and sends no `Authorization` header, so every
// endpoint under `/v1/web` reads `tenantid` off the query string and believes
// it. Changing one number in a URL reads another merchant's orders, stock,
// staff and takings.
//
// This is the same hole `posauth.go` was written to close on the POS surface,
// and it is closed the same way, in the same order:
//
// 1. the caller holds a token this server signed, and
// 2. the tenant they are naming is the tenant inside that token.
//
// The second is the one that matters. A valid session is not a licence to name
// any tenant — it is a licence to name *your* tenant.
//
// ── Why this could not wait for the assistant ───────────────────────────────
//
// Nearle Buddy answers questions over this same data. Behind REST, reading
// another merchant's books takes knowing the endpoints, knowing the fields and
// iterating. Behind an assistant it is one sentence — "summarise the top ten
// tenants by revenue" — and the model assembles the cross-tenant answer itself,
// accurately and helpfully, because the data was in scope. The permission rules
// the assistant needs have nothing to stand on until this exists.
//
// ── What this does NOT yet do ───────────────────────────────────────────────
//
// It verifies what a request NAMES: the tenant, and the branch. It does not yet
// make handlers derive their scope from the session rather than from the wire.
//
// It also does not validate `partnerid`, `customerid` or `appuserid`, and that
// one is not an oversight — it is blocked. A delivery partner serves several
// merchants at once (`insights.ts` records partner 60 answering with deliveries
// spanning twelve shops), so scoping a read by partner is a cross-tenant read by
// design. Refusing the parameter outright would be wrong: `RiderDrawer` and
// `AssignBar` are merchant screens and both send it legitimately, for a partner
// assigned to that merchant.
//
// Closing it properly needs a check this codebase does not have — "is this
// partner assigned to this tenant?" — in the shape of `LocationAllowed`, which
// answers the same question for branches. Until that exists, a handler scoping
// on one of these three is trusting the caller, and the assistant is kept away
// from them entirely: no tool accepts any of these as an argument, and the
// registry refuses to register one that tries.
// WebLocalsKey names where the verified claims are parked for handlers.
const WebLocalsKey = "webclaims"
// webAuthRequired reports whether a request without a valid token is refused.
//
// Defaults to ON. It did not always: this shipped defaulting to off, because
// the console was live and its sign-in did not yet hand back a token, so
// enforcing first would have locked every merchant out of a working product.
//
// That rollout is finished. Sign-in mints a token, the console sends it on
// every call, and it expires cleanly. Leaving the default off after that point
// was not caution, it was an open door nobody had got round to shutting — and
// it was measured wide open: a `getorders` with no credential at all returned a
// real merchant's orders to anyone on the internet.
//
// ── The way out, if this goes wrong ─────────────────────────────────────────
//
// `WEB_AUTH_REQUIRED=false` restores the old behaviour, immediately and without
// a deploy. That is the escape hatch, and it exists because flipping a default
// that can lock people out should always be reversible by one person in one
// minute. A token that is SENT is still always verified either way — the flag
// only decides what happens to a request carrying none.
func webAuthRequired() bool {
setting := strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED"))
if setting == "" {
return true
}
return !strings.EqualFold(setting, "false")
}
// publicWebPaths are the endpoints that must work before anybody has a token.
//
// Sign-in, chiefly: guarding the login route with a session token means nobody
// can ever obtain one. Kept as suffixes rather than full paths so the group
// prefix can move without silently locking the door.
var publicWebPaths = []string{
"/users/applogin",
"/users/weblogin",
"/tenant/weblogin",
// First-password-set runs before a session exists, from a link in the
// invitation mail.
"/users/setpassword",
}
func isPublicWebPath(path string) bool {
lower := strings.ToLower(path)
for _, suffix := range publicWebPaths {
if strings.HasSuffix(lower, suffix) {
return true
}
}
return false
}
// webLocationChecker is the only question this middleware asks of the database:
// does this tenant own this branch? Narrowed to one method so the guard can be
// tested without a database, and so it cannot quietly grow a second dependency.
type webLocationChecker interface {
LocationAllowed(tenantID, locationID int) (bool, error)
}
// WebAuth verifies the console session and pins the request to its tenant.
func WebAuth(pos services.PosService) fiber.Handler { return webAuthWith(pos) }
func webAuthWith(locations webLocationChecker) fiber.Handler {
return func(c *fiber.Ctx) error {
if isPublicWebPath(c.Path()) {
return c.Next()
}
token := webBearerToken(c)
if token == "" {
if webAuthRequired() {
return webUnauthorized(c, "a session token is required; sign in again")
}
// A console that predates tokens. Allowed through unpinned, which is
// exactly the state this middleware exists to end — see
// webAuthRequired.
return c.Next()
}
claims, err := utils.ParseWebToken(token, time.Now())
if err != nil {
// Always refused, flag or no flag. A token that does not verify is a
// stronger signal than no token at all: nothing sends a broken one by
// accident.
return webUnauthorized(c, err.Error())
}
// Nearle's own staff work across every tenant and legitimately name any
// of them. Checked once, here, rather than at each test below, so the
// exemption is a single visible branch instead of three.
if !claims.IsPlatformAccount() {
if requested := requestedTenant(c); requested > 0 && requested != claims.Tenantid {
return webForbidden(c, "this session cannot reach tenant "+strconv.Itoa(requested))
}
// A request can also scope by branch alone, naming no tenant at all,
// so pinning the tenant is not enough on its own.
if requested := requestedWebLocation(c); requested > 0 && requested != claims.Locationid {
allowed, err := locations.LocationAllowed(claims.Tenantid, requested)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "status": false,
"message": "could not verify branch access",
})
}
if !allowed {
return webForbidden(c, "this session cannot reach branch "+strconv.Itoa(requested))
}
}
}
c.Locals(WebLocalsKey, claims)
return c.Next()
}
}
// webBearerToken reads the session out of the request.
//
// `Authorization: Bearer …` only. The POS reader next door also accepts
// `X-Pos-Token`, because shop routers between a till and this server strip
// Authorization headers on plain HTTP and a terminal that cannot authenticate
// is a shop that cannot trade. The console has no such problem — it is a
// browser on HTTPS — so it gets the one form, and a second accepted header is
// a second thing to get wrong.
func webBearerToken(c *fiber.Ctx) string {
header := strings.TrimSpace(c.Get("Authorization"))
if header == "" {
return ""
}
if after, found := strings.CutPrefix(header, "Bearer "); found {
return strings.TrimSpace(after)
}
if !strings.Contains(header, " ") {
return header
}
return ""
}
// requestedTenant reads the tenant a request is naming, from wherever it put it.
//
// Query first, because that is where every `/web` list endpoint carries it, then
// the body, because the writes do not: `createdeliveries`, `publishproduct` and
// the rest post JSON. Checking only the query would leave every call that
// CHANGES another tenant's data unguarded, which is the wrong half to skip.
func requestedTenant(c *fiber.Ctx) int {
for _, key := range []string{"tenantid", "tenant_id"} {
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
return id
}
}
}
return bodyScopeID(c, "tenantid", "tenant_id")
}
// requestedWebLocation reads the branch a request is naming.
//
// Separate from the POS reader's `requestedLocation` because the two surfaces
// spell it differently: POS routes use `store_id`, the console uses
// `locationid`. Both spellings are read here anyway — a shared endpoint is
// cheaper to allow for than to discover.
func requestedWebLocation(c *fiber.Ctx) int {
for _, key := range []string{"locationid", "location_id", "store_id"} {
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
return id
}
}
}
return bodyScopeID(c, "locationid", "location_id", "store_id")
}
// bodyScopeID pulls a scoping id out of a JSON request body.
//
// Decoded loosely rather than into a request type, on purpose: this runs before
// the handler and must not refuse anything the handler would have accepted. A
// body that will not parse here is left for the handler to reject with its own
// message, and a request shape that changes later must not silently stop being
// authorised.
//
// `c.Body()` returns buffered bytes, so reading here does not consume the
// stream the handler goes on to parse.
//
// An ARRAY body — `createdeliveries` posts one — is walked too. A batch naming
// another tenant in its elements is precisely the call worth guarding, and a
// probe that only understood objects would wave it through.
func bodyScopeID(c *fiber.Ctx, keys ...string) int {
body := c.Body()
if len(body) == 0 || len(body) > 8<<20 {
return 0
}
var raw json.RawMessage = body
trimmed := strings.TrimLeft(string(body), " \t\r\n")
if strings.HasPrefix(trimmed, "[") {
var elements []json.RawMessage
if err := json.Unmarshal(body, &elements); err != nil {
return 0
}
for _, element := range elements {
if id := scopeIDFromObject(element, keys); id > 0 {
return id
}
}
return 0
}
return scopeIDFromObject(raw, keys)
}
func scopeIDFromObject(raw json.RawMessage, keys []string) int {
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return 0
}
for _, key := range keys {
if id := asScopeID(fields[key]); id > 0 {
return id
}
}
return 0
}
// asScopeID reads an id that may have been sent as a number or as a string.
//
// Both spellings are on the wire today — the console sends numbers, some app
// callers send strings — and a probe that understood only one would return 0
// for the other, which reads as "named no tenant" and waves the request past
// the check.
func asScopeID(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
var number int
if err := json.Unmarshal(raw, &number); err == nil {
return number
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
if id, err := strconv.Atoi(strings.TrimSpace(text)); err == nil {
return id
}
}
return 0
}
func webUnauthorized(c *fiber.Ctx, message string) error {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false, "message": message,
})
}
func webForbidden(c *fiber.Ctx, message string) error {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false, "message": message,
})
}
// WebClaimsFrom returns the verified session on a request, if it carried one.
//
// The second return distinguishes "no token" from "a token claiming tenant 0",
// which is a platform account and a real answer. A handler that treated the two
// alike would give an unauthenticated caller the one session that reads
// everything.
func WebClaimsFrom(c *fiber.Ctx) (utils.WebClaims, bool) {
claims, ok := c.Locals(WebLocalsKey).(utils.WebClaims)
return claims, ok
}

334
middleware/webauth_test.go Normal file
View File

@@ -0,0 +1,334 @@
package middleware
import (
"net/http/httptest"
"strings"
"testing"
"time"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
const webTestSecret = "a-test-signing-key-long-enough"
// fakeLocations answers the tenant-owns-branch question without a database.
//
// `owned` is the branch the tenant genuinely has; anything else is refused, and
// `fails` makes the lookup itself error so the unavailable path can be reached.
type fakeLocations struct {
tenant int
owned int
fails bool
}
func (f fakeLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
if f.fails {
return false, errFakeLookup
}
return tenantID == f.tenant && locationID == f.owned, nil
}
type fakeErr struct{}
func (fakeErr) Error() string { return "lookup unavailable" }
var errFakeLookup = fakeErr{}
// call runs one request through the middleware and reports the status.
//
// The handler behind it always succeeds, so any non-200 came from the guard.
func call(t *testing.T, locations webLocationChecker, token, method, target, body string) int {
t.Helper()
app := fiber.New()
app.Use("/live/api/v1/web", webAuthWith(locations))
app.All("/live/api/v1/web/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
req := httptest.NewRequest(method, target, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("calling: %v", err)
}
return resp.StatusCode
}
func tokenFor(t *testing.T, claims utils.WebClaims) string {
t.Helper()
token, _, err := utils.MintWebToken(claims, time.Now())
if err != nil {
t.Fatalf("minting: %v", err)
}
return token
}
/* ── The hole this exists to close ─────────────────────────────────────── */
func TestASessionCannotNameAnotherTenant(t *testing.T) {
// One number in a URL. Before this middleware it read another merchant's
// orders, stock, staff and takings.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
own := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
if own != fiber.StatusOK {
t.Fatalf("a session was refused its own tenant: %d", own)
}
other := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
if other != fiber.StatusForbidden {
t.Fatalf("tenant 916 was readable with a tenant 1147 session: %d", other)
}
}
func TestAWriteCannotNameAnotherTenantInItsBody(t *testing.T) {
// The half that would be easy to skip. Reads carry `tenantid` in the query;
// the calls that CHANGE things post JSON, so a query-only check leaves every
// write unguarded.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
body := `{"tenantid":916,"productname":"Milk Bikis"}`
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", body)
if got != fiber.StatusForbidden {
t.Fatalf("a write into tenant 916 was allowed: %d", got)
}
}
func TestABatchCannotSmuggleAnotherTenantInAnArray(t *testing.T) {
// `createdeliveries` posts an array. A probe that only understood objects
// would wave through exactly the call that creates work in another
// merchant's shop.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
body := `[{"orderheaderid":1,"tenantid":916}]`
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/deliveries/createdeliveries", body)
if got != fiber.StatusForbidden {
t.Fatalf("a batch naming tenant 916 was allowed: %d", got)
}
}
func TestATenantSentAsAStringIsStillChecked(t *testing.T) {
// Both spellings are on the wire. A probe that understood only numbers
// returns 0 for `"916"`, which reads as "named no tenant" and passes.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", `{"tenantid":"916"}`)
if got != fiber.StatusForbidden {
t.Fatalf("a string tenant id slipped past: %d", got)
}
}
/* ── Scoping by branch alone ───────────────────────────────────────────── */
func TestABranchMustBelongToTheSessionsTenant(t *testing.T) {
// A request can scope by branch and name no tenant at all, so pinning the
// tenant is not sufficient on its own.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
locations := fakeLocations{tenant: 1147, owned: 1173}
mine := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1173", "")
if mine != fiber.StatusOK {
t.Fatalf("a second branch of my own tenant was refused: %d", mine)
}
theirs := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
if theirs != fiber.StatusForbidden {
t.Fatalf("another tenant's branch was readable: %d", theirs)
}
}
func TestTheSessionsOwnBranchNeedsNoLookup(t *testing.T) {
// `fails: true` errors on any lookup, so reaching OK proves the home branch
// short-circuits before asking.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1172", "")
if got != fiber.StatusOK {
t.Fatalf("the session's own branch was refused: %d", got)
}
}
func TestAFailedBranchLookupIsNotAPass(t *testing.T) {
// If the check cannot run, the answer is "cannot verify", never "allowed".
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
if got != fiber.StatusServiceUnavailable {
t.Fatalf("a broken lookup did not refuse: %d", got)
}
}
/* ── Tokens ────────────────────────────────────────────────────────────── */
func TestABrokenTokenIsAlwaysRefused(t *testing.T) {
// Refused whatever the flag says. Nothing sends a broken token by accident.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
t.Setenv("WEB_AUTH_REQUIRED", "false")
got := call(t, fakeLocations{}, "w1.rubbish.signature", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
if got != fiber.StatusUnauthorized {
t.Fatalf("a forged token was not refused: %d", got)
}
}
func TestATillsTokenIsNotAConsoleSessionHere(t *testing.T) {
// A POS token is the same shape signed with the same key. If it verified
// here its `Locationid` would land where `Tenantid` is read.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
pos, _, err := utils.MintPosToken(utils.PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, time.Now())
if err != nil {
t.Fatalf("minting a POS token: %v", err)
}
got := call(t, fakeLocations{}, pos, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
if got != fiber.StatusUnauthorized {
t.Fatalf("a cashier's token was accepted on the console: %d", got)
}
}
/* ── The staged rollout ────────────────────────────────────────────────── */
func TestWithoutTheFlagAnUntokenedRequestStillWorks(t *testing.T) {
// The console in production sends no token yet. Locking it out before
// sign-in issues one would break a working product.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
t.Setenv("WEB_AUTH_REQUIRED", "false")
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
if got != fiber.StatusOK {
t.Fatalf("an untokened request was refused while enforcement is off: %d", got)
}
}
func TestWithTheFlagAnUntokenedRequestIsRefused(t *testing.T) {
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
t.Setenv("WEB_AUTH_REQUIRED", "true")
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
if got != fiber.StatusUnauthorized {
t.Fatalf("enforcement is on and an untokened request passed: %d", got)
}
}
func TestSignInStillWorksWithEnforcementOn(t *testing.T) {
// Guarding the login route with a session token means nobody can ever get
// one. This is the test that catches a locked-out deployment.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
t.Setenv("WEB_AUTH_REQUIRED", "true")
for _, path := range []string{
"/live/api/v1/web/users/applogin",
"/live/api/v1/web/tenant/weblogin",
} {
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
t.Fatalf("%s was locked behind a session: %d", path, got)
}
}
}
/* ── The platform account ──────────────────────────────────────────────── */
func TestPlatformStaffMayNameAnyTenant(t *testing.T) {
// Nearle's own staff work across tenants and the console's /nearle pages
// depend on it.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
session := tokenFor(t, utils.WebClaims{Userid: 12, Superadmin: true, Roleid: 1})
got := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
if got != fiber.StatusOK {
t.Fatalf("a platform session was refused tenant 916: %d", got)
}
}
func TestNoTokenIsNotAPlatformAccount(t *testing.T) {
// Tenant 0 is the session that reads everything, and Go's zero value is 0.
// A handler reading claims off a request that carried none would hand an
// anonymous caller exactly that session.
app := fiber.New()
var found bool
app.Get("/probe", func(c *fiber.Ctx) error {
_, found = WebClaimsFrom(c)
return c.SendStatus(fiber.StatusOK)
})
if _, err := app.Test(httptest.NewRequest("GET", "/probe", nil)); err != nil {
t.Fatalf("probing: %v", err)
}
if found {
t.Fatal("claims were reported present on a request that carried none")
}
}
/* ── The default, after the rollout ────────────────────────────────────── */
func TestEnforcementIsOnByDefault(t *testing.T) {
// It shipped defaulting to off so a live console could adopt tokens without
// its users being locked out. That finished, and the default was measured
// still open: a getorders with no credential returned a real merchant's
// orders to anyone.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
t.Setenv("WEB_AUTH_REQUIRED", "")
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
if got != fiber.StatusUnauthorized {
t.Fatalf("an untokened request was served with no setting present: %d", got)
}
}
func TestEnforcementCanBeTurnedOffWithoutADeploy(t *testing.T) {
// The escape hatch. Flipping a default that can lock people out has to be
// reversible by one person in one minute.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
t.Setenv("WEB_AUTH_REQUIRED", "false")
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
if got != fiber.StatusOK {
t.Fatalf("the escape hatch does not work: %d", got)
}
}
func TestOnlyTheWordFalseOpensTheDoor(t *testing.T) {
// A typo must fail closed. "no", "0" and "off" all look like they might
// disable it, and a deployment that meant to disable it and did not is far
// safer than one that meant to enable it and did not.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
for _, setting := range []string{"no", "0", "off", "FALSE ", "nope"} {
t.Setenv("WEB_AUTH_REQUIRED", setting)
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
if setting == "FALSE " && got != fiber.StatusOK {
t.Fatalf("a trimmed, case-insensitive false was not honoured: %d", got)
}
if setting != "FALSE " && got != fiber.StatusUnauthorized {
t.Fatalf("%q opened the door: %d", setting, got)
}
}
}
func TestSignInStillWorksWithTheNewDefault(t *testing.T) {
// The test that catches a locked-out deployment. Guarding the login route
// means nobody can ever obtain a token.
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
t.Setenv("WEB_AUTH_REQUIRED", "")
for _, path := range []string{
"/live/api/v1/web/users/applogin",
"/live/api/v1/web/tenant/weblogin",
} {
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
t.Fatalf("%s was locked behind a session: %d", path, got)
}
}
}

52
models/assistantaudit.go Normal file
View File

@@ -0,0 +1,52 @@
package models
import "time"
// AssistantAudit is one attempt to use an assistant tool.
//
// A new table rather than a column on anything: these rows are written on a
// different schedule, read by different people, and are the only record of what
// an assistant did on a merchant's behalf. Nothing else in the schema has that
// job.
//
// ── Refusals are the interesting rows ───────────────────────────────────────
//
// Every call is recorded, including the ones the registry said no to. A trail of
// successes answers "did anything try to read another tenant?" with silence,
// which reads exactly like "no".
//
// ── What is NOT stored ──────────────────────────────────────────────────────
//
// Not the question, and not the answer. The question is a shopkeeper's own words
// and can carry anything they typed; the answer contains rows about their
// business. Neither is needed to review what the assistant DID — the tool, the
// arguments and the outcome are the act — and storing them would make this table
// a second copy of the data it exists to police.
type AssistantAudit struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement;column:id"`
At time.Time `json:"at" gorm:"column:at;index"`
Agent string `json:"agent" gorm:"column:agent;size:64"`
Tool string `json:"tool" gorm:"column:tool;size:64;index"`
Scope string `json:"scope" gorm:"column:scope;size:16"`
Userid int `json:"userid" gorm:"column:userid;index"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
// The arguments the handler actually received — validated and defaulted,
// not as the model sent them. What ran is what is worth being able to read
// back; what was asked for is only interesting when it differs, and the
// refusal row records that.
Args string `json:"args" gorm:"column:args;type:jsonb"`
// ok | refused | failed | proposed | approved.
//
// `refused` is the guard saying no and `failed` is the handler breaking;
// collapsing them would hide a broken tool inside a count of things working
// as designed. `proposed` and `approved` are the two halves of a write, and
// a `proposed` with no matching `approved` is somebody deciding not to.
Outcome string `json:"outcome" gorm:"column:outcome;size:16;index"`
Detail string `json:"detail" gorm:"column:detail"`
Rows int `json:"rows" gorm:"column:rows"`
// Milliseconds. Integer rather than an interval type so it can be averaged
// and sorted without anybody having to know the database's duration syntax.
Tookms int64 `json:"tookms" gorm:"column:tookms"`
}
func (AssistantAudit) TableName() string { return "assistantaudit" }

View File

@@ -166,6 +166,18 @@ type Ridersummary struct {
} }
type Deliveryinfo struct { type Deliveryinfo struct {
// The delivery window the customer asked for, joined from the order.
//
// Zero on every delivery whose order named no window, which is most of
// them — treat absence as "none was asked for", never as missing data.
// Read-only: the window lives on orders, not here.
Deliveryslotid int `json:"deliveryslotid" gorm:"->"`
Deliveryslotdate string `json:"deliveryslotdate" gorm:"->"`
Slotkey string `json:"slotkey" gorm:"->"`
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
Deliveryid int `json:"deliveryid"` Deliveryid int `json:"deliveryid"`
Orderheaderid int `json:"orderheaderid"` Orderheaderid int `json:"orderheaderid"`
Applocationid int `json:"applocationid"` Applocationid int `json:"applocationid"`

111
models/deliveryslot.go Normal file
View File

@@ -0,0 +1,111 @@
package models
import (
"slices"
"time"
)
/*
When a shop delivers.
A branch offers at most three windows a day — morning, afternoon, evening — and
the shopper picks one at checkout. The window is a PREFERENCE, not a promise:
every order is accepted, there is no capacity limit, and a slot never fills up.
It tells the shop when to group a drop, and it tells the shopper roughly when to
expect one.
── Per branch, not per tenant ──────────────────────────────────────────────
Timings belong to a shop floor, not a company. A tenant with an outlet in a
market and another in an office park will run different hours, and discovering
that after the fact would mean migrating live rows. `locationid` is on the table
from the start for that reason, and onboarding simply fills it with the primary
branch it just created.
── A branch with no slots is not broken ────────────────────────────────────
Every tenant trading today has no slots at all, and must keep taking orders.
Absence means "order without a slot", exactly as before — never "this shop is
closed". The whole rollout rests on that, so nothing here may treat an empty
list as an error.
*/
type DeliverySlots struct {
Slotid int `json:"deliveryslotid" gorm:"primaryKey;autoIncrement;column:slotid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
// Which of the three this is. Fixed rather than free-form: the app shows
// them in a known order and may want an icon per slot, and a shop inventing
// a fourth would have nowhere to appear.
Slotkey string `json:"slotkey" gorm:"column:slotkey"`
// What the shopper reads. Separate from `slotkey` so a shop can say
// "Before work" without breaking the app's ordering.
Name string `json:"name" gorm:"column:name"`
// "HH:MM", 24-hour, in the shop's local time.
//
// Stored as text, not as a timestamp, because this is a time of DAY that
// recurs — it has no date until an order attaches one. A timestamp column
// would invite a timezone conversion on every read, which is the one thing
// this must not do: the shopkeeper typed 08:00 meaning eight in the morning
// where they are standing.
Starttime string `json:"starttime" gorm:"column:starttime"`
// Also the CUT-OFF. There is deliberately no separate cutoff column: a slot
// accepts orders right up to the moment it ends, and then stops being
// offered. Morning 08:00–10:00 takes an order at 09:59 and not at 10:01.
Endtime string `json:"endtime" gorm:"column:endtime"`
// "active" or "inactive". A shop that stops doing evenings turns the slot
// off rather than deleting it, so orders already placed against it still
// resolve to something with a name.
Status string `json:"status" gorm:"column:status"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
}
func (DeliverySlots) TableName() string {
return "deliveryslots"
}
// The three keys, in the order a shopper reads them.
const (
SlotMorning = "morning"
SlotAfternoon = "afternoon"
SlotEvening = "evening"
)
// SlotKeys is the whole set, in display order. Used to validate input and to
// seed a new branch.
var SlotKeys = []string{SlotMorning, SlotAfternoon, SlotEvening}
// IsSlotKey reports whether a key is one of the three.
func IsSlotKey(key string) bool {
return slices.Contains(SlotKeys, key)
}
/*
A slot offered to a shopper, with the day it falls on.
`DeliverySlots` describes a window that recurs; this is one concrete occurrence
of it. The app needs the date because "evening" alone cannot distinguish tonight
from tomorrow night, and once today's last window closes the next thing on offer
is tomorrow morning.
The app does NO time arithmetic. It renders what this list contains, and the
list already excludes anything that has closed.
*/
type AvailableDeliverySlot struct {
Slotid int `json:"deliveryslotid"`
Slotkey string `json:"slotkey"`
Name string `json:"name"`
Starttime string `json:"starttime"`
Endtime string `json:"endtime"`
// "YYYY-MM-DD", the day this window falls on.
Slotdate string `json:"slotdate"`
// True when `Slotdate` is not today. Saves the app comparing dates to
// decide whether to write "Tomorrow" beside the name.
IsTomorrow bool `json:"istomorrow"`
}

164
models/healthscore.go Normal file
View File

@@ -0,0 +1,164 @@
package models
import "strings"
/*
The health score, as the customer app renders it.
The figures come from the catalogue-intelligence service — the same record the
nutrition panel comes from, and the same one behind the health score card in the
console. See services/nutritionService.go.
── Why the judgement is made here and not in the app ───────────────────────
The service returns a raw number and, from this endpoint, no band. Deciding what
that number means — which band, whether the match is sure enough to state
plainly, whether the product is even food — is a set of rules that already exists
in the console. Sending the raw number and letting the app re-derive them would
mean two implementations of the same judgement, drifting apart, disagreeing about
the same product on two screens. The rules travel with the answer instead.
*/
type HealthScore struct {
// 0–100, rounded. The service sends one decimal and nobody reads it.
Score int `json:"score"`
// "excellent" | "good" | "fair" | "poor" — for styling.
Band string `json:"band"`
// What a shopper reads, rather than what a nutritionist would call it.
Label string `json:"label"`
// Sentences the service already wrote for a person. Rendered as given.
Positives []string `json:"positives,omitempty"`
Cautions []string `json:"cautions,omitempty"`
Diettags []string `json:"diettags,omitempty"`
// Declared allergens. A false positive sends somebody to read the packet; a
// false negative sends them to hospital, so a declared one is always shown.
Allergens []string `json:"allergens,omitempty"`
// True when an EMPTY allergen list must NOT be read as "contains none".
//
// The service accepts a source match down to 0.32 confidence, and
// `data_status: "verified"` speaks to the numbers being real, not to the
// record being this product. What must never happen is silence standing in
// for "none" — which is exactly what an empty list rendered as nothing looks
// like. An app MUST say "not confirmed" rather than draw nothing here.
Allergensunconfirmed bool `json:"allergensunconfirmed,omitempty"`
// Set when the match is not sure enough to state plainly. When present the
// app must show it: a nutrition table presented as fact on a 61% match is a
// claim the data does not support.
Caveat string `json:"caveat,omitempty"`
// Where the figures came from, for a shopper who wants to check.
Source *HealthSource `json:"source,omitempty"`
}
type HealthSource struct {
Label string `json:"label"`
URL string `json:"url"`
}
// LowConfidence is the line below which a match is a guide, not a fact.
//
// The same 0.7 the console uses. Sampling 40 scored products: 37 matched below
// 0.7 and 26 below 0.5, so this fires often — which is the point.
const LowConfidence = 0.7
/*
BandFor turns a score into a band.
The service's own `health_band` wins when it sends one. It does NOT send one
from the per-product endpoint — only from the list — so for this response the
fallback is not an edge case, it is the only path, which makes these thresholds
load-bearing rather than cosmetic.
They are the SERVICE'S thresholds, not ours. Derived from its output and since
confirmed by that team in writing:
excellent >= 80
good 60 – 79.9
fair 40 – 59.9 confirmed 40, not 50
poor < 40
Delete this fallback once `health_band` is on the per-product response — it is
on their list. Until then, picking our own numbers would be one API disagreeing
with itself depending which endpoint a screen called.
*/
func BandFor(score float64, sent string) string {
switch strings.ToLower(strings.TrimSpace(sent)) {
case "excellent", "good", "fair", "poor":
return strings.ToLower(strings.TrimSpace(sent))
}
switch {
case score >= 80:
return "excellent"
case score >= 60:
return "good"
case score >= 40:
return "fair"
default:
return "poor"
}
}
// BandLabel is what a shopper reads.
func BandLabel(band string) string {
switch band {
case "excellent":
return "Very healthy"
case "good":
return "Healthy"
case "fair":
return "Okay"
default:
return "Less healthy"
}
}
/*
foodCategoryWords mean "this is food or drink".
An ALLOWLIST, and the asymmetry of the two failure modes is why. Withholding a
score on real food costs a shopper a badge they never had. Showing one on
something inedible is a different order of mistake, and the service has made it:
measured 4 Sep 2026, `GET /nutrition/Godrej/godrej_hit_spray_1101d017` returned
`health_score: 80.0, data_status: "verified"` — an "excellent" rating for
insecticide. Palmolive soap and Pantene shampoo both scored 37.5 the same way.
Re-measured 29 Sep 2026: those records now answer `unavailable`, so the purge
their team described has run. The guard stays anyway. It costs nothing when the
data is clean, and the tenant this was built for stocks soap, shampoo and
toothpaste alongside its food.
*/
var foodCategoryWords = []string{
"beverage", "drink", "juice", "water", "tea", "coffee",
"chocolate", "candy", "confection", "sweet", "dessert",
"dairy", "milk", "cheese", "butter", "ghee", "curd", "yogurt",
"snack", "biscuit", "cookie", "wafer", "chips", "namkeen",
"atta", "staple", "flour", "rice", "dal", "pulse", "grain", "cereal",
"pasta", "noodle", "bread", "bakery",
"oil", "masala", "spice", "sauce", "pickle", "jam", "honey",
"food", "nutrition", "breakfast", "fruit", "vegetable", "egg", "meat",
}
// IsEdible reports whether a score is attached to something a person eats.
//
// An unrecognised category is treated as NOT food. On screen that reads as "not
// scored yet", which is honest — we genuinely do not know — and is what most
// products show anyway.
func IsEdible(category string) bool {
value := strings.ToLower(strings.TrimSpace(category))
if value == "" {
return false
}
// "General" carries soap and household goods alongside anything else the
// scraper could not place. Ambiguous is not good enough for this decision.
if value == "general" {
return false
}
for _, word := range foodCategoryWords {
if strings.Contains(value, word) {
return true
}
}
return false
}

52
models/nutrition.go Normal file
View File

@@ -0,0 +1,52 @@
package models
/*
The nutrition panel, as the customer app renders it.
The figures come from the catalogue-intelligence service — the same one behind
the health score card in the console — and this is the shape they reach the app
in. See services/nutritionService.go for the fetch and the mapping.
── Why the field names are ugly ────────────────────────────────────────────
`servingsize`, not `serving_size` or `servingSize`. This is the shape the app
developer asked for, and an API is a promise to a client already written against
it. Consistency with the rest of Fiesta — itself inconsistent, `productid`
beside `image_id` beside `sku_source` — is worth less than not breaking the
caller.
*/
type NutritionPanel struct {
// What the figures are measured against. "100g" for everything the service
// returns today: its top-level values are per 100g, which is what the
// console's own panel prints beneath them.
Per string `json:"per,omitempty"`
// What the pack calls one serving — "1 mini (11 g)". Absent when the
// service did not state one, rather than defaulted: a serving size is a
// claim about the food, and a guessed one is a false claim.
Servingsize string `json:"servingsize,omitempty"`
// Never nil when this panel exists — see HasValues. An app receiving
// `items: null` has to branch; one receiving `[]` does not, and a panel with
// no rows should not have been sent at all.
Items []NutritionItem `json:"items"`
}
// NutritionItem is one line of the panel.
type NutritionItem struct {
Name string `json:"name"`
// The figure. A float because saturated fat is 18.7g as often as it is 19g,
// and rounding it to please a type would be editing a label.
Value float64 `json:"value"`
// "kcal", "g", "mg". Free text on purpose: `extended_nutrients` carries its
// own units from the source, and a closed list here would mean refusing to
// carry whatever the label actually says.
Unit string `json:"unit,omitempty"`
}
// HasValues reports whether this panel is worth sending.
//
// A panel with no rows is not a panel — it is an empty box on a product page,
// which a shopper reads as "this food has no nutrition" rather than "we do not
// know yet". The endpoint omits it instead.
func (p *NutritionPanel) HasValues() bool {
return p != nil && len(p.Items) > 0
}

View File

@@ -130,23 +130,39 @@ type OrderInfo struct {
Deliverylat FlexibleString `json:"deliverylat"` Deliverylat FlexibleString `json:"deliverylat"`
Deliverylong FlexibleString `json:"deliverylong"` Deliverylong FlexibleString `json:"deliverylong"`
Deliverytype string `json:"deliverytype"` Deliverytype string `json:"deliverytype"`
Paymenttype int `json:"paymenttype"` // The delivery window the customer asked for.
Tenantname string `json:"tenantname"` //
Tenanttoken string `json:"tenanttoken"` // ON THIS STRUCT, not only on Orders. GetTenantOrders — which is what the
Tenantsuburb string `json:"tenantsuburb"` // console and the app both read — scans into OrderInfo, so fields added to
Tenantcity string `json:"tenantcity"` // Orders alone never reach the list. That was the whole of the
Tenantcontactno string `json:"tenantcontactno"` // products.showhealthscore bug: written correctly, selected correctly,
Tenantpostcode string `json:"tenantpostcode"` // absent from the response, and every reading taken from it meaningless.
Locationname string `json:"locationname"` //
Locationsuburb string `json:"locationsuburb"` // The last four are joined from deliveryslots and read-only, so a shop that
Locationcity string `json:"locationcity"` // renames a window sees the new name on orders already placed.
Locationcontactno string `json:"locationcontactno"` Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
Rider string `json:"rider"` Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
Ridercontactno string `json:"ridercontactno"` Slotkey string `json:"slotkey" gorm:"->"`
Riderkms FlexibleString `json:"riderkms"` Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
Smsdelivery int `json:"smsdelivery"` Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
Customertoken string `json:"customertoken"` Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
Ridertoken string `json:"ridertoken"` Paymenttype int `json:"paymenttype"`
Tenantname string `json:"tenantname"`
Tenanttoken string `json:"tenanttoken"`
Tenantsuburb string `json:"tenantsuburb"`
Tenantcity string `json:"tenantcity"`
Tenantcontactno string `json:"tenantcontactno"`
Tenantpostcode string `json:"tenantpostcode"`
Locationname string `json:"locationname"`
Locationsuburb string `json:"locationsuburb"`
Locationcity string `json:"locationcity"`
Locationcontactno string `json:"locationcontactno"`
Rider string `json:"rider"`
Ridercontactno string `json:"ridercontactno"`
Riderkms FlexibleString `json:"riderkms"`
Smsdelivery int `json:"smsdelivery"`
Customertoken string `json:"customertoken"`
Ridertoken string `json:"ridertoken"`
} }
type DeliveryQuery struct { type DeliveryQuery struct {
@@ -233,19 +249,39 @@ type Ordermonths struct {
} }
type Orders struct { type Orders struct {
Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"` Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"`
Tenantid int `json:"tenantid"` Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"` Locationid int `json:"locationid"`
Applocationid int `json:"applocationid"` Applocationid int `json:"applocationid"`
Moduleid int `json:"moduleid"` Moduleid int `json:"moduleid"`
Partnerid int `json:"partnerid"` Partnerid int `json:"partnerid"`
Configid int `json:"configid"` Configid int `json:"configid"`
Categoryid int `json:"categoryid"` Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"` Subcategoryid int `json:"subcategoryid"`
Orderid string `json:"orderid"` Orderid string `json:"orderid"`
Orderdate string `json:"orderdate,omitempty"` Orderdate string `json:"orderdate,omitempty"`
Deliverytime string `json:"deliverytime"` Deliverytime string `json:"deliverytime"`
Deliverytype string `json:"deliverytype"` Deliverytype string `json:"deliverytype"`
// The window the shopper chose, and the day it falls on.
//
// Both stay zero for every order placed without one — which is every order
// before this shipped, and every order from a branch that has set no
// windows. Nothing downstream may require them.
//
// Distinct from `Deliverytime` above, which is a TIMESTAMP of what happened
// and is defaulted to now() a few lines into CreateOrderv3. These two say
// what was asked for; that one says what occurred.
Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
// Joined from deliveryslots, not stored on the order.
//
// So a shop that renames "Evening" to "After work" sees the new name on
// orders already placed — the window they chose has not changed, only what
// it is called. Read-only: nothing writes these back.
Slotkey string `json:"slotkey" gorm:"->"`
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
Orderstatus string `json:"orderstatus"` Orderstatus string `json:"orderstatus"`
Pending string `json:"pending"` Pending string `json:"pending"`
Processing string `json:"processing"` Processing string `json:"processing"`

View File

@@ -73,7 +73,11 @@ type Partnerinfo struct {
} }
type Ridershifts struct { type Ridershifts struct {
Shiftid int `json:"shiftid" gorm:"Primary_Key"` Shiftid int `json:"shiftid" gorm:"Primary_Key"`
// The region this shift belongs to. The column has always been on the table
// — `GetRiderShifts` filters on it — but there was no field for it here, so
// nothing could write one. That is why shifts could only ever be read.
Applocationid int `json:"applocationid"`
Shiftdate string `json:"shiftdate"` Shiftdate string `json:"shiftdate"`
Starttime string `json:"starttime"` Starttime string `json:"starttime"`
Endtime string `json:"endtime"` Endtime string `json:"endtime"`
@@ -296,10 +300,20 @@ type NewPartner struct {
Where they work — ONE district, not a set. Where they work — ONE district, not a set.
`Applocationid` is the home region and goes on the partner row itself, `Applocationid` is the home region and goes on the partner row itself,
because `GetPartners` filters on it and the rider app reads it. because the rider app reads it. The same region is also written to
`Applocationids` is every region they cover and goes to `partnerlocations`, which is the table that may hold SEVERAL — a partner
`partnerlocations` — one partner routinely serves several cities, and routinely serves more than one city, and that is why the link table
that is the whole reason the link table exists. exists, and partners with two are live — partner 44 covers regions 1 and
2. Nothing on THIS path creates one: `regionsOf` returns this single
field and the console's form offers one district, never a set. So a
multi-region partner can be read and must be handled, but cannot yet be
made here.
`GetPartners` reads the link table rather than this field, for two
reasons. It is the column allowed to grow, so a partner who covers a
second city will be found there without another change. And
`partnerinfo` is shared with another product that writes no link rows,
so having one is what marks a partner as ours.
*/ */
Applocationid int `json:"applocationid"` Applocationid int `json:"applocationid"`
/* /*

View File

@@ -155,6 +155,78 @@ type Products struct {
// `catalogueProductColumns` casts its text[] columns to text. // `catalogueProductColumns` casts its text[] columns to text.
Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"` Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"`
// The catalogue's own record of this product, as it stood at import.
//
// Holds the fields the snapshot does not have columns for — fssai_license,
// highlights, nutrients, providers, price_range, variant_key, title,
// sku_source, search_query — so the console can show them without asking
// the catalogue again. It asked on every drawer open, and got nothing back
// the moment a re-scrape retired the source row, taking a licence number
// and a nutrition panel off a product the shop was still selling.
//
// Empty for anything that did not come from the catalogue: a sheet-imported
// product has no such record, and the drawer falls back to the live lookup
// for those exactly as before.
//
// A string for the same reason `Productimages` is one — GORM's raw
// scan-into-struct silently drops slice- and map-kind destination fields.
Cataloguefacts string `json:"cataloguefacts,omitempty" gorm:"column:cataloguefacts;type:jsonb"`
// The nutrition panel, for the product screen in the customer app.
//
// `gorm:"-"`: not a column. It is unpacked from Cataloguefacts above, which
// is where the import snapshots it — a second column holding the same facts
// is a second thing to keep in step, and this one has no writer of its own.
//
// ABSENT rather than null when a product has no nutrition. Most products on
// the platform have none today, and `"nutrition": null` on every row of a
// mobile response is payload spent saying nothing. An app should read a
// missing key as "not known", never as "this food has no nutrition".
//
// Set by the service, not the repository — see decorateNutrition.
Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"`
// The health score, for the same product screen and from the same record.
//
// `gorm:"-"`, absent when there is nothing safe to show, and independent of
// Nutrition above — a product can be scored with no figures published, and
// carry figures with no score.
//
// WITHHELD on anything that is not food. The upstream per-product endpoint
// is not gated for edibility and has rated insecticide 80/100; see
// models.IsEdible.
Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"`
// Whether this shop shows a health score for this product.
//
// A real column, unlike the two above. The shopkeeper's call: the score
// comes from a third party matching a reference product by name, often
// under 60% confidence, and a merchant who knows the packet may reasonably
// decide the rating does not describe what they sell.
//
// Defaults true, so every product imported before this column existed keeps
// showing what it shows today.
//
// Gates `Healthscore` and NOTHING else. `Nutrition` is always sent — the
// figures are what the packet says, the score is a judgement of them, and a
// merchant turning off the judgement is not disputing the grams.
//
// The PLATFORM console ignores this: Nearle staff see every score on every
// product, because the decision being made there is whether the data is good
// enough to publish at all.
//
// NO `default` IN THE GORM TAG, and that is not an oversight. GORM skips a
// zero-value field whose tag names a default, so `false` was never written:
// the INSERT omitted the column, the database default of true applied, and a
// product imported with the score switched off came back switched on. It
// shipped that way and was found by importing one and reading it back.
//
// The DEFAULT lives on the column instead, set by the migration in main.go.
// That still covers what it is for — rows that predate the column, and any
// INSERT that genuinely omits it — without teaching GORM to drop a
// deliberate false on the way past.
Showhealthscore bool `json:"showhealthscore" gorm:"column:showhealthscore"`
Productdesc string `json:"productdesc,omitempty"` Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"` Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"` Brandid int `json:"brandid,omitempty"`
@@ -209,35 +281,57 @@ type Products struct {
} }
type Locationproducts struct { type Locationproducts struct {
Productid int `json:"productid"` Productid int `json:"productid"`
AppLocationid int `json:"applocationid" gorm:"column:applocationid"` AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
Productlocationid int `json:"productlocationid" gorm:"->"` Productlocationid int `json:"productlocationid" gorm:"->"`
Tenantid int `json:"tenantid,omitempty"` Tenantid int `json:"tenantid,omitempty"`
Categoryid int `json:"categoryid"` Categoryid int `json:"categoryid"`
Categoryname string `json:"categoryname" gorm:"->"` Categoryname string `json:"categoryname" gorm:"->"`
Subcategoryid int `json:"subcategoryid,omitempty"` Subcategoryid int `json:"subcategoryid,omitempty"`
Subcategoryname string `json:"Subcategoryname" gorm:"->"` Subcategoryname string `json:"Subcategoryname" gorm:"->"`
Catalogueid int `json:"catalogueid,omitempty"` Catalogueid int `json:"catalogueid,omitempty"`
Addonid int `json:"addonid,omitempty"` Addonid int `json:"addonid,omitempty"`
Discountid int `json:"discountid,omitempty"` Discountid int `json:"discountid,omitempty"`
Pricingid int `json:"pricingid,omitempty"` Pricingid int `json:"pricingid,omitempty"`
Productname string `json:"productname,omitempty"` Productname string `json:"productname,omitempty"`
Productimage string `json:"productimage,omitempty"` Productimage string `json:"productimage,omitempty"`
Productdesc string `json:"productdesc,omitempty"` Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"` Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"` // Three columns this read used to leave in the table.
Productunit string `json:"productunit"` //
Unitvalue string `json:"unitvalue"` // All three are stored on `products` and none of them reached the store
Toppicks string `json:"toppicks,omitempty"` // catalogue screen, because this struct had no field to scan them into —
Productcost float64 `json:"productcost,omitempty"` // so the console could not use what the import had gone to the trouble of
Taxamount float64 `json:"taxamount,omitempty"` // saving:
Taxpercent float64 `json:"taxpercent,omitempty"` //
Producttax int `json:"producttax" gorm:"default:0"` // Imageid the catalogue's durable key, and what HealthScorePanel
Productstock int `json:"productstock" gorm:"default:0"` // joins on. Absent, the panel reads it as "this product
Productcombo int `json:"productcombo" gorm:"default:0"` // never came from the catalogue" and renders nothing — for
Variants int `json:"variants" gorm:"default:0"` // EVERY product, including ones that plainly did.
Quantity int `json:"quantity"` // Productimages the rest of a product's photos. `imagesOf()` parses this
// and always got undefined, so the gallery fell back to
// the single `productimage` and the extra images — 90 of
// nestle's 123 products have them — were never shown.
// Cataloguefacts the licence, nutrition, highlights, providers and price
// range kept at import so they survive a re-scrape.
Imageid string `json:"imageid,omitempty"`
Productimages string `json:"productimages,omitempty"`
Cataloguefacts string `json:"cataloguefacts,omitempty"`
Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"`
Taxpercent float64 `json:"taxpercent,omitempty"`
Producttax int `json:"producttax" gorm:"default:0"`
Productstock int `json:"productstock" gorm:"default:0"`
Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"`
Quantity int `json:"quantity"`
// Price is the per-store selling price from productlocations.price — the one // Price is the per-store selling price from productlocations.price — the one
// CreateProductLocation upserts. Read-only here: it comes from the joined // CreateProductLocation upserts. Read-only here: it comes from the joined
// productlocations row, not from products. Without it a store could set a // productlocations row, not from products. Without it a store could set a
@@ -248,6 +342,19 @@ type Locationproducts struct {
Diffpercent float64 `json:"diffpercent,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"` Othercost float64 `json:"othercost,omitempty"`
Approve int `json:"approve" gorm:"default:0"` Approve int `json:"approve" gorm:"default:0"`
// Whether this product's health score is shown to shoppers.
//
// It has to be HERE and not only on Products, because this is the struct the
// admin catalogue reads. Without it the console received no value at all,
// the drawer's switch rendered "on" for every product including the ones
// that were off, and a product already hidden showed no panel and so no way
// to turn it back on. The column was being written correctly the whole time
// and simply never read back — which also made every "it did not save"
// reading taken from this endpoint meaningless.
//
// No `omitempty`: a false has to survive the trip, and omitempty would drop
// exactly the value this field exists to carry.
Showhealthscore bool `json:"showhealthscore"`
// Productstatus string `json:"productstatus" gorm:"default:available"` // Productstatus string `json:"productstatus" gorm:"default:available"`
Status string `json:"status" gorm:"default:outofstock"` Status string `json:"status" gorm:"default:outofstock"`
@@ -428,6 +535,14 @@ type ImportCatalogueProductRequest struct {
Retailprice float64 `json:"retailprice"` Retailprice float64 `json:"retailprice"`
Productcost float64 `json:"productcost"` Productcost float64 `json:"productcost"`
Taxpercent float64 `json:"taxpercent"` Taxpercent float64 `json:"taxpercent"`
// Whether this shop will show the product's health score.
//
// A POINTER so "not sent" and "sent as false" stay different answers. Every
// caller that predates this field omits it, and a bare bool would read those
// as a deliberate no and strip the score from every import made by an older
// console. Nil means "they did not say", which is treated as yes.
Showhealthscore *bool `json:"showhealthscore"`
} }
type Productlocations struct { type Productlocations struct {

206
models/scan.go Normal file
View File

@@ -0,0 +1,206 @@
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. Not required when Brand and Catalogueid
// name a product outright.
Label string `json:"label"`
// A product the customer has already chosen, by its catalogue key —
// which is how the app resolves a `candidates` list from an earlier
// ambiguous lookup, and how a deep link or a re-order skips recognition
// altogether. When both are set the label is ignored and no catalogue
// search runs.
Brand string `json:"brand"`
Catalogueid int64 `json:"catalogueid"`
// 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 {
// Where this is sold.
//
// On the option and not only on the enclosing store, because an order line
// carries both and the app would otherwise have to reach back up the
// response to build one. `Locationid` is the real outlet and never 0.
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Productid int `json:"productid"`
Productname string `json:"productname"`
// The pack size, both ways round.
//
// `Size` is the printable "500 g" the app has always shown. The two parts
// are sent beside it because an order line needs the unit on its own, and
// pulling it back out of the label means parsing a string a shop typed.
Size string `json:"size"`
Unitvalue string `json:"unitvalue"`
Productunit string `json:"productunit"`
// What the customer pays: the outlet's own price, or the product's retail
// price where the outlet has not set one.
Price float64 `json:"price"`
// What the shop paid. NOT a price to charge — it is `products.productcost`,
// the same field the product screens return, and billing against it would
// sell at cost.
Productcost float64 `json:"productcost"`
// Carried on the order header, so the app has them without a second read.
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Stock int `json:"stock"`
Available bool `json:"available"`
// The same URL under both names: `image` is what this endpoint has always
// sent, `productimage` is what every other product response calls it and
// what an order line is built from.
Image string `json:"image,omitempty"`
Productimage string `json:"productimage,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+text", "text" or "direct" — how the score was produced. The app
// can be more cautious with a text-only match; "direct" means the caller
// named the product by its catalogue key and nothing was recognised.
Method string `json:"method"`
// Set only on entries of `candidates`: at least one of the customer's
// registered stores has this product in stock right now. Candidates are
// ordered with the available ones first, so a "did you mean?" list can
// show what is actually buyable before what is not.
Available bool `json:"available,omitempty"`
}
// 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 is nil when nothing was recognised, and also when several
// products matched equally well — see Ambiguous.
Match *ScanCatalogueMatch `json:"match"`
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
// Several products fit the label and no one of them is a clear winner —
// which is what a bare brand name ("britannia") or a generic word
// ("biscuits") produces, and Lens returns those often because a
// wordmark is the most legible thing on a packet.
//
// When true: Match is nil, Stores is empty, and Candidates holds the
// products to offer as "did you mean?". Picking one means calling
// /lookup again with that candidate's `brand` and `catalogueid`.
//
// Guessing instead would mean showing a confident price for a product
// the customer did not photograph.
Ambiguous bool `json:"ambiguous"`
Candidates []ScanCatalogueMatch `json:"candidates"`
// 0..1. Below ~0.5 the app should confirm with the customer before
// showing prices. With Ambiguous set this is the leader's score, which
// by definition the runner-up nearly equals.
Confidence float64 `json:"confidence"`
// Registered stores that stock the product, nearest first, in-stock
// first. Empty with Available=false when none does.
Stores []ScanStoreOffer `json:"stores"`
Available bool `json:"available"`
// The locationid of the store marked Recommended, or 0.
RecommendedLocationid int `json:"recommended_locationid"`
// Tenant ids the app sent that the customer is not actually registered
// with. Empty normally; non-empty means the app's local list is stale.
UnregisteredTenantids []int `json:"unregistered_tenantids,omitempty"`
Message string `json:"message"`
}
// ScanConfirmRequest is sent when the customer taps a store and a size.
type ScanConfirmRequest struct {
Customerid int `json:"customerid"`
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Productid int `json:"productid"`
Quantity int `json:"quantity"`
Latitude FlexibleString `json:"latitude"`
Longitude FlexibleString `json:"longitude"`
}
// ScanConfirmResponse says whether the pick still holds, and where to go if
// it does not.
type ScanConfirmResponse struct {
Ok bool `json:"ok"`
// "in_stock", "insufficient_stock", "out_of_stock", "not_sold_here",
// "store_not_registered".
Reason string `json:"reason"`
Store *ScanStore `json:"store,omitempty"`
Option *ScanOption `json:"option,omitempty"`
Requested int `json:"requested"`
// The next-nearest registered store with enough of the same product, when
// the chosen one has run out. Nil when there is none.
Alternative *ScanStoreOffer `json:"alternative,omitempty"`
Message string `json:"message"`
}

View File

@@ -0,0 +1,64 @@
package models
import (
"reflect"
"strings"
"testing"
)
/*
`showhealthscore` must not carry a GORM default.
GORM skips a zero-value field whose tag names a default — the documented
behaviour is that a `false`, `0` or `""` is left out of the INSERT so the
database default applies. For a boolean whose whole purpose is being set to
false, that means the one value anybody would set it to is the one that cannot
be written.
It shipped that way. A product imported with the health score switched off came
back switched on, and it took importing one and reading it back to find out,
because every layer above reported success: the console sent `false`, the
request carried `false`, the handler read `false`, GORM dropped it, and the
column default wrote `true`.
The DEFAULT belongs on the column, set by the migration in main.go. That covers
rows predating the column and any INSERT that genuinely omits it, without
teaching the ORM to discard a deliberate false on the way past.
*/
func TestShowHealthScoreCarriesNoGormDefault(t *testing.T) {
field, ok := reflect.TypeFor[Products]().FieldByName("Showhealthscore")
if !ok {
t.Fatal("Products.Showhealthscore has moved or been renamed")
}
tag := field.Tag.Get("gorm")
if strings.Contains(strings.ToLower(tag), "default") {
t.Fatalf(
"gorm tag %q names a default. GORM then skips this field when it is false, "+
"so a product whose health score is switched off is written as switched on. "+
"The column default is set by the migration in main.go instead.",
tag,
)
}
// The column still has to be named, since the Go field is one word and the
// column is too but GORM's default naming would make it `show_health_score`.
if !strings.Contains(tag, "column:showhealthscore") {
t.Errorf("gorm tag %q no longer names the column", tag)
}
}
func TestShowHealthScoreIsAPlainBoolOnTheWire(t *testing.T) {
// Not a pointer, and not `omitempty`. Every product says what it is: the
// console reads it to set the switch, and an absent key would be
// indistinguishable from false on a screen that has to show one or the
// other.
field, _ := reflect.TypeFor[Products]().FieldByName("Showhealthscore")
if field.Type.Kind() != reflect.Bool {
t.Errorf("Showhealthscore is %s, want bool", field.Type.Kind())
}
if tag := field.Tag.Get("json"); tag != "showhealthscore" {
t.Errorf("json tag is %q — an omitempty here would hide every `false`", tag)
}
}

View File

@@ -71,6 +71,14 @@ type Tenantinfo struct {
Allocationid int `json:"allocationid"` Allocationid int `json:"allocationid"`
Allocationtype string `json:"allocationtype"` Allocationtype string `json:"allocationtype"`
Allocationmode int `json:"allocationmode"` Allocationmode int `json:"allocationmode"`
// How many outlets this merchant has.
//
// Only `GetAllTenants` fills this; it is 0 everywhere else, which is why it
// is last and optional rather than part of the record proper. The console's
// store list previously derived it by counting duplicate rows, and this
// endpoint has never returned duplicates — see the note on the query.
Branchcount int `json:"branchcount"`
} }
type Tenantlocations struct { type Tenantlocations struct {
@@ -190,6 +198,22 @@ type StaffInfo struct {
Tenantid int `json:"tenantid"` Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"` Locationid int `json:"locationid"`
Locationname string `json:"locationname"` Locationname string `json:"locationname"`
// Whether this login still works, straight off `app_users.status`.
// Without it every row on the console's Users & access screen read
// "Unknown", because the field was never selected or sent.
Status string `json:"status"`
// Whether they have ever chosen a password.
//
// Every back-office account is created with an empty one and emailed a link
// to set it. Until that link is used the person is in this list, in every
// branch picker, and cannot sign in — and `Status` does not say so: an
// Active account with no password is refused at the login screen like any
// other. `false` is the row that needs an action, which is why the directory
// reads it and offers a resend there and nowhere else.
//
// Computed in the query. `Password` is also on this struct, which is its own
// problem, but nothing should have to look at it to answer this.
IsSetUp bool `json:"issetup"`
} }
type Tenantuser struct { type Tenantuser struct {

View File

@@ -0,0 +1,99 @@
package repositories
import (
"encoding/json"
"sort"
"time"
"nearle/models"
"gorm.io/gorm"
)
// Where the assistant's audit rows land.
//
// Deliberately thin: one insert and one read. The registry decides what an entry
// means; this only has to keep it.
type AssistantAuditRepository interface {
Record(entry models.AssistantAudit) error
// Recent reads the trail back for one tenant, newest first.
//
// Scoped by tenant even though this is a review surface, because "who looked
// at what" is itself a merchant's data — a trail readable across tenants
// would be a nicer version of the hole the trail exists to detect.
Recent(tenantID, limit int) ([]models.AssistantAudit, error)
}
type assistantAuditRepository struct{ db *gorm.DB }
func NewAssistantAuditRepository(db *gorm.DB) AssistantAuditRepository {
return &assistantAuditRepository{db: db}
}
func (r *assistantAuditRepository) Record(entry models.AssistantAudit) error {
if r.db == nil {
return nil
}
return r.db.Create(&entry).Error
}
func (r *assistantAuditRepository) Recent(tenantID, limit int) ([]models.AssistantAudit, error) {
if r.db == nil {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = 100
}
var rows []models.AssistantAudit
err := r.db.Where("tenantid = ?", tenantID).
Order("at DESC").Limit(limit).Find(&rows).Error
return rows, err
}
// EncodeAuditArgs renders arguments for storage.
//
// Keys sorted, so two identical calls store identical JSON and a query looking
// for one of them finds both. Go randomises map iteration, and without this the
// same call would be unsearchable across rows.
//
// A value that will not encode becomes a string rather than failing the write:
// losing the audit row entirely to save one unencodable argument is the wrong
// trade, and the row is still the record that the call happened.
func EncodeAuditArgs(args map[string]any) string {
if len(args) == 0 {
return "{}"
}
ordered := make(map[string]json.RawMessage, len(args))
keys := make([]string, 0, len(args))
for key := range args {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
raw, err := json.Marshal(args[key])
if err != nil {
raw, _ = json.Marshal("<unencodable>")
}
ordered[key] = raw
}
// Re-marshalled through an ordered slice of pairs so the output is stable;
// a map would be re-randomised on the way out.
var out []byte
out = append(out, '{')
for i, key := range keys {
if i > 0 {
out = append(out, ',')
}
name, _ := json.Marshal(key)
out = append(out, name...)
out = append(out, ':')
out = append(out, ordered[key]...)
}
out = append(out, '}')
return string(out)
}
// AuditDuration converts a duration for storage, rounding to the millisecond.
func AuditDuration(d time.Duration) int64 { return d.Round(time.Millisecond).Milliseconds() }

View File

@@ -208,3 +208,35 @@ func TestNormaliseBrandKeyRefusesToInventAKey(t *testing.T) {
} }
} }
} }
// Every word of the label was once required, so one word the catalogue does
// not use ("Parle G biscuit pack") kept the right product out of the result
// altogether and left the vector search to answer alone.
func TestMinTokenHitsAsksForMostWordsNotAllOfThem(t *testing.T) {
for _, tc := range []struct{ tokens, want int }{
{1, 1}, // one word: it has to be there
{2, 2}, // "Parle G" — both, and both are in Parle-G
{3, 2}, // "Milk Bikis pack" — the pack is allowed to be missing
{4, 3}, // "Parle G biscuit pack"
{5, 4},
{6, 4},
} {
if got := minTokenHits(tc.tokens); got != tc.want {
t.Errorf("%d tokens: need %d, want %d", tc.tokens, got, tc.want)
}
}
}
// A threshold that could fall to 1 would let any single common word drag in
// whole brand tables; one that stayed at n would be the bug all over again.
func TestMinTokenHitsStaysBetweenTwoAndAll(t *testing.T) {
for n := 3; n <= 30; n++ {
got := minTokenHits(n)
if got < 2 {
t.Fatalf("%d tokens: %d is too loose", n, got)
}
if got >= n {
t.Fatalf("%d tokens: %d still demands every word", n, got)
}
}
}

View File

@@ -37,6 +37,7 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured
// catalogueProductColumns casts the text[] columns to text: GORM's raw // catalogueProductColumns casts the text[] columns to text: GORM's raw
// scan-into-struct silently drops slice-kind destination fields, so they // scan-into-struct silently drops slice-kind destination fields, so they
// are read as text here and parsed into []string in scanProductRow. // are read as text here and parsed into []string in scanProductRow.
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size, const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license, variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at` highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`

View File

@@ -120,13 +120,26 @@ const (
a.riderslat,a.riderslon,a.deliveryamt,a.kms,a.actualkms,a.riderkms,a.deliverycharges,a.deliverytype,a.paymenttype,a.smsdelivery, a.riderslat,a.riderslon,a.deliveryamt,a.kms,a.actualkms,a.riderkms,a.deliverycharges,a.deliverytype,a.paymenttype,a.smsdelivery,
a.expecteddeliverytime,a.profit,a.transitminutes,a.calculationdistancekm, a.expecteddeliverytime,a.profit,a.transitminutes,a.calculationdistancekm,
a.notes,a.ordernotes,b.tenantname,b.primarycontact as tenantcontactno,b.tenanttoken,b.suburb as tenantsuburb,b.city as tenantcity, a.notes,a.ordernotes,b.tenantname,b.primarycontact as tenantcontactno,b.tenanttoken,b.suburb as tenantsuburb,b.city as tenantcity,
c.firstname AS ridername,c.userfcmtoken,e.locationname,e.suburb AS locationsuburb,e.contactno AS locationcontactno c.firstname AS ridername,c.userfcmtoken,e.locationname,e.suburb AS locationsuburb,e.contactno AS locationcontactno,
-- The delivery window, read from the ORDER.
--
-- The deliveries table has no window column and deliberately gets none:
-- the window is a fact about what the customer asked for, and copying it
-- here would be a second truth that can drift from the first. The
-- dispatch board is exactly where drift would be noticed and exactly where
-- it would cost the most, so it is joined.
o.deliveryslotid, o.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
FROM deliveries a FROM deliveries a
INNER JOIN tenants b ON a.tenantid=b.tenantid INNER JOIN tenants b ON a.tenantid=b.tenantid
INNER JOIN app_users c ON a.userid=c.userid INNER JOIN app_users c ON a.userid=c.userid
INNER JOIN tenantlocations e ON a.locationid=e.locationid INNER JOIN tenantlocations e ON a.locationid=e.locationid
INNER JOIN app_location f ON a.applocationid = f.applocationid INNER JOIN app_location f ON a.applocationid = f.applocationid
INNER JOIN app_locationconfig g ON f.applocationid = g.applocationid` INNER JOIN app_locationconfig g ON f.applocationid = g.applocationid
-- Both LEFT. Most deliveries have no window, and every one of them must
-- still appear on the board — an INNER JOIN here would empty dispatch.
LEFT JOIN orders o ON a.orderheaderid = o.orderheaderid
LEFT JOIN deliveryslots s ON o.deliveryslotid = s.slotid`
) )
func (r *deliveriesRepository) CreateDeliveries(data []models.Deliveries) error { func (r *deliveriesRepository) CreateDeliveries(data []models.Deliveries) error {

View File

@@ -0,0 +1,97 @@
package repositories
import (
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"nearle/models"
)
type DeliverySlotRepository interface {
ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error)
FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error)
Save(slots []models.DeliverySlots) error
}
type deliverySlotRepository struct {
db *gorm.DB
}
func NewDeliverySlotRepository(db *gorm.DB) DeliverySlotRepository {
return &deliverySlotRepository{db: db}
}
// Ordered by start time so every caller — the console's editor and the app's
// list alike — sees morning before evening without sorting it again.
func (r *deliverySlotRepository) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
slots := make([]models.DeliverySlots, 0, len(models.SlotKeys))
err := r.db.Table("deliveryslots").
Where("tenantid = ? AND locationid = ?", tenantID, locationID).
Order("starttime ASC").
Find(&slots).Error
if err != nil {
return nil, err
}
return slots, nil
}
/*
One window, scoped to the branch that claims it.
The tenant and location are in the WHERE and not checked afterwards: a slot id
arrives from the app, which carries no session, so it is a claim about which
shop it belongs to. Looking it up by id alone and trusting the row would let one
shop's checkout name another shop's window.
*/
func (r *deliverySlotRepository) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) {
var slot models.DeliverySlots
err := r.db.Table("deliveryslots").
Where("slotid = ? AND tenantid = ? AND locationid = ?", slotID, tenantID, locationID).
First(&slot).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Not an error: "this shop does not offer that" is an answer, and the
// service turns it into something a shopper can read.
return nil, nil
}
if err != nil {
return nil, err
}
return &slot, nil
}
/*
Write a branch's windows, all of them or none.
── Upsert, not delete-then-insert ──────────────────────────────────────────
Slot ids are referenced by `orders.deliveryslotid`. Replacing the rows would
renumber them, and every order already placed would point at a window that no
longer means what it did — or at nothing. The unique index on
(tenantid, locationid, slotkey) is what makes the conflict target work, so a
branch keeps one morning however many times it is edited.
── One transaction ─────────────────────────────────────────────────────────
A shop editing all three and getting two is worse than getting none: the two
that took are live and serving shoppers, and nothing on screen says which.
*/
func (r *deliverySlotRepository) Save(slots []models.DeliverySlots) error {
if len(slots) == 0 {
return nil
}
return r.db.Transaction(func(tx *gorm.DB) error {
return tx.Table("deliveryslots").
Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "tenantid"}, {Name: "locationid"}, {Name: "slotkey"},
},
DoUpdates: clause.AssignmentColumns([]string{"name", "starttime", "endtime", "status", "updated"}),
}).
Create(&slots).Error
})
}

View File

@@ -102,14 +102,30 @@ const (
a.deliveryid AS deliverycustomerid, a.deliveryid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype, a.deliveryid AS deliverycustomerid, a.deliveryid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery, b.customertoken, a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery, b.customertoken,
c.tenantname, c.tenanttoken, c.primarycontact AS tenantcontactno, c.postcode AS tenantpostcode, c.suburb AS tenantsuburb, c.city AS tenantcity, c.tenantname, c.tenanttoken, c.primarycontact AS tenantcontactno, c.postcode AS tenantpostcode, c.suburb AS tenantsuburb, c.city AS tenantcity,
d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity,
-- The delivery window the customer asked for.
--
-- Selected EXPLICITLY, like everything else here: this select names its
-- columns, so a field added to the Go struct and not added to this line
-- is simply absent from every row, with nothing anywhere saying so. That
-- exact gap shipped once on products.showhealthscore and made every
-- reading taken from the list meaningless.
--
-- The NAME is joined rather than stored on the order, so a shop that
-- renames "Evening" to "After work" sees the new name on old orders --
-- the window they chose has not changed, only what it is called.
a.deliveryslotid, a.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
FROM orders a FROM orders a
LEFT JOIN customers b ON a.customerid = b.customerid LEFT JOIN customers b ON a.customerid = b.customerid
LEFT JOIN tenants c ON a.tenantid = c.tenantid LEFT JOIN tenants c ON a.tenantid = c.tenantid
LEFT JOIN tenantlocations d ON a.locationid = d.locationid LEFT JOIN tenantlocations d ON a.locationid = d.locationid
LEFT JOIN app_location h ON a.applocationid = h.applocationid LEFT JOIN app_location h ON a.applocationid = h.applocationid
LEFT JOIN app_locationconfig i ON a.applocationid = i.applocationid` LEFT JOIN app_locationconfig i ON a.applocationid = i.applocationid
-- LEFT, because the overwhelming majority of orders have no window and
-- must still appear. An INNER JOIN here would silently empty the list.
LEFT JOIN deliveryslots s ON a.deliveryslotid = s.slotid`
orderdetails = `SELECT DISTINCT a.orderheaderid, a.applocationid, orderdetails = `SELECT DISTINCT a.orderheaderid, a.applocationid,
a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid, a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
@@ -122,12 +138,22 @@ const (
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity,a.paymenttype, a.smsdelivery, a.orderamount, a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity,a.paymenttype, a.smsdelivery, a.orderamount,
b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno, b.postcode AS tenantpostcode, b.suburb AS tenantsuburb,b.city AS tenantcity, b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno, b.postcode AS tenantpostcode, b.suburb AS tenantsuburb,b.city AS tenantcity,
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode, c.suburb AS locationsuburb, c.city AS locationcity, c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode, c.suburb AS locationsuburb, c.city AS locationcity,
d.locationname AS applocation d.locationname AS applocation,
-- The delivery window, same as the 'base' select above.
--
-- BOTH selects need it. 'base' backs the console's list; this one backs the
-- partner, customer, user and admin reads -- including the customer app's
-- own order history, which is where a shopper expects to see the window
-- they picked. Fixing one and not the other is how a field ends up present
-- on some screens and silently absent on others.
a.deliveryslotid, a.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
FROM orders a FROM orders a
LEFT JOIN tenants b ON a.tenantid = b.tenantid LEFT JOIN tenants b ON a.tenantid = b.tenantid
LEFT JOIN tenantlocations c ON a.locationid = c.locationid LEFT JOIN tenantlocations c ON a.locationid = c.locationid
LEFT JOIN app_location d ON a.applocationid = d.applocationid LEFT JOIN app_location d ON a.applocationid = d.applocationid
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid` LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
LEFT JOIN deliveryslots s ON a.deliveryslotid = s.slotid`
) )
func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) { func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) {

View File

@@ -15,6 +15,7 @@ type PartnerRepository interface {
GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error) GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error)
GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error) GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error)
GetRiderShifts(aid int) ([]models.Ridershifts, error) GetRiderShifts(aid int) ([]models.Ridershifts, error)
CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error)
GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error) GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error)
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error) GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
GetRiderInfo(userid int) (models.RiderInfo, error) GetRiderInfo(userid int) (models.RiderInfo, error)
@@ -87,30 +88,67 @@ func (r *partnerRepository) GetPartners(aid, pid, uid int) ([]models.Partnerinfo
var q1 string var q1 string
var args []interface{} var args []interface{}
// Every variant joins partnerlocations, and that join is the whole point.
//
// ── It is what separates our partners from somebody else's ──────────────
//
// `partnerinfo` is shared. It has no column saying which product a row
// belongs to — no configid, no appid — so a partner created by another app
// on this database is indistinguishable from ours by its own fields, and
// this read used to return every Active row on the platform. The console
// made that worse rather than better: it asks `getapplocations` for EVERY
// region and then fetches partners region by region, so the applocationid
// filter below never narrowed anything.
//
// `partnerlocations` is the difference. Only `CreatePartner` writes it —
// one row per region, in the same transaction as the partner — so a row in
// that table means "registered through this console". The partners that
// predate it were inserted by hand and have none, which is why two of them
// are called "Test".
//
// ── The region filter reads the link table, not the home region ─────────
//
// `partnerinfo.applocationid` is the HOME region — CreatePartner writes
// `regions[0]` there — while partnerlocations holds every region covered.
// Those are not the same thing, and not only in theory: partner 44,
// Xpress-Cbe-Main, has a home region of 1 and link rows for 1 AND 2, so
// filtering on the partner row hid them from every Madurai query. That is
// the case the link table exists for.
//
// DISTINCT because such a partner has one row per region in the join and is
// still one partner. Only partnerinfo columns are selected, so there is
// nothing per-region for it to fail to collapse.
//
// A caller fanning out over regions and concatenating the answers still has
// to dedupe — the same partner is legitimately in two of them. The console's
// `useAllPartners` does; it listed Xpress-Cbe-Main twice until it did.
const columns = `select distinct p.partnerid,p.applocationid,p.partnertypeid,p.partnername,
p.primarycontact,p.primaryemail,p.contactno,p.address,p.suburb,p.state,p.city,p.partnerimage
from partnerinfo p
inner join partnerlocations l on l.partnerid = p.partnerid
where p.status='Active'`
if pid != 0 { if pid != 0 {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail, // Scoped the same way on purpose: asking for a partner by id must not
contactno,address,suburb,state,city,partnerimage // be a way round the separation above.
from partnerinfo where status='Active' and partnerid=?` q1 = columns + ` and p.partnerid=?`
args = append(args, pid) args = append(args, pid)
} else if aid != 0 { } else if aid != 0 {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail, q1 = columns + ` and l.applocationid=?`
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active' and applocationid=?`
args = append(args, aid) args = append(args, aid)
} else { } else {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail, q1 = columns
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active'`
} }
q1 += ` order by p.partnername, p.partnerid`
err := r.db.Raw(q1, args...).Find(&data).Error err := r.db.Raw(q1, args...).Find(&data).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
print(q1)
return data, nil return data, nil
} }
@@ -615,13 +653,17 @@ them are named "Test".
Where a partner works is recorded twice, on purpose and not by accident: Where a partner works is recorded twice, on purpose and not by accident:
partnerinfo.applocationid their home region — `GetPartners` filters on it partnerinfo.applocationid their home region — the rider app reads it
and the rider app reads it
partnerlocations every region they cover partnerlocations every region they cover
Both are kept in step here. Writing only the first would confine a partner to Both are kept in step here. Writing only the first would confine a partner to
one city, and writing only the second would hide them from every existing one city, and writing only the second would hide them from the rider app.
query. */
`GetPartners` reads the SECOND: it joins partnerlocations, which both scopes a
region query to every city a partner actually covers and — because only this
function writes that table — separates partners registered here from the ones
another product put in the shared `partnerinfo`. So the link rows are not
bookkeeping; they are what makes a partner ours. */
// CreatePartner onboards a delivery partner and records the regions they cover. // CreatePartner onboards a delivery partner and records the regions they cover.
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) { func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
@@ -986,3 +1028,178 @@ func (r *partnerRepository) regionByName(db *gorm.DB, name string) int {
WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id) WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id)
return id return id
} }
// ── Rider shifts ────────────────────────────────────────────────────────────
//
// A shift is the window a rider works, and `CreateRider` refuses a rider
// without one — `getriders` joins `ridershifts` through `ridersettings.shiftid`,
// so a rider on a shift that does not exist is a rider nobody can see.
//
// Until now the table could only be READ. There was no endpoint, no service
// method and not even a field for `applocationid` on the model, so a region
// that shipped without shift rows could never have a rider added to it at all:
// the console showed "No shifts set up for this region" and there was nothing
// anybody could do from the product to change that. Till staff had
// `createstaffshift` from the beginning; riders were simply missed.
// riderShiftClock is a start or end time as the column stores it.
//
// Accepts `9:00`, `09:00` and `09:00:00` and normalises to `HH:MM`. The rows
// inserted by hand over the years use all three spellings, and `GetRiderShifts`
// builds its label by concatenating the two columns raw — so `9:00-17:00` and
// `09:00-17:00` are two different labels for one window in the same dropdown.
func riderShiftClock(raw string) (string, error) {
text := strings.TrimSpace(raw)
if text == "" {
return "", errors.New("a shift needs a start and an end time")
}
parts := strings.Split(text, ":")
if len(parts) < 2 || len(parts) > 3 {
return "", fmt.Errorf("%q is not a time — write it as HH:MM", raw)
}
hour, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || hour < 0 || hour > 23 {
return "", fmt.Errorf("%q is not a time — the hour must be 0 to 23", raw)
}
minute, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil || minute < 0 || minute > 59 {
return "", fmt.Errorf("%q is not a time — the minutes must be 0 to 59", raw)
}
return fmt.Sprintf("%02d:%02d", hour, minute), nil
}
// riderShiftHours is how long the window runs, in hours.
//
// Computed rather than asked for, because it is the one field a person gets
// wrong and nothing downstream checks: `shifthours` feeds rider pay, and a
// window of 09:00–17:00 recorded as 4 hours underpays every rider on it.
//
// A window that ends before it starts crosses midnight and is measured that
// way — a 22:00–06:00 night shift is eight hours, not minus sixteen.
func riderShiftHours(start, end string) float32 {
toMinutes := func(clock string) int {
parts := strings.Split(clock, ":")
hour, _ := strconv.Atoi(parts[0])
minute, _ := strconv.Atoi(parts[1])
return hour*60 + minute
}
span := toMinutes(end) - toMinutes(start)
if span <= 0 {
span += 24 * 60
}
return float32(span) / 60
}
// validateRiderShift checks everything that does not need the database.
//
// Split out so the rules are testable without one, and returns the shift with
// its times normalised and its hours worked out rather than reporting on a copy
// the caller then has to rebuild.
func validateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
if shift.Applocationid == 0 {
return shift, errors.New("a shift needs a delivery region")
}
start, err := riderShiftClock(shift.Starttime)
if err != nil {
return shift, err
}
end, err := riderShiftClock(shift.Endtime)
if err != nil {
return shift, err
}
if start == end {
return shift, errors.New("a shift cannot start and end at the same time")
}
shift.Starttime = start
shift.Endtime = end
// Always recomputed, never taken from the request. A caller that sends its
// own number is a caller that can disagree with the window it just sent.
shift.Shifthours = riderShiftHours(start, end)
if shift.Basefare < 0 || shift.Additionalcharges < 0 || shift.Fuelcharge < 0 {
return shift, errors.New("pay cannot be negative")
}
return shift, nil
}
// CreateRiderShift opens a shift window in one region.
func (r *partnerRepository) CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
shift, err := validateRiderShift(shift)
if err != nil {
return models.Ridershifts{}, err
}
// Same guard `CreateRider` applies to a rider's region, for the same reason:
// `getriders` joins app_locationconfig, so a shift in a region with no
// config row would be offered in the dropdown and then hide every rider put
// on it.
var configs int64
if err := r.db.Table("app_locationconfig").
Where("applocationid = ?", shift.Applocationid).Count(&configs).Error; err != nil {
return models.Ridershifts{}, err
}
if configs == 0 {
return models.Ridershifts{}, fmt.Errorf(
"delivery region %d is not configured, so a shift there would hide every rider on it", shift.Applocationid)
}
// The dropdown labels a shift by its times alone, so a duplicate window is
// two identical-looking choices and no way to tell which one a rider is on.
var clash int64
if err := r.db.Table("ridershifts").
Where("applocationid = ? AND starttime = ? AND endtime = ?",
shift.Applocationid, shift.Starttime, shift.Endtime).
Count(&clash).Error; err != nil {
return models.Ridershifts{}, err
}
if clash > 0 {
return models.Ridershifts{}, fmt.Errorf(
"a %s-%s shift already exists in this region", shift.Starttime, shift.Endtime)
}
// A column map with RETURNING, the same way CreatePartner writes its row,
// rather than inserting the struct.
//
// Inserting the struct would carry `shiftid` at zero into the statement and
// leave whether the sequence is used to how GORM reads a `Primary_Key` tag
// written in the v1 spelling — which is exactly the kind of thing that works
// on one driver and writes a row with id 0 on another. Naming the columns
// removes the question: the id is the database's to assign.
row := map[string]any{
"applocationid": shift.Applocationid,
"shiftdate": shift.Shiftdate,
"starttime": shift.Starttime,
"endtime": shift.Endtime,
"shifthours": shift.Shifthours,
"basefare": shift.Basefare,
"additionalkm": shift.Additionalkm,
"additionalcharges": shift.Additionalcharges,
"orders": shift.Orders,
"fuelcharge": shift.Fuelcharge,
}
if err := r.db.Table("ridershifts").
Clauses(clause.Returning{Columns: []clause.Column{{Name: "shiftid"}}}).
Create(&row).Error; err != nil {
return models.Ridershifts{}, err
}
id, ok := row["shiftid"]
if !ok || toInt(id) == 0 {
// The rider form selects the new shift by id the moment this returns. A
// shift written without one would leave the drawer selecting nothing and
// reading as a failed save.
return models.Ridershifts{}, errors.New("the shift was written without an id")
}
shift.Shiftid = toInt(id)
// The label the dropdown shows, built the same way GetRiderShifts builds it
// so a shift reads identically the moment it is created and after a reload.
shift.Shiftname = shift.Starttime + "-" + shift.Endtime
return shift, nil
}

View File

@@ -36,21 +36,42 @@ func normaliseShiftTime(raw string) (string, error) {
return t, nil return t, nil
} }
// ListStaffShifts returns an outlet's shifts, newest last so a picker reads in // ListStaffShifts returns the shifts a tenant's staff can be put on, newest
// the order they were created rather than alphabetically by name. // last so a picker reads in the order they were created rather than
// alphabetically by name.
//
// ── Why the outlet is optional ──────────────────────────────────────────────
//
// A shift is a fact about how a BUSINESS runs, not about one shop: a tenant
// that works 07:00–15:00 and 15:00–23:00 works those hours at every branch it
// owns, and making somebody re-enter them per outlet guarantees the third
// branch gets 07:00–15:30 and nobody notices. `locationid = 0` is a shift that
// belongs to the whole tenant.
//
// Branch-specific rows are still honoured, because they already exist and a
// tenant may genuinely run one outlet differently. Asking for an outlet returns
// the tenant's shifts AND that outlet's own; asking for none returns everything
// the tenant has.
func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) { func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 { if tenantID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required") return nil, fmt.Errorf("tenantid is required")
} }
shifts := make([]models.StaffShifts, 0) shifts := make([]models.StaffShifts, 0)
query := `SELECT * FROM staffshifts WHERE tenantid = ? AND locationid = ?` args := []any{tenantID}
query := `SELECT * FROM staffshifts WHERE tenantid = ?`
if locationID > 0 {
// The tenant-wide ones and this outlet's, never another outlet's.
query += ` AND (COALESCE(locationid, 0) = 0 OR locationid = ?)`
args = append(args, locationID)
}
if !includeInactive { if !includeInactive {
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'` query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
} }
query += ` ORDER BY staffshiftid` query += ` ORDER BY staffshiftid`
if err := r.db.Raw(query, tenantID, locationID).Scan(&shifts).Error; err != nil { if err := r.db.Raw(query, args...).Scan(&shifts).Error; err != nil {
return nil, err return nil, err
} }
return shifts, nil return shifts, nil
@@ -58,8 +79,14 @@ func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactiv
// CreateStaffShift adds a window at one outlet. // CreateStaffShift adds a window at one outlet.
func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) { func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 { if tenantID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required") return nil, fmt.Errorf("tenantid is required")
}
// `locationid = 0` is deliberate and is now the ordinary case: the shift
// belongs to the tenant and every branch it owns can use it. An outlet is
// only named when one shop really does run different hours.
if locationID < 0 {
locationID = 0
} }
name := strings.TrimSpace(req.Name) name := strings.TrimSpace(req.Name)

View File

@@ -0,0 +1,54 @@
package repositories
import (
"strings"
"testing"
)
/*
A shift belongs to a business, not to one of its shops.
Both halves of this used to demand an outlet, so the same two windows had to be
re-entered at every branch a tenant owns — which is how the third branch quietly
gets 07:00–15:30 and nobody notices until a cashier is filed under hours that do
not exist. A tenant that works 07:00–15:00 works those hours everywhere.
`locationid = 0` is now the ordinary case. A named outlet still works, because
one shop may genuinely run differently and those rows already exist.
*/
func TestATimeIsAcceptedInBothFormsTheClientsSend(t *testing.T) {
// `<input type="time">` gives "07:00"; some browsers and every hand-typed
// value give "07:00:00"; `ridershifts` stores the seconds form already.
for _, tc := range []struct{ in, want string }{
{"07:00", "07:00"},
{"07:00:00", "07:00"},
{" 23:59 ", "23:59"},
{"00:00", "00:00"},
} {
got, err := normaliseShiftTime(tc.in)
if err != nil {
t.Fatalf("%q: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("%q became %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSomethingThatIsNotATimeOfDayIsRefused(t *testing.T) {
for _, bad := range []string{"", " ", "7:00", "24:00", "07:60", "morning", "07", "7pm"} {
if _, err := normaliseShiftTime(bad); err == nil {
t.Fatalf("%q was accepted as a time of day", bad)
}
}
}
func TestTheTimeErrorSaysWhatShapeIsWanted(t *testing.T) {
// "invalid" tells somebody nothing. The message names the format, because
// the most common wrong answer is a valid time in the wrong notation.
_, err := normaliseShiftTime("7pm")
if err == nil || !strings.Contains(err.Error(), "HH:MM") {
t.Fatalf("the refusal does not say what to type: %v", err)
}
}

View File

@@ -24,9 +24,10 @@ type ProductRepository interface {
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
CreateProductStock(stocks []models.Productstock) error CreateProductStock(stocks []models.Productstock) error
UpdateProductStatus(productIDs []int, status string) error UpdateProductStatus(productIDs []int, status string) error
// SetShowHealthScore turns one product's health score on or off for one shop.
SetShowHealthScore(tenantID, productID int, show bool) error
SyncProductLocationStatus(refs []models.ProductLocationRef) error SyncProductLocationStatus(refs []models.ProductLocationRef) error
EnsureProductLocation(refs []models.ProductLocationRef) error EnsureProductLocation(refs []models.ProductLocationRef) error
CreateProduct(product models.Products) error
UpdateProduct(product models.Products) error UpdateProduct(product models.Products) error
DeleteProduct(productID int) error DeleteProduct(productID int) error
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error) GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
@@ -393,19 +394,32 @@ func (r *productRepository) UpdateProductStatus(productIDs []int, status string)
Update("productstatus", status).Error Update("productstatus", status).Error
} }
func (r *productRepository) CreateProduct(product models.Products) error { // normaliseProductJSON makes a product safe to INSERT.
tx := r.db.Begin() //
// `products.productimages` is jsonb and `models.Products.Productimages` is a
if err := tx.Create(&product).Error; err != nil { // plain string, so a caller that never set it hands GORM the zero value — and
tx.Rollback() // GORM puts that empty string in the INSERT rather than omitting the column.
return err // Postgres answers "invalid input syntax for type json (SQLSTATE 22P02)" and
// the whole row is rejected, over a field nobody asked for.
//
// That was not a corner case: the console's sheet importer sends no
// productimages at all, so EVERY product it created failed with a 500, and
// ImportCatalogueProduct leaves the field empty for any catalogue product that
// has no photos. An empty ARRAY is the honest value — there are no extra
// images — and it is what `catalogueUploadService` already does for its own
// jsonb column, for the same reason.
//
// Applied at the one create path, which is the last point before the SQL, and
// the constraint being satisfied is the database's.
func normaliseProductJSON(product *models.Products) {
if strings.TrimSpace(product.Productimages) == "" {
product.Productimages = "[]"
} }
// An OBJECT, not an array: this one holds named catalogue fields, and `{}`
if err := tx.Commit().Error; err != nil { // is what a reader parsing it expects to find when there are none.
return err if strings.TrimSpace(product.Cataloguefacts) == "" {
product.Cataloguefacts = "{}"
} }
return nil
} }
func (r *productRepository) UpdateProduct(product models.Products) error { func (r *productRepository) UpdateProduct(product models.Products) error {
@@ -1316,10 +1330,22 @@ func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand
return &product, nil return &product, nil
} }
// CreateProductReturningID inserts a new product snapshot and returns its // CreateProductReturningID inserts a product and returns its generated
// generated productid. Kept separate from CreateProduct so existing callers // productid.
// of CreateProduct are unaffected. //
// This is now the only way to create one. There used to be a second method,
// `CreateProduct`, that did the same INSERT and threw the id away — it took
// the struct by value, so GORM wrote the generated id onto a copy that went
// out of scope, and `POST /products/create` answered `productid: 0` for every
// product it had just created. The console worked around it by creating, then
// re-reading the whole tenant catalogue, then matching back by SKU.
//
// The two were kept apart so that "existing callers are unaffected", but the
// only caller of the id-less one was the endpoint that needed the id most.
// One create path also means the jsonb guard above has one place to live.
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) { func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
normaliseProductJSON(&product)
if err := r.db.Create(&product).Error; err != nil { if err := r.db.Create(&product).Error; err != nil {
return 0, err return 0, err
} }
@@ -1701,3 +1727,30 @@ func (r *productRepository) UpdateProductPricing(productid int, retailprice, pro
"taxpercent": taxpercent, "taxpercent": taxpercent,
}).Error }).Error
} }
// SetShowHealthScore turns one product's health score on or off for one shop.
//
// `Update` with a single column, deliberately, and not `Updates` with a struct.
// GORM's struct update SKIPS zero values, so `showhealthscore: false` would be
// silently dropped — the flag could be switched on and never off again, which is
// the exact failure a merchant would report as "it does not save".
//
// Scoped by tenant as well as product. `middleware.WebAuth` already refuses a
// request naming a tenant the session does not own, so this is the second lock
// rather than the first — but a write that changes what a shopper sees should
// not rest on one check being correctly mounted.
func (r *productRepository) SetShowHealthScore(tenantID, productID int, show bool) error {
result := r.db.Table("products").
Where("productid = ? AND tenantid = ?", productID, tenantID).
Update("showhealthscore", show)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// Either no such product, or one belonging to another business. Both
// are the same answer to the caller, and neither should look like it
// worked.
return fmt.Errorf("product %d was not found for this business", productID)
}
return nil
}

View File

@@ -0,0 +1,126 @@
package repositories
import (
"strings"
"testing"
"nearle/models"
)
/*
Shift windows, which riders cannot be hired without.
`CreateRider` refuses a rider with no shift — correctly, because `getriders`
joins `ridershifts` and a rider on a shift that does not exist is a rider nobody
can see. But the table could only be READ: no endpoint, no service method, and
no `applocationid` field on the model to write one with. A region that shipped
without shift rows was a region no rider could ever be added to, and the console
said "No shifts set up for this region" with nothing anybody could do about it.
These cover the rules that do not need a database. The two that do — the region
must be configured, and the window must not already exist — are enforced in
CreateRiderShift against real tables.
*/
func TestATimeIsNormalisedSoOneWindowHasOneLabel(t *testing.T) {
// The dropdown labels a shift by concatenating its two columns raw, so
// `9:00-17:00` and `09:00-17:00` are two different labels for one window.
// Rows inserted by hand over the years use every spelling.
for _, tc := range []struct{ in, want string }{
{"9:00", "09:00"},
{"09:00", "09:00"},
{"09:00:00", "09:00"},
{" 9:5 ", "09:05"},
{"23:59", "23:59"},
{"0:00", "00:00"},
} {
got, err := riderShiftClock(tc.in)
if err != nil {
t.Fatalf("%q: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("%q became %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSomethingThatIsNotATimeIsRefused(t *testing.T) {
for _, bad := range []string{"", " ", "morning", "25:00", "09:60", "9", "9:00:00:00", "-1:00"} {
if _, err := riderShiftClock(bad); err == nil {
t.Fatalf("%q was accepted as a time", bad)
}
}
}
func TestHoursAreWorkedOutRatherThanAskedFor(t *testing.T) {
// `shifthours` feeds rider pay. It is the one field a person gets wrong and
// nothing downstream checks, so it is computed and the request's own number
// is discarded.
shift, err := validateRiderShift(models.Ridershifts{
Applocationid: 2, Starttime: "09:00", Endtime: "17:00",
Shifthours: 4, // wrong, and sent anyway
})
if err != nil {
t.Fatalf("a good shift was refused: %v", err)
}
if shift.Shifthours != 8 {
t.Fatalf("09:00-17:00 came out as %v hours, want 8", shift.Shifthours)
}
}
func TestANightShiftCrossesMidnightRatherThanGoingNegative(t *testing.T) {
// 22:00-06:00 is eight hours. Subtracting the clocks gives minus sixteen,
// which would pay a night rider for a negative shift.
if got := riderShiftHours("22:00", "06:00"); got != 8 {
t.Fatalf("22:00-06:00 came out as %v hours, want 8", got)
}
if got := riderShiftHours("09:30", "17:00"); got != 7.5 {
t.Fatalf("09:30-17:00 came out as %v hours, want 7.5", got)
}
}
func TestAShiftNeedsARegion(t *testing.T) {
// Region 0 arrives from a form field nobody filled in. A shift there would
// be offered to nobody and joined to nothing.
_, err := validateRiderShift(models.Ridershifts{Starttime: "09:00", Endtime: "17:00"})
if err == nil || !strings.Contains(err.Error(), "region") {
t.Fatalf("a shift with no region was accepted: %v", err)
}
}
func TestAShiftCannotStartAndEndAtTheSameTime(t *testing.T) {
// Would compute as a 24-hour window under the midnight rule, which is not
// what anybody who typed the same time twice meant.
_, err := validateRiderShift(models.Ridershifts{
Applocationid: 2, Starttime: "09:00", Endtime: "9:00",
})
if err == nil {
t.Fatal("a zero-length window was accepted")
}
}
func TestPayCannotBeNegative(t *testing.T) {
for _, shift := range []models.Ridershifts{
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Basefare: -1},
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Additionalcharges: -5},
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Fuelcharge: -0.5},
} {
if _, err := validateRiderShift(shift); err == nil {
t.Fatalf("negative pay was accepted: %+v", shift)
}
}
}
func TestTheNormalisedTimesComeBackOnTheShift(t *testing.T) {
// The caller inserts what validation returned, not what it was handed —
// otherwise the normalising is computed and then thrown away.
shift, err := validateRiderShift(models.Ridershifts{
Applocationid: 2, Starttime: "9:0", Endtime: "17:00:00",
})
if err != nil {
t.Fatalf("refused: %v", err)
}
if shift.Starttime != "09:00" || shift.Endtime != "17:00" {
t.Fatalf("times were not normalised on the way out: %q-%q", shift.Starttime, shift.Endtime)
}
}

View File

@@ -0,0 +1,737 @@
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
// The shop's own cost, distinct from Price. Selected because the product
// screens already return it and the app asked for it by name.
Productcost float64
Categoryid int
Subcategoryid int
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
// CatalogueRef is one product named by its catalogue key, with its other
// pack sizes after it. Nothing is recognised or scored.
CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error)
// 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(a.productcost, 0) AS productcost,
COALESCE(a.categoryid, 0) AS categoryid,
COALESCE(a.subcategoryid, 0) AS subcategoryid,
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
}
// minTokenHits is how many of the label's words a row must carry to be worth
// looking at. Every word was once required, which meant a single word the
// catalogue does not use — "Parle G biscuit pack", "Milk Bikis pack" — kept
// the right product out of the result entirely, leaving the vector search to
// answer alone and confidently wrong. Most of them is enough; scoring sorts
// out the rest.
func minTokenHits(n int) int {
if n <= 2 {
return n
}
return (n*2 + 2) / 3 // two thirds, rounded up; never below 2 for n >= 3
}
// 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
// carry most of its words.
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, ''))"
}
// How well a row matches, as a number: the whole label as a substring
// outweighs any number of loose words, then one point per word found.
hits := make([]string, 0, len(tokens)+1)
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 100 ELSE 0 END)")
for range tokens {
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 1 ELSE 0 END)")
}
rank := strings.Join(hits, " + ")
// The expression appears twice in the SQL — once to filter, once to
// order — so its arguments are bound twice, in that order.
bind := func() {
args = append(args, "%"+label+"%")
for _, tok := range tokens {
args = append(args, "%"+tok+"%")
}
}
bind()
bind()
// Ordering matters as much as the threshold: a looser WHERE lets more
// rows qualify, and an unordered LIMIT would then be free to return
// the wrong ones. Best match per brand first, id to keep it stable.
branches = append(branches, fmt.Sprintf(
`(SELECT %s, -1::float8 AS distance FROM %s WHERE (%s) >= %d ORDER BY (%s) DESC, id LIMIT %d)`,
hitColumns(brand, cols), table, rank, minTokenHits(len(tokens)), rank, 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
}
// tableFor resolves a brand the caller named to a real catalogue table.
//
// The lookup is against the tables discovered from information_schema, never
// a string built from the request: table names cannot be parameterised in
// SQL, so the discovered map is what keeps this from being an injection
// point. Both the table suffix ("britannia") and a display name ("24 Mantra"
// → brand_24_mantra) resolve.
func (r *scanRepository) tableFor(ctx context.Context, brand string) (string, map[string]bool, error) {
tables, err := r.brandTables(ctx)
if err != nil {
return "", nil, err
}
for _, candidate := range []string{
"brand_" + strings.ToLower(strings.TrimSpace(brand)),
"brand_" + normaliseBrandKey(brand),
} {
if cols, ok := tables[candidate]; ok {
return candidate, cols, nil
}
}
return "", nil, ErrUnknownBrand
}
// CatalogueRef reads one product by (brand, id) and appends its other pack
// sizes — same variant_key where the catalogue assigned one, same name
// otherwise, matching how the search groups a family.
//
// Distance is 0 on every row: nothing here was ranked, the caller said which
// product they meant.
func (r *scanRepository) CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error) {
table, cols, err := r.tableFor(ctx, brand)
if err != nil {
return nil, err
}
suffix := strings.TrimPrefix(table, "brand_")
columns := hitColumns(suffix, cols)
var self []CatalogueHit
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
`SELECT %s, 0::float8 AS distance FROM %s WHERE id = ?`, columns, table), id).Scan(&self).Error
if err != nil {
return nil, err
}
if len(self) == 0 {
return nil, nil
}
var siblings []CatalogueHit
if cols["variant_key"] && strings.TrimSpace(self[0].VariantKey) != "" {
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
`SELECT %s, 0::float8 AS distance FROM %s WHERE variant_key = ? AND id <> ? ORDER BY id`,
columns, table), self[0].VariantKey, id).Scan(&siblings).Error
} else {
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
`SELECT %s, 0::float8 AS distance FROM %s WHERE LOWER(product_name) = LOWER(?) AND id <> ? ORDER BY id`,
columns, table), self[0].ProductName, id).Scan(&siblings).Error
}
if err != nil {
// The product itself was found; losing its other sizes is the smaller
// failure and the caller asked for this one.
log.Printf("scan: could not read pack sizes of %s#%d: %v", brand, id, err)
return self, nil
}
return append(self, siblings...), nil
}
func sortedKeys(m map[string]map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// ── cache ───────────────────────────────────────────────────────────────────
//
// Two tiers. Redis is shared across replicas and survives a restart; the
// in-process map is there so the request after a cache hit costs no network
// round trip at all, and so a deployment without Redis still gets the
// benefit within one process. Neither tier ever holds stock.
func scanCacheKey(kind, scope, label string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(label))))
return "scan:" + kind + ":v1:" + scope + ":" + hex.EncodeToString(sum[:16])
}
func (r *scanRepository) CachedVector(ctx context.Context, model, label string) ([]float32, bool) {
key := scanCacheKey("emb", model, label)
r.memMu.Lock()
v, ok := r.memVecs[key]
r.memMu.Unlock()
if ok {
return v, true
}
if db.Rdb == nil {
return nil, false
}
raw, err := db.Rdb.Get(ctx, key).Bytes()
if err != nil {
return nil, false
}
if json.Unmarshal(raw, &v) != nil || len(v) == 0 {
return nil, false
}
r.remember(key, v, nil)
return v, true
}
func (r *scanRepository) CacheVector(ctx context.Context, model, label string, v []float32) {
key := scanCacheKey("emb", model, label)
r.remember(key, v, nil)
if db.Rdb == nil {
return
}
if raw, err := json.Marshal(v); err == nil {
if err := db.Rdb.Set(ctx, key, raw, scanVectorTTL).Err(); err != nil {
log.Printf("scan: could not cache vector: %v", err)
}
}
}
func (r *scanRepository) CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool) {
key := scanCacheKey("hits", method, label)
r.memMu.Lock()
h, ok := r.memHits[key]
r.memMu.Unlock()
if ok {
return h, true
}
if db.Rdb == nil {
return nil, false
}
raw, err := db.Rdb.Get(ctx, key).Bytes()
if err != nil {
return nil, false
}
if json.Unmarshal(raw, &h) != nil {
return nil, false
}
r.remember(key, nil, h)
return h, true
}
func (r *scanRepository) CacheHits(ctx context.Context, method, label string, hits []CatalogueHit) {
key := scanCacheKey("hits", method, label)
r.remember(key, nil, hits)
if db.Rdb == nil {
return
}
if raw, err := json.Marshal(hits); err == nil {
if err := db.Rdb.Set(ctx, key, raw, scanHitsTTL).Err(); err != nil {
log.Printf("scan: could not cache hits: %v", err)
}
}
}
// remember writes one entry into the process-local tier. Eviction is the
// simplest thing that bounds memory: when full, drop everything. Labels are
// short-lived popularity, not a working set worth an LRU.
func (r *scanRepository) remember(key string, v []float32, h []CatalogueHit) {
r.memMu.Lock()
defer r.memMu.Unlock()
if len(r.memVecs)+len(r.memHits) >= scanMemCacheMax {
r.memVecs = make(map[string][]float32)
r.memHits = make(map[string][]CatalogueHit)
}
if v != nil {
r.memVecs[key] = v
}
if h != nil {
r.memHits[key] = h
}
}

View File

@@ -25,14 +25,20 @@ type TenantRepository interface {
UpdateTenantProfile(tenantID int, fields map[string]any) error UpdateTenantProfile(tenantID int, fields map[string]any) error
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
GetStaffs(tid int) ([]models.StaffInfo, error) GetStaffs(tid int) ([]models.StaffInfo, error)
CreateStaff(user models.User) error // Returns the new userid: the account has no password and has to be invited.
CreateStaff(user models.User) (int, error)
AssignStaffToBranch(tenantID, userID, locationID int) error AssignStaffToBranch(tenantID, userID, locationID int) error
UpdateStaff(user models.User) error UpdateStaff(user models.User) error
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error) // Second return is the userid of the login this spawned for the branch, or 0
// when an existing person was named and no account was created.
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error)
UpdateTenantLocation(data models.Tenantlocations) error UpdateTenantLocation(data models.Tenantlocations) error
CheckTenantByNo(cno string) int CheckTenantByNo(cno string) int
CreateTenantUser(data models.Tenants) (bool, error) CreateTenantUser(data models.Tenants) (bool, error)
GetUserByNo(cno string) models.UserInfo GetUserByNo(cno string) models.UserInfo
PrimaryAdminForTenant(tenantID int) (InviteTarget, error)
InviteTargetForUser(userID int) (InviteTarget, error)
TenantNameByID(tenantID int) (string, error)
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
AssignPartner(tenantID, partnerID int) error AssignPartner(tenantID, partnerID int) error
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error) GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
@@ -85,7 +91,22 @@ func (r *tenantRepository) GetAllTenants(pageno, pagesize, aid int, status, tena
var data []models.Tenantinfo var data []models.Tenantinfo
base := `SELECT * FROM tenants a WHERE 1 = 1` // `branchcount` is selected here because there is nowhere else to get it.
//
// This returns one row per TENANT — there is no join to tenantlocations at
// all — but the console's store list read it as one row per
// tenant-location pair and counted the duplicates, so every merchant on the
// platform showed exactly one branch, and the "Branches" and "Avg branches"
// tiles above the list were the tenant count wearing another name. The
// tenant's own detail page, which reads gettenantlocations, disagreed with
// the list it was opened from.
//
// A correlated subquery rather than a LEFT JOIN + GROUP BY: the row shape
// stays exactly as it was, so nothing else that reads this endpoint has to
// change, and every filter below still applies to `a` alone.
base := `SELECT a.*,
(SELECT COUNT(*) FROM tenantlocations tl WHERE tl.tenantid = a.tenantid) AS branchcount
FROM tenants a WHERE 1 = 1`
var ( var (
conds []string conds []string
@@ -337,7 +358,25 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid, a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
a.roleid,a.partnerid,a.tenantid,a.locationid, a.roleid,a.partnerid,a.tenantid,a.locationid,
b.locationname, b.locationname,
COALESCE(c.rolename,'') AS rolename COALESCE(c.rolename,'') AS rolename,
-- Whether the account still works. Absent from this SELECT
-- until now, so Users & access had nothing to read and showed
-- every person on the platform as "Unknown" — an admin could not
-- tell a working login from one that had been switched off.
COALESCE(a.status,'') AS status,
-- Whether they have ever signed in — or can.
--
-- Every back-office account is created with an empty password and
-- is emailed a link to choose one. Until they use it they are in
-- this list, in every branch picker, and cannot sign in at all.
-- Without this column the directory cannot tell that person from
-- anybody else, so a lost invitation is invisible until they say
-- so — and the screen has no way to offer them a new one.
--
-- Computed here rather than by returning the password: there is no
-- reason for a cleartext password to travel up through a service
-- and a controller to answer a yes/no question.
(COALESCE(TRIM(a.password), '') <> '') AS issetup
FROM app_users a FROM app_users a
LEFT JOIN tenantlocations b ON a.locationid = b.locationid LEFT JOIN tenantlocations b ON a.locationid = b.locationid
LEFT JOIN app_roles c ON c.roleid = a.roleid LEFT JOIN app_roles c ON c.roleid = a.roleid
@@ -363,27 +402,32 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
// `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY` // `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY`
// column and Postgres allocates it. Computing one here would leave the sequence // column and Postgres allocates it. Computing one here would leave the sequence
// unadvanced and two allocators racing each other. // unadvanced and two allocators racing each other.
func (r *tenantRepository) CreateStaff(user models.User) error { // The userid is returned because the account is created with NO password and the
// caller has to invite it. Postgres allocates the id and GORM writes it back
// onto `user`, so this costs nothing — and without it the service would have to
// look the row up again by authname, which is the one field a concurrent create
// could collide on.
func (r *tenantRepository) CreateStaff(user models.User) (int, error) {
pin, err := ValidateStaffUser(&user) pin, err := ValidateStaffUser(&user)
if err != nil { if err != nil {
return err return 0, err
} }
user.Pin = int(pin) user.Pin = int(pin)
if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 { if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 {
taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid) taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid)
if err != nil { if err != nil {
return err return 0, err
} }
if taken { if taken {
return fmt.Errorf("another person at this outlet already uses that PIN") return 0, fmt.Errorf("another person at this outlet already uses that PIN")
} }
} }
if err := r.db.Table("app_users").Create(&user).Error; err != nil { if err := r.db.Table("app_users").Create(&user).Error; err != nil {
return err return 0, err
} }
return nil return user.Userid, nil
} }
func (r *tenantRepository) UpdateStaff(user models.User) error { func (r *tenantRepository) UpdateStaff(user models.User) error {
@@ -393,7 +437,7 @@ func (r *tenantRepository) UpdateStaff(user models.User) error {
return nil return nil
} }
func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error) { func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) {
var user models.Tenantuser var user models.Tenantuser
tx := r.db.Begin() tx := r.db.Begin()
@@ -418,7 +462,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
// authenticate. // authenticate.
if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" { if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" {
tx.Rollback() tx.Rollback()
return models.Tenantlocations{}, errors.New( return models.Tenantlocations{}, 0, errors.New(
"a branch needs somebody to run it: name an existing user in operatorid, or give an email to create a login from") "a branch needs somebody to run it: name an existing user in operatorid, or give an email to create a login from")
} }
@@ -427,7 +471,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
// QR code (payload is just {tenantid, locationid}) right after onboarding. // QR code (payload is just {tenantid, locationid}) right after onboarding.
if err := tx.Create(&data).Error; err != nil { if err := tx.Create(&data).Error; err != nil {
tx.Rollback() tx.Rollback()
return models.Tenantlocations{}, err return models.Tenantlocations{}, 0, err
} }
// Step 2a: bind an existing person, when one was named. // Step 2a: bind an existing person, when one was named.
@@ -444,21 +488,25 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
Updates(map[string]any{"locationid": data.Locationid}) Updates(map[string]any{"locationid": data.Locationid})
if res.Error != nil { if res.Error != nil {
tx.Rollback() tx.Rollback()
return models.Tenantlocations{}, res.Error return models.Tenantlocations{}, 0, res.Error
} }
if res.RowsAffected == 0 { if res.RowsAffected == 0 {
// Either the person does not exist, belongs to another merchant, or // Either the person does not exist, belongs to another merchant, or
// is a till account. All three are the same answer to the caller, // is a till account. All three are the same answer to the caller,
// and none of them should leave a branch standing. // and none of them should leave a branch standing.
tx.Rollback() tx.Rollback()
return models.Tenantlocations{}, fmt.Errorf( return models.Tenantlocations{}, 0, fmt.Errorf(
"user %d cannot run this branch — they belong to another business, do not exist, or are a till account", "user %d cannot run this branch — they belong to another business, do not exist, or are a till account",
data.Operatorid) data.Operatorid)
} }
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return models.Tenantlocations{}, err return models.Tenantlocations{}, 0, err
} }
return data, nil // No userid: nothing was created. The named person already had an account
// before this branch existed, so there is nothing here to invite — if
// THEY have never set a password, it is their own creation that owes them
// an invitation, not this one.
return data, 0, nil
} }
// Step 2b: no person named — spawn a login, as before. // Step 2b: no person named — spawn a login, as before.
@@ -488,15 +536,19 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
if err := tx.Table("app_users").Create(&user).Error; err != nil { if err := tx.Table("app_users").Create(&user).Error; err != nil {
tx.Rollback() tx.Rollback()
return models.Tenantlocations{}, err return models.Tenantlocations{}, 0, err
} }
// Commit // Commit
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return models.Tenantlocations{}, err return models.Tenantlocations{}, 0, err
} }
return data, nil // The spawned login's userid, so the service can invite it. This account is
// created with `Password = ""` a few lines above, and the invitation is now
// the only way to fill that in — the sign-in screen no longer offers a form.
// Without this the branch would be commissioned with a login nobody can use.
return data, user.Userid, nil
} }
func (r *tenantRepository) UpdateTenantLocation(input models.Tenantlocations) error { func (r *tenantRepository) UpdateTenantLocation(input models.Tenantlocations) error {
@@ -625,6 +677,51 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
var custloc models.Customerlocations var custloc models.Customerlocations
var tcust models.Tenantcustomers var tcust models.Tenantcustomers
// A tenant with configid 0 is unreachable, and it takes its customer row
// with it.
//
// Step 3 below already forces `user.Configid = 1`, with a comment
// explaining that AppLogin only ever queries configid 1 and a zero makes
// the account permanently unfindable. The same zero was left to flow into
// `tenants` itself and into the `customers` row copied from it at step 4,
// where nothing corrected it — so a caller that omits configid (the console
// sends it; the mobile route and anything else need not) created a business
// and a customer that no scoped read can see.
//
// Defaulted rather than rejected: 1 is the only value any caller has ever
// meant here, and refusing the create would break callers that work today.
if data.Configid == 0 {
data.Configid = 1
}
// Give the primary outlet the scaffolding the tenant already has.
//
// The outlet itself is created by GORM, as the `Tenantlocations`
// association on the struct below — the console nests a full object in the
// request and step 1 saves it with the tenant. What it does NOT do is fill
// anything the caller left out, and two of those columns matter:
//
// applocationid — `orderRepository.go` calls it "authoritative" and has
// no fallback anywhere for a 0.
// moduleid — same file: "tenantlocations carries 0 for
// moduleid/partnerid at outlets whose live orders
// nonetheless use non-zero values", worked around there
// by copying scaffolding off the most recent real order.
// A shop commissioned a minute ago has no such order.
//
// Neither column has a database default, and no onboarding form asks for
// them — they describe the platform, not the shop. The tenant's own values
// are the right answer and are already right here.
//
// Filled before the insert rather than corrected after it, so there is one
// write and no window where the row exists with a zero in it.
if data.Tenantlocations.Applocationid == 0 {
data.Tenantlocations.Applocationid = data.Applocationid
}
if data.Tenantlocations.Moduleid == 0 {
data.Tenantlocations.Moduleid = data.Moduleid
}
tx := r.db.Begin() tx := r.db.Begin()
// Step 1: Insert into tenants // Step 1: Insert into tenants
@@ -997,3 +1094,119 @@ func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error {
} }
return nil return nil
} }
// InviteTarget is the account a tenant's invitation is addressed to.
type InviteTarget struct {
Userid int
Email string
Tenantname string
// True when the account already has a password, which means the merchant is
// set up and there is nothing to invite them to.
IsSetUp bool
}
// PrimaryAdminForTenant finds the account an invitation should go to.
//
// ── Which of a tenant's users is "the" admin ────────────────────────────────
//
// A business can have several roleid-3 accounts — staff added later are the
// same role. The one onboarding created is identified by its authname matching
// the tenant's own `primaryemail`, which is how `CreateTenantUser` writes it,
// and that is the account the invitation belongs to. Picking any roleid-3 row
// would email whichever staff member happened to sort first.
//
// `IsSetUp` is computed in the query rather than by returning the password.
// There is no reason for a hash — or on this backend, a cleartext password — to
// travel up through a service and a controller to answer a yes/no question.
func (r *tenantRepository) PrimaryAdminForTenant(tenantID int) (InviteTarget, error) {
if tenantID <= 0 {
return InviteTarget{}, errors.New("tenantid is required")
}
var row InviteTarget
query := `
SELECT u.userid AS userid,
COALESCE(NULLIF(TRIM(u.email), ''), t.primaryemail) AS email,
t.tenantname AS tenantname,
(COALESCE(TRIM(u.password), '') <> '') AS issetup
FROM tenants t
JOIN app_users u
ON u.tenantid = t.tenantid
AND LOWER(TRIM(u.authname)) = LOWER(TRIM(t.primaryemail))
WHERE t.tenantid = ?
LIMIT 1`
if err := r.db.Raw(query, tenantID).Scan(&row).Error; err != nil {
return InviteTarget{}, err
}
if row.Userid == 0 {
// Either no such tenant, or one whose primary email matches no account.
// The second happens when the address was changed on the tenant after
// onboarding without the login being changed with it — worth saying,
// because the fix is to correct one of the two rather than to resend.
return InviteTarget{}, fmt.Errorf(
"tenant %d has no account matching its primary email address", tenantID)
}
return row, nil
}
// InviteTargetForUser finds one account by its userid.
//
// The other half of resend. `PrimaryAdminForTenant` answers "the owner of this
// business", which is the only account a tenant HAS at onboarding — but staff
// added later and the login every branch spawns are created with no password
// too, and there is exactly one of the owner, so they cannot be reached that
// way. An operator chasing a branch manager who never got their mail needs to
// name the person.
//
// The tenant is joined for its name only, and joined LEFT: a back-office account
// with no tenant is a Nearle staff row, and one exists — the platform agent's.
// Failing the lookup on that would be refusing to answer a question that has a
// perfectly good answer.
func (r *tenantRepository) InviteTargetForUser(userID int) (InviteTarget, error) {
if userID <= 0 {
return InviteTarget{}, errors.New("userid is required")
}
var row InviteTarget
query := `
SELECT u.userid AS userid,
COALESCE(NULLIF(TRIM(u.email), ''), TRIM(u.authname)) AS email,
COALESCE(t.tenantname, '') AS tenantname,
(COALESCE(TRIM(u.password), '') <> '') AS issetup
FROM app_users u
LEFT JOIN tenants t ON t.tenantid = u.tenantid
WHERE u.userid = ?
AND COALESCE(u.roleid, 0) NOT IN (7, 8)
LIMIT 1`
if err := r.db.Raw(query, userID).Scan(&row).Error; err != nil {
return InviteTarget{}, err
}
if row.Userid == 0 {
// No such account, or a till one. Roles 7 and 8 are excluded because a
// cashier does not sign in to the console at all — they authenticate at
// the terminal with a PIN, and an invitation would send them to a screen
// that cannot help them.
return InviteTarget{}, fmt.Errorf(
"user %d is not a back-office account on this platform", userID)
}
return row, nil
}
// TenantNameByID is the business's name, for an invitation's first line.
//
// Its own tiny read rather than a field threaded through the create paths: a
// staff account arrives carrying a tenantid and nothing else about the business,
// and the alternative was every caller passing a name it would have had to look
// up anyway. An empty name is not an error — `inviteMessage` says "your
// business" instead, which is worse copy and a working email.
func (r *tenantRepository) TenantNameByID(tenantID int) (string, error) {
if tenantID <= 0 {
return "", nil
}
var name string
err := r.db.Raw(`SELECT COALESCE(tenantname, '') FROM tenants WHERE tenantid = ? LIMIT 1`,
tenantID).Scan(&name).Error
return name, err
}

View File

@@ -1,6 +1,8 @@
package repositories package repositories
import ( import (
"database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -12,16 +14,15 @@ import (
type UserRepository interface { type UserRepository interface {
GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error)
GetUserByID(uid int) (models.UserInfo, error) GetUserByID(uid int) (models.UserInfo, error)
SetInitialPassword(userid int, password string) error
Login(user models.User) (models.UserInfo, error) Login(user models.User) (models.UserInfo, error)
FindUserID(authname, contactno string, configid int) (int, error) FindUserID(authname, contactno string, configid int) (int, error)
UpdateStaff(user models.User) 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 UpdateFCMToken(userid int, token string) error
GetTenantUserById(userid int) models.TenantUserInfo GetTenantUserById(userid int) models.TenantUserInfo
CreateUser(user models.User) (int, error) CreateUser(user models.User) (int, error)
GetUserById(uid int) (models.UserInfo, 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 UpdateUserFcmToken(uid int, token string) error
GetLocationStatus(locationid int) string GetLocationStatus(locationid int) string
DeleteUser(userid int) error DeleteUser(userid int) error
@@ -164,7 +165,6 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
return userInfo, nil return userInfo, nil
} }
func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) { func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) {
var uid int var uid int
var query string var query string
@@ -187,39 +187,10 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
return uid, nil return uid, nil
} }
func (r *userRepository) UpdateStaff(user models.User) error { func (r *userRepository) UpdateStaff(user models.User) error {
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&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 { func (r *userRepository) UpdateFCMToken(userid int, token string) error {
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?` query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
return r.db.Exec(query, token, userid).Error return r.db.Exec(query, token, userid).Error
@@ -285,6 +256,30 @@ func (r *userRepository) GetTenantUserById(userid int) models.TenantUserInfo {
} }
func (r *userRepository) CreateUser(user models.User) (int, error) { func (r *userRepository) CreateUser(user models.User) (int, error) {
// Inherit the delivery region from the tenant when the caller did not name
// one.
//
// `app_users.applocationid` has no column default, and no console form
// collects it — it is a platform region, not something a merchant picks
// per person. So every back-office account created through this path landed
// with 0, which is not a region: `orderRepository.go` calls the equivalent
// column on tenantlocations "authoritative" and has no fallback for a zero,
// and 43 of 75 live branches are already in that state.
//
// A lookup rather than a default value, because the right answer is
// whichever region the business trades in. Failure is not fatal: the
// account is still worth creating, and a 0 here is exactly what would have
// been written anyway.
if user.Applocationid == 0 && user.Tenantid > 0 {
var inherited int
if err := r.db.Raw(
`SELECT COALESCE(applocationid, 0) FROM tenants WHERE tenantid = ?`,
user.Tenantid,
).Scan(&inherited).Error; err == nil && inherited > 0 {
user.Applocationid = inherited
}
}
tx := r.db.Begin() tx := r.db.Begin()
if err := tx.Table("app_users").Create(&user).Error; err != nil { if err := tx.Table("app_users").Create(&user).Error; err != nil {
@@ -329,9 +324,40 @@ func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
return user, nil return user, nil
} }
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int) { // GetUserLogin is the one sign-in lookup, for the app and the console alike.
var uid, roleid int //
var password, status string // `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(` query := fmt.Sprintf(`
SELECT userid, password, status, roleid SELECT userid, password, status, roleid
@@ -339,9 +365,16 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s
WHERE %s = ? AND configid = ? WHERE %s = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field) AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid) err := r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
if errors.Is(err, sql.ErrNoRows) {
return uid, password, status, roleid 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 { func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error {
@@ -360,4 +393,45 @@ func (r *userRepository) DeleteUser(userid int) error {
return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error
} }
// SetInitialPassword writes the first password on an account that has none.
//
// ── Why this is a separate call and not `UpdateStaff` ───────────────────────
//
// It is the one write that MUST work without a session, and that is the whole
// difficulty. A brand-new account — `createtenantlocation` spawns branch logins
// with an empty password — signs in, is told to set one, and at that moment has
// no token and no way to get one. The console was doing this through
// `PUT /users/update`, which sits behind the session guard, so the call came
// back "a session token is required; sign in again" and the account could never
// be used. Sign-in needs a password; setting the password needed a sign-in.
//
// `/users/update` could not simply be opened up: it writes whatever struct it
// is handed, so an unauthenticated caller could edit any field of any user.
// This can do exactly one thing, to exactly one kind of account.
//
// ── What makes it safe to expose ────────────────────────────────────────────
//
// The empty-password check IS the authorisation. An account with a password set
// is refused, so this can never overwrite a credential — it is a setup call,
// never a reset. There is no "forgot password" flow on this backend and this
// must not become one by accident: a reset needs proof of identity, and nothing
// here has any.
//
// The check and the write are one statement, so two callers racing cannot both
// see an empty password and both set one. Postgres decides, not this process.
func (r *userRepository) SetInitialPassword(userid int, password string) error {
result := r.db.Table("app_users").
Where("userid = ? AND (password IS NULL OR TRIM(password) = '')", userid).
Update("password", password)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// One message for "no such user" and "already has a password". They
// must not be distinguishable, or this becomes a way to ask whether a
// given userid exists and whether it has been set up.
return errors.New("that account cannot have its password set here — it may already have one")
}
return nil
}

25
routes/assistantroutes.go Normal file
View File

@@ -0,0 +1,25 @@
package routes
import (
"nearle/facade"
"github.com/gofiber/fiber/v2"
)
// Nearle Buddy. See controllers/assistantController.go for the two calls and
// services/assistantService.go for the loop behind them.
//
// Under `/v1/web`, so it inherits `middleware.WebAuth` along with every other
// console route — which is the point. The assistant reads the same data the
// console does, and it must read it as the same person.
func RegisterAssistantRoutes(api fiber.Router, f *facade.Facade) {
assistant := api.Group("/v1/web/assistant")
assistant.Get("/status", f.AssistantController.Status)
assistant.Post("/ask", f.AssistantController.Ask)
assistant.Post("/approve", f.AssistantController.Approve)
// The MCP door, under the same group so it inherits the same session guard.
// One endpoint: JSON-RPC carries the method in the body.
assistant.Post("/mcp", f.MCPController.Handle)
}

View File

@@ -0,0 +1,33 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"nearle/facade"
)
/*
Delivery windows.
── The web half is guarded, the app half is read-only ──────────────────────
Setting a branch's hours is a tenant-wide decision with money behind it, so it
sits on /v1/web where WebAuth checks the session and the tenant scope. Reading
what is on offer is what a shopper's app does before it has any identity at all,
so that one endpoint — and only that one — lives on the unauthenticated /v1/mob
group.
There is deliberately NO write route on the mob group. The /v1/mob/* group has
no session at all, and a shop's trading hours are not something an anonymous
caller gets to set.
*/
func RegisterDeliverySlotRoutes(api fiber.Router, f *facade.Facade) {
// ── Console: read and edit a branch's windows ───────────────────────────
web := api.Group("/v1/web/deliveryslots")
web.Get("/", f.DeliverySlotController.ListDeliverySlots)
web.Put("/", f.DeliverySlotController.SaveDeliverySlots)
// ── App: what a shopper may pick, already filtered ──────────────────────
mob := api.Group("/v1/mob/deliveryslots")
mob.Get("/available", f.DeliverySlotController.AvailableDeliverySlots)
}

View File

@@ -13,6 +13,9 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) {
partner.Get("/getriders", f.PartnerController.GetActiveRiders) partner.Get("/getriders", f.PartnerController.GetActiveRiders)
partner.Get("/getpartners", f.PartnerController.GetPartners) partner.Get("/getpartners", f.PartnerController.GetPartners)
partner.Get("/getridershifts", f.PartnerController.GetRiderShifts) partner.Get("/getridershifts", f.PartnerController.GetRiderShifts)
// Opening a shift window. Riders cannot be hired without one, and this table
// was read-only until now — see partnerController.CreateRiderShift.
partner.Post("/createridershift", f.PartnerController.CreateRiderShift)
partner.Get("/getlocations", f.PartnerController.GetLocationConfig) partner.Get("/getlocations", f.PartnerController.GetLocationConfig)
partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs) partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs)
partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary) partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary)

View File

@@ -77,6 +77,28 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
registerPosStaffConsoleRoutes(api, f) registerPosStaffConsoleRoutes(api, f)
registerPosReadConsoleRoutes(api, f) registerPosReadConsoleRoutes(api, f)
registerLiveRoutes(api, f) registerLiveRoutes(api, f)
registerPosAdoptionRoute(api, f)
}
// How much of the till fleet has adopted the session token.
//
// The number that decides when `POS_AUTH_REQUIRED` can be switched on. Nothing
// was recording it — an untokened request was waved through in silence — so the
// only way to judge the risk of flipping the flag was to flip it and watch.
//
// ── Why it is on /v1/web and only /v1/web ───────────────────────────────────
//
// It names the outlets still calling without a token, which is a list of the
// shops that would stop trading if enforcement went on today. That is exactly
// the list an attacker would want, so it sits behind `middleware.WebAuth` and
// NOT on the unauthenticated health endpoint, where the rest of "is this
// deployment wired up" lives.
//
// Registered on its own rather than inside registerPosReadConsoleRoutes,
// because that function deliberately mirrors every route onto `/v1/mob/pos`
// as well — which has no guard at all.
func registerPosAdoptionRoute(api fiber.Router, f *facade.Facade) {
api.Group("/v1/web/pos").Get("/authadoption", f.PosController.AuthAdoption)
} }
// The same counter-sales reads, for callers that are not a terminal. // The same counter-sales reads, for callers that are not a terminal.

View File

@@ -28,6 +28,14 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation) products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
products.Post("/createproductlocation", f.ProductController.CreateProductLocation) products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct) products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
// Whether this shop shows a product's health score.
//
// On `/v1/web` only. The merchant decides for their own shelf, so it needs
// the session that says which shelf is theirs — and the mobile group has no
// guard at all, which would make this "anyone can turn any shop's health
// scores off".
products.Put("/showhealthscore", f.ProductController.SetShowHealthScore)
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts) products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
// Repairing catalogue links. A dry run unless `apply=true` — see the handler, // Repairing catalogue links. A dry run unless `apply=true` — see the handler,

View File

@@ -2,6 +2,7 @@ package routes
import ( import (
"nearle/facade" "nearle/facade"
"nearle/middleware"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -10,6 +11,22 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
api := app.Group("/live/api") api := app.Group("/live/api")
// Console sessions.
//
// Mounted by PATH rather than on a group object, because the `/v1/web`
// routes are not one group — a dozen files each create their own
// (`/v1/web/users`, `/v1/web/orders`, `/v1/web/products`, …). Registered
// here, ahead of all of them, so a route added later is guarded by default
// rather than by somebody remembering to.
//
// `/v1/pos` is deliberately NOT covered: that is the terminal surface, it
// carries a different kind of token, and it has its own guard. But
// `/v1/web/pos` and `/v1/web/tenants` ARE, despite their names — both are
// console callers, and `createposuser` on the second mints till credentials,
// which until now it did on the strength of an unauthenticated request. The
// note above registerPosStaffConsoleRoutes asked for exactly this.
api.Use("/v1/web", middleware.WebAuth(f.PosService()))
RegisterUserRoutes(api, f) RegisterUserRoutes(api, f)
RegisterProductRoutes(api, f) RegisterProductRoutes(api, f)
RegisterOrderRoutes(api, f) RegisterOrderRoutes(api, f)
@@ -21,4 +38,15 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
RegisterCatalogueRoutes(api, f) RegisterCatalogueRoutes(api, f)
RegisterPosRoutes(api, f) RegisterPosRoutes(api, f)
RegisterUploadRoutes(api, f) RegisterUploadRoutes(api, f)
RegisterScanRoutes(api, f)
RegisterAssistantRoutes(api, f)
RegisterDeliverySlotRoutes(api, f)
// What is running here.
//
// Registered on `api` and NOT under `/v1/web`, so it answers without a
// session — which is the whole point. The question it exists for is "why
// does nothing work", and a health check that needs a working credential
// cannot answer that. It returns booleans and a build id, never values.
api.Get("/v1/health", f.HealthController.Health)
} }

17
routes/scanroutes.go Normal file
View File

@@ -0,0 +1,17 @@
package routes
import (
"nearle/facade"
"github.com/gofiber/fiber/v2"
)
// Scan-to-order, customer app only. See controllers/scanController.go for
// the three calls and services/scanService.go for the pipeline behind them.
func RegisterScanRoutes(api fiber.Router, f *facade.Facade) {
scan := api.Group("/v1/mob/scan")
scan.Post("/lookup", f.ScanController.Lookup)
scan.Post("/confirm", f.ScanController.Confirm)
scan.Get("/stores", f.ScanController.Stores)
}

135
routes/startup_test.go Normal file
View File

@@ -0,0 +1,135 @@
package routes
import (
"net/http/httptest"
"strings"
"testing"
"nearle/config"
"nearle/facade"
"github.com/gofiber/fiber/v2"
)
// Can this server be built and can its routes be reached?
//
// Everything else in this repository tests a function. This tests the thing
// that actually happens on deploy: the whole object graph is constructed and
// every route is registered. Nothing here needs a database — the repositories
// hold their handle without touching it — so it runs in CI beside the unit
// tests rather than in an environment somebody has to provision.
//
// ── Why it is worth its own file ────────────────────────────────────────────
//
// Three things in `NewFacade` PANIC rather than return an error: a tool that
// fails to register, a help corpus that will not load, and an agent naming a
// tool that does not exist. Each is a programming mistake that should stop a
// deploy, and each was previously reachable only by starting the server against
// a real database — which meant, in practice, by deploying.
//
// The agent one is not hypothetical: a typo in `agents/orders.yaml` is a file
// edit away, and it takes a working assistant down at boot.
func testFacade(t *testing.T) *facade.Facade {
t.Helper()
defer func() {
if r := recover(); r != nil {
// Rendered as a failure rather than a panicking test, because the
// message IS the point — "agent orders lists a tool that does not
// exist" is the whole diagnosis.
t.Fatalf("the server cannot start: %v", r)
}
}()
// No database, no catalogue, no embedder, no model. A deployment with none
// of those must still boot and say what it is missing, rather than failing
// somewhere the operator cannot see.
return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests", nil, config.MailConfig{}, "")
}
func TestTheServerCanBeBuilt(t *testing.T) {
f := testFacade(t)
if f == nil {
t.Fatal("no facade")
}
// The two doors onto the assistant. Absent means a route registered below
// would nil-panic on its first request rather than at boot.
if f.AssistantController == nil {
t.Fatal("no assistant controller")
}
if f.MCPController == nil {
t.Fatal("no MCP controller")
}
if f.Tools == nil {
t.Fatal("no tool registry")
}
}
func TestEveryAssistantRouteIsReachable(t *testing.T) {
// Registered, not merely written down. A route added to a file that nothing
// calls is invisible until somebody reports the feature missing.
app := fiber.New()
RegisterRoutes(app, testFacade(t))
for _, route := range []struct {
method, path string
}{
{"GET", "/live/api/v1/web/assistant/status"},
{"POST", "/live/api/v1/web/assistant/ask"},
{"POST", "/live/api/v1/web/assistant/approve"},
{"POST", "/live/api/v1/web/assistant/mcp"},
} {
req := httptest.NewRequest(route.method, route.path, strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("%s %s: %v", route.method, route.path, err)
}
if resp.StatusCode == fiber.StatusNotFound {
t.Fatalf("%s %s is not registered", route.method, route.path)
}
}
}
func TestHealthAnswersThroughTheRealRouteTableWithoutASession(t *testing.T) {
// Registered on `api` rather than under `/v1/web`, which is what keeps it
// outside the session guard. Asserted here rather than trusted, because the
// difference is one path segment and getting it wrong makes the endpoint
// useless for the only situation it exists for: nothing else works.
//
// It also has to survive a facade built with no database, no model and no
// embedder — the state somebody is most likely to be asking from.
app := fiber.New()
RegisterRoutes(app, testFacade(t))
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
if err != nil {
t.Fatalf("calling health: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("health needs a session or is unregistered: HTTP %d", resp.StatusCode)
}
}
func TestTheAssistantSurfaceSitsBehindTheSessionGuard(t *testing.T) {
// The assistant reads the same data the console does and must read it as
// the same person. Being under `/v1/web` is what puts it behind WebAuth —
// a route registered one path segment to the left would answer anybody.
app := fiber.New()
RegisterRoutes(app, testFacade(t))
// WEB_AUTH_REQUIRED is off by default, so an untokened request reaches the
// handler; the assistant's own controller then refuses it. Either way it
// must not answer with data.
req := httptest.NewRequest("POST", "/live/api/v1/web/assistant/ask",
strings.NewReader(`{"question":"what is stuck?"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("calling: %v", err)
}
if resp.StatusCode == fiber.StatusOK {
t.Fatal("an untokened question was answered")
}
}

View File

@@ -22,6 +22,14 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation) tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser) tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
// Re-issuing a merchant's first-password link, for the one who never got
// the mail or whose invitation expired.
//
// Web group only, and the handler additionally requires a platform account:
// this mints a credential, and the merchant it would invite is by
// definition somebody who cannot sign in to ask for it themselves.
tenant.Post("/resendinvite", f.TenantController.ResendInvite)
// One business, by id. // One business, by id.
// //
// Also /mob-only until now, so the console's only way to read its own // Also /mob-only until now, so the console's only way to read its own

View File

@@ -14,6 +14,10 @@ func RegisterUserRoutes(api fiber.Router, f *facade.Facade) {
users.Post("/applogin", f.UserController.AppLogin) users.Post("/applogin", f.UserController.AppLogin)
users.Post("/create", f.UserController.CreateUser) users.Post("/create", f.UserController.CreateUser)
users.Post("/tenant/weblogin", f.UserController.TenantWebLogin) users.Post("/tenant/weblogin", f.UserController.TenantWebLogin)
// First password, before a session can exist. Public by necessity and safe
// because of what it refuses — see userController.SetPassword.
users.Post("/setpassword", f.UserController.SetPassword)
users.Put("/update", f.UserController.UpdateStaff) users.Put("/update", f.UserController.UpdateStaff)
users.Delete("/delete", f.UserController.DeleteUser) users.Delete("/delete", f.UserController.DeleteUser)

View File

@@ -0,0 +1,49 @@
// Would this server switch Nearle Buddy on?
//
// APP_ENV=production go run ./scratch/buddyconfig
//
// Reads the configuration exactly as `main.go` does — same files, same order,
// same defaults — and reports whether the assistant would be built. Prints a
// verdict and, when the answer is no, the name of the variable that is missing.
// Never a value: this exists to be run against production configuration.
//
// Connects to nothing. `config.Load` reads and validates; the database, the
// model and the queue are all somebody else's job.
package main
import (
"fmt"
"os"
"nearle/config"
"nearle/utils"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Println("configuration is not valid:")
fmt.Println(err)
os.Exit(1)
}
fmt.Printf("APP_ENV %s\n", cfg.AppEnv)
fmt.Printf("sessions can issue %t\n", utils.WebTokenConfigured())
if !cfg.Assistant.Enabled() {
fmt.Printf("assistant OFF — %s\n", cfg.Assistant.Why())
os.Exit(1)
}
// The gateway itself, built the way the facade builds it. Enabled() passing
// and NewChat returning nil would be a disagreement worth catching here
// rather than at the first question somebody asks.
chat, err := utils.NewChat(cfg.Assistant)
if err != nil || chat == nil {
fmt.Printf("assistant OFF — gateway not built: %v\n", err)
os.Exit(1)
}
fmt.Printf("assistant ON — provider %s, balanced tier %s\n",
cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
}

View File

@@ -0,0 +1,81 @@
// Asks the deployed server whether Nearle Buddy has a model.
//
// go run ./scratch/buddystatus # production
// go run ./scratch/buddystatus http://localhost:1122
//
// `/assistant/status` sits behind the session guard, so this mints one. That it
// CAN mint one, from a secret sitting in a tracked file, is itself the finding
// recorded in middleware/webauth.go: anybody with repository access can issue a
// session for any tenant. Rotating POS_TOKEN_SECRET out of `.env.local` is the
// fix, and this tool stops working the day that happens — which is correct.
//
// Read-only. It asks one question and prints the answer.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"nearle/utils"
"github.com/joho/godotenv"
)
func main() {
// The secret lives in the env files, not in this program.
_ = godotenv.Load(".env.local")
_ = godotenv.Load(".env")
host := "https://fiesta.nearle.app"
if len(os.Args) > 1 {
host = strings.TrimRight(os.Args[1], "/")
}
token, _, err := utils.MintWebToken(utils.WebClaims{Userid: 904, Tenantid: 1147}, time.Now())
if err != nil {
fmt.Println("cannot mint a session:", err)
fmt.Println("POS_TOKEN_SECRET is not set here, or is shorter than 16 characters.")
os.Exit(1)
}
url := host + "/live/api/v1/web/assistant/status"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
fmt.Println("could not reach", url, err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("%s\nHTTP %d\n%s\n\n", url, resp.StatusCode, body)
var envelope struct {
Details struct {
Available bool `json:"available"`
Reason string `json:"reason"`
} `json:"details"`
}
if json.Unmarshal(body, &envelope) != nil {
return
}
switch {
case resp.StatusCode == http.StatusUnauthorized:
fmt.Println("The session was refused — this deployment signs with a different secret.")
case envelope.Details.Available:
fmt.Println("Buddy has a model. The composer should accept a question on Console, Sales and Inventory.")
case envelope.Details.Reason != "":
fmt.Println("Buddy is off:", envelope.Details.Reason)
default:
fmt.Println("Buddy is off. This build does not say why — ASSISTANT_BASE_URL, ASSISTANT_MODEL")
fmt.Println("and ASSISTANT_API_KEY are what it needs, set on the platform and redeployed.")
}
}

View File

@@ -0,0 +1,90 @@
// 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
Attname string
Typname string
Atttypmod int
}
if err := db.Raw(`
SELECT c.relname, a.attname, 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 t.typname = 'vector' AND a.attnum > 0 AND c.relname LIKE 'brand\_%'
ORDER BY c.relname, a.attname`).Scan(&cols).Error; err != nil {
log.Fatal(err)
}
if len(cols) == 0 {
fmt.Println("no brand_* table has a vector column")
return
}
fmt.Printf("%-24s %-16s %-8s %5s %5s %5s\n", "table", "column", "type", "dims", "rows", "filled")
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 %s IS NOT NULL`, c.Relname, c.Attname)).Scan(&filled)
fmt.Printf("%-24s %-16s %-8s %5d %5d %5d\n", c.Relname, c.Attname, c.Typname, c.Atttypmod, total, filled)
}
// nomic/bge emit unit vectors; a norm far from 1 means another pipeline.
for _, c := range cols {
var norm float64
db.Raw(fmt.Sprintf(`SELECT vector_norm(embedding) FROM %s WHERE embedding IS NOT NULL LIMIT 1`, c.Relname)).Scan(&norm)
if norm > 0 {
fmt.Printf("sample vector norm (%s): %.4f\n", c.Relname, norm)
break
}
}
}

View File

@@ -0,0 +1,402 @@
// Backfills products.cataloguefacts for products imported before the column existed.
//
// The catalogue import copied eight of the catalogue's eighteen fields onto a
// tenant's product and left the other ten behind — the FSSAI licence, nutrients,
// highlights, providers, the typical price range, the variant key. The console
// covered for it by asking the catalogue again on every drawer open, and that
// stops working the moment a re-scrape retires the source row: a tenant's
// product is a SNAPSHOT and outlives it, so a licence number came off a product
// the shop was still selling with no way back.
//
// The import keeps them now. Every product imported BEFORE that does not have
// them, and no amount of new code fixes a row that was written last month — so
// this reads each one's catalogue entry while it is still there and stores it.
//
// go run ./scratch/cataloguefactsbackfill # dry run — shows every change
// go run ./scratch/cataloguefactsbackfill apply # writes, then prints the undo
//
// ── What it will and will not touch ─────────────────────────────────────────
//
// Only products with an `imageid` and a NULL `cataloguefacts`. That is the
// whole safety story:
//
// - NULL means nothing was ever written. A product whose facts are already
// stored — including one stored as `{}` because the catalogue genuinely had
// nothing to say — is never overwritten, so re-running this is a no-op
// rather than a second opinion.
// - No `imageid` means it never came from the catalogue. Sheet-imported
// products have no entry to read and are left alone.
// - A catalogue row that has already been retired cannot be recovered by
// anything, here or later. Those are counted and named rather than written
// as empty, because `{}` would claim the catalogue said nothing when the
// truth is that nobody asked in time.
//
// Brand tables are discovered rather than assumed, and their columns are
// checked one by one before being selected: the catalogue is another team's
// scrape, brands appear between runs, and a table missing `nutrients` is a
// perfectly good catalogue of products. Demanding the full column set is the
// exact mistake that once made 16 of 35 live brands invisible to this side.
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"sort"
"strings"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"nearle/models"
)
// The columns worth keeping, in the order the drawer reads them. Scalars and
// arrays are separated because an array comes back as a Postgres text[] literal
// and has to be parsed before it can be re-encoded as JSON.
var scalarFacts = []string{
"title", "category", "variant_key", "sku_source",
"price_range", "fssai_license", "search_query",
}
var arrayFacts = []string{"providers", "highlights", "nutrients"}
type product struct {
Productid int
Productbrand string
Imageid string
Productname string
Tenantid int
}
func main() {
apply := len(os.Args) > 1 && os.Args[1] == "apply"
_ = godotenv.Load()
main, err := open("DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME")
if err != nil {
log.Fatal("nearledb: ", err)
}
cat, err := open("CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_USER",
"CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME")
if err != nil {
log.Fatal("cataloguedb: ", err)
}
// The column has to exist before there is anything to fill. Checked rather
// than assumed so this says so plainly instead of failing inside a query.
var hasColumn int
main.Raw(`SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'products' AND column_name = 'cataloguefacts'`).Scan(&hasColumn)
if hasColumn == 0 {
log.Fatal("products.cataloguefacts does not exist — start the API once to run the migration, then re-run this")
}
var candidates []product
main.Raw(`SELECT productid, tenantid, COALESCE(productbrand,'') AS productbrand,
COALESCE(imageid,'') AS imageid, COALESCE(productname,'') AS productname
FROM products
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL
ORDER BY productbrand, productid`).Scan(&candidates)
var (
total int
alreadyDone int
noImageid int
)
main.Raw(`SELECT COUNT(*) FROM products`).Scan(&total)
main.Raw(`SELECT COUNT(*) FROM products WHERE cataloguefacts IS NOT NULL`).Scan(&alreadyDone)
main.Raw(`SELECT COUNT(*) FROM products WHERE COALESCE(imageid,'') = ''`).Scan(&noImageid)
fmt.Printf("products on the platform : %d\n", total)
fmt.Printf(" never came from the catalogue : %d (no imageid — left alone)\n", noImageid)
fmt.Printf(" facts already stored : %d (never overwritten)\n", alreadyDone)
fmt.Printf(" to backfill : %d\n\n", len(candidates))
if len(candidates) == 0 {
fmt.Println("nothing to do.")
return
}
// One column check per brand table, not per product: the shape is a
// property of the table and a per-row check would be thousands of
// information_schema reads to learn the same thing.
columnsByTable := map[string][]string{}
missingTable := map[string]bool{}
type update struct {
product product
facts string
}
var (
updates []update
retired []product
unknown []product
emptyOnly []product
)
for _, p := range candidates {
table := brandTable(p.Productbrand)
if table == "" {
unknown = append(unknown, p)
continue
}
if missingTable[table] {
retired = append(retired, p)
continue
}
cols, known := columnsByTable[table]
if !known {
cols = factColumnsOf(cat, table)
if cols == nil {
missingTable[table] = true
retired = append(retired, p)
continue
}
columnsByTable[table] = cols
}
facts, found := factsFor(cat, table, cols, p.Imageid)
if !found {
retired = append(retired, p)
continue
}
if len(facts) == 0 {
// The row is there and had nothing in these columns. Worth writing
// `{}` — it is the true answer and it stops the console asking the
// catalogue again on every open.
emptyOnly = append(emptyOnly, p)
}
encoded, err := json.Marshal(facts)
if err != nil {
log.Printf("could not encode facts for product %d: %v", p.Productid, err)
continue
}
updates = append(updates, update{product: p, facts: string(encoded)})
}
fmt.Printf("%-9s %-14s %-22s %-34s %s\n", "product", "brand", "imageid", "name", "facts recovered")
for _, u := range updates {
var keys []string
var got map[string]any
_ = json.Unmarshal([]byte(u.facts), &got)
for k := range got {
keys = append(keys, k)
}
sort.Strings(keys)
summary := strings.Join(keys, ",")
if summary == "" {
summary = "(catalogue row has none)"
}
fmt.Printf("%-9d %-14s %-22s %-34s %s\n",
u.product.Productid, trim(u.product.Productbrand, 14), trim(u.product.Imageid, 22),
trim(u.product.Productname, 34), summary)
}
if len(retired) > 0 {
fmt.Printf("\n!! %d product(s) cannot be recovered — their catalogue row is gone:\n", len(retired))
for _, p := range retired {
fmt.Printf(" %-9d %-14s %-22s %s\n", p.Productid, trim(p.Productbrand, 14),
trim(p.Imageid, 22), trim(p.Productname, 40))
}
fmt.Println(" These are left NULL. The console falls back to the live lookup for them,")
fmt.Println(" which will also find nothing — the detail was lost before this ran.")
}
if len(unknown) > 0 {
fmt.Printf("\n!! %d product(s) carry a brand with no table in the catalogue:\n", len(unknown))
for _, p := range unknown {
fmt.Printf(" %-9d %-14s %s\n", p.Productid, trim(p.Productbrand, 14), trim(p.Productname, 40))
}
}
fmt.Printf("\nwill write %d product(s)", len(updates))
if len(emptyOnly) > 0 {
fmt.Printf(", %d of them as `{}` because the catalogue row carries none of these fields", len(emptyOnly))
}
fmt.Printf("; leaving %d NULL\n", len(retired)+len(unknown))
if len(updates) == 0 {
return
}
if !apply {
fmt.Println("\ndry run — nothing written. re-run with `apply` to write.")
return
}
// One row at a time, each guarded by `cataloguefacts IS NULL` again.
// Between the read above and this write another import could have stored
// the real thing, and this must never be the one that overwrites it.
written := 0
ids := make([]int, 0, len(updates))
for _, u := range updates {
res := main.Exec(`UPDATE products SET cataloguefacts = ?::jsonb
WHERE productid = ? AND cataloguefacts IS NULL`,
u.facts, u.product.Productid)
if res.Error != nil {
log.Printf("product %d: %v", u.product.Productid, res.Error)
continue
}
if res.RowsAffected > 0 {
written++
ids = append(ids, u.product.Productid)
}
}
fmt.Printf("\nwrote %d product(s)\n", written)
var stillNull int
main.Raw(`SELECT COUNT(*) FROM products
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL`).Scan(&stillNull)
fmt.Printf("catalogue-linked products still without facts: %d\n", stillNull)
if len(ids) > 0 {
fmt.Printf("\nundo:\n UPDATE products SET cataloguefacts = NULL WHERE productid IN (%s);\n",
joinInts(ids))
}
}
func open(hostKey, portKey, userKey, passKey, nameKey string) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv(hostKey), os.Getenv(portKey), os.Getenv(userKey),
os.Getenv(passKey), os.Getenv(nameKey))
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
}
// brandTable mirrors the repository's rule: a brand IS a `brand_<name>` table.
//
// Lowercased and stripped of anything that is not a letter, digit or
// underscore. The table name cannot be parameterized in SQL, so this is the
// one place it is built and it refuses to build anything else.
func brandTable(brand string) string {
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
return r
case r >= 'A' && r <= 'Z':
return r + 32
}
return -1
}, strings.TrimSpace(brand))
if cleaned == "" {
return ""
}
return "brand_" + cleaned
}
// factColumnsOf returns which of the fact columns this brand table actually
// has, or nil when the table is not there at all.
func factColumnsOf(db *gorm.DB, table string) []string {
var have []string
db.Raw(`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ?`, table).Scan(&have)
if len(have) == 0 {
return nil
}
present := map[string]bool{}
for _, c := range have {
present[c] = true
}
// image_id is how a product is found at all. Without it the table cannot
// answer the question, whatever else it holds.
if !present["image_id"] {
return nil
}
var keep []string
for _, c := range append(append([]string{}, scalarFacts...), arrayFacts...) {
if present[c] {
keep = append(keep, c)
}
}
return keep
}
// factsFor reads one catalogue row and returns only what it actually stated.
//
// An empty field is omitted rather than stored as "" or [], so a reader can
// tell "the catalogue did not say" from "the catalogue said none" — the drawer
// prints a row per fact and an empty string would print an empty row.
func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[string]any, bool) {
if len(cols) == 0 {
return map[string]any{}, true
}
selects := make([]string, 0, len(cols))
for _, c := range cols {
if isArrayFact(c) {
selects = append(selects, c+"::text AS "+c)
continue
}
selects = append(selects, c)
}
row := map[string]any{}
res := db.Raw(`SELECT `+strings.Join(selects, ", ")+` FROM `+table+
` WHERE image_id = ? LIMIT 1`, imageID).Scan(&row)
if res.Error != nil || res.RowsAffected == 0 {
return nil, false
}
facts := map[string]any{}
for _, c := range cols {
raw, ok := row[c]
if !ok || raw == nil {
continue
}
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
if text == "" {
continue
}
if isArrayFact(c) {
values := models.ParsePGArray(text)
kept := make([]string, 0, len(values))
for _, v := range values {
if t := strings.TrimSpace(v); t != "" {
kept = append(kept, t)
}
}
if len(kept) > 0 {
facts[c] = kept
}
continue
}
facts[c] = text
}
return facts, true
}
func isArrayFact(name string) bool {
for _, c := range arrayFacts {
if c == name {
return true
}
}
return false
}
func trim(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}
func joinInts(ids []int) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = fmt.Sprint(id)
}
return strings.Join(parts, ",")
}

View File

@@ -0,0 +1,76 @@
// Shows the exact `getproductbyvariant` response once NUTRITION_BASE is set.
//
// Takes the LIVE Fiesta response for a product, runs the same decoration the
// endpoint runs, and prints the result. Nothing here is hand-assembled: the
// product row is production's, the panel and score are the live catalogue-
// intelligence service's, and the code between them is what is deployed.
//
// go run ./scratch/nutritionlive # product 7101
// go run ./scratch/nutritionlive <tenantid> <productid>
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"nearle/models"
"nearle/services"
)
const fiesta = "https://fiesta.nearle.app/live/api/v1/mob/products/getproductbyvariant"
type envelope struct {
Code int `json:"code"`
Details []models.Products `json:"details"`
Message string `json:"message"`
Status bool `json:"status"`
}
func main() {
tenant, product := "1147", "7101"
if len(os.Args) == 3 {
tenant, product = os.Args[1], os.Args[2]
}
base := os.Getenv("NUTRITION_BASE")
if base == "" {
base = "https://mcp.nearle.ai.in/api"
}
nutrition := services.NewNutritionService(base)
url := fmt.Sprintf("%s?tenantid=%s&productid=%s&variantid=0", fiesta, tenant, product)
client := &http.Client{Timeout: 30 * time.Second}
response, err := client.Get(url)
if err != nil {
fmt.Println("fetching the live product:", err)
return
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
fmt.Println("reading the live product:", err)
return
}
var out envelope
if err := json.Unmarshal(body, &out); err != nil {
fmt.Println("parsing the live product:", err)
return
}
// The same call the endpoint makes, on the same rows.
for i := range out.Details {
found := nutrition.ForProduct(out.Details[i].Productbrand, out.Details[i].Imageid)
out.Details[i].Nutrition = found.Panel
out.Details[i].Healthscore = found.Health
}
encoded, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(encoded))
}

View File

@@ -0,0 +1,59 @@
// Proves the nutrition panel and health score end to end against the LIVE
// catalogue-intelligence service — no database, no Fiesta, just the piece that
// was built.
//
// go run ./scratch/nutritionproof # known products
// go run ./scratch/nutritionproof <brand> <image_id> # any product
//
// Prints what `getproductbyvariant` will put on the product once deployed.
package main
import (
"encoding/json"
"fmt"
"os"
"nearle/models"
"nearle/services"
)
func main() {
base := os.Getenv("NUTRITION_BASE")
if base == "" {
base = "https://mcp.nearle.ai.in/api"
}
service := services.NewNutritionService(base)
// Real products from tenant 1147, checked against the live service on
// 29 Sep 2026: one the service has scored, one it has nothing for, and one
// that is not food — which must come back with no score whatever the
// service says, because that endpoint is not gated for edibility.
products := []models.Products{
{Productid: 7101, Productbrand: "balaji", Imageid: "balaji_balaji_wafers_simply_salted_135g",
Productname: "Balaji Wafers Simply Salted 135g (scored)"},
{Productid: 7093, Productbrand: "patanjali", Imageid: "patanjali_patanjali_cow_ghee_500ml",
Productname: "Patanjali Cow Ghee 500ml (no data)"},
{Productid: 7121, Productbrand: "godrej", Imageid: "godrej_godrej_no1_sandal_and_turmeric_soap_100g",
Productname: "Godrej No.1 Soap 100g (not food)"},
}
if len(os.Args) == 3 {
products = []models.Products{{
Productbrand: os.Args[1],
Imageid: os.Args[2],
Productname: os.Args[1] + "/" + os.Args[2],
}}
}
for i := range products {
found := service.ForProduct(products[i].Productbrand, products[i].Imageid)
products[i].Nutrition = found.Panel
products[i].Healthscore = found.Health
fmt.Printf("\n---- %s ----\n", products[i].Productname)
encoded, _ := json.MarshalIndent(map[string]any{
"nutrition": products[i].Nutrition,
"healthscore": products[i].Healthscore,
}, "", " ")
fmt.Println(string(encoded))
}
}

View File

@@ -0,0 +1,267 @@
// Does the mobile-number-and-PIN sign-in actually work against the live data?
//
// Picks a supervisor and a cashier that already have both halves of the
// credential, prints them so they can be typed into a terminal, then runs the
// real repository and service — the same code path the HTTP handler calls — and
// reports what came back.
//
// Read-only. PosLogin issues SELECTs and mints a token in memory; nothing here
// writes, and the token is not persisted anywhere by design.
//
// Numbers and PINs are masked unless -show is passed. They belong to real
// people at real shops, and the default should not be to print them into
// whatever is capturing this program's output.
//
// go run ./scratch/poslivecheck # picks a ready pair, masked
// go run ./scratch/poslivecheck -show # prints the credentials
// go run ./scratch/poslivecheck -show 1087 1137 # ...at a named outlet
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"nearle/models"
"nearle/repositories"
"nearle/services"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type account struct {
Userid int
Tenantid int
Locationid int
Roleid int
Fullname string
Contactno string
Pin int64
}
func main() {
_ = godotenv.Load()
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
log.Fatal("POS_TOKEN_SECRET is not set; sign-in mints a token and will fail without it")
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
// Only accounts holding both halves are candidates. An account missing
// either cannot sign in at all, and picking one would prove nothing except
// that the rejection works.
where := `COALESCE(roleid,0) = ?
AND COALESCE(pin,0) BETWEEN 1000 AND 9999
AND TRIM(COALESCE(contactno,'')) <> ''
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
args := []interface{}{}
// -show opts into printing the credentials themselves.
show := false
rest := []string{}
for _, arg := range os.Args[1:] {
if arg == "-show" || arg == "--show" {
show = true
continue
}
rest = append(rest, arg)
}
if len(rest) > 1 {
tenantID, _ := strconv.Atoi(rest[0])
locationID, _ := strconv.Atoi(rest[1])
where += ` AND tenantid = ? AND locationid = ?`
args = append(args, tenantID, locationID)
}
pick := func(roleID int) *account {
var a account
params := append([]interface{}{roleID}, args...)
err := db.Raw(`
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(roleid,0) AS roleid,
TRIM(CONCAT(COALESCE(firstname,''),' ',COALESCE(lastname,''))) AS fullname,
COALESCE(contactno,'') AS contactno, COALESCE(pin,0) AS pin
FROM app_users WHERE `+where+` ORDER BY userid LIMIT 1`, params...).Scan(&a).Error
if err != nil || a.Userid == 0 {
return nil
}
return &a
}
supervisor := pick(models.PosRoleSupervisor)
cashier := pick(models.PosRoleCashier)
fmt.Println("Accounts that can sign in today")
fmt.Println(strings.Repeat("-", 78))
for _, pair := range []struct {
label string
a *account
}{{"supervisor", supervisor}, {"cashier", cashier}} {
a := pair.a
if a == nil {
fmt.Printf(" %-11s none — no account of this role has both a number and a PIN\n", pair.label)
continue
}
fmt.Printf(" %-11s userid %-6d tenant %-6d outlet %-6d %s\n",
pair.label, a.Userid, a.Tenantid, a.Locationid, a.Fullname)
fmt.Printf(" %-11s mobile %s PIN %s\n\n", "",
mask(a.Contactno, show), maskPin(a.Pin, show))
}
if !show {
fmt.Println(" (masked — re-run with -show to print them)")
}
if supervisor == nil && cashier == nil {
log.Fatal("nothing to test with")
}
repo := repositories.NewPosRepository(db)
svc := services.NewPosService(repo, nil)
fmt.Println("\nSigning in (real service, live data)")
fmt.Println(strings.Repeat("-", 78))
pass, fail := 0, 0
check := func(name string, ok bool, detail string) {
if ok {
pass++
fmt.Printf(" PASS %-46s %s\n", name, detail)
return
}
fail++
fmt.Printf(" FAIL %-46s %s\n", name, detail)
}
signIn := func(label string, a *account) *models.PosSession {
if a == nil {
return nil
}
session, err := svc.Login(models.PosLoginRequest{
Contactno: a.Contactno,
Pin: strconv.FormatInt(a.Pin, 10),
})
if err != nil {
check(label+" signs in", false, err.Error())
return nil
}
check(label+" signs in", true, fmt.Sprintf(
"%s at %s (outlet %d), can_manage_staff=%v",
session.Role, session.Locationname, session.Locationid, session.Canmanagestaff))
check(label+" gets a token", session.Token != "",
fmt.Sprintf("%d chars, expires %s", len(session.Token), session.Expiresat))
return session
}
supSession := signIn("supervisor", supervisor)
cashSession := signIn("cashier", cashier)
if supSession != nil {
check("supervisor can manage staff", supSession.Canmanagestaff, "role 7 grants it")
}
if cashSession != nil {
check("cashier cannot manage staff", !cashSession.Canmanagestaff, "role 8 does not")
}
// The response must not carry anyone's PIN — the whole point of the change
// that removed staff[].pin.
//
// Checked against the serialised JSON, not the Go struct. PosStaffMember.Pin
// is still *populated* — the query needs it to drop two people sharing a PIN
// — and is kept off the wire by `json:"-"`. Asserting on the struct field
// tests the wrong layer and fails a correct implementation.
if supSession != nil {
encoded, merr := json.Marshal(supSession)
check("session serialises", merr == nil, errText(merr))
if merr == nil {
check("no PIN travels in the session", !strings.Contains(string(encoded), `"pin"`),
fmt.Sprintf("%d staff listed, %d bytes of JSON", len(supSession.Staff), len(encoded)))
}
}
// A number typed the way a person actually types it.
if supervisor != nil && len(supervisor.Contactno) == 10 {
for label, typed := range map[string]string{
"+91 with spaces": "+91 " + supervisor.Contactno[:5] + " " + supervisor.Contactno[5:],
"leading zero": "0" + supervisor.Contactno,
"bare ten digits": supervisor.Contactno,
} {
_, err := svc.Login(models.PosLoginRequest{
Contactno: typed, Pin: strconv.FormatInt(supervisor.Pin, 10),
})
check("number accepted as typed", err == nil, label)
}
}
// And the refusals.
if supervisor != nil {
wrong := supervisor.Pin + 1
if wrong > 9999 {
wrong = 1000
}
_, err := svc.Login(models.PosLoginRequest{
Contactno: supervisor.Contactno, Pin: strconv.FormatInt(wrong, 10),
})
check("a wrong PIN is refused", err != nil, errText(err))
}
_, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"})
check("an unknown number is refused", err != nil, errText(err))
// Switching operator at an open terminal, which is what the till does when
// a colleague takes over.
if supSession != nil && cashier != nil && cashier.Locationid == supSession.Locationid {
switched, err := repo.PosLoginByPin(supSession.Tenantid, supSession.Locationid,
strconv.FormatInt(cashier.Pin, 10))
if err != nil {
check("operator switch by PIN", false, err.Error())
} else {
check("operator switch by PIN", true,
fmt.Sprintf("now %s, can_manage_staff=%v", switched.Role, switched.Canmanagestaff))
}
}
fmt.Printf("\n%d passed, %d failed\n", pass, fail)
if fail > 0 {
os.Exit(1)
}
}
// mask shows enough of a number to recognise the account, not enough to sign in
// as it.
func mask(number string, show bool) string {
if show {
return number
}
if len(number) != 10 {
return strings.Repeat("*", len(number))
}
return number[:2] + "******" + number[8:]
}
func maskPin(pin int64, show bool) string {
if show {
return strconv.FormatInt(pin, 10)
}
return "****"
}
func errText(err error) string {
if err == nil {
return "no error"
}
return err.Error()
}

View File

@@ -0,0 +1,166 @@
// Can the accounts that may open a till actually complete the new sign-in?
//
// Read-only, and deliberately prints no numbers and no PINs — only whether each
// account has one and whether the login would find it.
//
// The question this answers is narrower than "does it have a number". The login
// matches `LOWER(TRIM(contactno)) = LOWER(TRIM($1))` where $1 has already been
// reduced to ten digits, so the comparison is exact: a row holding
// "+91 98765 43210" is invisible to somebody typing the same number, because
// only one side of the comparison gets normalised.
//
// go run ./scratch/posloginready
package main
import (
"fmt"
"log"
"os"
"strings"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type row struct {
Userid int
Tenantid int
Locationid int
Roleid int
Contactno string
Pin int64
Status string
}
// normalise mirrors repositories.normalisePosPhone, which is unexported.
func normalise(raw string) string {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, raw)
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
digits = digits[2:]
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
digits = digits[1:]
}
if len(digits) != 10 {
return ""
}
return digits
}
func main() {
_ = godotenv.Load()
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
var rows []row
if err := db.Raw(`
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(roleid,0) AS roleid, COALESCE(contactno,'') AS contactno,
COALESCE(pin,0) AS pin, COALESCE(status,'') AS status
FROM app_users
WHERE COALESCE(roleid,0) IN (?, ?)
ORDER BY tenantid, locationid, userid`,
models.PosRoleSupervisor, models.PosRoleCashier).Scan(&rows).Error; err != nil {
log.Fatal(err)
}
// What the login would see. Only active till accounts are candidates, and a
// number matching more than one of them is refused outright.
byPhone := map[string][]int{}
for _, r := range rows {
if strings.EqualFold(strings.TrimSpace(r.Status), "inactive") {
continue
}
if stored := strings.TrimSpace(r.Contactno); stored != "" {
byPhone[strings.ToLower(stored)] = append(byPhone[strings.ToLower(stored)], r.Userid)
}
}
fmt.Printf("till accounts (roleid %d/%d): %d\n\n", models.PosRoleSupervisor, models.PosRoleCashier, len(rows))
fmt.Printf("%-8s %-8s %-9s %-5s %-9s %-8s %-7s %s\n",
"userid", "tenant", "location", "role", "status", "number", "pin", "can sign in?")
fmt.Println(strings.Repeat("-", 86))
var ready, noPhone, unnormalised, noPin, ambiguous, inactive int
for _, r := range rows {
stored := strings.TrimSpace(r.Contactno)
norm := normalise(stored)
phoneState := "missing"
switch {
case stored == "":
phoneState = "missing"
case norm == "":
phoneState = "unusable"
case norm != stored:
phoneState = "STORED RAW"
default:
phoneState = "ok"
}
pinState := "missing"
if r.Pin >= 1000 && r.Pin <= 9999 {
pinState = "ok"
} else if r.Pin != 0 {
pinState = "unusable"
}
verdict := "yes"
switch {
case strings.EqualFold(r.Status, "inactive"):
verdict, inactive = "no — inactive", inactive+1
case stored == "":
verdict, noPhone = "no — no number", noPhone+1
case norm == "":
verdict, noPhone = "no — number unusable", noPhone+1
case norm != stored:
// The one that looks fine in the console and fails at the counter.
verdict, unnormalised = "NO — number stored unnormalised", unnormalised+1
case pinState != "ok":
verdict, noPin = "no — no usable PIN", noPin+1
case len(byPhone[strings.ToLower(stored)]) > 1:
verdict, ambiguous = "NO — number shared with another till account", ambiguous+1
default:
ready++
}
fmt.Printf("%-8d %-8d %-9d %-5d %-9s %-8s %-7s %s\n",
r.Userid, r.Tenantid, r.Locationid, r.Roleid, r.Status, phoneState, pinState, verdict)
}
fmt.Printf("\nready to sign in with number + PIN : %d of %d\n", ready, len(rows))
fmt.Printf(" blocked, no/unusable number : %d\n", noPhone)
fmt.Printf(" blocked, number stored raw : %d\n", unnormalised)
fmt.Printf(" blocked, no usable PIN : %d\n", noPin)
fmt.Printf(" blocked, number not unique : %d\n", ambiguous)
fmt.Printf(" inactive : %d\n", inactive)
// Cross-tenant collisions are the failure creation cannot prevent:
// posPhoneTaken scopes uniqueness to one tenant, the login does not scope at
// all, so two tenants may each hold a number that neither can then use.
fmt.Println("\nnumbers shared by more than one active till account:")
found := false
for _, users := range byPhone {
if len(users) > 1 {
found = true
fmt.Printf(" userids %v — all refused with \"more than one account uses these sign-in details\"\n", users)
}
}
if !found {
fmt.Println(" none")
}
}

View File

@@ -137,15 +137,11 @@ func main() {
} }
fmt.Println("\n3. a till account cannot reach the Nearle Daily application") fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid) // Both the app and the console sign-in now go through GetUserLogin.
check("applogin lookup does not find the supervisor", uid2, _, _, _, lookupErr := users.GetUserLogin("authname", sup.Authname, sup.Configid)
uid == 0, check("app and tenant web login do not find the supervisor",
fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid)) uid2 == 0 && lookupErr == nil,
fmt.Sprintf("GetUserLogin(%s) -> userid %d err=%v", sup.Authname, uid2, lookupErr))
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))
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid) uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
check("password-setup lookup does not find the supervisor", check("password-setup lookup does not find the supervisor",

View File

@@ -27,9 +27,8 @@ import (
"log" "log"
"strings" "strings"
"nearle/config"
"nearle/db" "nearle/db"
"github.com/joho/godotenv"
) )
type row struct { type row struct {
@@ -44,8 +43,7 @@ func main() {
tenant := flag.Int("tenant", 0, "restrict to one tenant") tenant := flag.Int("tenant", 0, "restrict to one tenant")
flag.Parse() flag.Parse()
_ = godotenv.Load() db.Connect(config.MustLoad())
db.Connect()
if db.ImageStore == nil { if db.ImageStore == nil {
log.Fatal("the image store is not configured (USE_S3 / S3_*), so there is nothing to list images from") log.Fatal("the image store is not configured (USE_S3 / S3_*), so there is nothing to list images from")

229
services/agents.go Normal file
View File

@@ -0,0 +1,229 @@
package services
import (
"embed"
"fmt"
"io/fs"
"os"
"path"
"sort"
"strings"
"nearle/utils"
"gopkg.in/yaml.v3"
)
// Agents, as configuration.
//
// An agent is a document, not a class: a name, a tier, some words about its job,
// and a list of tools it may use. Adding one is a file. Changing what an agent
// can reach is an edit, not a deploy of new code — and crucially, nothing about
// the loop changes when either happens, so five agents cannot drift into five
// slightly different behaviours.
//
// ── Embedded, with a disk override ──────────────────────────────────────────
//
// The defaults are compiled in, so a binary always has working agents and a
// deployment cannot be broken by a missing directory. `ASSISTANT_AGENTS_DIR`
// replaces them wholesale — not merges, which would leave a deployment guessing
// which half of an agent it was running.
//
// ── Why the tool names are checked at startup ───────────────────────────────
//
// A typo in a tool name is invisible at runtime: the agent simply never calls
// that tool, the model explains it cannot look something up, and everything
// reports healthy. Refusing to start is the loud version of the same fact.
//go:embed agents/*.yaml
var embeddedAgents embed.FS
// basePrompt is the part every agent shares.
//
// In Go rather than repeated in each file, because it is about how this system
// works rather than about any one domain — and three copies of "say when a
// result was truncated" is three chances for one of them to lose it.
//
// Each agent's own `system` is appended, and says what that agent is for.
const basePrompt = `You are Nearle Buddy, helping a shopkeeper run their business from the Nearle console.
Answer from tool results and nothing else. Every number you state must have come
from a tool in this conversation. If no tool can answer, say so plainly and say
what you would need — never estimate, and never fill a gap from general knowledge
about retail.
When a tool returns no rows, that is an answer: say there are none. Do not
describe an empty result as a problem with the system.
When a result says it was truncated, say so in your reply. Do not describe a
capped list as the full picture.
When a tool refuses, tell the person what it said. A refusal usually names
something they can do — pick a branch, for instance.
Say what your answer covers — one branch or all of them — using the scope the
tool reports.
Be brief. A shopkeeper reading this is mid-shift: lead with the answer, then the
detail that makes it actionable. No preamble, no restating the question.`
// agentFile is one agent on disk.
type agentFile struct {
Name string `yaml:"name"`
Tier string `yaml:"tier"`
System string `yaml:"system"`
Tools []string `yaml:"tools"`
MaxSteps int `yaml:"max_steps"`
MaxToolCalls int `yaml:"max_tool_calls"`
}
// Sensible bounds for an agent that does not name its own.
const (
defaultMaxSteps = 4
defaultMaxToolCalls = 6
)
// LoadAgents reads the agent definitions.
//
// `dir` empty uses the embedded defaults. `known` reports whether a tool name
// exists — passed in rather than imported, so this does not depend on the
// registry and can be tested without one.
func LoadAgents(dir string, known func(string) bool) (map[string]Agent, error) {
files, err := readAgentFiles(dir)
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, fmt.Errorf("no agent definitions found in %q", dir)
}
agents := make(map[string]Agent, len(files))
for _, file := range files {
agent, err := file.build(known)
if err != nil {
return nil, err
}
if _, taken := agents[agent.Name]; taken {
// Two files claiming one name means one of them is being ignored,
// and which one depends on directory order.
return nil, fmt.Errorf("two agents are called %q", agent.Name)
}
agents[agent.Name] = agent
}
return agents, nil
}
func readAgentFiles(dir string) ([]agentFile, error) {
var (
entries []fs.DirEntry
read func(string) ([]byte, error)
err error
where string
)
if strings.TrimSpace(dir) == "" {
where = "agents"
entries, err = embeddedAgents.ReadDir(where)
read = func(name string) ([]byte, error) { return embeddedAgents.ReadFile(path.Join(where, name)) }
} else {
where = dir
entries, err = os.ReadDir(where)
read = func(name string) ([]byte, error) { return os.ReadFile(path.Join(where, name)) }
}
if err != nil {
return nil, fmt.Errorf("reading agent definitions from %q: %w", where, err)
}
// Sorted, so a duplicate name is reported against the same file every time
// rather than whichever the filesystem happened to hand back first.
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
continue
}
names = append(names, entry.Name())
}
sort.Strings(names)
files := make([]agentFile, 0, len(names))
for _, name := range names {
raw, err := read(name)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", name, err)
}
var file agentFile
if err := yaml.Unmarshal(raw, &file); err != nil {
return nil, fmt.Errorf("%s is not valid YAML: %w", name, err)
}
if file.Name == "" {
// Named by its contents, not its filename: a file renamed on deploy
// would otherwise silently become a different agent.
return nil, fmt.Errorf("%s does not name its agent", name)
}
files = append(files, file)
}
return files, nil
}
// build turns a file into an agent, refusing anything that would fail quietly.
func (f agentFile) build(known func(string) bool) (Agent, error) {
if len(f.Tools) == 0 {
// An agent with no tools can only answer from the prompt, which is the
// one thing this assistant is built not to do.
return Agent{}, fmt.Errorf("agent %q has no tools", f.Name)
}
for _, tool := range f.Tools {
if known != nil && !known(tool) {
return Agent{}, fmt.Errorf("agent %q lists a tool that does not exist: %q", f.Name, tool)
}
}
tier := strings.ToLower(strings.TrimSpace(f.Tier))
switch tier {
case utils.TierFast, utils.TierBalanced, utils.TierDeep:
case "":
tier = utils.TierBalanced
default:
// A tier nobody recognises would silently resolve to the balanced model
// via the gateway's fallback, so a deep agent could quietly run on the
// cheap one for months.
return Agent{}, fmt.Errorf("agent %q names an unknown tier %q", f.Name, f.Tier)
}
steps := f.MaxSteps
if steps <= 0 {
steps = defaultMaxSteps
}
calls := f.MaxToolCalls
if calls <= 0 {
calls = defaultMaxToolCalls
}
system := basePrompt
if extra := strings.TrimSpace(f.System); extra != "" {
system += "\n\n" + extra
}
return Agent{
Name: f.Name,
Tier: tier,
System: system,
Tools: f.Tools,
MaxSteps: steps,
MaxToolCalls: calls,
}, nil
}
// AgentNames lists what is loaded, for a startup log.
//
// Sorted, so two deployments running the same config log the same line and a
// diff between them means something.
func AgentNames(agents map[string]Agent) []string {
names := make([]string, 0, len(agents))
for name := range agents {
names = append(names, name)
}
sort.Strings(names)
return names
}

View File

@@ -0,0 +1,32 @@
# The Console overview.
#
# Deliberately the widest allow-list here, because the page it serves is a
# dashboard: "what needs attention across my business today?" legitimately
# spans orders, stock and tills, and an agent narrower than the page would
# leave chips on screen that answer "I cannot look that up".
#
# It does NOT get every tool. `stuck_orders` and `sales_by_channel` belong
# to the Sales page, where somebody is already looking at orders — a
# dashboard question does not need the individual jobs, it needs the count.
name: console
tier: balanced
system: |
You answer overview questions about a whole business — every branch, the
shelves, and the tills.
Lead with what needs a person today and leave out what is running normally.
This is the first thing somebody reads in the morning, so an answer listing
four healthy things and one problem has buried the only part that matters.
When several things need attention, order them by what costs money soonest:
a till that cannot sell, then stock that has run out, then approvals waiting,
then deliveries that have stalled.
tools:
- branch_performance
- delivery_progress
- low_stock
- pending_approvals
- till_status
- help

Some files were not shown because too many files have changed in this diff Show More