Compare commits

101 Commits

Author SHA1 Message Date
f6d5c7c87b status fix 2026-10-09 17:22:27 +05:30
f0361d8343 status updated 2026-10-09 16:27:51 +05:30
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
be47435547 env 2026-09-15 12:49:34 +05:30
1b2cc77063 merchant login app 2026-09-11 11:23:07 +05:30
8cc567c89b daily merchant app login fix 2026-09-11 11:10:25 +05:30
e7577fe0cf rider partner 2026-09-09 15:44:27 +05:30
62a441d798 profile name change 2026-09-08 19:21:23 +05:30
3853b84c9f profile 2026-09-08 19:02:15 +05:30
d9cad2bbe4 Timing 2026-09-08 15:41:31 +05:30
7d9e628fd0 app sbucategories 2026-09-08 11:49:34 +05:30
e87f09c4f2 console categories 2026-09-07 17:53:35 +05:30
c699a400c3 product categories 2026-09-07 17:28:38 +05:30
f1b3a5eba4 deliveries 2026-09-05 15:02:23 +05:30
593b11f1b8 rider creation 2026-09-04 16:02:47 +05:30
e51ad615ed assign 2026-09-04 11:32:12 +05:30
fb5d7da81f deliveries 2026-09-03 19:12:03 +05:30
18a0f9b34d pricing 2026-09-03 16:23:29 +05:30
0d17f46fcf pricing in app 2026-09-03 15:44:09 +05:30
7ed821e8b0 pricing 2026-09-03 11:21:12 +05:30
da0e9d987e variants as a single product 2026-09-03 10:43:05 +05:30
2401158190 bulk request and approve 2026-09-02 16:50:37 +05:30
e45da9f7df variants units 2026-09-02 16:01:24 +05:30
09efc5403f orders 2026-09-02 15:37:59 +05:30
68871cb043 product variant id 2026-09-02 13:11:01 +05:30
9f9b05ed1c tenant profile 2026-09-02 12:30:05 +05:30
c0d39e550d changes 2026-09-02 10:49:46 +05:30
1e3fe88e87 user accounts 2026-09-01 13:53:22 +05:30
6b27448ff9 role id 2026-09-01 13:43:05 +05:30
ab74e70ba5 login bug 2026-09-01 13:17:52 +05:30
485f31239d guide changes 2026-09-01 12:01:43 +05:30
ae6162a632 changes 2026-08-31 19:00:35 +05:30
d8cfe2e87b store filter 2026-08-31 16:39:20 +05:30
bc10b589cd fix on stocks on store catalogue 2026-08-31 16:26:06 +05:30
d55f101834 fix on shelf 2026-08-31 15:51:36 +05:30
4bce5ac854 product on shelf 2026-08-31 14:59:48 +05:30
ffc66462fc changes on agent 2026-08-31 12:33:10 +05:30
76554d26e5 bugs on variant id 2026-08-29 16:45:47 +05:30
c5109bf216 catalogue images 2026-08-29 11:29:15 +05:30
fb4fcaee66 catalogue brands 2026-08-29 10:58:01 +05:30
7c5be9b5cf bugs fixed 2026-08-28 18:19:52 +05:30
40500f936a variant and price 2026-08-28 16:54:55 +05:30
6c1ad472f2 changes according to the test 2026-08-28 13:19:13 +05:30
c31f3aca27 changes with app variant 2026-08-28 13:02:03 +05:30
aef105979a changes with app product stock 2026-08-28 12:43:07 +05:30
46da26c452 changes 2026-08-28 11:56:57 +05:30
edb9c2803f lat and long 2026-08-27 13:59:16 +05:30
dc9c049bb9 live stock update 2026-08-27 11:17:17 +05:30
Suriya
ca846f9cd6 require a mobile number when creating a till account
A till signs in with a mobile number and a PIN, but createposuser still
accepted an account without a number. Such an account cannot reach the
sign-in screen at all, and the failure surfaces at a counter in front of
a queue rather than at the point of creation. Enforced in CreatePosUser,
so both doors are covered: POST /pos/users from the terminal and
createposuser from the console share that path.

Two checks, not one. normalisePosPhone answers ("", nil) rather than an
error for a value holding no digits, so "abc" would have passed an
emptiness check, then been written as a blank and skipped the uniqueness
check below it — which is the hole this closes.

Scope is new accounts only. The column stays nullable and UpdatePosUser
still reads an empty contactno as "leave alone", so the accounts that
predate the number keep working through the backfill and cannot have
theirs cleared. The PIN stays optional at creation.

Also in this change:

- docs: correct both phone-login handovers, which claimed creation
  already required a number. The sequencing note in the PIN handover
  said step 2 was a backfill that could never be finished; it now is
  one, and POS_LOGIN.md says which half of the pair creation enforces.

- docs: remove credentials from the examples. POS_PHONE_LOGIN_HANDOVER
  carried a real-looking back-office pair and a generated till password,
  and POS_LOGIN.md a second one.

- posController.Staff: the comment justified scoping by token because
  "the answer carries PINs". It has not since the PIN left the wire. The
  scoping is still right for a different reason, which the comment now
  gives.

- scratch/posstaffsetup: takes both mobile numbers as arguments and
  refuses to run without them. Generating stand-ins would have produced
  exactly what this change prevents. Validated before the database is
  opened, in plan mode too, so a dry run cannot print a plan that apply
  would reject halfway through and leave half a shop set up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:26:41 +05:30
d566ca5591 pos login with the ph number and pin 2026-08-12 17:10:27 +05:30
ff72af9a8a changes fixed in the catalogue 2026-08-11 17:10:21 +05:30
fba42259ea fix in the product import 2026-08-11 15:49:04 +05:30
c179e4d642 product images added to the catalogue 2026-08-11 15:05:27 +05:30
d35caf34d3 pos login edited with phone number 2026-08-11 11:08:43 +05:30
3531c656d4 fix in the catlogue 2026-08-10 15:36:39 +05:30
Suriya
5c04d40f0e Scope /health/terminal to the caller's tenant, and backfill the blank codes
The outlet check could not go in the middleware with the others: it scopes a
request by the location it names, and this route names only a terminal code —
free text minted at the till. The outlet is not known until after the lookup,
so the check happens in the handler, reading the outlet off the heartbeat
itself rather than off the request.

Guessing "T4A9" now answers 403 instead of another shop's pending bills,
takings so far today, and app version. Silent when no token is presented, in
step with middleware.PosAuth: while POS_AUTH_REQUIRED is off, real tills are
still calling this unauthenticated and refusing them would blank the fleet
board for exactly the terminals it watches.

scratch/termbackfill repairs the rows left behind by the posTerminalFor bug.
The code was never lost — it is the third segment of the invoice number the
till printed in the same transaction — so this derives rather than guesses,
and refuses to write a code that outlet has never filed a bill under. 39 of
40 recovered; the holdout is a synthetic proof bill whose only sibling is
itself, and unverifiable codes stay blank rather than becoming plausible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:22:15 +05:30
Suriya
f7895d3ccf Correct what /posroles tells a console about each role
The supervisor blurb still said "Also signs into the app", which was true when it
was written and is now the opposite of true: a till account has no Nearle Daily
login at all. A console showing that text would be telling a store admin the one
thing about these roles they most need not to believe.

The cashier blurb said "Billing only", which understated it in the other
direction — a cashier now has their own username and password, so a shop can
open without a supervisor standing there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:14:10 +05:30
Suriya
9e9401215d Give a cashier their own till login, not just a PIN behind a supervisor
A PIN cannot open a closed terminal. The PIN route needs a session that already
exists, so a PIN-only account works only while somebody else is standing there
to unlock the till first. For a supervisor that was an outright deadlock and was
fixed last commit. For a cashier it is subtler and just as wrong: the shop
cannot open until two people have arrived, and whoever gets in at seven is as
often the cashier as the supervisor.

So every till account now gets a username and a password, and the role decides
the shell rather than the credential deciding it. A cashier signs in exactly the
way a supervisor does and is still held to billing only, because that comes from
roleid 8 and not from how they got in.

The earlier reasoning — that a second password is one more credential to leak
for no capability gained — was measuring the wrong thing. It counted the cost of
the credential and not the cost of the shop that cannot open without one.

CreatePosUser generates both when the request omits them, so provisioning is one
call per person and nobody has to invent a naming scheme. An explicit value
always wins. A generated name that collides walks to the next free one, because
a second cashier at one counter is ordinary rather than an error; a name the
caller supplied is refused instead, because silently signing somebody in as
another person's address is worse than a message. Uniqueness is checked against
authname and email together, since the insert writes the same value to both and
app_users_email_unique would otherwise fail the transaction rather than return
something anyone can act on.

The password comes back exactly once, in the creation response. Listing till
users still reports only has_password, so an admin who loses it reissues rather
than looks it up — the right shape even while the column behind it is plaintext.

The domain is deliberately unroutable. These are till credentials, never a
mailbox, and an address that looks deliverable invites somebody to try sending a
reset to it.

Verified against live rows by scratch/posseparation, which now checks the
cashier path too: cashier.1185@pos.nearle.in opens a closed terminal alone and
comes back can_manage_staff=false. All five outlets that stock products have
both accounts, each proved by an actual sign-in.

Also drops a stray `print(queryBuilder.String())` from GetAllUsers, which was
writing the whole SQL statement to stderr on every call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:04:24 +05:30
Suriya
c0a7fbc1b1 Stop the till and Nearle Daily from sharing accounts
app_users is the only thing the two products have in common, and the code was
treating it as though it were the whole relationship. Both directions leaked.

Back-office roles were leaking into the till. PosRoleCanManageStaff returned
true for roleid 1 to 6, on the reasoning that somebody who already administers a
shop from a browser is not made less privileged by standing at the counter. That
sounds fine and is wrong: measured against live data it handed till-supervisor
powers to 68 accounts, 59 of them Nearle Daily Super admins, not one of whom is
the administrator of anybody's POS. Meanwhile the actual shop accounts carry
roleid 0 and were refused, so the mapping was backwards from intent in both
halves at once.

Till accounts were leaking into the application. GetStaffs is WHERE tenantid
with no role filter, so a Counter Cashier appeared in the tenant staff list
beside the delivery riders — a row every action on that page would fail against,
since a cashier has no app login, no rider shift and no back-office screen.

So: eligibility for a till is now granted explicitly by provisioning a
Supervisor or a Cashier, never inherited from a back-office role, and roles 7
and 8 are excluded from every Nearle Daily lookup. The exclusion lives in the
queries rather than in a check after them, because a check bolted on afterwards
has to be repeated at six call sites and is one edit away from being forgotten
at one of them — and that one would be the hole. A till account is not rejected
by the app login; it is not found.

Two things this surfaced that were not visible before.

A Supervisor could not open a till. PIN sign-in needs a session that already
exists, so once back-office roles were refused, an outlet whose only POS
accounts were PIN-only had no way in at all. Supervisors are now provisioned
with a username and password as well as a PIN; cashiers deliberately get neither,
because they sign on at a counter somebody has already opened and a second
password would be one more credential to leak for no capability gained.

UpdatePosUser silently dropped authname. It wrote the password, reported
success, and left the account unreachable by either lookup — the failure
surfaced at a counter as "not recognised" rather than on the screen that caused
it. Contactno had the same gap.

Verified against live rows rather than asserted, by scratch/posseparation: a
provisioned supervisor signs in and gets the supervisor shell; five real
back-office accounts including Super admins are refused; the supervisor is
invisible to applogin, tenant weblogin and the password-setup lookup; and no
till account appears in getallusers, while asking for role 7 by name still
returns them so the console can read its own people.

All five outlets that stock products now have a Supervisor and a Cashier.

Also moves the loose markdown into docs/, which was already staged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:52:23 +05:30
Suriya
f5e16b54cc Add a read-only report of who can actually open a till at an outlet
Answers the question a shop asks on day one and nothing in the product could:
what do I type into the terminal. It separates the two credentials because they
are not interchangeable — a password opens a closed terminal and the account
decides which shell it opens, a PIN only switches operator on one already open —
and it prints the shell each account would land in rather than the raw roleid,
since roleid 0 is not in app_roles and reads as nothing at all.

`top` ranks outlets by products actually stocked *and* filters to ones somebody
can sign in to. That filter is the point: the best-stocked outlets on the
platform — Dilse at 471 products, Ninhao at 286 — have no account with a
password, so a demo pointed at either opens a till nobody can unlock.

Counts products through productlocations rather than per tenant, because a
tenant with a full catalogue can still have an outlet stocking none of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:26:10 +05:30
256 changed files with 42289 additions and 1412 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

94
.env Normal file
View File

@@ -0,0 +1,94 @@
# Fiesta configuration — the shared base file.
#
# Loaded AFTER `.env.<APP_ENV>` (see config/config.go), and godotenv never
# overwrites a value that is already set, so anything here is a fallback for
# what the environment file and the real environment leave unset. Keep it
# local: with APP_ENV unset this is loaded straight after `.env.local`, and a
# production value here would silently reach a local run.
#
# go run . → .env.local, then this file
# APP_ENV=production go run . → .env.production, then this file
#
# The full list of settings, with what each one does, is in `.env.example`.
# ── Where it listens ────────────────────────────────────────────────────────
# 1122 locally; production serves on 1009. Change it to run a second copy
# beside something else; the console then points at the same number.
APP_PORT=1122
ENV=development
# ── The main database (nearledb) ────────────────────────────────────────────
#
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
#
# There is no "local mode" that protects you: `go run .` against the live host
# creates real tenants, real logins and real stock movements, and main.go runs
# schema migrations on boot. If the point of running locally is to try a change
# before it is deployed, a local Postgres with a dump restored into it is the
# only version that actually does that.
# These match docker-compose.local.yml, so `docker compose -f
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
DB_HOST=localhost
DB_PORT=5433
DB_NAME=nearledb
DB_USER=nearle
DB_PASSWORD=localdev
# ── The catalogue database (pgvector) ───────────────────────────────────────
#
# A separate connection on purpose, so catalogue work never touches nearledb.
# Leave blank to start without it: catalogue endpoints then fail at query time
# rather than at boot, which is fine for testing anything else.
# 5434, not 5432: a developer machine usually has something on 5432 already,
# and a silent connection to the wrong database is worse than a refused one.
CATALOGUE_DB_HOST=localhost
CATALOGUE_DB_PORT=5434
CATALOGUE_DB_NAME=cataloguedb
CATALOGUE_DB_USER=nearle
CATALOGUE_DB_PASSWORD=localdev
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
#
# Optional. Losing the health board is an inconvenience; losing a sale is not,
# so the API runs without it.
# Set to enable POS terminal presence. The local compose publishes redis on
# 6379, so localhost is all it needs; leave blank to run without it.
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=
REDIS_DB=0
# ── Auth ────────────────────────────────────────────────────────────────────
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
# sign in against the local stack; production sets its own in the platform.
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
JWT_SECRET_KEY=
USER_CONTEXT_KEY=
# ── 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

215
.env.example Normal file
View File

@@ -0,0 +1,215 @@
# Fiesta configuration — the complete list of settings, with local values.
#
# How the files are picked (config/config.go):
#
# APP_ENV unset / local → .env.local then .env
# APP_ENV=production → .env.production then .env
#
# A real environment variable always wins over a file, and no file has to
# exist: on the deployed host the values come from the platform's environment
# settings (Dokploy / Kubernetes), not from a file. Anything added here must be
# added there too — a variable in this file and not in the platform is a
# variable that is unset in production.
#
# Startup checks every required setting and prints everything that is missing
# in one go, before any connection is attempted.
#
# The credentials in the committed .env.production have to be treated as
# public: rotate them, and keep new values out of git.
# ── Environment ─────────────────────────────────────────────────────────────
# local | production. Production requires POS_TOKEN_SECRET and never falls
# back to localhost defaults. Set in the real environment, not in a file: a
# file cannot decide which file gets loaded.
#APP_ENV=local
# ── Where it listens ────────────────────────────────────────────────────────
# 1122 locally; production serves on 1009 (matching the Dockerfile's EXPOSE).
APP_PORT=1122
# ── The main database (nearledb) ────────────────────────────────────────────
#
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
#
# There is no "local mode" that protects you: `go run .` against the live host
# creates real tenants, real logins and real stock movements, and main.go runs
# schema migrations on boot. Startup warns when APP_ENV=local and DB_HOST is
# not a local address, but it does not stop you.
#
# These match docker-compose.local.yml, so `docker compose -f
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
DB_HOST=localhost
DB_PORT=5433
DB_NAME=nearledb
DB_USER=nearle
DB_PASSWORD=localdev
# ── The catalogue database (pgvector) ───────────────────────────────────────
#
# A separate connection on purpose, so catalogue work never touches nearledb.
# Leave CATALOGUE_DB_HOST blank to start without it: catalogue endpoints then
# fail at query time rather than at boot. With a host set, the other four are
# required. 5434, not 5432: a developer machine usually has something on 5432.
CATALOGUE_DB_HOST=localhost
CATALOGUE_DB_PORT=5434
CATALOGUE_DB_NAME=cataloguedb
CATALOGUE_DB_USER=nearle
CATALOGUE_DB_PASSWORD=localdev
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
#
# Optional: leave REDIS_HOST blank to run without it. Losing the health board
# is an inconvenience; losing a sale is not, so the API runs without it.
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=default
REDIS_PASSWORD=
REDIS_DB=0
# ── DigitalOcean Spaces (S3-compatible) — catalogue product images ──────────
#
# Optional locally. With USE_S3=true every S3_* value below is required.
USE_S3=false
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_ENDPOINT=
S3_BUCKET=
S3_REGION=
# ── POS terminals — the MQTT broker the in-store tills publish to ───────────
#
# Optional locally: with MQTT_URL blank the ingest and the console live stream
# stay quiet and the HTTP endpoints still work. In production a blank MQTT_URL
# means every till queues its bills silently — on startup you should see
# three lines reading "pos: subscribed to nearle/pos/+/+/...".
MQTT_URL=
MQTT_USER=
MQTT_PASSWORD=
# Unique per replica: a second connection with the same id evicts the first.
# Defaults to HOSTNAME (the pod name) when unset.
MQTT_CLIENT_ID=
# always | never — otherwise a StatefulSet pod ending in "-0" is elected and
# anything else consumes. See messaging/posmqtt.go.
#POS_MQTT_CONSUMER=
# ── Auth ────────────────────────────────────────────────────────────────────
# Signs POS terminal sessions; at least 16 characters. Falls back to
# JWT_SECRET_KEY when unset. Required in production.
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
JWT_SECRET_KEY=
USER_CONTEXT_KEY=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=

85
.env.local Normal file
View File

@@ -0,0 +1,85 @@
# Fiesta — LOCAL configuration (docker-compose.local.yml).
#
# This is the file you get by default: APP_ENV unset means `.env.local`.
# Production values live in `.env.production` and are only loaded by asking:
# APP_ENV=production ./nearle
#
# 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.
#
# `config.Load()` reads `.env.$APP_ENV` and then `.env` from the working
# directory, so `go run .` from this folder picks this file up with no flags.
# A real environment variable always wins over either file. The full list of
# settings is in `.env.example`.
# ── Where it listens ────────────────────────────────────────────────────────
# Production serves on 1009 (see .env.production). Change this locally to run
# beside something else; the console then points at the same number.
APP_PORT=1122
ENV=development
# ── The main database (nearledb) ────────────────────────────────────────────
#
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
#
# There is no "local mode" that protects you: `go run .` against the live host
# creates real tenants, real logins and real stock movements, and main.go runs
# schema migrations on boot. If the point of running locally is to try a change
# before it is deployed, a local Postgres with a dump restored into it is the
# only version that actually does that.
# These match docker-compose.local.yml, so `docker compose -f
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
DB_HOST=localhost
DB_PORT=5433
DB_NAME=nearledb
DB_USER=nearle
DB_PASSWORD=localdev
# ── The catalogue database (pgvector) ───────────────────────────────────────
#
# A separate connection on purpose, so catalogue work never touches nearledb.
# Leave blank to start without it: catalogue endpoints then fail at query time
# rather than at boot, which is fine for testing anything else.
# 5434, not 5432: a developer machine usually has something on 5432 already,
# and a silent connection to the wrong database is worse than a refused one.
CATALOGUE_DB_HOST=localhost
CATALOGUE_DB_PORT=5434
CATALOGUE_DB_NAME=cataloguedb
CATALOGUE_DB_USER=nearle
CATALOGUE_DB_PASSWORD=localdev
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
#
# Optional. Losing the health board is an inconvenience; losing a sale is not,
# so the API runs without it.
# Set to enable POS terminal presence. The local compose publishes redis on
# 6379, so localhost is all it needs; leave blank to run without it.
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=
REDIS_DB=0
# ── Auth ────────────────────────────────────────────────────────────────────
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
# sign in against the local stack; production sets its own in the platform.
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
JWT_SECRET_KEY=
USER_CONTEXT_KEY=
# ── 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

84
.env.production Normal file
View File

@@ -0,0 +1,84 @@
# Fiesta — PRODUCTION configuration.
#
# NOT loaded by default, on purpose. `go run .` and `./nearle` with no APP_ENV
# read `.env.local`; this file is reached only by asking for it:
#
# APP_ENV=production ./nearle
#
# ⚠️ Everything here is live. `db.Connect()` runs schema migrations on boot and
# every handler writes for real, so a process started with APP_ENV=production
# creates real tenants, real logins and real stock movements. There is no dry
# 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.
#
# On the deployed host these values come from the platform's environment
# settings (Dokploy / Kubernetes), never from this file: the image is built
# without any `.env.*` (see .dockerignore). Keep the two in step — a variable
# added here and not there is a variable that is unset in production, and
# startup will refuse to boot on a missing required one.
#
# This file is currently committed to git, which means every credential in it
# has to be treated as public: rotate them, and keep the new values out of
# the repository.
# ── Where it listens ────────────────────────────────────────────────────────
APP_PORT=1009
ENV=production
# ── The main database (nearledb) ────────────────────────────────────────────
DB_HOST=66.116.207.225
DB_PORT=5433
DB_NAME=nearledb
DB_USER=admin
DB_PASSWORD="Package@123#"
# ── The catalogue database (pgvector) ───────────────────────────────────────
#
# Read-only integration, on its own connection so catalogue work never touches
# nearledb. Note the database is named `pgvector`, not `cataloguedb` as it is
# locally — `CATALOGUE_DB_NAME` is what reconciles the two.
CATALOGUE_DB_HOST=31.97.228.132
CATALOGUE_DB_PORT=6054
CATALOGUE_DB_NAME=pgvector
CATALOGUE_DB_USER=admin
CATALOGUE_DB_PASSWORD="'Package@321#'"
# ^ the single quotes are PART OF THE PASSWORD, not quoting. Verified against
# the live host: stripping them gives "password authentication failed".
# ── DigitalOcean Spaces (S3-compatible) — catalogue product images ──────────
USE_S3=true
S3_ACCESS_KEY=DO801G8Q8JAZKF49U3WJ
S3_SECRET_KEY=lBQExYfkVqH+ybmGVmQH5MkThBbrIohA/VQLgcPUvug
S3_ENDPOINT=https://nearle.sgp1.digitaloceanspaces.com
S3_BUCKET=nearle
S3_REGION=sgp1
# ── POS terminals — the MQTT broker the in-store tills publish to ───────────
#
# A BLANK MQTT_URL MEANS THE INGEST DOES NOT START. The service comes up
# looking healthy and every till queues its bills silently. On startup you
# should see three lines reading "pos: subscribed to nearle/pos/+/+/...".
MQTT_URL=tcp://66.116.225.226:1883
MQTT_USER=pos_ingest
MQTT_PASSWORD=AXbEPrNDWnMLdp7T1tFETwyU
# Unique per replica: a second connection with the same id evicts the first.
MQTT_CLIENT_ID=nearle-pos-ingest
# ── POS presence — terminal heartbeats under a 90-second TTL ────────────────
#
# Shared with the express backend; POS keys are namespaced pos:* so they cannot
# collide with delivery:*, city:* or rider_*. Optional: without it the health
# board goes dark, but bills still arrive and commit. Losing presence is an
# inconvenience; losing a sale is not.
REDIS_HOST=66.116.226.255
REDIS_PORT=6379
REDIS_USER=default
REDIS_PASSWORD=Package@324969#
REDIS_DB=0
# ── Auth ────────────────────────────────────────────────────────────────────
POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl
# ── Nearle Buddy ────────────────────────────────────────────────────────────
# One variable. Provider, endpoint and model are constants in config.go.
ASSISTANT_API_KEY=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

10
.gitignore vendored
View File

@@ -53,6 +53,10 @@ Thumbs.db
# credentials in this repository's history — removing it from the index stops # credentials in this repository's history — removing it from the index stops
# 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.
.env
.env.*
!.env.example # 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".
AppEnv string
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
Port string Port string
DBName string
DBUser string DB DBConfig
DBPassword string Catalogue DBConfig // Host empty → catalogue endpoints disabled.
DBPort string Redis RedisConfig
DBHost string S3 S3Config
UserContextKey string 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)
}
}
}

26
controllers/appRequest.go Normal file
View File

@@ -0,0 +1,26 @@
package controllers
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// isAppRequest reports whether a request arrived on the customer app's base.
//
// Several handlers are registered on both `/v1/web/...` and `/v1/mob/...`, and
// a few of them owe the two callers different answers. The clearest case is
// stock: the console lists a product with an empty shelf so a merchant can
// refill it, and the app must not list the same product at all, because a
// shopper can only choose it and be refused at checkout.
//
// The base is read from the path rather than passed down through the service,
// so the difference stays where it belongs — at the edge, in the one place that
// knows which audience asked. Services keep answering the same question the
// same way for everyone.
//
// Matched with the surrounding slashes so a tenant, product or keyword
// containing "mob" cannot make a console request look like an app one.
func isAppRequest(c *fiber.Ctx) bool {
return strings.Contains(c.Path(), "/v1/mob/")
}

View File

@@ -0,0 +1,50 @@
package controllers
import (
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
/*
The console and the customer app share handlers, and a few of those handlers owe
the two callers different answers. This is the switch that tells them apart, so
it is worth a test of its own: get it wrong in the permissive direction and
shoppers are offered empty shelves again; get it wrong in the strict direction
and a merchant's restocking screen goes blank, hiding the work they came to do.
*/
func pathSays(t *testing.T, path string, want bool) {
t.Helper()
app := fiber.New()
got := false
app.Get("/*", func(c *fiber.Ctx) error {
got = isAppRequest(c)
return nil
})
if _, err := app.Test(httptest.NewRequest("GET", path, nil)); err != nil {
t.Fatalf("Test(%q): %v", path, err)
}
if got != want {
t.Errorf("isAppRequest(%q) = %v, want %v", path, got, want)
}
}
func TestTheAppBaseIsRecognised(t *testing.T) {
pathSays(t, "/live/api/v1/mob/products/getallproducts", true)
}
func TestTheConsoleBaseIsNotTreatedAsTheApp(t *testing.T) {
// The console must keep seeing empty shelves — restocking them is the whole
// point of that screen.
pathSays(t, "/live/api/v1/web/products/getallproducts", false)
}
func TestAKeywordContainingMobDoesNotImpersonateTheApp(t *testing.T) {
// Matched with its slashes, so a search for "mobil" or a shop called
// "Mobius" cannot flip a console request into an app one and empty the
// merchant's screen.
pathSays(t, "/live/api/v1/web/products/getallproducts?keyword=mobile", false)
pathSays(t, "/live/api/v1/web/tenants/search?keyword=mob", false)
}

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

@@ -146,3 +146,48 @@ func (ctl *CatalogueController) GetProductBySKU(c *fiber.Ctx) error {
"details": product, "details": product,
}) })
} }
// GetProductByImageID resolves a catalogue row by the id the ingest pipeline
// treats as canonical, so a caller holding an ingest manifest can find the
// catalogueid it needs to import the product into a shop.
func (ctl *CatalogueController) GetProductByImageID(c *fiber.Ctx) error {
brand := c.Query("brand")
imageID := c.Query("image_id")
if brand == "" || imageID == "" {
return c.JSON(fiber.Map{
"code": 400,
"message": "brand and image_id are required",
"status": false,
})
}
product, err := ctl.catalogueService.GetProductByImageID(brand, imageID)
if err != nil {
if errors.Is(err, repositories.ErrUnknownBrand) {
return c.JSON(fiber.Map{
"code": 400,
"message": "Unknown brand: " + brand,
"status": false,
})
}
return c.JSON(fiber.Map{
"code": 500,
"message": "Failed to fetch catalogue product",
"status": false,
})
}
if product == nil {
return c.JSON(fiber.Map{
"code": 404,
"message": "Product not found",
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": product,
})
}

View File

@@ -0,0 +1,159 @@
package controllers
import (
"net/http"
"strconv"
"strings"
"nearle/models"
"nearle/services"
"github.com/gofiber/fiber/v2"
)
type CatalogueUploadController struct {
service services.CatalogueUploadService
}
func NewCatalogueUploadController(service services.CatalogueUploadService) *CatalogueUploadController {
return &CatalogueUploadController{service: service}
}
// RecordUpload stores the receipt for a spreadsheet just accepted by the ingest
// service.
//
// Called the moment the drop is accepted, before any polling — that is the one
// instant the batch id is guaranteed to exist and guaranteed not to have been
// lost. Everything after it (the review wait, the run, the shelving) can be
// recovered from the id; the id itself cannot be recovered from anything.
func (ctl *CatalogueUploadController) RecordUpload(c *fiber.Ctx) error {
var input models.CatalogueUpload
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if strings.TrimSpace(input.Batchid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "batchid is required — it is the only credential for reading the result back",
"status": false,
})
}
if input.Tenantid <= 0 {
// A receipt with no tenant belongs to nobody and would be visible to
// every store login that asks for "all". Refused rather than stored
// unscoped.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required — a receipt has to belong to a merchant",
"status": false,
})
}
if err := ctl.service.Record(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
return c.JSON(fiber.Map{"code": 200, "message": "Upload recorded", "status": true, "details": input})
}
// GetUploads lists receipts for whoever is asking.
//
// `tenantid=0` means every tenant and is how a Nearle Admin sees the platform.
// The console decides which to send from the session — the same rule the import
// itself follows — because a store login reading another merchant's receipts
// would see what that merchant stocks.
func (ctl *CatalogueUploadController) GetUploads(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid", "0"))
locationID, _ := strconv.Atoi(c.Query("locationid", "0"))
pageNo, _ := strconv.Atoi(c.Query("pageno", "1"))
pageSize, _ := strconv.Atoi(c.Query("pagesize", "50"))
data, err := ctl.service.List(tenantID, locationID, pageNo, pageSize)
if err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
// Never null. An empty list is a legitimate answer — a shop that has never
// uploaded — and a JSON null here makes every caller guard a case that is
// really just "none yet".
if data == nil {
data = []models.CatalogueUpload{}
}
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data})
}
// UpdateUpload caches what the ingest service last reported.
//
// Written by the browser that is polling, because that is where the reading
// happens: the batch id is the credential and the read is anonymous, so the
// person waiting is already holding the answer. This endpoint only writes it
// down so the next person does not have to wait for it again.
func (ctl *CatalogueUploadController) UpdateUpload(c *fiber.Ctx) error {
var input models.CatalogueUpload
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if strings.TrimSpace(input.Batchid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "batchid is required", "status": false,
})
}
if err := ctl.service.UpdateStatus(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
return c.JSON(fiber.Map{"code": 200, "message": "Upload updated", "status": true})
}
// MarkShelved records that the products reached a branch's shelf with a price
// and their opening stock.
//
// Separate from UpdateUpload on purpose. The ingest service confirms the global
// catalogue, which every merchant shares and which therefore carries no price
// and no stock — so "added" and "on sale here" are two different claims, made by
// two different systems, and a receipt that ran them together would report
// products as sellable that no customer can buy.
func (ctl *CatalogueUploadController) MarkShelved(c *fiber.Ctx) error {
var input struct {
Batchid string `json:"batchid"`
Shelved int `json:"shelved"`
Skipped int `json:"skipped"`
}
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if strings.TrimSpace(input.Batchid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "batchid is required", "status": false,
})
}
if err := ctl.service.MarkShelved(input.Batchid, input.Shelved, input.Skipped); err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
return c.JSON(fiber.Map{"code": 200, "message": "Shelving recorded", "status": true})
}
// AttachSheet supplies the prices and opening stock for a receipt that has
// none, so its products can still be put on a shelf.
//
// Needed because the receipt and the sheet used to part company. The ingest
// service holds neither price nor stock — its catalogue is shared by every
// merchant — so both live only in the spreadsheet, and a receipt written before
// those rows were stored has no way to finish. Handing the file over again is
// the only way back, and this is where it lands.
func (ctl *CatalogueUploadController) AttachSheet(c *fiber.Ctx) error {
var input struct {
Batchid string `json:"batchid"`
Sheetrows string `json:"sheetrows"`
}
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if strings.TrimSpace(input.Batchid) == "" || strings.TrimSpace(input.Sheetrows) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "batchid and sheetrows are both required", "status": false,
})
}
if err := ctl.service.AttachSheet(input.Batchid, input.Sheetrows); err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
return c.JSON(fiber.Map{"code": 200, "message": "Sheet attached", "status": true})
}

View File

@@ -0,0 +1,175 @@
package controllers
import (
"net/http"
"strconv"
"github.com/gofiber/fiber/v2"
"nearle/models"
"nearle/services"
)
type DeliverySlotController struct {
service services.DeliverySlotService
// Asked whether the shop is trading at all. Held here rather than inside
// the slot service, which answers one question — is this window still
// running — and should not also have to know about shop closures.
tenants services.TenantService
}
func NewDeliverySlotController(
service services.DeliverySlotService,
tenants services.TenantService,
) *DeliverySlotController {
return &DeliverySlotController{service: service, tenants: tenants}
}
/*
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,
})
}
/*
A shut shop offers nothing.
Without this, a branch closed for the day still hands the app tomorrow
morning — and the shopper picks it, reaches checkout, and is refused by
the closed-shop guard in order creation with no warning anything was
wrong. The empty list is already the app's "no windows here" path, so
this needs nothing new on their side.
The reason rides along so the app can say WHY rather than just showing
nothing, on an endpoint it is already calling.
*/
if reason := ctl.tenants.StoreClosedReason(tenantID, locationID); reason != "" {
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": []models.AvailableDeliverySlot{},
"closedreason": reason,
})
}
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,165 @@
package controllers
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"nearle/messaging"
"nearle/services"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
// The console's live event stream.
//
// Server-Sent Events rather than a WebSocket, deliberately. The traffic is one
// way — the console never sends anything up this pipe — and SSE is plain HTTP,
// so it inherits the reverse proxy, the TLS termination and the load balancer
// already in front of this service with no upgrade handshake to configure. The
// browser's own `EventSource` also reconnects on its own, which is a reconnect
// loop nobody has to write or get wrong.
//
// What goes down it is a nudge, never a figure: "outlet 1185 sold something,
// products 42 and 77". The console re-reads through the normal endpoints. See
// `messaging/livehub.go` for why.
type LiveController struct {
posService services.PosService
}
func NewLiveController(posService services.PosService) *LiveController {
return &LiveController{posService: posService}
}
const (
// Below every idle timeout worth worrying about: nginx and most cloud load
// balancers cut an idle connection at 60s, and a quiet shop produces no
// events for hours.
liveHeartbeat = 25 * time.Second
// A connection is recycled rather than held forever, so a replica being
// drained empties out on its own and a client that has silently gone away
// stops being written to. The browser reconnects immediately; the console
// refetches on reconnect anyway, so the seam is invisible.
liveMaxAge = 30 * time.Minute
)
// Stream opens the event stream for one outlet.
//
// GET /live/api/v1/web/live/events?tenantid=1147&locationid=1185
//
// Scoped exactly like every other console POS read: the tenant must own the
// outlet. Without that check any caller could name any locationid and watch a
// competitor's tills report in.
func (ctl *LiveController) Stream(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if tenantID <= 0 || locationID <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"status": false, "code": http.StatusBadRequest,
"message": "tenantid and locationid are required",
})
}
allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"status": false, "code": http.StatusInternalServerError,
"message": "could not verify the outlet",
})
}
if !allowed {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"status": false, "code": http.StatusForbidden,
"message": fmt.Sprintf("outlet %d does not belong to tenant %d", locationID, tenantID),
})
}
events, release := messaging.Hub.Subscribe(locationID)
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
// Nginx buffers proxied responses by default, which holds each event until
// the buffer fills — for a stream of 80-byte messages, indefinitely.
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(fasthttp.StreamWriter(func(w *bufio.Writer) {
// The writer owns the subscription from here: this closure outlives the
// handler, so releasing in a defer above would unsubscribe instantly.
defer release()
// Tells EventSource how long to wait before reconnecting, and gives the
// client something to prove the stream is open.
if _, err := fmt.Fprintf(w, "retry: 3000\nevent: open\ndata: {\"locationid\":%d}\n\n", locationID); err != nil {
return
}
if err := w.Flush(); err != nil {
return
}
heartbeat := time.NewTicker(liveHeartbeat)
defer heartbeat.Stop()
deadline := time.NewTimer(liveMaxAge)
defer deadline.Stop()
for {
select {
case event, open := <-events:
if !open {
return
}
payload, err := json.Marshal(event)
if err != nil {
continue
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, payload); err != nil {
return
}
// A failed flush is how a disconnected client is discovered:
// fasthttp surfaces the broken pipe here and nowhere else.
if err := w.Flush(); err != nil {
return
}
case <-heartbeat.C:
// An SSE comment. EventSource ignores it; every proxy in the
// path sees traffic and keeps the connection open.
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
if err := w.Flush(); err != nil {
return
}
case <-deadline.C:
fmt.Fprint(w, "event: bye\ndata: {}\n\n")
_ = w.Flush()
return
}
}
}))
return nil
}
// Health reports what the stream is doing. Useful for answering "is anyone
// actually connected" without reading logs.
func (ctl *LiveController) Health(c *fiber.Ctx) error {
outlets, connections, dropped := messaging.Hub.Stats()
return c.JSON(fiber.Map{
"status": true, "code": http.StatusOK,
"details": fiber.Map{
"outlets_watched": outlets,
"connections": connections,
"dropped": dropped,
},
})
}

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,10 +16,26 @@ 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
// Asked whether the shop is trading. Same reasoning: the customer-facing
// store list and this check run the same rule, so a shop shown as open is
// never refused here.
tenantService services.TenantService
} }
func NewOrderController(orderService services.OrderService) *OrderController { func NewOrderController(
return &OrderController{orderService: orderService} orderService services.OrderService,
deliverySlotService services.DeliverySlotService,
tenantService services.TenantService,
) *OrderController {
return &OrderController{
orderService: orderService,
deliverySlotService: deliverySlotService,
tenantService: tenantService,
}
} }
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error { func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
@@ -126,7 +142,6 @@ func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
}) })
} }
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 +175,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,12 +364,82 @@ 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")
} }
/*
Is the shop even trading?
Checked HERE and not only in the app, because /v1/mob/* carries no
session: anything arriving is a claim. The ordinary case is innocent and
still has to be caught — a shopper with the app open when the shopkeeper
closes, or a stale screen from this morning.
409 with the shop's own reason, written to be shown as-is.
*/
if reason := ctl.tenantService.StoreClosedReason(data.Tenantid, data.Locationid); reason != "" {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict,
"message": reason,
"status": false,
})
}
// 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
// the only kind this endpoint takes.
//
// Every revenue figure in the product filters on `configid = 1` — the
// per-branch summary, the tenant revenue summary, the sales summary and the
// top-locations list all carry it. An order created with configid 0 is
// therefore accepted, deducts stock, appears in the order LIST, and counts
// for nothing in any total: the branch reads zero orders and zero revenue
// while the stock has genuinely moved.
//
// Confirmed against live data before defaulting it: every real order across
// every tenant carries configid 1. Nothing legitimately arrives here as 0,
// so this only ever rescues a caller that forgot the field rather than
// relabelling an order that meant something else.
if data.Configid == 0 {
data.Configid = 1
}
order, err := ctl.orderService.CreateOrder(data) order, err := ctl.orderService.CreateOrder(data)
if err != nil { if err != nil {
log.Println("CreateOrder service error:", err) log.Println("CreateOrder service error:", err)
// A rejected order is not a server fault, and the status code has to say
// so: a client that retries on 5xx will hammer a request that can never
// succeed, while a 4xx tells it to fix the request and stop trying.
//
// "names no outlet" is the caller having omitted locationid, which is
// squarely a bad request. It was falling through to the 500 default —
// right message, wrong class — because only the stock case was mapped.
statusCode := http.StatusInternalServerError statusCode := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "insufficient stock") { lowered := strings.ToLower(err.Error())
switch {
case strings.Contains(lowered, "insufficient stock"):
statusCode = http.StatusConflict statusCode = http.StatusConflict
case strings.Contains(lowered, "names no outlet"):
statusCode = http.StatusBadRequest
} }
return c.Status(statusCode).JSON(fiber.Map{ return c.Status(statusCode).JSON(fiber.Map{
"code": statusCode, "code": statusCode,

View File

@@ -1,6 +1,8 @@
package controllers package controllers
import ( import (
"errors"
"nearle/models"
"nearle/services" "nearle/services"
"net/http" "net/http"
"strconv" "strconv"
@@ -71,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"))
@@ -193,3 +229,237 @@ func (ctl *PartnerController) GetRiderInfo(c *fiber.Ctx) error {
"details": result, "details": result,
}) })
} }
// CreateRider hires a rider — three tables in one transaction.
//
// `tenantid` comes from the query string, which is where this console's other
// scoped writes take it from. It is required: a rider with no tenant belongs to
// the region and to no merchant, which is the state the 84 existing riders are
// already in and not one worth creating more of.
func (ctl *PartnerController) CreateRider(c *fiber.Ctx) error {
var rider models.NewRider
if err := c.BodyParser(&rider); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "Invalid request body",
})
}
// Taken from the scope rather than trusted from the body. A store admin
// must not be able to put a rider on another merchant's books by editing a
// payload, and the body is the caller's to edit.
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
rider.Tenantid = tid
}
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
rider.Partnerid = pid
}
// A rider belongs to a merchant OR to a delivery partner — never to
// neither, and never to both.
//
// This used to demand a tenantid outright, which made a PARTNER's rider
// impossible to create: a partner supplies riders to many merchants and
// their riders sit under no single one. It is the same endpoint because it
// is the same rider — the difference is only who they ride for, and that is
// what the assign screen later branches on.
//
// Both at once is refused rather than silently preferred. A rider carrying
// a tenantid AND a partnerid appears in two directories and two assign
// pickers, and nothing downstream says which one owns them.
switch {
case rider.Tenantid == 0 && rider.Partnerid == 0:
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "a rider needs either a tenantid (the shop's own rider) " +
"or a partnerid (a delivery partner's rider)",
})
case rider.Tenantid != 0 && rider.Partnerid != 0:
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "a rider belongs to a shop or to a partner, not to both",
})
}
userid, err := ctl.partnerService.CreateRider(rider)
if err != nil {
// 400, not 500: every failure this can produce is something the caller
// sent — a shift that does not exist, an unconfigured region, a phone
// number already in use. Answering 500 would send them to look at the
// server for a problem in their own form.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": err.Error(),
})
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated,
"status": true,
// Said plainly because it is the single most confusing thing about this
// flow: the rider is created and will NOT show in the on-duty fleet
// until they open the app and start a shift.
"message": "Rider created. They appear in the fleet once they sign in and start a shift.",
"details": fiber.Map{"userid": userid},
})
}
func (ctl *PartnerController) UpdateRider(c *fiber.Ctx) error {
var rider models.NewRider
if err := c.BodyParser(&rider); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "Invalid request body",
})
}
if err := ctl.partnerService.UpdateRider(rider); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"message": "Success",
})
}
// GetRiderRoster lists riders whether or not they are working today.
//
// The scope is required. Unscoped this returns every rider on the platform,
// which is not a merchant's business — the same guard the other tenant-scoped
// reads carry.
func (ctl *PartnerController) GetRiderRoster(c *fiber.Ctx) error {
tid, _ := strconv.Atoi(c.Query("tenantid"))
aid, _ := strconv.Atoi(c.Query("applocationid"))
pid, _ := strconv.Atoi(c.Query("partnerid"))
if tid == 0 && aid == 0 && pid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "One of tenantid, applocationid or partnerid is required",
})
}
result, err := ctl.partnerService.GetRiderRoster(tid, aid, pid)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError,
"status": false,
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"message": "Successful",
"details": result,
})
}
/* ── Onboarding a delivery partner ────────────────────────────────────────────
Platform-side only. A merchant does not create the company that supplies their
riders — they are assigned one, which is `AssignPartner` on the tenant. Both of
these live in the web group for the same reason `createrider` does: a partner is
onboarded from a console, not from a phone. */
// CreatePartner onboards a delivery partner and the regions they cover.
func (ctl *PartnerController) CreatePartner(c *fiber.Ctx) error {
var input models.NewPartner
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
partnerid, err := ctl.partnerService.CreatePartner(input)
if err != nil {
// 400, not 500: every failure this produces is something the caller
// sent — a missing name, an unconfigured region, a number already in
// use. A 500 sends them to look at the server for their own form.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
})
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true, "message": "Successfully Created",
"details": fiber.Map{"partnerid": partnerid},
})
}
// UpdatePartner edits a partner and, when regions are sent, re-states them.
func (ctl *PartnerController) UpdatePartner(c *fiber.Ctx) error {
var input models.NewPartner
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
input.Partnerid = pid
}
if err := ctl.partnerService.UpdatePartner(input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
})
}
// GetPartnerLocations lists the regions one partner covers.
func (ctl *PartnerController) GetPartnerLocations(c *fiber.Ctx) error {
pid, _ := strconv.Atoi(c.Query("partnerid"))
if pid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "partnerid is required",
})
}
rows, err := ctl.partnerService.GetPartnerLocations(pid)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "Success", "details": rows,
})
}
// riderOwner enforces that a rider belongs to a shop or to a partner.
//
// Its own function so the rule can be tested without a request, and so both
// call sites — create today, anything that moves a rider tomorrow — cannot
// drift apart on it.
func riderOwner(tenantid, partnerid int) error {
switch {
case tenantid == 0 && partnerid == 0:
return errors.New("a rider needs either a tenantid (the shop's own rider) " +
"or a partnerid (a delivery partner's rider)")
case tenantid != 0 && partnerid != 0:
return errors.New("a rider belongs to a shop or to a partner, not to both")
}
return nil
}

View File

@@ -181,6 +181,12 @@ func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
// TerminalHealth returns one till's live state, for a support call that starts // TerminalHealth returns one till's live state, for a support call that starts
// with a terminal code. // with a terminal code.
//
// The outlet check cannot live in the middleware like every other POS route's
// does. The middleware scopes a request by the location it *names*, and this
// request names none — only a terminal code, which is free text minted at the
// till and belongs to whichever shop is holding that device. So the outlet is
// not known until after the lookup, and the check has to happen here.
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error { func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
terminalID := strings.TrimSpace(c.Query("terminal_id")) terminalID := strings.TrimSpace(c.Query("terminal_id"))
if terminalID == "" { if terminalID == "" {
@@ -200,6 +206,11 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
// Not an error. The till has simply not reported inside its TTL, which // Not an error. The till has simply not reported inside its TTL, which
// is the answer the caller wanted — said plainly rather than as a 404 // is the answer the caller wanted — said plainly rather than as a 404
// that reads like the terminal does not exist. // that reads like the terminal does not exist.
//
// Answered without an outlet check, and safely so: there is nothing to
// check against and nothing to leak. "Offline" is the same answer for a
// terminal code that was never issued, so guessing codes reveals only
// that guessing does not work.
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"code": http.StatusOK, "code": http.StatusOK,
"status": true, "status": true,
@@ -211,9 +222,54 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
}) })
} }
if err := ctl.posTerminalInScope(c, fields); err != nil {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields}) return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
} }
// posTerminalInScope refuses a heartbeat belonging to somebody else's shop.
//
// Reads the outlet off the heartbeat itself, because that — not the request —
// is the authority on which shop a terminal code belongs to. A caller who
// guesses "T4A9" gets a 403 rather than another shop's pending-bill count,
// takings so far today, and app version.
//
// Silent when the request carries no token, matching middleware.PosAuth: while
// POS_AUTH_REQUIRED is off, tills in the field are still calling these routes
// unauthenticated, and refusing them here would take the fleet board down for
// exactly the terminals it exists to watch. Once the flag is on, an untokened
// request never reaches this handler.
func (ctl *PosController) posTerminalInScope(c *fiber.Ctx, fields map[string]string) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return nil
}
locationID, err := strconv.Atoi(strings.TrimSpace(fields["location_id"]))
if err != nil || locationID <= 0 {
// A heartbeat that cannot say where it came from cannot be shown to a
// caller who must be scoped. Refusing beats guessing.
return fmt.Errorf("this terminal's outlet could not be determined")
}
if locationID == claims.Locationid {
return nil
}
allowed, err := ctl.posService.LocationAllowed(claims.Tenantid, locationID)
if err != nil {
return fmt.Errorf("could not verify outlet access")
}
if !allowed {
return fmt.Errorf("this terminal belongs to an outlet this session cannot reach")
}
return nil
}
// LocationHealth returns every till at a shop — the "which counters are dark" // LocationHealth returns every till at a shop — the "which counters are dark"
// board. Tills that have stopped reporting come back marked offline rather than // board. Tills that have stopped reporting come back marked offline rather than
// being omitted, because a missing till is exactly what somebody is looking for. // being omitted, because a missing till is exactly what somebody is looking for.
@@ -387,6 +443,11 @@ func posIngestError(c *fiber.Ctx, op string, err error) error {
// The one POS route that is deliberately left unauthenticated — it is where a // The one POS route that is deliberately left unauthenticated — it is where a
// token comes from. Everything else on the group sits behind the session this // token comes from. Everything else on the group sits behind the session this
// issues. // issues.
//
// A mobile number and a PIN. Because it is unauthenticated and the PIN is four
// digits, this is the one route on the group that needs a rate limit in front
// of it — the pair is only strong while an attacker cannot try ten thousand
// times. That belongs at the edge, not here.
func (ctl *PosController) Login(c *fiber.Ctx) error { func (ctl *PosController) Login(c *fiber.Ctx) error {
var req models.PosLoginRequest var req models.PosLoginRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
@@ -396,10 +457,23 @@ func (ctl *PosController) Login(c *fiber.Ctx) error {
}) })
} }
if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" { // Which account, and which of the two credentials was offered. Both are
// checked here so an empty field is answered as the malformed request it is,
// rather than spending a database round trip to say the same thing.
identity := strings.TrimSpace(req.Contactno)
if identity == "" {
identity = strings.TrimSpace(req.Authname)
}
if identity == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{ return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "code": http.StatusBadRequest, "status": false,
"message": "an email or mobile number is required", "message": "a mobile number is required",
})
}
if strings.TrimSpace(req.Pin) == "" && strings.TrimSpace(req.Password) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "a PIN is required",
}) })
} }
@@ -415,7 +489,7 @@ func (ctl *PosController) Login(c *fiber.Ctx) error {
}) })
} }
log.Printf("pos login (%s): %v", req.Authname, err) log.Printf("pos login (%s): %v", identity, err)
return c.Status(http.StatusForbidden).JSON(fiber.Map{ return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false, "message": err.Error(), "code": http.StatusForbidden, "status": false, "message": err.Error(),
}) })
@@ -459,9 +533,14 @@ func (ctl *PosController) Session(c *fiber.Ctx) error {
// Staff lists who may ring a bill at this terminal's outlet. // Staff lists who may ring a bill at this terminal's outlet.
// //
// Scoped by the caller's own session rather than by a query parameter. A till // Scoped by the caller's own session rather than by a query parameter. A till
// asking "who works here" must not be able to ask on behalf of another shop, // asking "who works here" must not be able to ask on behalf of another shop, so
// and the answer carries PINs — so the outlet comes from the token, and a // the outlet comes from the token, and a request without one is refused
// request without one is refused whatever POS_AUTH_REQUIRED says. // whatever POS_AUTH_REQUIRED says.
//
// The answer no longer carries PINs — that stopped when the PIN became half of
// the sign-in, see models.PosStaffMember. The scoping outlives the reason: an
// outlet's roster is still its own business, and a list of who is on shift
// where is worth something to somebody casing a chain.
func (ctl *PosController) Staff(c *fiber.Ctx) error { func (ctl *PosController) Staff(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c) claims, ok := middleware.PosClaimsFrom(c)
if !ok { if !ok {
@@ -637,7 +716,7 @@ func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
if !ok { if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false, "code": http.StatusUnauthorized, "status": false,
"message": "sign the terminal in with an email and password before using PIN sign-in", "message": "sign the terminal in with a mobile number and PIN before switching operator",
}) })
} }
@@ -692,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")
@@ -817,13 +910,125 @@ func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
{ {
"role_id": models.PosRoleSupervisor, "role": "supervisor", "role_id": models.PosRoleSupervisor, "role": "supervisor",
"label": models.PosRoleName(models.PosRoleSupervisor), "label": models.PosRoleName(models.PosRoleSupervisor),
"description": "Runs the terminal and creates counter staff. Also signs into the app.", "description": "Runs the terminal: imports, settings, voids, and " +
"creating counter staff. Signs in at a till only — a till " +
"account has no Nearle Daily login.",
}, },
{ {
"role_id": models.PosRoleCashier, "role": "cashier", "role_id": models.PosRoleCashier, "role": "cashier",
"label": models.PosRoleName(models.PosRoleCashier), "label": models.PosRoleName(models.PosRoleCashier),
"description": "Billing only.", "description": "Billing only. Signs in at a till with their own username " +
"and password, so a shop can open without a supervisor present.",
}, },
}, },
}) })
} }
// ─────────────────────────────────────────────────────────── Staff shifts
//
// Working windows for till staff, managed from the console. Same scoping rule
// as the till-user routes above: the outlet is asserted by the caller and
// checked against the tenant before anything is written.
// WebListStaffShifts returns an outlet's shifts, for a picker.
func (ctl *PosController) WebListStaffShifts(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
// 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)
}
if locationID > 0 {
if err := ctl.posWebScope(tenantID, locationID); err != nil {
return posBadRequest(c, err)
}
}
shifts, err := ctl.posService.ListStaffShifts(tenantID, locationID,
strings.EqualFold(c.Query("include_inactive"), "true"))
if err != nil {
return posServerError(c, "WebListStaffShifts", err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{"location_id": locationID, "shifts": shifts},
})
}
// WebCreateStaffShift adds a working window at one outlet.
func (ctl *PosController) WebCreateStaffShift(c *fiber.Ctx) error {
var req models.StaffShifts
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
// 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)
}
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)
if err != nil {
return posBadRequest(c, err)
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true,
"message": "Shift created", "details": shift,
})
}
// WebUpdateStaffShift edits a window. Deactivate by sending status "Inactive" —
// shifts are not deleted, because a person may still be assigned to one and an
// orphaned shiftid reads as a shift that never existed.
func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
var req models.StaffShifts
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
return posBadRequest(c, err)
}
shift, err := ctl.posService.UpdateStaffShift(req.Tenantid, req.Locationid, req)
if err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"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

@@ -1,8 +1,10 @@
package controllers package controllers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"nearle/models" "nearle/models"
"nearle/services" "nearle/services"
@@ -189,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",
@@ -201,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,
}) })
} }
@@ -383,6 +392,41 @@ func (ctl *ProductController) GetAllProducts(c *fiber.Ctx) error {
categoryID, subcategoryID, productID, applocationID, tenantID, categoryID, subcategoryID, productID, applocationID, tenantID,
locationID, keyword, productStatus, approve, pageno, pagesize, locationID, keyword, productStatus, approve, pageno, pagesize,
) )
// The customer app is not shown what the shop cannot sell.
//
// Scoped to the /v1/mob base rather than applied in the service, because
// this one handler answers on BOTH bases and the two callers want opposite
// things. The console reads it to restock — an empty line is exactly what a
// merchant needs to see and act on, so filtering there would hide the work.
// A shopper reading the same list can only be misled by it: measured
// 2026-09-02 on R mart, four products were on offer in the app with a zero
// balance, including Cadbury Bournvita 500g and two Amul packs. Ordering one
// gets a 409 at checkout, after the shopper has chosen it.
//
// It filters on Productstock, the same number the response carries, so the
// list and the figure beside it cannot disagree.
if err == nil && isAppRequest(c) {
details = services.InStockOnlyGrouped(details)
// And nothing a shopper cannot be charged for. An unpriced product is
// unfinished, not free, and ₹0 on a phone reads as the latter.
details = services.PricedOnly(details)
// One product, three sizes — not three products.
//
// A shop stocking Aachi Baby Fryums in 100g, 500g and 1kg has three
// product rows, each with its own price and its own shelf. The shopper
// should meet ONE listing and pick the size; the sizes travel with the
// parent in `variantoptions`, so nothing becomes unreachable.
//
// Best-effort, like the stock filter above: if the lookup fails the
// shopper sees the three rows they see today, which is worse than the
// grouping and much better than an error.
if isChild, cErr := ctl.productService.VariantChildIDs(tenantID); cErr == nil {
details = services.WithoutVariantChildren(details, isChild)
}
}
if err != nil { if err != nil {
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"status": false, "status": false,
@@ -404,8 +448,33 @@ func (ctl *ProductController) GetProductByVariant(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid")) tenantID, _ := strconv.Atoi(c.Query("tenantid"))
variantid, _ := strconv.Atoi(c.Query("variantid")) variantid, _ := strconv.Atoi(c.Query("variantid"))
locationID, _ := strconv.Atoi(c.Query("locationid")) locationID, _ := strconv.Atoi(c.Query("locationid"))
// `productid` is the parameter the ordering screen should send.
//
// With it the caller no longer has to know whether the thing it tapped has
// sizes: a product in a variant group comes back with the whole group to
// choose from, a product in none comes back on its own. Previously only
// `variantid` was accepted, so an ungrouped product — `variants = 0`, which
// is most of them — could not be requested at all and the order screen had
// nothing to work with.
productID, _ := strconv.Atoi(c.Query("productid"))
result, err := ctl.productService.GetProductByVariant(tenantID, variantid, locationID) // One of the two has to name something, and `variantid=0` names nothing.
//
// 0 is not a variant group — it is the value every UNGROUPED product
// carries, so a query for it used to match the tenant's whole ungrouped
// catalogue and return six unrelated products as each other's variants. The
// ordering screen then had a list it could not choose from, and no order
// could be placed. Refusing here turns that into one sentence naming the
// parameter to send instead.
if variantid <= 0 && productID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"status": false,
"message": "send productid (preferred) or a variantid above 0 — variantid 0 is not a variant group, it is what every ungrouped product carries",
})
}
result, err := ctl.productService.GetProductByVariant(tenantID, variantid, locationID, productID)
if err != nil { if err != nil {
@@ -524,6 +593,33 @@ func (ctl *ProductController) CreateProductLocation(c *fiber.Ctx) error {
}) })
} }
// UpdateProductVariant puts a product into a variant group, or takes it out.
//
// variantid 0 means ungrouped and is allowed — it is a real thing to want. The
// read path is what refuses to treat 0 as a group to search for.
func (ctl *ProductController) UpdateProductVariant(c *fiber.Ctx) error {
var body struct {
Productid int `json:"productid"`
Variantid int `json:"variantid"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if body.Productid <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "productid is required",
})
}
if err := ctl.productService.UpdateProductVariant(body.Productid, body.Variantid); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "message": "Success"})
}
func (ctl *ProductController) CreateProductVariant(c *fiber.Ctx) error { func (ctl *ProductController) CreateProductVariant(c *fiber.Ctx) error {
var input models.Productvariant var input models.Productvariant
@@ -572,10 +668,54 @@ func (ctl *ProductController) ImportCatalogueProduct(c *fiber.Ctx) error {
} }
for _, req := range data { for _, req := range data {
if req.Tenantid == 0 || req.Locationid == 0 || req.Brand == "" || req.Catalogueid == 0 || req.Categoryid == 0 { // `categoryid` IS required, and the comment that used to sit here
// argued the opposite. It said an unclassified product is "visibly
// unfinished" and therefore harmless, and that importing "reaches no
// shop and cannot be sold".
//
// Both halves were wrong, and a sweep of all 262 tenants measured it:
// 7 products across 3 tenants sat with categoryid 0, and 6 outlets were
// serving shoppers an EMPTY shop while their consoles listed stock.
// Nothing was visibly unfinished — `getlocationproducts` does not filter
// on category, so the product looked entirely normal to the merchant.
// And import DOES reach a shop: it writes productlocations.
//
// The state was also unrecoverable. GetProductsBySubcategory rejects
// categoryid 0 outright, `UpdateProduct` writes only
// productlocations.status, and re-import corrected pricing alone — so
// no request could put it right. Re-import now repairs the category
// (productService.ImportCatalogueProduct), and this refuses to create
// the state in the first place.
//
// A wrongly filed product remains the better failure: it is findable,
// and it is fixable by re-importing. An unfiled one was neither.
// Named individually rather than as one list of five.
//
// The old message recited every required field whichever one was
// actually missing, so a request rejected for `categoryid: 0` read as
// "catalogueid is required" and sent somebody looking at the wrong
// field entirely. An error that does not say what is wrong costs more
// than the branch it saves.
var missing []string
if req.Tenantid == 0 {
missing = append(missing, "tenantid")
}
if req.Locationid == 0 {
missing = append(missing, "locationid")
}
if req.Brand == "" {
missing = append(missing, "brand")
}
if req.Catalogueid == 0 {
missing = append(missing, "catalogueid")
}
if req.Categoryid == 0 {
missing = append(missing, "categoryid")
}
if len(missing) > 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{ return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "code": http.StatusBadRequest,
"message": "tenantid, locationid, brand, catalogueid, and categoryid are required", "message": fmt.Sprintf("missing required field(s): %s", strings.Join(missing, ", ")),
"status": false, "status": false,
}) })
} }
@@ -654,6 +794,93 @@ func (ctl *ProductController) GetTenantCategories(c *fiber.Ctx) error {
}) })
} }
// Recategorise re-files a tenant's products in bulk.
//
// PUT and not POST: it changes rows that already exist and creates none. The
// response carries how many rows actually moved, which is the only way a caller
// can tell "all done" from "every id I sent was wrong".
func (ctl *ProductController) Recategorise(c *fiber.Ctx) error {
var body struct {
Tenantid int `json:"tenantid"`
Updates []models.ProductCategoryUpdate `json:"updates"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "invalid body",
"status": false,
})
}
if body.Tenantid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
moved, err := ctl.productService.RecategoriseProducts(body.Tenantid, body.Updates)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError,
"message": err.Error(),
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": fiber.Map{"moved": moved},
})
}
// ResolveCategories exchanges category names for this tenant's category ids.
//
// POST, not GET, because it CREATES the categories it cannot find — a sheet
// naming an aisle this shop has never stocked should not fail, it should open
// the aisle. The response maps the name a caller sent to the id it must store.
//
// Keyed on the lowercased, trimmed name, so a caller can look up whatever
// casing its own sheet used without having to guess how it was filed.
func (ctl *ProductController) ResolveCategories(c *fiber.Ctx) error {
var body struct {
Tenantid int `json:"tenantid"`
Names []string `json:"names"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "invalid body",
"status": false,
})
}
if body.Tenantid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
resolved, err := ctl.productService.EnsureTenantCategories(body.Tenantid, body.Names)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError,
"message": err.Error(),
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": resolved,
})
}
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error { func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
var input struct { var input struct {
Tenantid int `json:"tenantid"` Tenantid int `json:"tenantid"`
@@ -691,3 +918,134 @@ func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
"status": true, "status": true,
}) })
} }
// PublishProduct releases a product from the admin catalogue to every outlet.
//
// The two rules this enforces — a price is required, and publishing covers the
// whole tenant — live in the repository, so they hold for any caller rather
// than only for the console form that happens to ask nicely.
func (ctl *ProductController) PublishProduct(c *fiber.Ctx) error {
var input struct {
Tenantid int `json:"tenantid"`
Productid int `json:"productid"`
Price float64 `json:"price"`
// Optional. Omitted leaves the product at 0%, which is a real rate for
// staples rather than a missing value — so it is written as given
// rather than skipped when zero.
Taxpercent float64 `json:"taxpercent"`
}
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
})
}
outlets, err := ctl.productService.PublishProduct(input.Tenantid, input.Productid, input.Price, input.Taxpercent)
if err != nil {
// 400 rather than 500: every failure here is something the caller can
// act on — no price, no outlets, or a product that is not theirs.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": fmt.Sprintf("Published to %d outlet(s)", outlets),
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
})
}
// UnpublishProduct withdraws a product from every shop, keeping its rows.
func (ctl *ProductController) UnpublishProduct(c *fiber.Ctx) error {
var input struct {
Tenantid int `json:"tenantid"`
Productid int `json:"productid"`
}
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
})
}
outlets, err := ctl.productService.UnpublishProduct(input.Tenantid, input.Productid)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": fmt.Sprintf("Withdrawn from %d outlet(s)", outlets),
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
})
}
// RelinkCatalogue repairs a tenant's pointers back into the global catalogue.
//
// Defaults to a DRY RUN. The pass rewrites the column that decides what a
// browse screen claims a shop already holds, and clearing a link is not
// reversible from the outside — so the whole plan should be readable before any
// of it happens. Pass `apply=true` to write.
func (ctl *ProductController) RelinkCatalogue(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid", "0"))
if tenantID <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required — this repairs one merchant's links, not the platform's",
"status": false,
})
}
// Anything but an explicit `apply=true` is a dry run, including a typo.
apply := c.Query("apply", "") == "true"
report, err := ctl.productService.RelinkCatalogue(tenantID, !apply)
if err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
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,96 @@
package controllers
import (
"errors"
"strconv"
"nearle/models"
"nearle/repositories"
"github.com/gofiber/fiber/v2"
)
/*
Attaching sizes to a product, and taking them off again.
The pair that was missing. `createproductvariant` wrote a name into a table
without a parent, so there was no way to say "500ml is a size of Coke" — and
without that the ordering screen had nothing but `products.variants` to work
from, a bare group number that is 0 on every product.
*/
// AddProductVariant — POST /products/addproductvariant
//
// Body: {tenantid, productid, variantproductid, variantname, varianttype?, price?}
func (ctl *ProductController) AddProductVariant(c *fiber.Ctx) error {
var body models.Productvariant
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"code": fiber.StatusBadRequest, "status": false,
"message": "could not read the request body",
})
}
if body.Tenantid <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"code": fiber.StatusBadRequest, "status": false,
"message": "tenantid is required",
})
}
saved, err := ctl.productService.AddProductVariant(body)
if err != nil {
// The refusals are all about the CALLER's request, so they answer 400
// with the reason. Anything else is ours and answers 500.
for _, known := range []error{
repositories.ErrVariantParentMissing,
repositories.ErrVariantProductMissing,
repositories.ErrVariantSelfReference,
repositories.ErrVariantNotYours,
repositories.ErrVariantDuplicate,
} {
if errors.Is(err, known) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"code": fiber.StatusBadRequest, "status": false,
"message": err.Error(),
})
}
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"code": fiber.StatusInternalServerError, "status": false,
"message": "could not add the variant",
})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"code": fiber.StatusCreated, "status": true,
"message": "Success", "details": saved,
})
}
// RemoveProductVariant — DELETE /products/removeproductvariant?tenantid=&variantid=
//
// Detaches the size. Neither product is deleted: a variant is a relationship,
// and removing it leaves two ordinary products behind.
func (ctl *ProductController) RemoveProductVariant(c *fiber.Ctx) error {
tenantid, _ := strconv.Atoi(c.Query("tenantid"))
variantid, _ := strconv.Atoi(c.Query("variantid"))
if tenantid <= 0 || variantid <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"code": fiber.StatusBadRequest, "status": false,
"message": "tenantid and variantid are both required",
})
}
if err := ctl.productService.RemoveProductVariant(tenantid, variantid); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"code": fiber.StatusInternalServerError, "status": false,
"message": "could not remove the variant",
})
}
return c.JSON(fiber.Map{
"code": fiber.StatusOK, "status": true, "message": "Success",
})
}

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,41 @@
package controllers
import "testing"
/*
A rider belongs to a shop or to a delivery partner — never to neither, never to
both.
`CreateRider` used to demand a tenantid outright, which made a partner's rider
impossible to create: a partner supplies riders to many merchants and their
riders sit under no single one. The two ids are now exclusive, and both mistakes
are refused rather than one being silently preferred — a rider carrying both
appears in two directories and two assign pickers, with nothing downstream
saying which owns them.
`riderOwner` is the rule on its own so it can be checked without a request.
*/
func TestRiderOwnerAcceptsAShopsOwnRider(t *testing.T) {
if err := riderOwner(1147, 0); err != nil {
t.Fatalf("a tenant's own rider is valid: %v", err)
}
}
func TestRiderOwnerAcceptsAPartnersRider(t *testing.T) {
if err := riderOwner(0, 44); err != nil {
t.Fatalf("a partner's rider is valid: %v", err)
}
}
func TestRiderOwnerRefusesNeither(t *testing.T) {
if err := riderOwner(0, 0); err == nil {
t.Fatal("a rider owned by nobody appears in no directory and must be refused")
}
}
func TestRiderOwnerRefusesBoth(t *testing.T) {
if err := riderOwner(1147, 44); err == nil {
t.Fatal("a rider owned by both appears in two pickers and must be refused")
}
}

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

@@ -0,0 +1,68 @@
package controllers
import (
"encoding/json"
"fmt"
"nearle/models"
"github.com/gofiber/fiber/v2"
)
// parseStockRequests reads either one request or a list of them.
//
// Sniffing the first byte rather than trying one shape and falling back to the
// other: BodyParser consumes what it reads, so a failed first attempt can leave
// nothing for the second. The body is JSON here and `[` is unambiguous.
func parseStockRequests(c *fiber.Ctx) ([]models.StockRequest, error) {
body := c.Body()
for _, b := range body {
switch b {
case ' ', '\t', '\r', '\n':
continue
case '[':
var many []models.StockRequest
if err := json.Unmarshal(body, &many); err != nil {
return nil, err
}
return many, nil
}
break
}
var one models.StockRequest
if err := c.BodyParser(&one); err != nil {
return nil, err
}
return []models.StockRequest{one}, nil
}
// dedupeIDs drops repeats and anything non-positive, preserving order.
//
// Repeats matter here rather than being tidiness: approving adds stock, so the
// same id twice in one batch would try to receive the same delivery twice. The
// service has its own guard, but a batch should not be relying on it.
func dedupeIDs(ids []int) []int {
seen := make(map[int]bool, len(ids))
out := make([]int, 0, len(ids))
for _, id := range ids {
if id <= 0 || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
// stockBatchMessage says what happened in words a merchant can act on.
//
// "8 approved, 2 could not be" beats "Success" when two of ten did not land —
// the whole point of reporting per row is that the person can go and look.
func stockBatchMessage(ok, failed int, verb string) string {
if failed == 0 {
return fmt.Sprintf("%d %s", ok, verb)
}
return fmt.Sprintf("%d %s, %d could not be", ok, verb, failed)
}

View File

@@ -0,0 +1,102 @@
package controllers
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
/*
Batch decisions on stock requests.
Approving is not a status write — the service adds the requested quantity to the
branch's stock — so a batch has to behave like a list of separate actions that
each really happened, not like one all-or-nothing write. These tests pin the
parts that would quietly move stock twice or lose a row.
*/
func parseVia(t *testing.T, body string) ([]int, error) {
t.Helper()
app := fiber.New()
var got []int
var perr error
app.Post("/", func(c *fiber.Ctx) error {
reqs, err := parseStockRequests(c)
perr = err
for _, r := range reqs {
got = append(got, r.Productid)
}
return nil
})
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if _, err := app.Test(req); err != nil {
t.Fatalf("Test: %v", err)
}
return got, perr
}
func TestOneRequestStillWorksUnwrapped(t *testing.T) {
// The existing caller sends an object, and must keep working untouched.
got, err := parseVia(t, `{"productid":42,"qty":5}`)
if err != nil {
t.Fatalf("single: %v", err)
}
if len(got) != 1 || got[0] != 42 {
t.Errorf("got %v, want [42]", got)
}
}
func TestAListOfRequestsIsRead(t *testing.T) {
got, err := parseVia(t, `[{"productid":1,"qty":2},{"productid":2,"qty":3}]`)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 {
t.Errorf("got %v, want two requests", got)
}
}
func TestLeadingWhitespaceDoesNotHideAList(t *testing.T) {
// A client that pretty-prints its body still sends a list.
got, err := parseVia(t, " \n\t[{\"productid\":7,\"qty\":1}]")
if err != nil {
t.Fatalf("padded list: %v", err)
}
if len(got) != 1 || got[0] != 7 {
t.Errorf("got %v, want [7]", got)
}
}
func TestTheSameRequestCannotBeApprovedTwiceInOneBatch(t *testing.T) {
// Approving adds stock. The same id twice would receive one delivery twice.
got := dedupeIDs([]int{5, 5, 6, 5})
if len(got) != 2 || got[0] != 5 || got[1] != 6 {
t.Errorf("got %v, want [5 6]", got)
}
}
func TestNonPositiveIdsAreDropped(t *testing.T) {
// 0 is what an unparsed or missing field becomes; it is not a request.
if got := dedupeIDs([]int{0, -3, 9}); len(got) != 1 || got[0] != 9 {
t.Errorf("got %v, want [9]", got)
}
}
func TestOrderIsPreservedSoTheReportMatchesTheScreen(t *testing.T) {
got := dedupeIDs([]int{3, 1, 2})
if got[0] != 3 || got[1] != 1 || got[2] != 2 {
t.Errorf("got %v, want the order sent", got)
}
}
func TestThePartialOutcomeIsStatedNotHidden(t *testing.T) {
if msg := stockBatchMessage(8, 2, "approved"); msg != "8 approved, 2 could not be" {
t.Errorf("got %q", msg)
}
if msg := stockBatchMessage(5, 0, "approved"); msg != "5 approved" {
t.Errorf("got %q", msg)
}
}

View File

@@ -18,22 +18,59 @@ func NewStockRequestController(stockRequestService services.StockRequestService)
return &StockRequestController{stockRequestService: stockRequestService} return &StockRequestController{stockRequestService: stockRequestService}
} }
// CreateStockRequest accepts one request or a list of them.
//
// A shop restocking after a delivery is asking for twenty things at once, and
// sending twenty HTTP requests to say so is slow, gives no single answer, and
// leaves a half-sent batch behind when the connection drops. The single-object
// form is unchanged, so every existing caller keeps working.
//
// Each row is reported individually rather than the batch failing whole: a
// request for a product that no longer exists should not discard the other
// nineteen, and the requester needs to know WHICH one it was.
func (ctl *StockRequestController) CreateStockRequest(c *fiber.Ctx) error { func (ctl *StockRequestController) CreateStockRequest(c *fiber.Ctx) error {
var input models.StockRequest inputs, err := parseStockRequests(c)
if err := c.BodyParser(&input); err != nil { if err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false}) return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
} }
if len(inputs) == 0 {
if input.Status == "" { return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "send at least one request", "status": false})
input.Status = "Pending"
} }
err := ctl.stockRequestService.CreateStockRequest(&input) created := make([]models.StockRequest, 0, len(inputs))
if err != nil { failed := make([]fiber.Map, 0)
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
for i := range inputs {
if inputs[i].Status == "" {
inputs[i].Status = "Pending"
}
if err := ctl.stockRequestService.CreateStockRequest(&inputs[i]); err != nil {
failed = append(failed, fiber.Map{
"productid": inputs[i].Productid,
"reason": err.Error(),
})
continue
}
created = append(created, inputs[i])
} }
return c.JSON(fiber.Map{"code": 200, "message": "Stock request created", "status": true, "details": input}) // A single-object caller gets the object back, exactly as before.
if len(inputs) == 1 && len(failed) == 0 {
return c.JSON(fiber.Map{"code": 200, "message": "Stock request created", "status": true, "details": created[0]})
}
if len(created) == 0 {
return c.JSON(fiber.Map{
"code": http.StatusInternalServerError, "status": false,
"message": "no requests could be created", "details": fiber.Map{"failed": failed},
})
}
return c.JSON(fiber.Map{
"code": 200, "status": true,
"message": stockBatchMessage(len(created), len(failed), "created"),
"details": fiber.Map{"created": created, "failed": failed},
})
} }
func (ctl *StockRequestController) GetStockRequests(c *fiber.Ctx) error { func (ctl *StockRequestController) GetStockRequests(c *fiber.Ctx) error {
@@ -52,19 +89,70 @@ func (ctl *StockRequestController) GetStockRequests(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data}) return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data})
} }
// UpdateStockRequest decides one request or a batch of them.
//
// `requestid` for one, `requestids` for many, one status for the batch. That
// shape rather than a list of {id,status} pairs because the action a merchant
// takes is "approve these" or "reject these" — a mixed batch is two actions,
// and letting one call do both makes an accidental mass-approve possible.
//
// Approving is not a status write: the service adds the requested quantity to
// the branch’s stock. So each id is applied on its own and reported on its own.
// If the fourth of ten fails, the first three have really been received and the
// merchant has to know that, rather than being told the batch failed and
// approving it a second time.
func (ctl *StockRequestController) UpdateStockRequest(c *fiber.Ctx) error { func (ctl *StockRequestController) UpdateStockRequest(c *fiber.Ctx) error {
var input struct { var input struct {
RequestID int `json:"requestid"` RequestID int `json:"requestid"`
RequestIDs []int `json:"requestids"`
Status string `json:"status"` Status string `json:"status"`
} }
if err := c.BodyParser(&input); err != nil { if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false}) return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
} }
err := ctl.stockRequestService.UpdateStockRequest(input.RequestID, input.Status) if input.Status == "" {
if err != nil { return c.JSON(fiber.Map{"code": http.StatusBadRequest, "status": false,
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false}) "message": "status is required"})
} }
ids := input.RequestIDs
if input.RequestID != 0 {
ids = append([]int{input.RequestID}, ids...)
}
ids = dedupeIDs(ids)
if len(ids) == 0 {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "status": false,
"message": "send requestid, or requestids for several"})
}
updated := make([]int, 0, len(ids))
failed := make([]fiber.Map, 0)
for _, id := range ids {
if err := ctl.stockRequestService.UpdateStockRequest(id, input.Status); err != nil {
failed = append(failed, fiber.Map{"requestid": id, "reason": err.Error()})
continue
}
updated = append(updated, id)
}
// The single-id caller keeps the answer it has always had.
if len(ids) == 1 {
if len(failed) > 0 {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "status": false,
"message": failed[0]["reason"]})
}
return c.JSON(fiber.Map{"code": 200, "message": "Stock request updated", "status": true}) return c.JSON(fiber.Map{"code": 200, "message": "Stock request updated", "status": true})
}
if len(updated) == 0 {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "status": false,
"message": "no requests could be updated", "details": fiber.Map{"failed": failed}})
}
return c.JSON(fiber.Map{
"code": 200, "status": true,
"message": stockBatchMessage(len(updated), len(failed), "updated"),
"details": fiber.Map{"updated": updated, "failed": failed},
})
} }

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,
}) })
} }
@@ -580,3 +618,306 @@ func (ctl *TenantController) GetTenantByKeyword(c *fiber.Ctx) error {
"details": data, "details": data,
}) })
} }
// AssignStaff moves one of a merchant's people to a branch, or takes them off.
//
// `locationid` 0 unassigns, and is a real instruction rather than a missing
// value — somebody can leave a shop before the next one opens, and the console
// needs a way to say that which is not "delete the account".
//
// The tenant comes from the request and every check is scoped to it in the
// query, so a userid belonging to another business matches nothing and the call
// fails rather than moving a stranger's staff.
func (ctl *TenantController) AssignStaff(c *fiber.Ctx) error {
var input struct {
Tenantid int `json:"tenantid"`
Userid int `json:"userid"`
Locationid int `json:"locationid"`
Unassign bool `json:"unassign"`
}
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid input",
})
}
if input.Tenantid <= 0 || input.Userid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "tenantid and userid are required",
})
}
// The rule lives in services.ResolveAssignment so it can be tested without
// a request: a zero locationid must never be read as "unassign", because a
// dropped field looks exactly like one.
location, err := services.ResolveAssignment(input.Locationid, input.Unassign)
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
})
}
if err := ctl.tenantService.AssignStaffToBranch(input.Tenantid, input.Userid, location); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
}
// UpdateTenantProfile lets a merchant change their own business record.
//
// The body is read as a free-form map rather than into `models.Tenants`,
// deliberately. Binding to the struct would make every column on the table a
// candidate for writing and leave "which of these may a merchant set?" answered
// by whichever fields a form happened to send. The allowlist in
// services.TenantProfileUpdate answers it in one place instead, and everything
// absent from a request is left alone rather than blanked.
func (ctl *TenantController) UpdateTenantProfile(c *fiber.Ctx) error {
fields := map[string]any{}
if err := c.BodyParser(&fields); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid input",
})
}
// The row to write is named by `tenantid`, and it is the one value in the
// body that is never a value to write.
tenantID := 0
switch id := fields["tenantid"].(type) {
case float64:
tenantID = int(id)
case int:
tenantID = id
}
if tenantID <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "tenantid is required",
})
}
if err := ctl.tenantService.UpdateTenantProfile(tenantID, fields); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
}
// UpdateOwnProfile lets somebody change their own name, mobile or email.
//
// Read as a map rather than into `models.User` for the same reason as the shop
// profile: `app_users` keeps identity next to authorisation, so binding to the
// struct would make `roleid`, `locationid`, `status`, `password` and `pin`
// candidates for writing. The allowlist in services.OwnProfileUpdate answers
// "what may a person change about themselves?" in one place.
//
// Scoped by userid AND tenantid — the existing `users/update` checks only the
// userid, which is why the store user's account page has been read-only rather
// than editable.
func (ctl *TenantController) UpdateOwnProfile(c *fiber.Ctx) error {
fields := map[string]any{}
if err := c.BodyParser(&fields); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid input",
})
}
readID := func(key string) int {
switch id := fields[key].(type) {
case float64:
return int(id)
case int:
return id
}
return 0
}
userID, tenantID := readID("userid"), readID("tenantid")
if userID <= 0 || tenantID <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "userid and tenantid are both required",
})
}
if err := ctl.tenantService.UpdateOwnProfile(userID, tenantID, fields); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
}
// AssignPartner puts a merchant under a delivery partner, or takes them out of
// one.
//
// `partnerid` 0 is a real instruction here — it means "this merchant uses their
// own riders" — so it is read as sent rather than treated as absent. Everywhere
// else in the tenant API a zero means "not supplied"; this is the exception and
// it is the reason the route exists separately.
func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
var body struct {
Tenantid int `json:"tenantid"`
Partnerid *int `json:"partnerid"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
})
}
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
body.Tenantid = tid
}
if body.Tenantid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": "tenantid is required",
})
}
// A pointer, so "no partner" and "field omitted" are different requests.
// Sent as a plain int, an omitted field would read as 0 and quietly unassign
// a merchant's partner.
if body.Partnerid == nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "partnerid is required — send 0 to take the partner away",
})
}
if err := ctl.tenantService.AssignPartner(body.Tenantid, *body.Partnerid); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
})
}
return c.JSON(fiber.Map{
"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.",
})
}
/*
PUT /v1/web/tenants/storeopen
Open or close a branch for the day.
── Why this is on the guarded group ────────────────────────────────────────
Closing a shop stops it taking money. On /v1/mob/* — which carries no session
at all — anyone who could guess a locationid could shut any shop on the
platform. WebAuth checks the tenant scope, so a business can only reach its
own branches.
A business objection — a date in the past, a branch that is not yours — is 409
and not 500: the request was well formed, and the message is written to be
shown to the person who typed it.
*/
func (ctl *TenantController) SetStoreOpen(c *fiber.Ctx) error {
var req struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
// A POINTER, so "not sent" and "sent as false" stay different answers.
// A plain bool would read a missing field as "close this shop".
Isopen *bool `json:"isopen"`
Closeduntil string `json:"closeduntil"`
}
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.Isopen == nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "isopen is required", "status": false,
})
}
if err := ctl.tenantService.SetStoreOpen(
req.Tenantid, req.Locationid, *req.Isopen, req.Closeduntil,
); 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,
})
}

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

114
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,114 @@
# A local stack to develop Fiesta against, so a change can be tried before it is
# deployed.
#
# Named `.local` because it is NOT the deployment compose. Nothing here should
# ever run on a server: the passwords are literals, the ports are published to
# the host, and the whole point is that the data is disposable.
#
# docker compose -f docker-compose.local.yml up -d
#
# ── READ THIS FIRST ─────────────────────────────────────────────────────────
#
# An EMPTY database is not enough to boot Fiesta. `main.go` runs migrations on
# startup, and most of them assume tables that nothing in this repository
# creates — `ALTER TABLE products`, `ALTER TABLE productlocations`. AutoMigrate
# covers only stockrequests, the POS order tables and staffshifts. Against a
# blank database the first ALTER fails and `log.Fatal` stops the process.
#
# So load the schema before the first run:
#
# pg_dump --schema-only --no-owner --no-privileges \
# -h <live-host> -p 5433 -U <user> -d nearledb \
# > init/nearledb/01-schema.sql
#
# Anything in ./init/nearledb is applied, in filename order, the first time the
# volume is created. To reload after changing it, drop the volume:
#
# docker compose -f docker-compose.local.yml down -v
#
# ── Why bother ──────────────────────────────────────────────────────────────
#
# Because pointing `DB_HOST` at production is not local testing — it is
# production with a local UI. `go run .` there creates real tenants and real
# logins, and runs those same schema migrations against live data.
services:
# The main database. Port 5433 on the host, matching production's DB_PORT, so
# the only line that changes between the two is DB_HOST.
nearledb:
image: postgres:16-alpine
container_name: nearle-db-local
environment:
POSTGRES_DB: nearledb
POSTGRES_USER: nearle
POSTGRES_PASSWORD: localdev
# Asia/Kolkata to match the DSN Fiesta builds. Timestamps written here
# otherwise differ from production by five and a half hours, which is the
# sort of thing that looks like a bug in the code being tested.
TZ: Asia/Kolkata
PGTZ: Asia/Kolkata
ports:
- '5433:5432'
volumes:
- nearledb-data:/var/lib/postgresql/data
- ./init/nearledb:/docker-entrypoint-initdb.d:ro
healthcheck:
# `go run .` fails hard if the database is not up yet, so the compose
# waits for a real connection rather than for the container to exist.
test: ['CMD-SHELL', 'pg_isready -U nearle -d nearledb']
interval: 5s
timeout: 3s
retries: 20
# The catalogue database, kept separate exactly as it is in production — the
# comment on `db.CatalogueDB` is explicit that catalogue work must never touch
# nearledb, and one container per database is the cheapest way to keep that
# true locally too.
#
# The pgvector image rather than plain postgres: the live catalogue is a
# pgvector database. Nothing in Fiesta's Go code uses a vector column today —
# it reads the per-brand tables — but a schema dump from the real one will
# carry `CREATE EXTENSION vector`, and that fails on a stock image.
#
# 5434 on the host, not 5432: a developer machine usually already has
# something on 5432, and a silent connection to the wrong database is worse
# than a refused one.
cataloguedb:
image: pgvector/pgvector:pg16
container_name: nearle-catalogue-local
environment:
POSTGRES_DB: cataloguedb
POSTGRES_USER: nearle
POSTGRES_PASSWORD: localdev
TZ: Asia/Kolkata
PGTZ: Asia/Kolkata
ports:
- '5434:5432'
volumes:
- cataloguedb-data:/var/lib/postgresql/data
- ./init/cataloguedb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U nearle -d cataloguedb']
interval: 5s
timeout: 3s
retries: 20
# POS terminal presence, under a TTL.
#
# Optional in the same way it is optional in production: `db.InitRedis()`
# failing costs the till health board and nothing else, because losing a sale
# matters and losing a dashboard does not. Included because it is one line.
redis:
image: redis:7-alpine
container_name: nearle-redis-local
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
volumes:
nearledb-data:
cataloguedb-data:

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

View File

@@ -29,10 +29,10 @@ told.**
```bash ```bash
BASE=https://fiesta.nearle.app/live/api/v1/pos BASE=https://fiesta.nearle.app/live/api/v1/pos
# 1. Sign in # 1. Sign in — a mobile number and a 4-digit PIN
curl -s -X POST $BASE/login \ curl -s -X POST $BASE/login \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}' -d '{"contactno":"9876543210","pin":"4821","terminal_id":"T5EDD"}'
# 2. Use the token on everything else # 2. Use the token on everything else
curl -s $BASE/session -H "Authorization: Bearer $TOKEN" curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
@@ -44,9 +44,9 @@ curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
These steps are in order, and the order matters. These steps are in order, and the order matters.
**1. Sign in.** `POST /login` with the operator's own credentials — the same **1. Sign in.** `POST /login` with the operator's **mobile number and 4-digit
`app_users` account they use for the web console. There is no separate POS PIN** — the pair the back office issued them. Both are held on their own
password. `app_users` row; there is no separate POS credential store.
**2. Read `store_id` out of the response.** Do not ask anyone to type it. It is **2. Read `store_id` out of the response.** Do not ask anyone to type it. It is
whatever the back office says that account's outlet is. whatever the back office says that account's outlet is.
@@ -74,8 +74,8 @@ The only unauthenticated route. It is where a token comes from.
```json ```json
{ {
"authname": "rsselvapuram@gmail.com", "contactno": "9876543210",
"password": "…", "pin": "4821",
"terminal_id": "T5EDD", "terminal_id": "T5EDD",
"device_id": "a5f3…", "device_id": "a5f3…",
"location_id": 1135, "location_id": 1135,
@@ -85,15 +85,15 @@ The only unauthenticated route. It is where a token comes from.
| Field | Required | Notes | | Field | Required | Notes |
|---|---|---| |---|---|---|
| `authname` | yes* | Email. **Or** send `contactno` instead. | | `contactno` | yes | Mobile number. Send it as typed — `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account. |
| `contactno` | yes* | Mobile number, as an alternative to `authname`. | | `pin` | yes | Exactly 4 digits, never starting with `0`. |
| `password` | yes | |
| `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. | | `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. |
| `device_id` | no | The device's stable UUID. | | `device_id` | no | The device's stable UUID. |
| `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. | | `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. |
| `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. | | `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. |
| `authname` + `password` | no | The previous way in. Still accepted, so a shop whose numbers have not been backfilled is not stranded — see [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md). |
\* one of `authname` or `contactno`. `pin` wins if a password is sent as well; `authname` wins over `contactno`.
### Response — `200` ### Response — `200`
@@ -128,7 +128,7 @@ The only unauthenticated route. It is where a token comes from.
"staff": [ "staff": [
{ "user_id": 1148, "full_name": "Ragul Kannan", { "user_id": 1148, "full_name": "Ragul Kannan",
"role": "Super admin", "pin": "1111", "status": "Active" } "role": "Super admin", "status": "Active" }
] ]
} }
} }
@@ -156,7 +156,10 @@ sign-in.
**`locations`** — every outlet this account may open a till at. Length 1 is the **`locations`** — every outlet this account may open a till at. Length 1 is the
normal case. normal case.
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty.** **`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty**, and it
**no longer carries `pin`**: a PIN is now half of the sign-in, so a list of them
is a list of working credentials for the outlet. Switch operator through
`POST /login/pin` instead.
--- ---
@@ -191,9 +194,13 @@ Requires the token. Returns `401` when there isn't one.
Who may ring a bill at this terminal's outlet. For pulling down somebody hired Who may ring a bill at this terminal's outlet. For pulling down somebody hired
mid-shift without signing the terminal out. mid-shift without signing the terminal out.
**Takes no parameters.** The answer carries PINs, so the outlet comes from the **Takes no parameters.** The outlet comes from the caller's own token — a till
caller's own token — a till must not be able to ask who works at the shop next must not be able to ask who works at the shop next door. A request without a
door. A request without a token is refused whatever the enforcement setting is. token is refused whatever the enforcement setting is.
Names and roles only; **`pin` is not returned here either**, for the same reason
it left the login session. A supervisor who needs to see or change one uses
`GET /pos/users`, which is role-gated.
```json ```json
{ {
@@ -228,10 +235,84 @@ four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most
accounts carry an id that is not in the table at all — any mapping written on accounts carry an id that is not in the table at all — any mapping written on
the terminal would be wrong. the terminal would be wrong.
Back-office roles 1–6 also count as supervisors: somebody who already ### The till and Nearle Daily do not share accounts
administers the shop from a browser is not made less privileged by standing at
the counter. **`role_id` 0 is not a role** — it is what an account carries when `app_users` is the only thing the two products have in common. An account
nobody set one, and it grants nothing. belongs to one or the other, never to both:
| | Nearle Daily app + console | POS terminal |
|---|---|---|
| roles | `1`–`6` — Super admin, Operations, Admin, Manager | `7` Supervisor, `8` Cashier |
| `/applogin`, `/tenant/weblogin`, `/tenant/login` | yes | **not found** |
| `POST /v1/pos/login` | **403** | yes |
| listed by `/getallusers`, `/getstaffs` | yes | **hidden** |
A Nearle Daily **Super admin is not the administrator of anybody's POS.** The
back office reaches a till by *provisioning* a Supervisor from the console; it
never becomes one by signing in.
This was the other way round until it was measured. Roles 1–6 counted as
supervisors, on the reasoning that somebody who already administers a shop from
a browser is not made less privileged by standing at the counter. That handed
till-supervisor powers to **68 live accounts, 59 of them platform Super
admins**, while the actual shop accounts carry `roleid 0` and were refused.
Both directions are now closed in the queries themselves rather than in a check
each call site has to remember — a till account is not *rejected* by the app
login, it is simply not found.
**`role_id` 0 is not a role.** It is what an account carries when nobody set
one, 22 live accounts have it including a delivery rider, and it grants nothing
on either side.
### Every till account gets its own number and PIN
Both roles. A PIN now opens a *closed* terminal too, paired with the account's
mobile number — which is what removed the old deadlock: `/pos/login/pin` needs a
session that already exists, so before this a PIN-only account could not unlock
a till at all. For a Supervisor that was an outright deadlock; for a Cashier it
meant a shop that could not open until two people had arrived, and whoever gets
in at seven is as often the cashier as the supervisor.
**So a till account needs both `contactno` and `pin` set.** One without the
other cannot sign in. A username and password still work, and every account
created before this still has them.
Creation enforces half of that: `POST /pos/users` and `createposuser` refuse a
request with no mobile number, or one that holds no ten-digit number. The PIN
stays optional at creation — an account can be provisioned before somebody has
chosen one — so it is the half still worth checking before a shop goes live.
So a Cashier signs in exactly like a Supervisor does, and the *role* decides
what they get — not which credential they used:
```
POST /v1/pos/login supervisor.1185@pos.nearle.in -> full shell
POST /v1/pos/login cashier.1185@pos.nearle.in -> billing only
```
`POST /pos/users` generates both when the request omits them, and returns the
password **once**, in the creation response only:
```json
{ "user_id": 1452, "role": "Cashier",
"authname": "cashier.1185@pos.nearle.in",
"password": "<14 generated characters>", "pin": "4513", "has_password": true }
```
`GET /pos/users` never returns a password, only `has_password`. An admin who
loses it reissues rather than looks it up.
Send `authname` and `password` explicitly if the shop wants its people signing
in as themselves. A generated name that collides — a second cashier at one
outlet — becomes `cashier2.1185@pos.nearle.in`; a name **you** supplied is never
adjusted, it is refused, because silently signing somebody in as another
person's address is worse than an error.
**The PIN is no longer optional in practice.** The field still is — creation
accepts an account without one — but an account with no PIN cannot sign a
terminal in, and is told so by name: *"this account has no PIN set; ask your
supervisor to set one in the web console first."*
--- ---
@@ -239,11 +320,12 @@ nobody set one, and it grants nothing.
For a cashier taking over a counter a supervisor has already opened. For a cashier taking over a counter a supervisor has already opened.
**Requires an existing valid token.** That is the security model, not an **Requires an existing valid token.** A PIN alone is four digits — ten thousand
oversight: four digits is ten thousand guesses, which is no barrier at all to an guesses, and no barrier to an anonymous caller. Tying it to a session confines
anonymous caller. Tying it to a session means a supervisor has opened the the guesses to one outlet's staff, at a terminal somebody has already opened.
terminal with a real password first, and the guesses are confined to that one That is why this route takes a bare PIN and `/login` does not: there, the PIN is
outlet's staff. checked against one mobile number, and the number is what makes the pair worth
anything.
```bash ```bash
curl -s -X POST $BASE/login/pin \ curl -s -X POST $BASE/login/pin \
@@ -286,11 +368,16 @@ own store id.
|---|---| |---|---|
| `full_name` | required; split across `firstname`/`lastname` | | `full_name` | required; split across `firstname`/`lastname` |
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted | | `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
| `pin` | 4 digits. See the rules below | | `pin` | optional, 4 digits. See the rules below |
| `password` + `authname` | optional; for someone who also signs the terminal in | | `authname` | optional. **Generated if omitted** — `cashier.1185@pos.nearle.in`, or `cashier2.…` if that is taken |
| `password` | optional. **Generated if omitted**, and returned once in this response |
**At least one of `pin` or `password` is required.** Creating a person who can **Everyone gets a username and a password, cashiers included**, because a PIN
sign in by neither would look like it worked right up until somebody tried. cannot open a closed terminal. Omit both fields and they are generated for you,
so provisioning a shop is one call per person.
The response is the only time the password is returned; `GET /pos/users` reports
`has_password` and nothing more.
:warning: **PIN rules, and why** :warning: **PIN rules, and why**
@@ -417,22 +504,35 @@ GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach o
| Code | Meaning | What the till should do | | Code | Meaning | What the till should do |
|---|---|---| |---|---|---|
| `400` | Body unreadable, or neither `authname` nor `contactno` sent | Fix the request | | `400` | Body unreadable, or `a mobile number is required` / `a PIN is required` — one of the two fields is empty | Fix the request; do not send it again unchanged |
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **Wrong email and wrong password give the same message** — deliberately, so the endpoint isn't a directory of who banks here | | `401` | `those sign-in details were not recognised` | Ask them to re-type. **A wrong number and a wrong PIN give the same message** — deliberately, so the endpoint isn't a directory of who banks here. A number that cannot be ten digits, and a PIN that is not four, answer the same way |
| `403` | Real account, but it can't open this till | Show the message; re-typing won't help | | `403` | Real account, but it can't open this till | Show the message; re-typing won't help |
The `403` messages, verbatim: The `403` messages, verbatim:
- `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console`
- `this account is inactive; contact your administrator` - `this account is inactive; contact your administrator`
- `this account has no PIN set; ask your supervisor to set one in the web console first`
- `this account has no password set; set one in the web console first` - `this account has no password set; set one in the web console first`
- `this account is not attached to a tenant and cannot open a till` - `this account is not attached to a tenant and cannot open a till`
- `no active outlet is registered for this account` - `no active outlet is registered for this account`
- `this account cannot open a till at outlet 1185` - `this account cannot open a till at outlet 1185`
- `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login` - `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login`
That last one is real, not theoretical: `authname` is not unique in this schema. That last one is real, not theoretical: neither `authname` nor `contactno` is
Live data has the same address twice. We refuse rather than pick one, because unique in this schema. Live data has the same address twice, and 34 mobile
picking wrong means billing into another tenant's books. numbers shared by 104 accounts. We refuse rather than pick one, because picking
wrong means billing into another tenant's books.
Sign-in only ever looks at **till accounts** (roleid 7 and 8), which is what
makes a number workable as a credential: it has to be unique among a tenant's
own till staff, not across all 608 users on the platform.
The **first** one is the common case now, and it is deliberately specific where a
bad password is deliberately vague. By the time it fires the caller has already
proved the credential, so naming the reason leaks nothing they did not just
demonstrate — and the vague answer would send a shop owner hunting for a
password that was never wrong.
### Authenticated routes ### Authenticated routes

View File

@@ -0,0 +1,221 @@
# POS sign-in by mobile number, and shift assignment — handover to the terminal team
> ## ⚠️ Superseded by [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md)
>
> `POST /pos/login` now takes a mobile number and a **PIN**, not a mobile number
> and a password, and the session no longer carries `staff[].pin`. Read the new
> handover instead — build against this one and the sign-in screen will be
> wrong.
>
> Still accurate here, and not repeated there: the `createposuser` /
> `updateposuser` / `getposusers` field changes (§2), the number format rules
> (§3), and shifts (§4). **Not** accurate here: §2's claim that `shift_*`
> appears on `staff[]` in the `/pos/login` session — it never did.
Backend is done and builds clean. **Nothing about this breaks the current app** —
username sign-in keeps working exactly as it does today. Read §5 before you ship
anything, because the switch has one ordering rule that will lock out every
cashier if it is done in the wrong order.
---
## 1. What changed, in one line
`POST /pos/login` now accepts a **mobile number** as well as a username, only
till accounts are candidates, and a till account can carry a **shift**.
---
## 2. Endpoints — what was edited and how
### `POST /live/api/v1/pos/login` — CHANGED (backwards compatible)
The request body already had both fields. **Nothing in the contract changed.**
What changed is behaviour behind it.
```jsonc
// Sign in by mobile — the new way
{ "contactno": "9876543210", "password": "…" }
// Sign in by username — still works, unchanged
{ "authname": "cashier.1135@pos.nearle.in", "password": "…" }
```
`authname` wins if both are sent. The response is unchanged.
**Behavioural change:** the account lookup is now restricted to till roles
(Supervisor `7`, Cashier `8`).
*Why it matters to you:* previously a number shared with a back-office account
returned two candidates and the login was refused outright. On live data **34
numbers are shared by 104 accounts** — one by eleven — so without this,
sign-in by phone would simply have failed for a large number of people. Now a
number only has to be unique among till accounts.
A back-office user who types their own password at a till still gets the
specific `403 "this account is not set up for the till"` rather than a vague
rejection. That did not change.
### `POST /live/api/v1/web/tenants/createposuser` — CHANGED (two new fields)
```jsonc
{
"tenantid": 1087,
"locationid": 1135,
"full_name": "Priya Raman",
"role": "cashier",
"pin": "4731",
"contactno": "9876543210", // NEW — the sign-in number, and required
"shift_id": 3 // NEW — optional, 0/omitted = unassigned
}
```
The response gains `shift_id`, and `contactno` now comes back **normalised to
ten digits**.
### `PUT /live/api/v1/web/tenants/updateposuser` — CHANGED (two new fields)
Accepts `contactno` and `shift_id`. Every field stays optional; only what is
sent is written.
### `GET /live/api/v1/web/tenants/getposusers` — CHANGED (four new fields)
Each user in `details.users[]` now also carries:
```jsonc
{
"contactno": "9876543210",
"shift_id": 3,
"shift_name": "Morning",
"shift_start": "07:00",
"shift_end": "15:00"
}
```
Blank on accounts created before shifts existed. **`shift_*` also appears on
`staff[]` in the `/pos/login` session**, so the terminal gets it for free.
### `GET/POST/PUT /live/api/v1/web/tenants/{getstaffshifts,createstaffshift,updatestaffshift}` — NEW
Console-only. The terminal does not need to call these; shifts arrive with the
session. Documented for completeness:
```jsonc
// POST createstaffshift
{ "tenantid": 1087, "locationid": 1135,
"name": "Morning", "start_time": "07:00", "end_time": "15:00",
"weekdays": "1111100" } // 7 chars from Monday; empty = every day
```
Also mirrored under `/v1/mob/tenants/*`.
---
## 3. Number format — the one thing to get exactly right
The server reduces every number to **ten digits** before storing or matching:
non-digits are stripped, then a leading `91` or `0` is dropped once. Anything
that is not ten digits afterwards is **rejected**, not stored.
So all of these are the same account:
```
"+91 98765 43210" → 9876543210
"098765-43210" → 9876543210
"9876543210" → 9876543210
```
**What you should send:** the ten digits, or anything in the list above — the
server normalises either way. **Do not** send a country code the user did not
type, and do not reject `+91` locally; let it through and let the server reduce
it. What matters is that you never send something that normalises to a
*different* number than what the console stored.
---
## 4. Shift is informational
`shift_id` / `shift_name` / `shift_start` / `shift_end` are for **display**.
Nothing on the server refuses a bill rung outside a shift window, and you should
not add that check on the device either. A cashier locked out mid-queue by a
clock is a worse failure than a bill filed against the wrong window. Show whose
shift it is; do not gate on it.
Times are 24-hour `HH:MM`. A shift may legitimately wrap midnight (`22:00`
→ `06:00`) — do not assume `end > start`.
---
## 5. 🔴 Sequencing — read this before shipping
**Every existing POS account has no mobile number.** All 12 live accounts have
`contactno = ""`:
```
supervisor.1135@pos.nearle.in phone=""
cashier.1135@pos.nearle.in phone=""
… 12 of 12
```
If the app ships sign-in-by-phone **only**, every cashier in every shop is
locked out on the next app update.
**Required order:**
1. Backend deploys. *(Nothing changes for the app — username login is untouched.)*
2. Back office adds a mobile number to every existing till account through the
console. New accounts already require one — `createposuser` refuses a
request without it.
3. **Only then** the app makes mobile the primary sign-in field.
**Recommendation for the app:** keep both. One field labelled *"Mobile number or
username"* — if the value is all digits send it as `contactno`, otherwise as
`authname`. That is a handful of lines, works before and after the backfill, and
means a shop with one un-backfilled account is not stranded.
---
## 6. Errors you should handle
| Message | Meaning | What the app should do |
|---|---|---|
| generic 401 rejection | wrong number/username or wrong password | "Check your details" — do **not** say which was wrong |
| `this account is not set up for the till…` | a back-office login was used | Show it verbatim; it names the fix |
| `more than one account uses these sign-in details…` | ambiguous match | Show verbatim; it needs the back office |
| `this account has no password set…` | provisioned without a password | Show verbatim |
| `another till account in this business already signs in with 9876543210` | console-side only | Not seen by the app |
---
## 7. What did **not** change
- The response shape of `/pos/login`, including `token`, `expires_at`,
`can_manage_staff`, `locations[]` and `staff[]`
- `POST /pos/login/pin`
- Token format, TTL (30 days) and the `PosAuth` guard
- `POST /pos/orders`, `/pos/customers`, `/pos/health`, `GET /pos/catalogue`
- `POS_AUTH_REQUIRED` still defaults to off
---
## 8. Verify after deploy
```bash
B=https://fiesta.nearle.app/live/api/v1
# username sign-in still works (regression check — run this first)
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"authname":"supervisor.1135@pos.nearle.in","password":"…"}' \
| grep -o '"can_manage_staff":[a-z]*'
# expect: "can_manage_staff":true
# after a number is set on that account, the same account by phone
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"contactno":"9876543210","password":"…"}' \
| grep -o '"can_manage_staff":[a-z]*'
# a back-office account is still refused with the specific message
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"authname":"<a back-office account>","password":"…"}'
# expect: 403 "this account is not set up for the till"
```

View File

@@ -0,0 +1,321 @@
# POS sign-in by mobile number and PIN — handover to the terminal team
`POST /pos/login` now takes a **mobile number and a four-digit PIN**. Backend is
done, builds clean, and is proved end to end against a real database (§8).
Two things need reading before you ship: **§5**, because shipping the new screen
before the back office has filled in numbers and PINs locks out every cashier in
every shop; and **§4**, because the login response no longer carries staff PINs
and any terminal switching operator from that array will stop working.
Supersedes `POS_PHONE_LOGIN_HANDOVER.md`, which described the same endpoint
taking a number and a *password*.
---
## 1. What changed, in one line
A till signs in with the two things a person standing at a counter can actually
type — their number and their PIN — and the response stops handing out everyone
else's PIN.
---
## 2. The endpoint
`POST https://fiesta.nearle.app/live/api/v1/pos/login`
Unauthenticated. It is where a token comes from. Everything else on the POS
group sits behind the session it issues.
### Payload
```jsonc
{
"contactno": "9876543210", // required
"pin": "4821", // required — exactly 4 digits, never starts with 0
"terminal_id": "T5EDD", // optional, recorded on the session
"device_id": "a5f3…", // optional
"location_id": 1135, // optional, multi-outlet accounts only
"configid": 1 // optional, only if you are told to send it
}
```
| Field | Required | Notes |
|---|---|---|
| `contactno` | **yes** | Send it as typed. `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account — the server reduces every number to ten digits before matching. |
| `pin` | **yes** | Exactly 4 digits. `0451` is not a PIN here and never was — see §3. |
| `terminal_id` | no | This till's short code. Recorded on the session so a stolen token can be told apart from the terminal it was issued to. |
| `device_id` | no | The device's stable UUID. |
| `location_id` | no | Means something **only** for an account entitled to more than one outlet. It is a request, not an assertion: checked against what the account may reach, and refused if it is not one of them. |
| `configid` | no | Inferred when absent. Send it only after the ambiguity error in §6. |
| `authname` + `password` | no | The previous way in. **Still works** — see §5. |
If both are sent, `pin` is used over `password` and `authname` over `contactno`.
### Response — `200`, unchanged in shape
```jsonc
{
"code": 200,
"status": true,
"message": "Login successful",
"details": {
"token": "eyJ1aWQiOjQwMDEsInRpZCI6MTA4Nywi….PKmoMn92BMs",
"expires_at": "2026-09-11T11:31:08Z",
"user_id": 4001,
"full_name": "Meena Sundaram",
"email": "meena@example.com",
"role_id": 7,
"role": "Supervisor",
"can_manage_staff": true,
"tenant_id": 1087,
"tenant_name": "R Mart",
"store_id": "1135",
"location_id": 1135,
"location_name": "Selvapuram",
"gstin": "33AABCU9603R1ZM",
"address": "4 Trichy Road",
"phone": "04422334455",
"locations": [
{ "location_id": 1135, "location_name": "Selvapuram",
"address": "4 Trichy Road", "city": "Coimbatore", "status": "Active" }
],
"staff": [
{ "user_id": 4002, "full_name": "Priya Raman",
"role": "Cashier", "status": "Active" }
]
}
}
```
**The only change to the response is that `staff[].pin` is gone.** Everything
else — `token`, `expires_at`, `store_id`, `locations[]`, `can_manage_staff` —
is byte-for-byte what it was. §4 is why, and what to do instead.
Two corrections to what the previous handover promised about this response:
`staff[]` has **no** `shift_id` / `shift_name` / `shift_start` / `shift_end`
fields, and never did. The shift fields exist on `GET /web/tenants/getposusers`.
If the terminal needs the shift on the sign-in screen, say so and it can be
added — do not write code against it today.
---
## 3. What counts as a PIN, and the trap in it
Four digits, and **never a leading zero**. `app_users.pin` is a `bigint`, so
`0451` is stored as `451` and read back as three digits — somebody would type
four and be refused for ever. Creation refuses those, so this only matters for
what you let a person type: accept four digits, send them as a string.
**Do not add a client-side guessable-PIN check.** The console refuses to *issue*
`1234`, `1111`, `9999` and a handful more, but live data already holds `1234` on
eleven accounts and `1111` on nine — issued before that rule existed. Sign-in
deliberately accepts them, because refusing them would lock twenty real people
out of terminals this platform signed them up to. A rule about what may be
created is not a rule about what may be typed.
**Number format.** Non-digits are stripped, then a leading `91` or `0` is
dropped once. Anything that is not ten digits afterwards is rejected. Do not
add a country code the user did not type, and do not reject `+91` locally — let
it through and let the server reduce it.
---
## 4. 🔴 `staff[].pin` is gone — read this if you switch operators offline
The login session used to carry every colleague's PIN so the till could switch
operator without a round trip. That was defensible while a PIN was *shift
attribution*: the token decided which books a terminal could reach, and the PIN
only decided whose name went on the bill.
That stopped being true the moment the PIN became half of the sign-in. The array
would now be a list of working credentials for the whole outlet — including the
supervisor's, which carries `can_manage_staff`. Any cashier could read it and
sign back in as their own manager.
So `pin` no longer appears in `staff[]`, and no longer appears in `GET
/pos/staff` either. `staff[]` still carries `user_id`, `full_name`, `role` and
`status`, so the operator picker still works.
**Switch operator through `POST /pos/login/pin`** — an existing session, a bare
PIN, and you get a fresh session with the new person's role:
```bash
curl -s -X POST "$B/pos/login/pin" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"pin":"7391"}'
```
**The cost, stated plainly: this needs the network.** A till that switched
operators offline against the cached array can no longer do so. If that matters
for your shops, tell us — the options are a short-lived cache of hashed PINs on
the device, or a separate endpoint a supervisor calls once to warm one. Neither
is built, and neither should be built on a guess about how shops actually work.
One wart we did **not** change: `staff[]` can contain a back-office account that
has a PIN and sits at that location — the query filters on having a PIN, not on
role. Tapping them and typing their PIN answers *"this account is not set up for
the till"*. Filtering by role would look tidier and would empty the staff list
for a great many real shops, whose people carry role ids that are not POS roles
at all. Show the message; it names the fix.
---
## 5. 🔴 Sequencing — read this before shipping
**Every till account on the platform predates the number it now signs in with.**
When the previous handover was written, all 12 live POS accounts had
`contactno = ""`. Some also have no PIN. An account needs **both** to sign in.
If the app ships number-and-PIN **only**, every cashier in every shop is locked
out on the next update.
**Required order:**
1. **Backend deploys.** Nothing changes for the app — username and password
still work, unchanged.
2. **Back office fills in a mobile number and a PIN on every existing till
account** through the console (`PUT /web/tenants/updateposuser`). New
accounts cannot be created without a number — `createposuser` refuses one
with `400 "a mobile number is required…"`, so this is a finite backfill of
the accounts that predate the rule rather than a gap that keeps reopening.
**A PIN is still optional at creation**, so an account can be created that
cannot yet sign in. It is told so by name at the counter — see the `403` in
§6 — but the check in step 2 below is what catches it first.
Editing is unaffected: an update that does not mention `contactno` leaves the
stored number alone rather than clearing it, so a partial edit cannot strand
somebody mid-backfill.
3. **Only then** does the app make number-and-PIN the primary sign-in.
**Recommendation for the app:** ship the new screen, and keep a small
"sign in with a username instead" link behind it until step 2 is confirmed
finished for every shop. One un-backfilled account then means one awkward login,
not a shop that cannot open.
**Verify step 2 is actually done** before you flip anything — a shop is ready
only when every one of its till accounts has both fields:
```bash
curl -s "$B/web/tenants/getposusers?tenantid=1087&locationid=1135" \
-H "Authorization: Bearer $CONSOLE_TOKEN" | jq '.details.users[] | {full_name, contactno, pin}'
```
---
## 6. Errors you should handle
| Code | Message | What it means | What the app should do |
|---|---|---|---|
| `400` | `a mobile number is required` | The field was empty, or held no digits at all | Fix locally; do not resend unchanged |
| `400` | `a PIN is required` | Neither `pin` nor `password` was sent | Fix locally |
| `401` | `those sign-in details were not recognised` | Wrong number, wrong PIN, a number that cannot be ten digits, a PIN that is not four, or somebody who has left | *"Check your number and PIN"* — do **not** say which was wrong |
| `403` | `this account has no PIN set; ask your supervisor to set one in the web console first` | Provisioned without one | Show verbatim; it names the fix |
| `403` | `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console` | A back-office login was used | Show verbatim |
| `403` | `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login` | Ambiguous match | Show verbatim; it needs the back office |
| `403` | `this account cannot open a till at outlet 1185` | `location_id` named an outlet this account cannot reach | Drop `location_id` and retry, then show the picker from `locations[]` |
The `401` is deliberately one message for several causes. Distinguishing them
turns an unauthenticated endpoint into a directory of who banks here.
---
## 7. 🔴 One thing this endpoint cannot fix: rate limiting
A four-digit PIN is ten thousand guesses. On `/pos/login/pin` that is contained,
because the route needs a valid session and can only reach one outlet's staff.
On `/pos/login` it is **not** — the route is unauthenticated by necessity, and
the pair is only strong while an attacker cannot sit and try every PIN against a
number they know.
Nothing in this change adds a limiter, and the endpoint is the wrong place for
one. **Before this becomes the only way into a till, `/pos/login` needs a rate
limit at the edge** — per source and per `contactno`, with a lockout after a
few failures. Please raise it with whoever owns the ingress; it is not the
terminal team's job, but shipping the screen without it is what would make it
somebody's incident.
---
## 8. How this was verified
Not against production — there are no live credentials in this working copy, so
nothing here was run against the real database. Proved instead against a
throwaway Postgres seeded with a shop and six accounts, running the **real**
repository, service and SQL:
```bash
docker run -d --rm --name nearle-posproof -e POSTGRES_PASSWORD=proof \
-e POSTGRES_DB=proof -p 55432:5432 postgres:16-alpine
POS_PROOF_DSN='postgres://postgres:proof@localhost:55432/proof?sslmode=disable' \
POS_TOKEN_SECRET=proof-secret-at-least-16 \
go run ./scratch/posphonepinproof # 18 passed, 0 failed
```
Covered: sign-in by number and PIN; the same account typed four different ways;
a cashier getting a cashier's session; a weak-but-issued PIN still admitted; a
wrong PIN, an unknown number, an account with no PIN, a back-office account, a
malformed number, a malformed PIN and a leaver all refused with the right
answer; username-and-password still working; `POST /login/pin` still switching
operator; no PIN anywhere in the response; a token minted.
Unit tests for the same rules: `go test ./repositories/` —
`repositories/posLogin_test.go`.
**Still to do against live data, by whoever has the credentials:** confirm the
regression check below, and count how many till accounts still lack a number or
a PIN (§5, step 2).
```bash
B=https://fiesta.nearle.app/live/api/v1
# regression: username sign-in must still work — run this first
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"authname":"supervisor.1135@pos.nearle.in","password":"…"}' \
| grep -o '"can_manage_staff":[a-z]*'
# expect: "can_manage_staff":true
# the new way in, once that account has a number and a PIN
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"contactno":"9876543210","pin":"4821"}' \
| grep -o '"can_manage_staff":[a-z]*'
# and no PIN in the response
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"contactno":"9876543210","pin":"4821"}' | grep -c '"pin"'
# expect: 0
```
---
## 9. What did **not** change
- The response shape, apart from `staff[].pin` leaving
- `token` format, TTL (30 days) and the `PosAuth` guard
- `POST /pos/login/pin` — same request, same answer, and it still needs a session
- `POST /pos/orders`, `/pos/customers`, `/pos/health`, `GET /pos/catalogue`
- `GET /pos/users`, which still shows PINs to a supervisor and blanks them for a
cashier — a supervisor sets those PINs, so seeing them tells them nothing they
could not already change
- `POS_AUTH_REQUIRED` still defaults to off
---
## 10. Where this lives, if you need to read it
| What | Where |
|---|---|
| Which credential was offered, and how a row is checked against it | `repositories/posAuthRepository.go` — `posLoginSecret` |
| Which column names the account, and number normalisation | `repositories/posAuthRepository.go` — `posLoginIdentity` |
| PIN format at sign-in vs. the rule for issuing one | `repositories/posUserRepository.go` — `posLoginPin`, `validatePosPin` |
| The staff list, and why `Pin` is `json:"-"` | `models/pos.go` — `PosStaffMember` |
| Request/response shapes | `models/pos.go` — `PosLoginRequest`, `PosSession` |
| Full endpoint reference | `docs/POS_LOGIN.md` |

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.

172
docs/STORE_OPEN_APP.md Normal file
View File

@@ -0,0 +1,172 @@
# Store open / closed — the app contract
A shop can now close itself for the day. When it does, customers must not be
able to order from it.
Two things to build: show the closed state, and handle the refusal if someone
orders anyway. Everything else is done.
---
## 1. Four new fields on the store list
```
GET https://fiesta.nearle.app/live/api/v1/mob/tenants/getcustomertenants
?customerid=&tenant=0&latitude=&longitude=&categoryid=
```
Every store in `details[]` now carries:
```json
{
"isopen": true,
"closeduntil": "",
"isaccepting": true,
"closedreason": ""
}
```
### Read `isaccepting`, not `isopen`
This is the one thing to get right.
| field | what it is |
|---|---|
| **`isaccepting`** | **the answer.** Can this store take an order right now? |
| `isopen` | the raw switch — what the shopkeeper last pressed |
| `closeduntil` | the first day back, `"YYYY-MM-DD"`, or `""` |
| `closedreason` | why not, written to show the customer. `""` when open |
They differ. A shop that closed on Friday "until Monday" has `isopen: false`
every day including Monday — the flag is never flipped back. On Monday the
server works out that the date has passed and sets `isaccepting: true`.
Branch the UI on `isaccepting`. `isopen` and `closeduntil` are there if you
want to say more, not to decide with.
### `closedreason` is already written for the customer
```
"This store is closed today"
"This store is closed today and reopens on 12 Oct"
"This store is not currently available"
```
Show it as it comes. Don't build the sentence from `closeduntil` — the server
already handles the cases where there is no date, where the branch has been
switched off by Nearle rather than by the shop, and where the date is
unreadable.
---
## 2. A closed store is still returned
It is **not** filtered out of the list. You decide whether to grey it or hide
it.
**I would grey it.** A shop that vanishes reads to a regular customer as gone
for good; one marked "Closed today — reopens 12 Oct" brings them back
tomorrow. But it is your call, and the data supports either.
What the store must not do is look ordinary. If `isaccepting` is false,
whatever you render has to stop a customer getting as far as a basket.
---
## 3. Delivery windows follow automatically
```
GET /v1/mob/deliveryslots/available?tenantid=&locationid=
```
A closed store now returns an **empty list**, plus the reason:
```json
{ "code": 200, "status": true, "details": [], "closedreason": "This store is closed today" }
```
You already handle an empty `details` as "no windows here" — see
`DELIVERY_SLOTS_APP.md` §2 — so this needs nothing new. It stops a customer
picking tomorrow morning from a shop that is shut and only finding out at
checkout.
---
## 4. The refusal
```
POST /v1/mob/orders/createorder
```
If the store is closed, the order is refused:
```json
{
"code": 409,
"status": false,
"message": "This store is closed today and reopens on 12 Oct"
}
```
**Handle this properly rather than as a generic failure.** It is not a bug or
an edge case — it happens to real people in normal use: a customer with the
app open when the shopkeeper closes, or a screen left open since this morning.
On 409: show the `message`, and refresh the store list so the UI catches up.
The server checks on every order because `/v1/mob/*` carries no session —
anything arriving is a claim. Hiding the store in the app is presentation;
this is the enforcement.
---
## 5. Nothing changes for an open store
Every store on the platform has `isopen: true` and `isaccepting: true` right
now. There was no backfill and there will not be one. A shop that never
touches the switch behaves exactly as it does today.
---
## 6. Done on our side
| | |
|---|---|
| `tenantlocations.isopen` + `closeduntil` | ✅ live, default open |
| Four fields on `getcustomertenants` | ✅ live, verified on all 9 stores |
| `PUT /v1/web/tenants/storeopen` (guarded) | ✅ live — 401 unauthenticated, absent from `/v1/mob` |
| `createorder` refuses a closed store | ✅ built, 409 with the shop's wording |
| Closed store returns no delivery windows | ✅ built |
| Merchant console: switch in the shop profile header | ✅ live |
| Nearle console: "Trading" column per branch | ✅ live |
| Auto-reopen on the date | ✅ computed on read — no job, nothing to miss |
**Verified against production:** the four fields, and that the write endpoint
is live and guarded.
**Not yet exercised against production:** the 409 and the empty window list.
Both are covered by unit tests and need a real store closed to confirm
end to end. If you want to test, ask for tenant `1141` / branch `1179` to be
closed — it is a test store with delivery windows already set.
---
## 7. Two rules worth knowing
**Closed with no date stays closed.** A power cut has no end date. The shop
reopens when a person says so, not on a timer. `closeduntil: ""` with
`isaccepting: false` is a normal state, not missing data.
**"Closed until the 12th" means open ON the 12th.** The date names the first
day back, which is how the phrase reads in English. The console says
"Reopens on" so nobody has to work it out.
---
## Questions
The rule lives in one function server-side (`models/storeopen.go`), and the
store list, order creation and the delivery-window endpoint all call it. So if
anything about open/closed looks inconsistent between those three, it is one
place to fix and not three to reconcile. Ask rather than working around it in
the app — a local override is how the two sides drift apart.

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"
) )
@@ -20,6 +25,20 @@ type Facade struct {
StockRequestController *controllers.StockRequestController StockRequestController *controllers.StockRequestController
CatalogueController *controllers.CatalogueController CatalogueController *controllers.CatalogueController
PosController *controllers.PosController PosController *controllers.PosController
LiveController *controllers.LiveController
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.
@@ -30,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
@@ -45,14 +83,41 @@ 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)
//Tenant Module
//
// 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.
//
// BEFORE the order controller, which takes it: order creation asks this
// service whether the shop is trading before accepting, using the same
// rule the customer-facing store list runs. No dependency the other way.
tenantService := services.NewTenantService(tenantRepo, inviteService)
tenantController := controllers.NewTenantController(tenantService)
// 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, tenantService)
// 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, tenantService)
// Deliveries Module // Deliveries Module
deliveriesRepo := repositories.NewDeliveriesRepository(db) deliveriesRepo := repositories.NewDeliveriesRepository(db)
@@ -64,11 +129,6 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
utilsService := services.NewUtilsService(utilsRepo) utilsService := services.NewUtilsService(utilsRepo)
utilsController := controllers.NewUtilsController(utilsService) utilsController := controllers.NewUtilsController(utilsService)
//Tenant Module
tenantRepo := repositories.NewTenantRepository(db)
tenantService := services.NewTenantService(tenantRepo)
tenantController := controllers.NewTenantController(tenantService)
//Partner Module //Partner Module
partnerRepo := repositories.NewPartnerRepository(db) partnerRepo := repositories.NewPartnerRepository(db)
partnerService := services.NewPartnerService(partnerRepo) partnerService := services.NewPartnerService(partnerRepo)
@@ -94,6 +154,92 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
posService := services.NewPosService(posRepo, posPresence) posService := services.NewPosService(posRepo, posPresence)
posController := controllers.NewPosController(posService) posController := controllers.NewPosController(posService)
// Shares the POS service purely for its outlet-ownership check — the
// stream itself reads no database and holds no state beyond its
// subscribers.
liveController := controllers.NewLiveController(posService)
// Catalogue Upload Module — our own receipt for every spreadsheet sent to
// the ingest service. Their host deletes an unreviewed drop after seven
// days and the batch id is the only credential for reading the result
// back, so the id has to be kept somewhere that outlives a browser tab.
catalogueUploadRepo := repositories.NewCatalogueUploadRepository(db)
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService)
// Scan Module — a label from the customer's camera to "buy it here".
// Reads both databases: the catalogue to recognise the product, nearledb
// for who the customer is and what their outlets have on the shelf.
scanRepo := repositories.NewScanRepository(db, catalogueDB)
scanService := services.NewScanService(scanRepo, embedder)
scanController := controllers.NewScanController(scanService)
// 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,
@@ -106,6 +252,14 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
StockRequestController: stockRequestController, StockRequestController: stockRequestController,
CatalogueController: catalogueController, CatalogueController: catalogueController,
PosController: posController, PosController: posController,
LiveController: liveController,
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=

10
init/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Schema and data dumps. A dump taken from production carries real customers,
# real orders and cleartext passwords — none of it belongs in this repository,
# and a local seed is regenerated in one command anyway.
*.sql
# ...except the synthetic fixture. It invents a merchant rather than copying
# one, so there is nothing in it to leak and everybody gets the same shop to
# develop against.
!02-seed.sql
*.dump

79
init/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Local database seed
Anything in `nearledb/` or `cataloguedb/` is applied by Postgres, in filename
order, **the first time the volume is created**. Editing a file later does
nothing on its own — drop the volume to re-apply:
```
docker compose -f ../docker-compose.local.yml down -v
```
## Why this is not optional
Fiesta runs migrations on boot, and most of them assume tables that nothing in
this repository creates:
```
ALTER TABLE products ADD COLUMN IF NOT EXISTS productimages
ALTER TABLE products ADD COLUMN IF NOT EXISTS imageid
ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat
```
`AutoMigrate` covers only `stockrequests`, the two POS order tables and
`staffshifts`. Against an empty database the first `ALTER` fails and
`log.Fatal` stops the process — so a schema is required before the first run.
## Getting the schema
Structure only, no data, no ownership:
```
pg_dump --schema-only --no-owner --no-privileges \
-h <live-host> -p 5433 -U <user> -d nearledb \
> nearledb/01-schema.sql
```
**Take the schema, not the data.** A dump with rows in it puts real customers,
real orders and real (cleartext) passwords on a laptop, and this directory is
inside a git repository. `.gitignore` excludes `*.sql` here for that reason.
## Getting something to test against
`nearledb/02-seed.sql` is committed and applied automatically, so a fresh
volume already has a merchant to sign into. It invents one rather than copying
one, which is why it can live here at all.
| Account | Password | Opens |
|---|---|---|
| `super@nearle.invalid` | `localdev` | Nearle Admin — the platform workspace |
| `admin@testmart.invalid` | `localdev` | Store Admin — all of Testmart's branches |
| `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');

232
init/nearledb/02-seed.sql Normal file
View File

@@ -0,0 +1,232 @@
-- A synthetic shop to develop against.
--
-- INVENTED DATA, and that is the point. Copying rows out of production to get a
-- working local console puts real customers, real orders and real (cleartext)
-- passwords on a laptop — so this file makes up a merchant instead, which means
-- it can be committed, shared, and reset without anybody thinking about what is
-- in it.
--
-- Everything uses ids from 9000 up, well clear of anything real, so a local
-- database that has also had production rows loaded into it will not collide.
--
-- What it gives you:
--
-- * a complete merchant (9001 Testmart) — profile filled in, so the setup
-- walkthrough shows it finished
-- * an INCOMPLETE merchant (9002 Halfmart) with `categoryid = 0` — the exact
-- shape that broke `gettenantinfo` and made the profile step impossible to
-- finish. Worth keeping as a permanent regression fixture.
-- * two branches, so the "All branches" tenant-wide read has something to
-- aggregate and cannot silently show one outlet
-- * one account per role, so every workspace can be signed into
-- * products in each of the three app-visibility states — on sale, no stock,
-- no price — because those three are what most console bugs turn out to be
--
-- Passwords are the literal string below. Fiesta compares passwords in clear,
-- which is a real problem in production and simply a fact here.
BEGIN;
-- ── Masters the tenant joins hang off ───────────────────────────────────────
INSERT INTO app_location (applocationid, locationname, latitude, longitude, radius)
VALUES (9001, 'Testville', '11.0168', '76.9558', 25000)
ON CONFLICT (applocationid) DO NOTHING;
INSERT INTO app_category (categoryid, categoryname)
VALUES (9001, 'Grocery')
ON CONFLICT (categoryid) DO NOTHING;
-- ── Merchant one: complete ──────────────────────────────────────────────────
INSERT INTO tenants (
tenantid, tenantname, companyname, configid, categoryid, applocationid,
primaryemail, primarycontact, address, suburb, city, state, postcode,
latitude, longitude, tenantimage, tenantinfo, licenseno, registrationno,
minorder, approved, status
) VALUES (
9001, 'Testmart', 'Testmart Retail', 1, 9001, 9001,
'owner@testmart.invalid', '9000000001', '1 Test Street', 'Testville', 'Coimbatore',
'Tamil Nadu', '641001', '11.0168', '76.9558',
'https://placehold.co/200x200?text=Testmart', 'Daily needs and fresh produce.',
'12345678901234', 'REG-TESTMART-1',
99, 1, 'Active'
) ON CONFLICT (tenantid) DO NOTHING;
-- ── Merchant two: the regression fixture ────────────────────────────────────
--
-- `categoryid = 0` matches no `app_category` row. Both master joins in
-- GetTenantByID were INNER, so this tenant came back as an all-zero record —
-- the console showed an empty profile form over a real business, and the setup
-- walkthrough's first step could never complete. Four of two hundred live
-- tenants are in this state. Keep it: it is the cheapest possible guard against
-- that join going back.
INSERT INTO tenants (
tenantid, tenantname, configid, categoryid, applocationid,
primaryemail, primarycontact, address, city, state, postcode, approved, status
) VALUES (
9002, 'Halfmart', 1, 0, 9001,
'owner@halfmart.invalid', '9000000002', '2 Test Street', 'Coimbatore',
'Tamil Nadu', '641002', 1, 'Active'
) ON CONFLICT (tenantid) DO NOTHING;
-- ── Two branches, so tenant-wide reads have something to aggregate ──────────
INSERT INTO tenantlocations (
locationid, tenantid, locationname, email, contactno, address, suburb, city,
state, postcode, latitude, longitude, opentime, closetime, applocationid,
deliveryradius, deliverymins, status
) VALUES
(9101, 9001, 'Testmart Main', 'main@testmart.invalid', '9000000011',
'1 Test Street', 'Testville', 'Coimbatore', 'Tamil Nadu', '641001',
'11.0168', '76.9558', '08:00', '22:00', 9001, 5000, 30, 'Active'),
(9102, 9001, 'Testmart North', 'north@testmart.invalid', '9000000012',
'9 North Road', 'Northville', 'Coimbatore', 'Tamil Nadu', '641004',
'11.0500', '76.9600', '09:00', '21:00', 9001, 5000, 30, 'Active'),
(9103, 9002, 'Halfmart Main', 'main@halfmart.invalid', '9000000021',
'2 Test Street', 'Testville', 'Coimbatore', 'Tamil Nadu', '641002',
'11.0170', '76.9560', '08:00', '22:00', 9001, 5000, 30, 'Active')
ON CONFLICT (locationid) DO NOTHING;
-- ── One account per role ────────────────────────────────────────────────────
--
-- `authname` and `configid` are both set on every row. Login is
-- `WHERE authname = ? AND configid = ?` and never looks at the email column, so
-- an account missing either is created, listed, and refused at the sign-in
-- screen. That was a real bug; these rows are what it looks like done right.
--
-- roleid decides the workspace: 1 and 3 reach Store Admin, everything else is a
-- branch user. 7 and 8 are till accounts and are excluded from every
-- back-office query by the backend itself.
INSERT INTO app_users (
userid, authname, firstname, lastname, email, dialcode, contactno,
configid, roleid, password, tenantid, locationid, applocationid,
status, issuperadmin
) VALUES
(9201, 'admin@testmart.invalid', 'Tessa', 'Admin', 'admin@testmart.invalid',
'+91', '9000000101', 1, 3, 'localdev', 9001, 9101, 9001, 'Active', false),
(9202, 'main@testmart.invalid', 'Mani', 'Manager', 'main@testmart.invalid',
'+91', '9000000102', 1, 4, 'localdev', 9001, 9101, 9001, 'Active', false),
-- Hired, not yet placed. `locationid = 0` is the state the people screen
-- exists to resolve, and it was invisible until GetStaffs stopped INNER
-- JOINing tenantlocations.
(9203, 'newhire@testmart.invalid', 'Nila', 'Newhire', 'newhire@testmart.invalid',
'+91', '9000000103', 1, 4, 'localdev', 9001, 0, 9001, 'Active', false),
(9204, 'super@testmart.invalid', 'Sup', 'Ervisor', 'super@testmart.invalid',
'+91', '9000000104', 1, 7, 'localdev', 9001, 9101, 9001, 'Active', false),
(9205, 'cash@testmart.invalid', 'Cash', 'Ier', 'cash@testmart.invalid',
'+91', '9000000105', 1, 8, 'localdev', 9001, 9101, 9001, 'Active', false),
(9206, 'admin@halfmart.invalid', 'Hal', 'Admin', 'admin@halfmart.invalid',
'+91', '9000000201', 1, 3, 'localdev', 9002, 9103, 9001, 'Active', false)
ON CONFLICT (userid) DO NOTHING;
-- ── Products, in each of the three visibility states ────────────────────────
--
-- categoryid 2 is what the customer app browses. A product filed anywhere else
-- is invisible to shoppers however well priced and stocked, which is the single
-- most common cause of "it is not showing in the app".
INSERT INTO products (
productid, tenantid, categoryid, subcategoryid, productname, productbrand,
productsku, productunit, productcost, retailprice, taxpercent, approve,
productimage, productdesc
) VALUES
(9301, 9001, 2, 0, 'Test Rice 5kg', 'Testbrand', 'TM-RICE-5K', '5kg',
320, 395, 5, 1, 'https://placehold.co/120x120?text=Rice', 'Everyday long grain'),
(9302, 9001, 2, 0, 'Test Oil 1L', 'Testbrand', 'TM-OIL-1L', '1L',
150, 198, 5, 1, 'https://placehold.co/120x120?text=Oil', 'Cooking oil'),
(9303, 9001, 2, 0, 'Test Biscuits 100g', 'Testbrand', 'TM-BISC-100', '100g',
18, 25, 12, 1, 'https://placehold.co/120x120?text=Biscuits', 'Sweet biscuits'),
-- Filed under no category: priced, released and stocked below, and still
-- invisible to the app. The state that catches everybody.
(9304, 9001, 0, 0, 'Test Uncategorised', 'Testbrand', 'TM-UNCAT', '1pc',
10, 15, 0, 1, 'https://placehold.co/120x120?text=Uncat', 'No category on purpose')
ON CONFLICT (productid) DO NOTHING;
-- price + publishedat = priced and released. Both are needed: a product with
-- one and not the other looks identical on the shelf and cannot be sold.
INSERT INTO productlocations (
productlocationid, tenantid, locationid, productid, price, status, publishedat
) VALUES
(9401, 9001, 9101, 9301, 395, 'Active', NOW()), -- on sale
(9402, 9001, 9101, 9302, 198, 'Active', NOW()), -- released, no stock below
(9403, 9001, 9101, 9303, 0, 'Active', NULL), -- no price, not released
(9404, 9001, 9101, 9304, 15, 'Active', NOW()), -- everything but a category
(9405, 9001, 9102, 9301, 395, 'Active', NOW()) -- same product, second branch
ON CONFLICT (productlocationid) DO NOTHING;
-- Stock is SUM(in) - SUM(out) per outlet, never a stored figure.
INSERT INTO productstocks (
productstockid, tenantid, locationid, productid, stockdate, stocktype, quantity, status
) VALUES
(9501, 9001, 9101, 9301, NOW(), 'in', 40, 'Active'),
(9502, 9001, 9101, 9301, NOW(), 'out', 5, 'Active'), -- balance 35
(9503, 9001, 9101, 9304, NOW(), 'in', 10, 'Active'),
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
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;

585
main.go
View File

@@ -1,13 +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/routes" "nearle/routes"
"nearle/utils"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
@@ -17,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{})
@@ -55,7 +104,486 @@ func main() {
log.Fatal("POS schema migration failed:", err) log.Fatal("POS schema migration failed:", err)
} }
f := facade.NewFacade(db.DB, db.CatalogueDB) // 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
// existed and pointed at the rider table, so an account with no shift is
// simply unassigned rather than broken.
if err := db.DB.AutoMigrate(&models.StaffShifts{}); err != nil {
log.Fatal("staff shift schema migration failed:", err)
}
// The rest of a product's photos.
//
// An explicit ALTER rather than AutoMigrate on `models.Products`: that model
// has drifted from the live table over time, and letting GORM reconcile the
// whole thing to add one column would rewrite far more than anyone asked
// for. `IF NOT EXISTS` makes it a no-op on every boot after the first.
//
// Not fatal on failure. A missing column costs the extra images and nothing
// else — `productimage` still carries the first — and refusing to start the
// API over a gallery would be the worse trade.
if err := db.DB.Exec(
`ALTER TABLE products ADD COLUMN IF NOT EXISTS productimages jsonb`).Error; err != nil {
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)
}
// Whether a branch is trading today.
//
// SEPARATE FROM tenantlocations.status, which is the platform's word for
// whether the branch exists at all — setting that to Inactive is how a
// branch is decommissioned, and the staff console reads it that way. A
// shopkeeper closing for an afternoon is saying something else entirely,
// and sharing one column would make a day off look like a shop that shut
// down.
//
// Default TRUE, so every branch trading the moment this ships keeps
// trading. There is no backfill and there must never be one.
if err := db.DB.Exec(
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS isopen boolean NOT NULL DEFAULT true`).Error; err != nil {
log.Println("⚠️ could not add tenantlocations.isopen, every branch will keep reporting as open:", err)
}
// The day it opens again, when the shop knows it.
//
// Nullable, because a power cut has no end date. Nothing flips isopen back
// on a timer — models.StoreIsOpen works the answer out on read, so a branch
// that said "back on Monday" is open on Monday whether or not a job ran.
if err := db.DB.Exec(
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS closeduntil DATE`).Error; err != nil {
log.Println("⚠️ could not add tenantlocations.closeduntil, branches will not reopen on their own:", err)
}
// When a product became visible to a store, and the only thing that decides
// whether it is.
//
// `productlocations.status` cannot do this job and was never able to:
// syncProductLocationStatus overwrites it with 'available'/'outofstock' on
// every stock movement, so the 'Draft' the import wrote survived on exactly
// one row out of 6,755. A separate column is untouched by that, and "when
// was this published" is worth knowing regardless.
//
// The backfill is not optional and must land in the same deploy as the
// column. Membership of a store catalogue was the *existence* of the row,
// so reading `publishedat IS NOT NULL` without it empties every shop on the
// platform at once.
//
// The backfill runs ONCE — only on the boot that adds the column.
//
// It used to run on every boot, and that quietly cancelled the whole point
// of the column. `WHERE publishedat IS NULL` matches a legacy row on the
// first boot and, on every boot after, matches exactly the products
// somebody is deliberately holding back: imported, not yet priced, waiting
// for review. A restart published all of them and backdated the release to
// the row's `created`, so it did not even look recent.
//
// Observed 2026-08-31: seventeen products imported at R mart at 15:55 sat
// correctly unpublished, and the next deploy published all seventeen
// stamped 15:55. With several deploys a day, the admin-catalogue tier could
// not survive an afternoon.
//
// Detecting "the column was just added" rather than tracking a migration
// version: the question is answerable from the schema itself, so it needs
// no new table and cannot drift out of step with one.
var hadPublishedAt int64
if err := db.DB.Raw(`
SELECT COUNT(1) FROM information_schema.columns
WHERE table_name = 'productlocations' AND column_name = 'publishedat'`).
Scan(&hadPublishedAt).Error; err != nil {
log.Fatal("could not check productlocations.publishedat:", err)
}
if hadPublishedAt == 0 {
// First boot with the column. Every existing row predates publication
// as a concept, and membership of this table WAS publication — so
// leaving them null would empty every shop on the platform at once.
if err := db.DB.Exec(
`ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat timestamp`).Error; err != nil {
log.Fatal("could not add productlocations.publishedat:", err)
}
if err := db.DB.Exec(`
UPDATE productlocations
SET publishedat = COALESCE(created, NOW())
WHERE publishedat IS NULL`).Error; err != nil {
log.Fatal("could not backfill productlocations.publishedat:", err)
}
log.Println("productlocations.publishedat added and backfilled (one time)")
}
// A key generator for productvariants.variantid.
//
// The column is NOT NULL with no default and no identity, unlike
// products.productid next door which is an identity column. So every insert
// had to supply the id by hand, and GORM does not — it sent nothing and
// Postgres refused the row with a not-null violation. That is why variants
// could never be attached to a product: the write could not land at all.
//
// Guarded on the absence of a default rather than tracked as a migration
// version, matching the checks above: the question is answerable from the
// schema itself. The sequence starts above whatever ids are already there,
// so the two rows on production keep theirs.
var variantKeyed int64
if err := db.DB.Raw(`
SELECT COUNT(1) FROM information_schema.columns
WHERE table_name = 'productvariants' AND column_name = 'variantid'
AND (column_default IS NOT NULL OR is_identity = 'YES')`).
Scan(&variantKeyed).Error; err != nil {
log.Fatal("could not check productvariants.variantid:", err)
}
if variantKeyed == 0 {
if err := db.DB.Exec(`
CREATE SEQUENCE IF NOT EXISTS productvariants_variantid_seq
START WITH 1 OWNED BY productvariants.variantid`).Error; err != nil {
log.Fatal("could not create productvariants_variantid_seq:", err)
}
if err := db.DB.Exec(`
SELECT setval('productvariants_variantid_seq',
COALESCE((SELECT MAX(variantid) FROM productvariants), 0) + 1, false)`).Error; err != nil {
log.Fatal("could not position productvariants_variantid_seq:", err)
}
if err := db.DB.Exec(`
ALTER TABLE productvariants
ALTER COLUMN variantid SET DEFAULT nextval('productvariants_variantid_seq')`).Error; err != nil {
log.Fatal("could not default productvariants.variantid:", err)
}
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.
//
// `catalogueid` was never able to be this. The catalogue is rebuilt by
// scrape and renumbered every time — pepsico's live ids run 3, 6, 9 … 27,
// 30 — so a product imported when it was id 26 now points at nothing.
// Measured 2026-08-31: eleven of the nineteen links on the platform were
// dangling, which silently breaks three things (the "already imported"
// ticks, re-importing, and dedupe on the next scrape).
//
// Additive and nullable: every existing row keeps working, and a re-import
// or `/relinkcatalogue` is how one acquires the key.
if err := db.DB.Exec(
`ALTER TABLE products ADD COLUMN IF NOT EXISTS imageid text`).Error; err != nil {
log.Fatal("could not add products.imageid:", err)
}
// Not unique: two tenants legitimately stock the same catalogue product,
// and each keeps its own snapshot row. The lookup is always per tenant.
if err := db.DB.Exec(
`CREATE INDEX IF NOT EXISTS products_tenant_imageid_idx
ON products (tenantid, imageid)`).Error; err != nil {
log.Println("⚠️ could not add products.imageid index:", err)
}
// Receipts for spreadsheets sent to the catalogue ingest service.
//
// The ingest service holds a drop on its own terms: an unreviewed one is
// deleted after seven days, the list of a sender's drops needs a credential
// production does not issue, and the batch id — which is the ONLY
// credential for reading a result back — is handed out once, to a browser.
// Lose it and the upload becomes unfindable even to the person who sent it.
// This table is where we keep it.
//
// Raw SQL rather than AutoMigrate, matching the two ALTERs above: GORM
// reconciling a model against a live table has rewritten more than was
// asked for here before, and `IF NOT EXISTS` makes this a no-op after the
// first boot.
if err := db.DB.Exec(`
CREATE TABLE IF NOT EXISTS catalogueuploads (
uploadid SERIAL PRIMARY KEY,
tenantid INT NOT NULL,
locationid INT NOT NULL DEFAULT 0,
categoryid INT NOT NULL DEFAULT 0,
batchid VARCHAR(64) NOT NULL,
runid VARCHAR(64),
filename TEXT,
sender TEXT,
uploadedby INT NOT NULL DEFAULT 0,
uploadedname TEXT,
rowcount INT NOT NULL DEFAULT 0,
laststatus VARCHAR(32) NOT NULL DEFAULT 'pending',
inserted INT NOT NULL DEFAULT 0,
backfilled INT NOT NULL DEFAULT 0,
skipped INT NOT NULL DEFAULT 0,
rejected INT NOT NULL DEFAULT 0,
shelvedcount INT NOT NULL DEFAULT 0,
skippedcount INT NOT NULL DEFAULT 0,
shelvedat TIMESTAMP,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`).Error; err != nil {
log.Fatal("could not create catalogueuploads:", err)
}
// The upload is recorded the instant the drop is accepted and then polled,
// so a refresh, a retried request or a second tab would each insert the same
// receipt again. The constraint is what makes `Record` idempotent — without
// it one upload shows up three times and none of them is wrong.
if err := db.DB.Exec(
`CREATE UNIQUE INDEX IF NOT EXISTS catalogueuploads_batchid_key
ON catalogueuploads (batchid)`).Error; err != nil {
log.Fatal("could not add catalogueuploads.batchid unique index:", err)
}
// Every read is "this shop's uploads, newest first".
if err := db.DB.Exec(
`CREATE INDEX IF NOT EXISTS catalogueuploads_scope_idx
ON catalogueuploads (tenantid, locationid, created DESC)`).Error; err != nil {
log.Println("⚠️ could not add catalogueuploads scope index:", err)
}
// The sheet's own prices and opening stock, kept so shelving can happen
// after the browser that uploaded it is gone.
//
// Added separately from the CREATE above because the table already exists in
// production without it. The ingest service holds none of this — its
// catalogue is shared by every merchant and carries no price and no stock —
// so before this column the join could only be made in the tab that did the
// upload, and that tab is normally long closed by the time their admin
// releases the drop and the run finishes.
if err := db.DB.Exec(
`ALTER TABLE catalogueuploads ADD COLUMN IF NOT EXISTS sheetrows jsonb`).Error; err != nil {
log.Fatal("could not add catalogueuploads.sheetrows:", err)
}
// 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)
@@ -65,14 +593,43 @@ func main() {
// //
// A broker that is configured but unreachable is fatal on purpose: coming // A broker that is configured but unreachable is fatal on purpose: coming
// up healthy while every till quietly queues is the worse failure. // up healthy while every till quietly queues is the worse failure.
// The consumer is also the publisher for `nearle/pos/{loc}/catalogue`.
// Registering it here inverts the dependency: the repositories that move
// stock cannot import `messaging` — wiring runs this way and the reverse
// would be a cycle — so they hold an interface and this supplies it. Left
// unset the notification is simply skipped, which is what happens when the
// broker is unavailable and must not stop the API booting.
// The console's live stream listens to the same broker, read-only, and on
// EVERY replica — unlike the ingest consumer below, which is elected. A
// console connected to a non-elected replica must still see its tills.
// Never fatal: no broker simply means the console keeps polling.
messaging.StartLiveHub()
posMqtt, err := messaging.StartPosMqttConsumer(f.PosService()) posMqtt, err := messaging.StartPosMqttConsumer(f.PosService())
if err != nil { if err != nil {
log.Fatal("POS MQTT consumer failed to start:", err) log.Fatal("POS MQTT consumer failed to start:", err)
} }
if posMqtt != nil {
repositories.SetCatalogueNotifier(posMqtt)
}
// Start server // How much of the till fleet is carrying a session token, in the log every
// half hour.
//
// `POS_AUTH_REQUIRED` is off, and the only thing between here and switching
// it on is that number — nothing was recording it, so an untokened till was
// waved through in silence and the risk of flipping the flag could only be
// measured by flipping it. The same figures are on
// `GET /v1/web/pos/authadoption`, behind the session guard.
go middleware.LogPosAdoption(context.Background())
// 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() {
if err := app.Listen(":1122"); err != nil { log.Printf("🚀 listening on :%s", port)
if err := app.Listen(":" + port); err != nil {
log.Fatal("Server failed to start:", err) log.Fatal("Server failed to start:", err)
} }
}() }()

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

356
messaging/livehub.go Normal file
View File

@@ -0,0 +1,356 @@
package messaging
import (
"encoding/json"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"nearle/models"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// Live console events.
//
// The console polls every 30 seconds. That is a safe floor, and it means a bill
// rung at 10:00:01 shows up on the owner's screen at 10:00:30. This closes that
// gap: the tills already publish every sale to the broker, so a second consumer
// listens to the same stream and pushes a nudge to whichever consoles are
// watching that outlet.
//
// Two things it deliberately is NOT:
//
// - **It does not carry data.** An event says "stock at outlet 1185 changed,
// these product ids are involved" and nothing more. The console then re-reads
// through the normal API, which is the only thing that knows the authoritative
// number after locks, dedup and rejections. Pushing figures from here would
// mean a screen showing a total the database never agreed to.
// - **It does not replace the poll.** The console keeps its 30-second refetch.
// A dropped connection, a full buffer or a replica restart then costs latency
// rather than correctness, which is the trade an operations screen wants.
//
// ── Why a second consumer and not a hook in the ingest path ──────────────────
//
// `StartPosMqttConsumer` runs on ONE replica (see `posConsumerElected`) because
// two writers ingesting the same bills would fight over the same rows. Hanging
// this off that consumer would mean only consoles that happened to land on the
// elected replica ever received anything, which is a bug that looks like flaky
// network. This client only reads and touches no database, so every replica can
// safely run one and serve its own connected consoles.
//
// Enabled by MQTT_URL, the same switch the ingest consumer uses. Unset, the hub
// still runs and simply never emits — the console degrades to polling, which is
// exactly what it does today.
const (
// How many events a single console connection may fall behind before it
// starts losing them. Small on purpose: these are nudges, and a client that
// cannot keep up with a handful is better served by its next poll than by a
// backlog of stale ones.
liveBuffer = 16
// Belt and braces around a burst. A terminal replaying a day of offline
// bills would otherwise emit one event per batch as fast as the broker can
// deliver them; the console cannot use more than a couple a second.
liveMinInterval = 400 * time.Millisecond
)
// LiveEvent is one nudge. Kept small — it crosses the wire on every sale.
type LiveEvent struct {
// "sale", "customer" or "terminal".
Type string `json:"type"`
// tenantlocations.locationid. The topic's store segment carries it as a
// string (see models.PosOrderBatch).
Locationid int `json:"locationid"`
// Which products moved, when the event knows. Empty means "something did".
Productids []int `json:"productids,omitempty"`
// Which till, for the presence board.
Terminalid string `json:"terminalid,omitempty"`
At string `json:"at"`
}
type liveSubscriber struct {
ch chan LiveEvent
last time.Time
}
// LiveHub fans events out to the consoles currently watching an outlet.
type LiveHub struct {
mu sync.RWMutex
subscribers map[int]map[*liveSubscriber]struct{}
client mqtt.Client
dropped uint64
}
// Hub is the process-wide hub. Nil until StartLiveHub runs; every method
// tolerates a nil receiver so callers never have to check.
var Hub *LiveHub
// StartLiveHub creates the hub and, if a broker is configured, connects a
// read-only consumer to it.
//
// Never returns nil: a hub with no broker is a working hub with no events, and
// the SSE endpoint must still accept connections so the console's reconnect
// logic has something to talk to.
func StartLiveHub() *LiveHub {
hub := &LiveHub{subscribers: make(map[int]map[*liveSubscriber]struct{})}
Hub = hub
url := strings.TrimSpace(os.Getenv("MQTT_URL"))
if url == "" {
log.Println("live: MQTT_URL not set, console event stream will stay quiet")
return hub
}
// A distinct client id per replica. Sharing one with the ingest consumer
// would make the broker disconnect whichever connected first.
clientID := "nearle-console-live-" + hostSuffix()
opts := mqtt.NewClientOptions().
AddBroker(url).
SetClientID(clientID).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise.
SetOrderMatters(false)
// MQTT_USER is the name the ingest consumer and the env files use. This
// read MQTT_USERNAME for a while, so the live stream connected to the
// production broker with no credentials at all; MQTT_USERNAME is still
// honoured for any deployment that set it.
user := strings.TrimSpace(os.Getenv("MQTT_USER"))
if user == "" {
user = strings.TrimSpace(os.Getenv("MQTT_USERNAME"))
}
if user != "" {
opts.SetUsername(user)
opts.SetPassword(os.Getenv("MQTT_PASSWORD"))
}
opts.OnConnect = func(client mqtt.Client) {
for topic, handler := range map[string]mqtt.MessageHandler{
topicOrders: hub.onOrders,
topicCustomers: hub.onCustomers,
topicHealth: hub.onHealth,
} {
// QoS 0. A missed nudge costs at most one poll interval, and QoS 1
// would have the broker retaining state for a listener that does
// not need it.
if token := client.Subscribe(topic, 0, handler); token.Wait() && token.Error() != nil {
log.Printf("live: could not subscribe to %s: %v", topic, token.Error())
continue
}
log.Printf("live: watching %s", topic)
}
}
client := mqtt.NewClient(opts)
hub.client = client
// Connect in the background: a broker that is slow to answer must not hold
// up the HTTP server coming online.
go func() {
if token := client.Connect(); token.Wait() && token.Error() != nil {
log.Printf("live: broker unreachable, console falls back to polling: %v", token.Error())
}
}()
return hub
}
/* ── Subscription ─────────────────────────────────────────────────────────── */
// Subscribe registers a console connection watching one outlet.
//
// Returns the channel to read and the function that releases it. The caller
// MUST call release — an SSE handler that returns without it leaks a channel
// the hub goes on writing to for the life of the process.
func (h *LiveHub) Subscribe(locationid int) (<-chan LiveEvent, func()) {
if h == nil {
// A closed channel reads immediately and forever, which would spin the
// handler. An open one that never delivers is the honest no-op.
return make(chan LiveEvent), func() {}
}
sub := &liveSubscriber{ch: make(chan LiveEvent, liveBuffer)}
h.mu.Lock()
if h.subscribers[locationid] == nil {
h.subscribers[locationid] = make(map[*liveSubscriber]struct{})
}
h.subscribers[locationid][sub] = struct{}{}
h.mu.Unlock()
var once sync.Once
release := func() {
once.Do(func() {
h.mu.Lock()
if set := h.subscribers[locationid]; set != nil {
delete(set, sub)
if len(set) == 0 {
delete(h.subscribers, locationid)
}
}
h.mu.Unlock()
close(sub.ch)
})
}
return sub.ch, release
}
// Broadcast delivers an event to everyone watching that outlet.
//
// Never blocks. A subscriber whose buffer is full loses the event and is
// counted rather than waited for: one wedged console must not stall the fan-out
// to every other console, and the poll will collect what was missed.
func (h *LiveHub) Broadcast(event LiveEvent) {
if h == nil || event.Locationid == 0 {
return
}
if event.At == "" {
event.At = time.Now().UTC().Format(time.RFC3339)
}
// A write lock, not a read lock, even though this only walks the map: the
// loop below writes `sub.last` and `h.dropped`. Under RLock those are
// concurrent writes from every broker goroutine at once — a data race, and
// one `go test -race` would fail on. Fan-out is a handful of non-blocking
// sends, so holding the write lock costs nothing worth reclaiming.
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
for sub := range h.subscribers[event.Locationid] {
// Coalesce a burst per subscriber, not globally: a busy outlet must not
// throttle a quiet one.
if now.Sub(sub.last) < liveMinInterval {
continue
}
select {
case sub.ch <- event:
sub.last = now
default:
h.dropped++
}
}
}
/* ── Broker handlers ──────────────────────────────────────────────────────── */
func (h *LiveHub) onOrders(_ mqtt.Client, msg mqtt.Message) {
var batch models.PosOrderBatch
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
return // The ingest consumer logs the bad payload; one complaint is enough.
}
store, terminal := topicIdentity(msg.Topic())
if batch.Storeid == "" {
batch.Storeid = store
}
if batch.Terminalid == "" {
batch.Terminalid = terminal
}
// Deduplicated: a bill with four lines of the same SKU is one product.
seen := make(map[int]struct{})
productids := make([]int, 0, 8)
for _, order := range batch.Orders {
for _, item := range order.Items {
id, err := strconv.Atoi(strings.TrimSpace(item.Productid))
if err != nil || id == 0 {
continue
}
if _, done := seen[id]; done {
continue
}
seen[id] = struct{}{}
productids = append(productids, id)
}
}
h.Broadcast(LiveEvent{
Type: "sale",
Locationid: locationFromStore(batch.Storeid),
Productids: productids,
Terminalid: batch.Terminalid,
})
}
func (h *LiveHub) onCustomers(_ mqtt.Client, msg mqtt.Message) {
var batch models.PosCustomerBatch
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
return
}
store, terminal := topicIdentity(msg.Topic())
if batch.Storeid == "" {
batch.Storeid = store
}
h.Broadcast(LiveEvent{
Type: "customer",
Locationid: locationFromStore(batch.Storeid),
Terminalid: firstNonEmpty(batch.Terminalid, terminal),
})
}
// onHealth drives the presence board.
//
// Read from the topic rather than the body, the same rule bills follow: a till
// that could name its own store could appear in another tenant's console.
func (h *LiveHub) onHealth(_ mqtt.Client, msg mqtt.Message) {
store, terminal := topicIdentity(msg.Topic())
h.Broadcast(LiveEvent{
Type: "terminal",
Locationid: locationFromStore(store),
Terminalid: terminal,
})
}
/* ── Helpers ──────────────────────────────────────────────────────────────── */
// locationFromStore parses the topic's store segment.
//
// `models.PosOrderBatch` documents it: "Storeid carries the numeric
// tenantlocations.locationid as a string." Anything unparseable yields 0, which
// Broadcast drops — an event with no outlet has nowhere to go.
func locationFromStore(store string) int {
id, err := strconv.Atoi(strings.TrimSpace(store))
if err != nil {
return 0
}
return id
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// hostSuffix keeps each replica's client id distinct without needing config.
func hostSuffix() string {
if host := strings.TrimSpace(os.Getenv("HOSTNAME")); host != "" {
return host
}
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
// Stats reports what the hub is doing, for the health endpoint.
func (h *LiveHub) Stats() (outlets, connections int, dropped uint64) {
if h == nil {
return 0, 0, 0
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, set := range h.subscribers {
connections += len(set)
}
return len(h.subscribers), connections, h.dropped
}

View File

@@ -99,6 +99,18 @@ func (f *fakePosService) ListUsers(int, int, bool) ([]models.PosUser, error) {
return nil, nil return nil, nil
} }
func (f *fakePosService) ListStaffShifts(int, int, bool) ([]models.StaffShifts, error) {
return nil, nil
}
func (f *fakePosService) CreateStaffShift(int, int, models.StaffShifts) (*models.StaffShifts, error) {
return nil, nil
}
func (f *fakePosService) UpdateStaffShift(int, int, models.StaffShifts) (*models.StaffShifts, error) {
return nil, nil
}
func (f *fakePosService) DeactivateUser(int, int, int) error { return nil } func (f *fakePosService) DeactivateUser(int, int, int) error { return nil }
func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) { func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) {

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

111
models/catalogueupload.go Normal file
View File

@@ -0,0 +1,111 @@
package models
import "time"
// CatalogueUpload is our own receipt for a spreadsheet sent to the ingest
// service — the record that the upload happened, who sent it, and for which
// shop.
//
// It exists because nothing else keeps one. The ingest service holds the drop,
// but on its terms and not ours:
//
// 1. **A drop nobody acts on is deleted after seven days**
// (`BATCH_RETENTION_DAYS`). If an admin never presses Start, the only proof
// the upload ever happened disappears — including from the sender.
// 2. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
// the credential that sent them, production has no API keys configured, and
// the one admin account is a superuser over their whole application. So the
// list is not available to us and should not be.
// 3. **The batch id is the credential.** `GET /api/uploads/catalog/{batch_id}`
// is anonymous by design — holding the id is the proof of having sent the
// drop. Which means whoever holds the id can read the result, and nobody
// else can. Losing the id loses the result permanently.
//
// So the id is the thing worth keeping, and this row is where we keep it. Every
// status field beside it is a CACHE of what the ingest service last told us,
// written by whichever browser was polling. It is never the authority — the
// service is — and it exists so a receipt reads sensibly before anyone re-polls
// it.
//
// The tenant and branch are ours alone. The ingest service writes the GLOBAL
// catalogue and has no concept of either, so "which shop was this for" is a
// question only this row can answer.
type CatalogueUpload struct {
Uploadid int `json:"uploadid" gorm:"primaryKey;autoIncrement;column:uploadid"`
// Who the sheet was for. The ingest service knows neither.
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
Locationid int `json:"locationid" gorm:"column:locationid"`
// The category every row was filed under, chosen at upload time. Worth
// keeping: a product filed outside the category the app browses is
// invisible to shoppers, and this is the only record of what was chosen.
Categoryid int `json:"categoryid" gorm:"column:categoryid"`
// The drop id, and the only thing here that cannot be reconstructed.
Batchid string `json:"batchid" gorm:"column:batchid"`
// The run an admin released the drop into, once they have. Cached from
// `released_to` so a receipt can be followed without re-walking the drop.
Runid string `json:"runid" gorm:"column:runid"`
Filename string `json:"filename" gorm:"column:filename"`
// The label the ingest inbox shows their admin. Stored so we can tell,
// afterwards, what they were looking at when they approved it.
Sender string `json:"sender" gorm:"column:sender"`
Uploadedby int `json:"uploadedby" gorm:"column:uploadedby"`
Uploadedname string `json:"uploadedname" gorm:"column:uploadedname"`
// Rows we parsed in the browser, before sending. Independent of anything
// the service reports, so a drop that never runs still says how big it was.
Rowcount int `json:"rowcount" gorm:"column:rowcount"`
// The sheet itself, as the console parsed it: SKU, price and opening stock
// per row, as JSON.
//
// Stored because the ingest service cannot hold it and nothing else can.
// Their pipeline writes the GLOBAL catalogue, which every merchant shares
// and which therefore carries no price and no stock; both live only in the
// sheet. Shelving is the step that joins the two, and it used to be possible
// only in the browser tab that did the upload, because that tab was the only
// place the parsed rows existed.
//
// That failed in the ordinary case rather than a rare one. A drop waits for
// their admin to release it and the run then takes minutes, so by the time
// there is anything to shelve the tab is usually gone — and the products sit
// in the global catalogue, unpriced and unstocked, with no way left to
// finish. Keeping the rows here is what lets the Uploads page complete it
// days later.
Sheetrows string `json:"sheetrows" gorm:"column:sheetrows;type:jsonb"`
// ── Cached from the ingest service, by whoever last polled ──────────────
Laststatus string `json:"laststatus" gorm:"column:laststatus;default:pending"`
// Their counts, so a settled receipt reads correctly with no network call.
Inserted int `json:"inserted" gorm:"column:inserted"`
Backfilled int `json:"backfilled" gorm:"column:backfilled"`
Skipped int `json:"skipped" gorm:"column:skipped"`
Rejected int `json:"rejected" gorm:"column:rejected"`
// ── Ours: the half the ingest service cannot do ────────────────────────
//
// Their pipeline writes the global catalogue, which every merchant shares
// and which therefore holds no price and no stock. Shelving is what turns
// "the product exists" into "this shop can sell it", and it is a separate
// action that can be left undone — so it is recorded separately.
Shelvedcount int `json:"shelvedcount" gorm:"column:shelvedcount"`
Skippedcount int `json:"skippedcount" gorm:"column:skippedcount"`
Shelvedat *time.Time `json:"shelvedat" gorm:"column:shelvedat"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
// Joined for display, never stored. A receipt outlives the page that made
// it, so it has to be able to name its own shop.
Tenantname string `json:"tenantname" gorm:"->;column:tenantname"`
Locationname string `json:"locationname" gorm:"->;column:locationname"`
}
func (CatalogueUpload) TableName() string {
return "catalogueuploads"
}

View File

@@ -24,8 +24,11 @@ type Customers struct {
Landmark string `json:"landmark"` Landmark string `json:"landmark"`
Doorno string `json:"doorno"` Doorno string `json:"doorno"`
Postcode string `json:"postcode"` Postcode string `json:"postcode"`
Latitude string `json:"latitude"` // Numbers from a map picker, strings from a text field — see
Longitude string `json:"longitude"` // Customerlocations below. A customer must exist before an order can be
// placed, so a 400 here blocks the whole flow, not just the address.
Latitude FlexibleString `json:"latitude"`
Longitude FlexibleString `json:"longitude"`
Applocationid int `json:"applocationid"` Applocationid int `json:"applocationid"`
Locationid int `json:"locationid,omitempty" gorm:"-"` Locationid int `json:"locationid,omitempty" gorm:"-"`
Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"` Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"`
@@ -84,7 +87,9 @@ type CustomerLocationResult struct {
type Customerlocations struct { type Customerlocations struct {
Locationid int `json:"locationid" gorm:"Primary_Key"` Locationid int `json:"locationid" gorm:"Primary_Key"`
Customerid int `json:"customerid"` Customerid int `json:"customerid"`
Applocationid int `json:"applocationid" gorm:"-"` // The query joins customers to select b.applocationid; `gorm:"-"` then threw
// it away, so every address came back with 0 however the join resolved.
Applocationid int `json:"applocationid" gorm:"->"`
Address string `json:"address"` Address string `json:"address"`
Suburb string `json:"suburb"` Suburb string `json:"suburb"`
City string `json:"city"` City string `json:"city"`
@@ -92,10 +97,25 @@ type Customerlocations struct {
Landmark string `json:"landmark"` Landmark string `json:"landmark"`
Doorno string `json:"doorno"` Doorno string `json:"doorno"`
Postcode string `json:"postcode"` Postcode string `json:"postcode"`
Latitude string `json:"latitude"` // Latitude and longitude arrive as NUMBERS from a map picker and as strings
Longitude string `json:"longitude"` // from a text field. A strict type rejected the first, and BodyParser fails
Primaryaddress int `json:"primaryaddress"` // the WHOLE request on one unreadable field — so a shopper who set their
Status int `json:"status"` // location on a map got 400 and no saved address. Same treatment the order
// model already gives Pickuplat and Pickuplong.
Latitude FlexibleString `json:"latitude"`
Longitude FlexibleString `json:"longitude"`
// A "make this my default" checkbox sends true, a form sends 1, a text
// input sends "1". All three mean the same thing.
Primaryaddress FlexibleInt `json:"primaryaddress"`
// Sent as 1, as "1", or as the word "Active", depending on the caller.
Status FlexibleInt `json:"status"`
// Selected by GetCustomerLocations and, until now, dropped on the floor:
// the struct had no field for it, so the column marking the chosen address
// never reached the app.
Defaultaddress string `json:"defaultaddress"`
} }
type CustomerRequest struct { type CustomerRequest struct {

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"`
}

116
models/flexibleInt.go Normal file
View File

@@ -0,0 +1,116 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// FlexibleInt is an integer column that accepts what clients actually send.
//
// The counterpart to FlexibleString, and it exists for the same reason: a strict
// type here does not reject one field, it rejects the WHOLE request. Fiber's
// BodyParser fails on the first field it cannot convert, so `"status": "Active"`
// or `"primaryaddress": true` returns 400 "Invalid request body" and nothing is
// saved. On the address form that surfaced as a shopper adding a delivery
// address, seeing no error worth acting on, and then finding no address to pick
// at checkout — measured 2026-09-02: three plausible payloads, three 400s.
//
// Accepted, in the shapes a form or a phone actually produces:
//
// 1 a number
// "1" a number as a string, which is what a text input gives
// true a checkbox — "set as my default address"
// "" an untouched field, read as 0 rather than an error
// "Active" a word, mapped by meaning: active/yes/true/default are 1
//
// A word it does not recognise is an error, not a silent 0. Guessing there
// would store something the shopper did not choose.
type FlexibleInt int
func (fi *FlexibleInt) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" {
return nil
}
if b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
s = strings.TrimSpace(s)
if s == "" {
*fi = 0
return nil
}
if n, err := strconv.Atoi(s); err == nil {
*fi = FlexibleInt(n)
return nil
}
switch strings.ToLower(s) {
case "active", "yes", "true", "default", "primary":
*fi = 1
case "inactive", "no", "false":
*fi = 0
default:
return fmt.Errorf("cannot read %q as a number", s)
}
return nil
}
if b[0] == 't' || b[0] == 'f' {
var v bool
if err := json.Unmarshal(b, &v); err != nil {
return err
}
if v {
*fi = 1
} else {
*fi = 0
}
return nil
}
// A number, possibly written with a decimal point by a client that has no
// integer type of its own.
var f float64
if err := json.Unmarshal(b, &f); err != nil {
return err
}
*fi = FlexibleInt(int(f))
return nil
}
func (fi FlexibleInt) MarshalJSON() ([]byte, error) {
return json.Marshal(int(fi))
}
// Scan reads the column back. bigint arrives as int64; the string cases cover a
// text column that holds a number, which this schema has in places.
func (fi *FlexibleInt) Scan(value interface{}) error {
switch v := value.(type) {
case nil:
*fi = 0
case int64:
*fi = FlexibleInt(v)
case int:
*fi = FlexibleInt(v)
case float64:
*fi = FlexibleInt(int(v))
case []byte:
n, _ := strconv.Atoi(strings.TrimSpace(string(v)))
*fi = FlexibleInt(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(v))
*fi = FlexibleInt(n)
default:
return fmt.Errorf("cannot read %T as a number", value)
}
return nil
}
func (fi FlexibleInt) Value() (driver.Value, error) {
return int64(fi), nil
}

View File

@@ -0,0 +1,88 @@
package models
import (
"encoding/json"
"testing"
)
/*
A strict type on one field rejects the whole request.
Fiber's BodyParser stops at the first field it cannot convert and returns 400
"Invalid request body" — so `"primaryaddress": true` from a checkbox threw away
the address, the street and the pincode with it. The shopper saw no error worth
acting on and then had no address to choose at checkout.
Measured 2026-09-02 against the running backend: three plausible payloads for
one address form, three 400s.
*/
func readInt(t *testing.T, body string) (FlexibleInt, error) {
t.Helper()
var v struct {
N FlexibleInt `json:"n"`
}
err := json.Unmarshal([]byte(`{"n":`+body+`}`), &v)
return v.N, err
}
func mustRead(t *testing.T, body string, want FlexibleInt) {
t.Helper()
got, err := readInt(t, body)
if err != nil {
t.Fatalf("%s: %v", body, err)
}
if got != want {
t.Errorf("%s -> %d, want %d", body, got, want)
}
}
func TestACheckboxIsANumber(t *testing.T) {
// "Set as my default address" sends a boolean.
mustRead(t, "true", 1)
mustRead(t, "false", 0)
}
func TestATextInputIsANumber(t *testing.T) {
mustRead(t, `"1"`, 1)
mustRead(t, `"0"`, 0)
}
func TestAWordIsReadByMeaning(t *testing.T) {
mustRead(t, `"Active"`, 1)
mustRead(t, `"active"`, 1)
mustRead(t, `"Inactive"`, 0)
}
func TestAnUntouchedFieldIsZeroNotAnError(t *testing.T) {
mustRead(t, `""`, 0)
mustRead(t, "null", 0)
}
func TestAClientWithNoIntegerTypeStillWorks(t *testing.T) {
// JavaScript has one number type, so 1 can arrive as 1.0.
mustRead(t, "1.0", 1)
}
func TestAWordItCannotReadIsRefusedRatherThanGuessed(t *testing.T) {
// Storing 0 for something unrecognised would record a choice the shopper
// never made. Better to fail loudly than to invent an answer.
if _, err := readInt(t, `"maybe"`); err == nil {
t.Error(`"maybe" was silently accepted`)
}
}
func TestLatitudeSurvivesBothShapes(t *testing.T) {
// A map picker sends a number; a text field sends a string. Both are the
// same place, and rejecting either loses the whole address.
var withNumber, withString Customerlocations
if err := json.Unmarshal([]byte(`{"latitude":11.0168}`), &withNumber); err != nil {
t.Fatalf("number latitude: %v", err)
}
if err := json.Unmarshal([]byte(`{"latitude":"11.0168"}`), &withString); err != nil {
t.Fatalf("string latitude: %v", err)
}
if withNumber.Latitude != withString.Latitude {
t.Errorf("%q from a number, %q from a string", withNumber.Latitude, withString.Latitude)
}
}

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

@@ -103,6 +103,12 @@ type OrderInfo struct {
Canceltime string `json:"canceltime"` Canceltime string `json:"canceltime"`
Deliverycharge float32 `json:"deliverycharge"` Deliverycharge float32 `json:"deliverycharge"`
Orderamount float32 `json:"orderamount"` Orderamount float32 `json:"orderamount"`
// Ordervalue and Taxamount were absent from this struct, so even once the
// query selected them there was nowhere for GORM to put the values. The
// console reads `ordervalue || orderamount || deliveryamt` in that order,
// which means the field it prefers was the one it could never receive.
Ordervalue float32 `json:"ordervalue"`
Taxamount float32 `json:"taxamount"`
Customerid int `json:"customerid"` Customerid int `json:"customerid"`
Pickupcustomer string `json:"pickupcustomer"` Pickupcustomer string `json:"pickupcustomer"`
Pickupcontactno string `json:"pickupcontactno"` Pickupcontactno string `json:"pickupcontactno"`
@@ -124,6 +130,22 @@ 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"`
// The delivery window the customer asked for.
//
// ON THIS STRUCT, not only on Orders. GetTenantOrders — which is what the
// console and the app both read — scans into OrderInfo, so fields added to
// Orders alone never reach the list. That was the whole of the
// products.showhealthscore bug: written correctly, selected correctly,
// absent from the response, and every reading taken from it meaningless.
//
// The last four are joined from deliveryslots and read-only, so a shop that
// renames a window sees the new name on orders already placed.
Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
Slotkey string `json:"slotkey" gorm:"->"`
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
Paymenttype int `json:"paymenttype"` Paymenttype int `json:"paymenttype"`
Tenantname string `json:"tenantname"` Tenantname string `json:"tenantname"`
Tenanttoken string `json:"tenanttoken"` Tenanttoken string `json:"tenanttoken"`
@@ -240,6 +262,26 @@ type Orders struct {
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

@@ -61,10 +61,23 @@ type Partnerinfo struct {
Postcode string `json:"postcode"` Postcode string `json:"postcode"`
Partnerinfo string `json:"partnerinfo"` Partnerinfo string `json:"partnerinfo"`
Partnerimage string `json:"partnerimage"` Partnerimage string `json:"partnerimage"`
// Read back so a directory can show who the partner is and whether they
// are trading. Both columns have always been on the table; the struct had
// no field for them, so `getpartners` could not report either.
Companyname string `json:"companyname"`
Status string `json:"status"`
// Every region this partner covers, from `partnerlocations`. A partner is
// not confined to the one city on their own row — that column records where
// they were set up, and the link table records where they actually work.
Locations []PartnerLocation `json:"locations" gorm:"-"`
} }
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"`
@@ -129,3 +142,189 @@ type RiderlogDetails struct {
Breakhours float32 `json:"breakhours"` Breakhours float32 `json:"breakhours"`
Logstatus int `json:"logstatus"` Logstatus int `json:"logstatus"`
} }
// NewRider is one rider being onboarded, as the console sends it.
//
// A rider is three rows, not one. `app_users` holds the person, `ridersettings`
// the vehicle and licence, and `app_userpools` their place in the availability
// pool — and `getriders` INNER JOINs all three, so a rider missing any of them
// is not a partial rider, they are no rider at all. The flat shape here is
// deliberate: the caller should not have to know the table layout to hire
// somebody.
//
// `Tenantid` is filled in by the controller from the caller's scope, never read
// from the body — a store admin must not be able to put a rider on another
// merchant's books by editing a payload.
type NewRider struct {
Userid int `json:"userid"`
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Contactno string `json:"contactno"`
Email string `json:"email"`
Password string `json:"password"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Postcode string `json:"postcode"`
// Whose rider they are, where they ride, and who they ride for.
//
// Tenantid and Partnerid are exclusive: a rider belongs to a shop or to a
// delivery partner, never to both and never to neither. The controller
// refuses either mistake.
//
// Locationid is the branch an OWN rider works out of. It is meaningless for
// a partner's rider — a partner supplies several merchants and is not tied
// to any one shop's outlet — so it is only ever set alongside a tenantid.
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Applocationid int `json:"applocationid"`
Partnerid int `json:"partnerid"`
Shiftid int `json:"shiftid"`
// The vehicle half — `ridersettings`.
Identificationno string `json:"identificationno"`
Vehiclename string `json:"vehiclename"`
Vehicleno string `json:"vehicleno"`
Licenseno string `json:"licenseno"`
Registrationno string `json:"registrationno"`
Status string `json:"status"`
}
// RiderRosterRow is one rider in the directory.
//
// Distinct from RiderInfo, which is the ON-DUTY read: that one requires a
// riderlog stamped today, which is right for an assignment picker and wrong for
// a staff list, where somebody who has not started their shift must still
// appear. This carries the last log rather than requiring one.
type RiderRosterRow struct {
Userid int `json:"userid"`
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Fullname string `json:"fullname"`
Contactno string `json:"contactno"`
Email string `json:"email"`
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Applocationid int `json:"applocationid"`
Applocation string `json:"applocation"`
Partnerid int `json:"partnerid"`
Partnername string `json:"partnername"`
Shiftid int `json:"shiftid"`
Shiftname string `json:"shiftname"`
Identificationno string `json:"identificationno"`
Vehiclename string `json:"vehiclename"`
Vehicleno string `json:"vehicleno"`
Licenseno string `json:"licenseno"`
Registrationno string `json:"registrationno"`
// Duty state, as facts rather than as a filter.
Onduty int `json:"onduty"`
Lastlogdate string `json:"lastlogdate"`
// True when there is a log dated today with logstatus 0 — on shift right now.
Isonduty bool `json:"isonduty"`
Status string `json:"status"`
}
// Ridersettings is the vehicle-and-licence half of a rider.
//
// `riderid` is DELIBERATELY ABSENT. It is a GENERATED ALWAYS identity column,
// and including it makes GORM send a zero, which Postgres rejects outright:
// "cannot insert a non-DEFAULT value into column riderid". Leaving it off the
// struct is what keeps the insert to the columns that are actually ours to set.
type Ridersettings struct {
Userid int `json:"userid"`
Partnerid int `json:"partnerid"`
Shiftid int `json:"shiftid"`
Identificationno string `json:"identificationno"`
Vehiclename string `json:"vehiclename"`
Vehicleno string `json:"vehicleno"`
Licenseno string `json:"licenseno"`
Registrationno string `json:"registrationno"`
}
// Appuserpools is a rider's place in the availability pool.
//
// `poolid` is absent for the same reason as `riderid` above — same identity
// column, same rejection.
//
// `Onduty` means "may be given work", not "on shift right now". The second
// question is answered by riderlogs, which the rider's own app writes.
type Appuserpools struct {
Userid int `json:"userid"`
Partnerid int `json:"partnerid"`
Onduty int `json:"onduty"`
Status string `json:"status"`
}
// PartnerLocation is one region a partner covers — a row of `partnerlocations`.
type PartnerLocation struct {
Partnerlocationid int `json:"partnerlocationid" gorm:"column:partnerlocationid"`
Partnerid int `json:"partnerid" gorm:"column:partnerid"`
Applocationid int `json:"applocationid" gorm:"column:applocationid"`
Applocation string `json:"applocation" gorm:"column:applocation"`
}
// NewPartner is everything the console collects to onboard a delivery partner.
//
// Separate from `Partnerinfo`, which is the READ shape. Binding a create to the
// read model is how a caller ends up able to set columns nobody meant to expose
// — `allocationid`, `partnertypeid`, the billing links — because a form happened
// to send them.
//
// A partner is a company that supplies riders. It is onboarded by the platform,
// never by a merchant: the merchant's side of this is being ASSIGNED one, which
// is `AssignPartner` on the tenant.
type NewPartner struct {
Partnerid int `json:"partnerid"`
Partnername string `json:"partnername"`
Companyname string `json:"companyname"`
Registrationno string `json:"registrationno"`
Primarycontact string `json:"primarycontact"`
Primaryemail string `json:"primaryemail"`
Contactno string `json:"contactno"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Postcode int `json:"postcode"`
Partnerinfo string `json:"partnerinfo"`
Partnerimage string `json:"partnerimage"`
Status string `json:"status"`
/*
Where they work — ONE district, not a set.
`Applocationid` is the home region and goes on the partner row itself,
because the rider app reads it. The same region is also written to
`partnerlocations`, which is the table that may hold SEVERAL — a partner
routinely serves more than one city, and that is why the link table
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"`
/*
The district by NAME, for one that is not open yet.
Nearle runs three of Tamil Nadu's thirty-eight districts, and a partner
could only be placed in those three because `partnerinfo.applocationid`
has to point at an `app_location` row. Sending the name instead opens the
district — see `EnsureRegion` — so the form can offer all 38 and mean it.
Ignored when `Applocationid` is set, which is the ordinary case.
*/
District string `json:"district"`
}

View File

@@ -226,24 +226,41 @@ type PosCatalogueResponse struct {
// PosLoginRequest is what a till sends to sign in. // PosLoginRequest is what a till sends to sign in.
// //
// Authname or Contactno, matching the web console's own login — a shop should // A mobile number and a four-digit PIN. That is what a person standing at a
// not need a second set of credentials just because the screen is a till. // counter can actually type between customers, and it is the pair the console
// issues them — anything longer gets written on the side of the terminal, which
// is worse than a short credential.
// //
// Locationid is optional and only means anything for a user entitled to more // Locationid is optional and only means anything for a user entitled to more
// than one outlet: it says which of theirs this terminal is standing in. It is // than one outlet: it says which of theirs this terminal is standing in. It is
// checked against what they may reach, never trusted on its own. // checked against what they may reach, never trusted on its own.
type PosLoginRequest struct { type PosLoginRequest struct {
Authname string `json:"authname"` // The mobile number this person signs in with. Sent in whatever form they
// typed it — "+91 98765 43210", "098765-43210", "9876543210" — and reduced
// to ten digits by the server before it is matched.
Contactno string `json:"contactno"` Contactno string `json:"contactno"`
Password string `json:"password"`
// A four-digit PIN, and the credential this endpoint now checks.
//
// Four digits is ten thousand guesses, which would be no barrier at all on
// its own — it is a barrier here only because it is checked against one
// mobile number, and a mobile number is unique among a tenant's till
// accounts. Rate limiting at the edge is what stands between that and a
// patient attacker; this endpoint cannot supply it.
Pin string `json:"pin"`
// A username and password, the way in before PINs.
//
// Kept working, not deprecated in place, because every till account on the
// platform predates the mobile number it now signs in with. Removing this
// before the back office has filled those in would close every shop on the
// same morning. See docs/POS_PHONE_PIN_LOGIN_HANDOVER.md §5.
Authname string `json:"authname,omitempty"`
Password string `json:"password,omitempty"`
Configid int `json:"configid"` Configid int `json:"configid"`
Locationid int `json:"location_id"` Locationid int `json:"location_id"`
// A PIN, for signing on at a terminal a supervisor has already opened. Only
// honoured by the PIN route, which requires an existing session — four
// digits is no barrier to an anonymous caller.
Pin string `json:"pin"`
// Which physical till is asking. Recorded on the session so a stolen token // Which physical till is asking. Recorded on the session so a stolen token
// can be told apart from the terminal it was issued to. // can be told apart from the terminal it was issued to.
Terminalid string `json:"terminal_id"` Terminalid string `json:"terminal_id"`
@@ -319,24 +336,25 @@ type PosSession struct {
// what gets stamped on a bill as `cashiername` and settled against at the end // what gets stamped on a bill as `cashiername` and settled against at the end
// of a shift. // of a shift.
// //
// The PIN travels in the clear, over TLS, and that is a considered choice // The PIN used to travel down with this list, on the reasoning that a PIN was
// rather than an oversight. A four-digit PIN is brute-forceable in microseconds // *shift attribution* rather than a security boundary: the token decided which
// whatever it is wrapped in, so hashing it here would buy the appearance of // books a till could reach, and the PIN only decided which of the people
// strength and not the substance. What it would cost is real: the terminal // already inside a shop got credited with a sale.
// salts every PIN with its own random salt before storing it, so a hash
// computed here could never be verified there without inventing a shared
// scheme and keeping two codebases agreeing about it for ever.
// //
// The honest framing is that a PIN is *shift attribution*, not a security // That reasoning ended when the PIN became half of the sign-in. A list of PINs
// boundary. The boundary is the session token — which is what stops a till // is now a list of working credentials for the outlet — including the
// reaching another tenant's books at all. The PIN decides which of the people // supervisor's, which carries `can_manage_staff` — so a cashier handed this
// already inside a shop gets credited with a sale, and the terminal still // array could sign back in as their own manager. Hence `json:"-"`: the field is
// stores it hashed at rest. // still read from the database, because the query needs it to drop two people
// who share a PIN, but it cannot reach the wire from here.
//
// Switching operator at an open terminal goes through `POST /pos/login/pin`,
// which checks the PIN against the outlet the caller's token already names.
type PosStaffMember struct { type PosStaffMember struct {
Userid int `json:"user_id"` Userid int `json:"user_id"`
Fullname string `json:"full_name"` Fullname string `json:"full_name"`
Role string `json:"role"` Role string `json:"role"`
Pin string `json:"pin,omitempty"` Pin string `json:"-"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
} }
@@ -393,21 +411,37 @@ func PosRoleFromName(name string) int {
return 0 return 0
} }
// PosRoleEligible reports whether a role may open a till at all.
//
// The terminal and the Nearle Daily application share one `app_users` table,
// and that is the only thing they share. An account belongs to one product or
// the other and never to both: a person who administers a shop from a browser
// does not thereby get a cash drawer, and a cashier does not thereby get the
// back office.
//
// Eligibility is therefore granted explicitly — by provisioning a Supervisor or
// a Cashier from the console — and is never inherited from a back-office role.
// Anything else is refused at sign-in, including roleid 0, which is not a role
// but the absence of one.
func PosRoleEligible(roleID int) bool {
return roleID == PosRoleSupervisor || roleID == PosRoleCashier
}
// PosRoleCanManageStaff reports whether a role may create and edit till users. // PosRoleCanManageStaff reports whether a role may create and edit till users.
// //
// Supervisors, plus the back office's own admin and manager roles — somebody // Supervisors, and nobody else.
// who can already administer the shop from a browser is not made less
// privileged by standing at the counter.
// //
// A cashier is never included, and neither is roleid 0. Zero is not a role: it // This used to include the back office's own roles 1 to 6, on the reasoning
// is what an account carries when nobody set one, and live data has riders and // that somebody who can already administer a shop from a browser is not made
// shop accounts sharing it. // less privileged by standing at the counter. That was wrong, and live data
// showed how wrong: it handed till-supervisor powers to 68 accounts, 59 of them
// Nearle Daily Super admins, not one of whom is the administrator of anybody's
// POS. The actual shop accounts carry roleid 0 and were refused.
//
// The back office reaches the till by *provisioning* a supervisor from the
// console, not by becoming one at the counter.
func PosRoleCanManageStaff(roleID int) bool { func PosRoleCanManageStaff(roleID int) bool {
switch roleID { return roleID == PosRoleSupervisor
case PosRoleSupervisor, 1, 2, 3, 4, 5, 6:
return true
}
return false
} }
// PosUser is a person who signs in at a till. // PosUser is a person who signs in at a till.
@@ -422,6 +456,18 @@ type PosUser struct {
Role string `json:"role"` Role string `json:"role"`
Pin string `json:"pin,omitempty"` Pin string `json:"pin,omitempty"`
Haspassword bool `json:"has_password"` Haspassword bool `json:"has_password"`
// The shift this person works, resolved for display. Zero / empty when the
// account has none, which is every account created before shifts existed.
Shiftid int `json:"shift_id,omitempty"`
Shiftname string `json:"shift_name,omitempty"`
Shiftstart string `json:"shift_start,omitempty"`
Shiftend string `json:"shift_end,omitempty"`
// The password, returned only in the answer to a creation or a reset and
// never by a listing. An admin who loses it reissues rather than looks it
// up — the right shape even while the column behind it is plaintext.
Password string `json:"password,omitempty"`
Locationid int `json:"location_id"` Locationid int `json:"location_id"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -439,7 +485,21 @@ type PosUserRequest struct {
Pin string `json:"pin"` Pin string `json:"pin"`
Password string `json:"password"` Password string `json:"password"`
Authname string `json:"authname"` Authname string `json:"authname"`
// The mobile number this person signs in with.
//
// Normalised to ten digits before it is stored, because the till matches on
// it exactly and "+91 98765 43210" typed back as "9876543210" would not
// find the row. Unique among a tenant's till accounts.
Contactno string `json:"contactno"` Contactno string `json:"contactno"`
// Which shift this person works — a staffshifts.staffshiftid, stored on
// app_users.shiftid. Zero leaves it unset.
//
// Informational. Nothing refuses a bill rung outside it; the terminal shows
// it so a counter knows who is due.
Shiftid int `json:"shift_id"`
Status string `json:"status"` Status string `json:"status"`
} }

53
models/posshift.go Normal file
View File

@@ -0,0 +1,53 @@
package models
import "time"
// StaffShifts is a working window at one outlet.
//
// Separate from `ridershifts`, which already exists and was the obvious thing
// to reuse — but is the wrong shape twice over. It carries delivery economics
// (`basefare`, `fuelcharge`, `additionalkm`, `firstmilecharge`) that mean
// nothing at a counter, and it is scoped by `applocationid`, a city, where a
// shop's hours belong to the shop. A supermarket in Selvapuram and one in
// Gandhipuram do not open at the same time because they share a city.
//
// A person is assigned exactly one of these via `app_users.shiftid`, which is
// the column that already existed. That makes a shift a *template* — "the
// morning shift" — rather than a roster of dated assignments. A roster is the
// richer model and the one to reach for if per-date planning is ever wanted;
// this is the smaller thing that answers "who is on the early shift" without a
// second table and a second screen.
//
// Informational. Nothing refuses a bill rung outside a shift: the terminal has
// never been run against this, and a cashier locked out mid-queue by a clock is
// a worse failure than a bill filed against the wrong window.
type StaffShifts struct {
Staffshiftid int `json:"staff_shift_id" gorm:"primaryKey;autoIncrement;column:staffshiftid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
// What a person calls it — "Morning", "Evening", "Weekend cover".
Name string `json:"name" gorm:"column:name"`
// Stored as text in 24-hour `HH:MM`, matching how `ridershifts` already
// holds its own times. Not a `time` column: these are wall-clock windows
// that repeat, not instants, and a date component on them invites exactly
// the timezone confusion that put a day of POS bills under the wrong
// business date.
Starttime string `json:"start_time" gorm:"column:starttime"`
Endtime string `json:"end_time" gorm:"column:endtime"`
// Which days it runs, as a 7-character mask starting Monday — "1111100"
// is Monday to Friday. Empty means every day, so a shop that never varies
// its week does not have to say so.
Weekdays string `json:"weekdays" gorm:"column:weekdays"`
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 (StaffShifts) TableName() string {
return "staffshifts"
}

View File

@@ -21,6 +21,23 @@ type Productcount struct {
Outofstock int `json:"outofstock"` Outofstock int `json:"outofstock"`
} }
// ProductCategoryUpdate is one row of a bulk re-filing: which product, which
// category it sits in, and — the field that actually decides what a shopper
// sees — which subcategory.
//
// Both are here because they do different jobs. GetProducts filters on
// categoryid (the customer app asks for 2), while GetProductsBySubcategory
// GROUPS on subcategoryid, so the aisle heading in the app comes from the
// second and the first only decides whether the product is returned at all.
// A caller re-filing products into aisles sends categoryid 2 unchanged and the
// subcategory it worked out.
type ProductCategoryUpdate struct {
Productid int `json:"productid"`
Categoryid int `json:"categoryid"`
// Optional: 0 leaves whatever the product already has.
Subcategoryid int `json:"subcategoryid"`
}
type ProductCategory struct { type ProductCategory struct {
Categoryid int `json:"categoryid"` Categoryid int `json:"categoryid"`
Moduleid int `json:"moduleid"` Moduleid int `json:"moduleid"`
@@ -36,14 +53,59 @@ type ProductCategory struct {
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
} }
// One size of one product — "500ml" hanging under the parent "Coke".
//
// The table always had the two columns that make this a relationship rather
// than a list of words: `productid` names the PARENT, `variantproductid` names
// the product a shopper actually orders when they pick this size. Neither was
// mapped here, so nothing could read or write them: `createproductvariant`
// wrote rows with no parent, and `getproductvariants` returned a flat
// per-tenant list of names. Measured 2026-09-02 across ten tenants: two rows
// existed on the whole platform, named "testing" and "demo", attached to
// nothing.
//
// The consequence is the ordering bug. With no way to attach a variant to a
// product, the app had only `products.variants` — a bare group number, 0 on
// every product — to go on.
type Productvariant struct { type Productvariant struct {
Variantid int `json:"variantid" gorm:"Primary_Key"` Variantid int `json:"variantid" gorm:"primaryKey;autoIncrement"`
Tenantid int `json:"tenantid"` Tenantid int `json:"tenantid"`
// The parent. A variant is meaningless without one, so this is what
// AddProductVariant refuses to accept as zero.
Productid int `json:"productid"`
// The product to actually put in the basket for this size. It is a real
// product row, so it carries its own price, stock and barcode — which is
// why a variant does not need to duplicate any of them.
Variantproductid int `json:"variantproductid"`
Variantname string `json:"variantname"` Variantname string `json:"variantname"`
Varianttype string `json:"varianttype,omitempty"`
// A per-variant price override. 0 means "no override, use the product’s
// own price" — which is a real answer, so it is always emitted. With
// omitempty the key vanished at 0 and a client reading `price` got
// undefined, then rendered it.
Price float64 `json:"price"`
Categoryid int `json:"categoryid" gorm:"default:0"` Categoryid int `json:"categoryid" gorm:"default:0"`
Categoryname string `json:"categoryname" gorm:"-"` Categoryname string `json:"categoryname" gorm:"-"`
Subcategoryid int `json:"subcategoryid"` Subcategoryid int `json:"subcategoryid"`
Status string `json:"status" gorm:"default:active"` Status string `json:"status" gorm:"default:Active"`
// Read-only, from the variant's own product row. The app needs a name and a
// price to draw a size picker; without these it would have to fetch each
// variant separately to render one screen.
Variantproductname string `json:"variantproductname" gorm:"->"`
Variantprice float64 `json:"variantprice" gorm:"->"`
Variantstock int `json:"variantstock" gorm:"->"`
// The size, as the product itself records it. Without these the only way to
// label a size was to join the parent’s unit fields to the variant’s name,
// which is how "null kg" reaches a screen.
Variantunitvalue string `json:"variantunitvalue" gorm:"->"`
Variantproductunit string `json:"variantproductunit" gorm:"->"`
} }
type Products struct { type Products struct {
@@ -56,18 +118,121 @@ type Products struct {
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"`
// The catalogue's own stable key for this product, e.g. `cheetos_chips_2d6bf74f`.
//
// `catalogueid` cannot do this job and never could. The catalogue is
// rebuilt by scrape and every row is renumbered when it is: pepsico's live
// ids run 3, 6, 9 … 27, 30, so a product imported when it was id 26 now
// points at nothing. Eleven of the nineteen links on the platform were
// dangling this way, and none could be repaired — the re-scrape had also
// changed the pack sizes, so the product they named no longer existed.
//
// `image_id` is the key the catalogue itself deduplicates on and it
// survives both. Written on import; `catalogueid` is kept beside it for
// rows imported before this column existed, and as the id the import call
// still addresses.
Imageid string `json:"imageid,omitempty" gorm:"column:imageid"`
Addonid int `json:"addonid,omitempty"` Addonid int `json:"addonid,omitempty"`
Discountid int `json:"discountid"` Discountid int `json:"discountid"`
Discountvalue float64 `json:"discountvalue"` Discountvalue float64 `json:"discountvalue"`
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"`
// Every photo the product has, as a JSON array of URLs.
//
// `Productimage` above stays the first of these and is left untouched: every
// existing reader — the store catalogue, the customer app, the POS catalogue
// pull, each order line — reads that column, and repointing them all at an
// array is a far larger change than giving the extra photos somewhere to live.
//
// Before this the import kept `Images[0]` and discarded the rest, so a product
// with ten photos in the global catalogue arrived in a shop with one. 90 of
// nestle's 123 products have more than one.
//
// Held as a string rather than a []string because GORM's raw scan-into-struct
// silently drops slice-kind destination fields — the same reason
// `catalogueProductColumns` casts its text[] columns to text.
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"`
Productbrand string `json:"productbrand,omitempty"` Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit,omitempty"` Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue,omitempty"` Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"` Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"` Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"` Taxamount float64 `json:"taxamount,omitempty"`
@@ -76,6 +241,19 @@ type Products struct {
Productstock int `json:"productstock" gorm:"default:0"` Productstock int `json:"productstock" gorm:"default:0"`
Productcombo int `json:"productcombo" gorm:"default:0"` Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"` Variants int `json:"variants" gorm:"default:0"`
// The sizes hanging under this product, so one call draws the whole screen.
//
// A separate field from `Variants` above, which is the legacy group NUMBER
// and stays exactly as it was — renaming it would break every caller, and it
// is still what an old client reads. This is the list.
//
// Always present, and EMPTY for a product with no sizes. That is the case
// that matters: an empty list is a complete answer meaning "order this one
// directly", which is what lets the app proceed instead of stalling on a
// choice that does not exist.
Variantoptions []Productvariant `json:"variantoptions" gorm:"-"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
// Price is the EFFECTIVE selling price at the location a query was scoped // Price is the EFFECTIVE selling price at the location a query was scoped
// to: productlocations.price when the store has set one, otherwise the // to: productlocations.price when the store has set one, otherwise the
@@ -85,7 +263,7 @@ type Products struct {
// returned only Retailprice, which the admin catalogue never writes. // returned only Retailprice, which the admin catalogue never writes.
// Same meaning as Locationproducts.Price, so both product feeds agree. // Same meaning as Locationproducts.Price, so both product feeds agree.
Price float64 `json:"price" gorm:"->"` Price float64 `json:"price" gorm:"->"`
Retailprice float64 `json:"retailprice,omitempty"` Retailprice float64 `json:"retailprice"`
Diffprice float64 `json:"diffprice,omitempty"` Diffprice float64 `json:"diffprice,omitempty"`
Diffpercent float64 `json:"diffpercent,omitempty"` Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"` Othercost float64 `json:"othercost,omitempty"`
@@ -119,10 +297,32 @@ type Locationproducts struct {
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"`
// Three columns this read used to leave in the table.
//
// All three are stored on `products` and none of them reached the store
// catalogue screen, because this struct had no field to scan them into —
// so the console could not use what the import had gone to the trouble of
// saving:
//
// Imageid the catalogue's durable key, and what HealthScorePanel
// joins on. Absent, the panel reads it as "this product
// never came from the catalogue" and renders nothing — for
// EVERY product, including ones that plainly did.
// 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"` Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"` Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit,omitempty"` Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue,omitempty"` Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"` Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"` Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"` Taxamount float64 `json:"taxamount,omitempty"`
@@ -137,13 +337,31 @@ type Locationproducts struct {
// productlocations row, not from products. Without it a store could set a // productlocations row, not from products. Without it a store could set a
// price and never read it back, so the UI always showed the master price. // price and never read it back, so the UI always showed the master price.
Price float64 `json:"price" gorm:"->"` Price float64 `json:"price" gorm:"->"`
Retailprice float64 `json:"retailprice,omitempty"` Retailprice float64 `json:"retailprice"`
Diffprice float64 `json:"diffprice,omitempty"` Diffprice float64 `json:"diffprice,omitempty"`
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"`
// Set only when the admin has released this product to the shops; NULL
// while it sits in the admin catalogue awaiting a price. This — not
// `Status` — is what a store view filters on. See models.Productlocations.
Publishedat *time.Time `json:"publishedat"`
} }
type Productstocks struct { type Productstocks struct {
@@ -249,6 +467,31 @@ type TenantInfo struct {
Categoryid int `json:"categoryid"` Categoryid int `json:"categoryid"`
Registrationno string `json:"registrationno"` Registrationno string `json:"registrationno"`
Orderscount int `json:"orderscount"` Orderscount int `json:"orderscount"`
/*
Whether this branch is taking orders, and why not when it is not.
`Isopen` and `Closeduntil` are the stored columns; `Isaccepting` and
`Closedreason` are worked out per request by models.StoreIsOpen, which
also folds in `status` and lets a dated close expire on its own.
THE APP SHOULD READ `isaccepting`, not `isopen`. The raw flag says what
the shopkeeper last pressed; the computed one is the answer — a branch
that said "back on Monday" has isopen false on Monday and is open.
The branch is still RETURNED when closed rather than filtered out. A shop
that vanishes reads to a regular customer as gone for good; one marked
"closed today" brings them back tomorrow. An app that would rather hide
it can, from this field.
*/
// The BRANCH status, aliased in the query so it does not collide with the
// tenant status already on this struct. Feeds StoreIsOpen.
Locationstatus string `json:"-" gorm:"column:locationstatus"`
Isopen bool `json:"isopen"`
Closeduntil string `json:"closeduntil"`
Isaccepting bool `json:"isaccepting" gorm:"-"`
Closedreason string `json:"closedreason" gorm:"-"`
// Products []Products `json:"products" gorm:"-"` // Products []Products `json:"products" gorm:"-"`
ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"` ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"`
} }
@@ -291,6 +534,13 @@ type TenantCategory struct {
type ImportedCatalogueRef struct { type ImportedCatalogueRef struct {
Brand string `json:"brand"` Brand string `json:"brand"`
Catalogueid int64 `json:"catalogueid"` Catalogueid int64 `json:"catalogueid"`
// The stable key, when the product carries one.
//
// A browse screen should match on this in preference to the id: the id is
// renumbered by every re-scrape, so a tick placed by catalogueid lands on
// whatever product now holds that number, or on nothing at all. Empty for a
// product imported before the column existed.
Imageid string `json:"imageid"`
} }
// ImportCatalogueProductRequest is the payload for importing a product from // ImportCatalogueProductRequest is the payload for importing a product from
@@ -310,6 +560,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 {
@@ -324,6 +582,20 @@ type Productlocations struct {
Quantity int `json:"quantity" gorm:"<-:false"` Quantity int `json:"quantity" gorm:"<-:false"`
Stocktype string `json:"stocktype" gorm:"<-:false"` Stocktype string `json:"stocktype" gorm:"<-:false"`
Status string `json:"status"` Status string `json:"status"`
// When this product was released to the store, and the only thing that
// decides whether a store user can see it.
//
// NULL means the admin has imported it but not published it: it belongs to
// the admin catalogue alone. Deliberately NOT another `Status` value —
// syncProductLocationStatus rewrites that column on every stock movement,
// which is why the import's 'Draft' survived on one row out of 6,755.
//
// Read-only through this struct (`<-:false`). It is set by Publish and
// cleared by Unpublish, never as a side effect of an ordinary
// product-location write: setting a price must not be able to release a
// product to every shop in the tenant.
Publishedat *time.Time `json:"publishedat" gorm:"column:publishedat;<-:false"`
} }
// ProductLocationRef identifies a single (tenant, location, product) row in // ProductLocationRef identifies a single (tenant, location, product) row in

View File

@@ -0,0 +1,71 @@
package models
import (
"encoding/json"
"testing"
)
/*
A missing string must reach a client as "", never as an absent key.
`unitvalue` and `productunit` were both `omitempty`, and on this platform
`unitvalue` is empty for every product — measured 2026-09-02 on R mart: 24
products, 0 with both fields, 7 with a unit and no value. So the key vanished
from the JSON, a client read `undefined`, and joining it to the unit rendered
the literal text "null kg" on a shopper's screen.
The same applies to money: a price of 0 is a real answer ("this has no price
set"), and a client that receives no key at all cannot tell that from a field
it forgot to request.
*/
func fieldsOf(t *testing.T, v any) map[string]any {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
func TestAnEmptyUnitIsSentAsEmptyNotOmitted(t *testing.T) {
got := fieldsOf(t, Products{Productid: 1})
for _, key := range []string{"unitvalue", "productunit"} {
if _, present := got[key]; !present {
t.Errorf("%q was omitted; a client reads that as null and renders it", key)
}
}
}
func TestAZeroPriceIsSentAsZeroNotOmitted(t *testing.T) {
got := fieldsOf(t, Products{Productid: 1})
if _, present := got["retailprice"]; !present {
t.Error("retailprice was omitted; 0 is a real answer and must be visible")
}
}
func TestAVariantAlwaysCarriesItsPriceAndUnit(t *testing.T) {
got := fieldsOf(t, Productvariant{Variantid: 1})
for _, key := range []string{"price", "variantprice", "variantunitvalue", "variantproductunit"} {
if _, present := got[key]; !present {
t.Errorf("variant option omitted %q", key)
}
}
}
func TestAProductWithNoVariantsSendsAnEmptyListNotNull(t *testing.T) {
// The case that lets an order proceed: [] means "order this one directly".
// A JSON null would force every client to null-check before counting.
got := fieldsOf(t, Products{Productid: 1, Variantoptions: []Productvariant{}})
list, ok := got["variantoptions"].([]any)
if !ok {
t.Fatalf("variantoptions was %T, want a list", got["variantoptions"])
}
if len(list) != 0 {
t.Errorf("expected an empty list, got %d", len(list))
}
}

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

89
models/storeopen.go Normal file
View File

@@ -0,0 +1,89 @@
package models
import (
"strings"
"time"
)
/*
Is a branch taking orders right now?
Two things have to be true: the branch exists as far as the platform is
concerned, and the shop has not closed itself for the day. They are different
questions with different owners — Nearle decommissions a branch, a shopkeeper
closes for an afternoon — and this is the only place they are answered
together.
── Reopening is worked out, not scheduled ──────────────────────────────────
A shop that said "back on Monday" is open on Monday because this function says
so, not because a job ran. Nothing has to fire, nothing can be missed, and a
server that was down over the weekend still gets it right. The alternative — a
nightly task flipping the flag — fails silently exactly once and leaves a shop
shut with nobody watching.
── Why the date means the day it REOPENS ───────────────────────────────────
"Closed until the 12th" reads, in English, as open on the 12th. So the date
names the first day back, and a shop closing for today alone sets tomorrow.
The console says "Reopens on" rather than "Closed until" so nobody has to
work that out.
*/
func StoreIsOpen(status string, isOpen bool, closedUntil string, now time.Time) bool {
// Decommissioned beats everything. A branch Nearle has switched off does
// not come back because a date passed.
if !strings.EqualFold(strings.TrimSpace(status), "active") {
return false
}
if isOpen {
return true
}
// Closed, with a date to come back on.
reopen, ok := parseStoreDate(closedUntil)
if !ok {
// Closed with no end in sight — a power cut nobody can put a date on.
// Stays closed until a person says otherwise, which is the safe way
// round: a shop wrongly shown open takes orders it cannot fill.
return false
}
today := now.Truncate(24 * time.Hour)
return !today.Before(reopen.Truncate(24 * time.Hour))
}
/*
Why a shopper cannot order, in words fit to show them.
Empty when the branch is open. Returned straight to the app, so it says what a
person needs to know and not what the database holds.
*/
func StoreClosedReason(status string, isOpen bool, closedUntil string, now time.Time) string {
if StoreIsOpen(status, isOpen, closedUntil, now) {
return ""
}
if !strings.EqualFold(strings.TrimSpace(status), "active") {
return "This store is not currently available"
}
if reopen, ok := parseStoreDate(closedUntil); ok {
return "This store is closed today and reopens on " + reopen.Format("2 Jan")
}
return "This store is closed today"
}
// Accepts a bare date or a timestamp, because the column is a DATE but GORM
// and the console have both been known to hand over a full RFC3339 string.
// Anything unreadable is "no date", never today.
func parseStoreDate(value string) (time.Time, bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, false
}
if len(trimmed) >= 10 {
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
return parsed, true
}
}
return time.Time{}, false
}

111
models/storeopen_test.go Normal file
View File

@@ -0,0 +1,111 @@
package models
import (
"strings"
"testing"
"time"
)
func at(day string) time.Time {
parsed, err := time.Parse("2006-01-02", day)
if err != nil {
panic(err)
}
return parsed
}
func TestOpenByDefault(t *testing.T) {
// Every branch trading today has isopen true and no date. None of them may
// go dark the moment this ships.
if !StoreIsOpen("Active", true, "", at("2026-10-09")) {
t.Error("a normal trading branch was reported closed")
}
}
func TestClosedWithNoDateStaysClosed(t *testing.T) {
// A power cut nobody can put an end to. It stays shut until a person says
// otherwise — the safe way round, because a shop wrongly shown open takes
// orders it cannot fill.
if StoreIsOpen("Active", false, "", at("2026-10-09")) {
t.Error("a branch closed with no reopen date was reported open")
}
}
func TestReopensOnTheDateItNamed(t *testing.T) {
// "Closed until the 12th" means open ON the 12th.
cases := []struct {
today string
want bool
}{
{"2026-10-10", false}, // still closed
{"2026-10-11", false}, // last closed day
{"2026-10-12", true}, // back
{"2026-10-13", true}, // and stays back
}
for _, tc := range cases {
got := StoreIsOpen("Active", false, "2026-10-12", at(tc.today))
if got != tc.want {
t.Errorf("on %s: open=%v, want %v", tc.today, got, tc.want)
}
}
}
func TestReopeningNeedsNobodyToRemember(t *testing.T) {
// The whole reason this is computed and not scheduled: no job ran, nothing
// flipped the flag, and the shop is open anyway.
if !StoreIsOpen("Active", false, "2026-10-01", at("2026-12-25")) {
t.Error("a branch whose reopen date passed months ago is still shut")
}
}
func TestDecommissionedBeatsEverything(t *testing.T) {
// Status is Nearle's word for whether the branch exists. A date passing
// must not bring back a branch that was switched off.
if StoreIsOpen("Inactive", true, "", at("2026-10-09")) {
t.Error("an inactive branch was reported open")
}
if StoreIsOpen("Inactive", false, "2026-10-01", at("2026-12-25")) {
t.Error("an inactive branch came back because a date passed")
}
}
func TestUnreadableDateIsNotTreatedAsToday(t *testing.T) {
// A zero time would be before today and quietly reopen the shop.
if StoreIsOpen("Active", false, "not a date", at("2026-10-09")) {
t.Error("an unreadable reopen date reopened the branch")
}
}
func TestAcceptsATimestampAsWellAsADate(t *testing.T) {
// The column is a DATE, but GORM and the console have both been known to
// hand over a full timestamp.
if !StoreIsOpen("Active", false, "2026-10-12T00:00:00Z", at("2026-10-12")) {
t.Error("a timestamp form of the reopen date was not understood")
}
}
func TestReasonIsWrittenForAShopper(t *testing.T) {
if msg := StoreClosedReason("Active", true, "", at("2026-10-09")); msg != "" {
t.Errorf("an open store gave a reason: %q", msg)
}
msg := StoreClosedReason("Active", false, "2026-10-12", at("2026-10-09"))
if !strings.Contains(msg, "12 Oct") {
t.Errorf("the reason does not say when it reopens: %q", msg)
}
// No date: say it is closed, and do not invent a return.
plain := StoreClosedReason("Active", false, "", at("2026-10-09"))
if plain == "" || strings.Contains(plain, "reopens") {
t.Errorf("a close with no date promised a return: %q", plain)
}
}
func TestStatusIsNotCaseSensitive(t *testing.T) {
// The column holds "Active" today, but nothing enforces the capital.
for _, s := range []string{"Active", "active", "ACTIVE", " Active "} {
if !StoreIsOpen(s, true, "", at("2026-10-09")) {
t.Errorf("status %q read as not active", s)
}
}
}

View File

@@ -24,7 +24,28 @@ type Tenantinfo struct {
Latitude string `json:"latitude"` Latitude string `json:"latitude"`
Longitude string `json:"longitude"` Longitude string `json:"longitude"`
Tenantimage string `json:"tenantimage"` Tenantimage string `json:"tenantimage"`
// When the merchant was created.
//
// The column has always been there — `tenants.created` defaults to now() —
// and `GetTenantByID` already does SELECT a.*, so the value was in every
// result set and thrown away for want of a field to scan into. A shop
// profile that cannot say how long the business has been on the platform is
// missing a fact the database has held all along.
Created time.Time `json:"created"`
Tenantinfo string `json:"tenantinfo"` Tenantinfo string `json:"tenantinfo"`
// The FSSAI or trade licence.
//
// The column has always existed and `GetCustomerTenants` already selects it
// for the customer app — it was missing from THIS struct alone, so
// `gettenantinfo` could never return it however well it was stored.
//
// That is not cosmetic. The shop-profile screen reads a merchant's own
// record back through this endpoint, and the setup walkthrough decides
// whether the profile step is finished by looking at exactly this field. A
// merchant could type their licence, save it, see it stored — and the step
// would stay open forever, because the read that judges it never carried
// the value.
Licenseno string `json:"licenseno"`
Paymenttype int `json:"paymenttype"` Paymenttype int `json:"paymenttype"`
Paymode1 int `json:"paymode1"` Paymode1 int `json:"paymode1"`
Paymode2 int `json:"paymode2"` Paymode2 int `json:"paymode2"`
@@ -36,6 +57,13 @@ type Tenantinfo struct {
Approved int `json:"approved"` Approved int `json:"approved"`
Moduleid int `json:"moduleid"` Moduleid int `json:"moduleid"`
Subcategoryname string `json:"subcategoryname"` Subcategoryname string `json:"subcategoryname"`
// The app category this store trades under, by name.
//
// `GetTenantByID` has always joined `app_category b` and selected
// `b.categoryname`, and the struct had nowhere to put it — so the value was
// computed on every read and discarded, leaving the console to print
// "Category 2" at a merchant who trades under Daily Needs.
Categoryname string `json:"categoryname"`
Firstname string `json:"firstname"` Firstname string `json:"firstname"`
Lastname string `json:"lastname"` Lastname string `json:"lastname"`
Accountname string `json:"Accountname"` Accountname string `json:"Accountname"`
@@ -43,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 {
@@ -68,6 +104,50 @@ type Tenantlocations struct {
Deliverymins int `json:"deliverymins"` Deliverymins int `json:"deliverymins"`
Cancelsecs int `json:"cancelsecs"` Cancelsecs int `json:"cancelsecs"`
Status string `json:"status" gorm:"default:Active"` Status string `json:"status" gorm:"default:Active"`
/*
Is this branch trading today?
SEPARATE FROM `Status`, deliberately. Status is the platform's word for
whether a branch exists at all — setting it to Inactive is how a branch is
decommissioned, and the staff console reads it that way. A shopkeeper
closing for an afternoon because the power went out is saying something
completely different, and the two must not share a field: a day off would
read to Nearle as a shop that had shut down.
Default true. Every branch trading today keeps trading.
*/
Isopen bool `json:"isopen" gorm:"column:isopen;default:true"`
/*
The date it opens again, when the shop knows it.
"Closed until the 12th" means closed on the 11th and OPEN on the 12th —
the date names the first day back, which is how the phrase reads.
Nothing flips `Isopen` back on a timer. The answer is worked out on read
(see StoreIsOpen), so a branch that said "back on Monday" is open on
Monday whether or not anybody remembered. A job would drift; this cannot.
Empty means closed until somebody says otherwise, which is the right
default for a power cut nobody can put an end date on.
*/
Closeduntil string `json:"closeduntil" gorm:"column:closeduntil"`
// Who will run this outlet, when the caller already has somebody in mind.
//
// `gorm:"-"` because it is not a column — it names an existing `app_users`
// row to bind to the new branch instead of spawning a fresh login.
//
// Spawning was the only option, and it produced an account named after the
// SHOP, on the SHOP's email address, one per outlet. Two people behind the
// same counter shared one credential and nothing recorded which of them did
// anything. Naming a person here is what lets a merchant decide who runs a
// branch, and hire that person before the branch exists.
//
// Zero keeps the old behaviour exactly, so every existing caller is
// unaffected.
Operatorid int `json:"operatorid" gorm:"-"`
} }
type Tenantslot struct { type Tenantslot struct {
@@ -147,6 +227,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

@@ -101,4 +101,11 @@ type TenantUserInfo struct {
Categoryname string `json:"categoryname"` Categoryname string `json:"categoryname"`
Subcategoryid int `json:"subcategoryid"` Subcategoryid int `json:"subcategoryid"`
Issuperadmin bool `json:"issuperadmin"` Issuperadmin bool `json:"issuperadmin"`
// Carried so this response is a SUPERSET of what `UserInfo` returned.
// `/mob/users/tenant/login` used to answer with UserInfo, and a shipped app
// may read any of these three; dropping them while repointing the route
// would have been a breaking change disguised as a fix.
Shiftid int `json:"shiftid"`
Shiftname string `json:"shiftname"`
Status string `json:"status"`
} }

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

@@ -0,0 +1,242 @@
package repositories
import (
"strings"
"testing"
)
// The bug: a brand table one column short of the old eighteen-column check was
// not degraded, it was INVISIBLE — missing from getbrands and "Unknown brand"
// on every read. 19 of the owning team's 35 brands were reachable; the other 16
// existed and could not be seen from this side at all.
func columnSet(names ...string) map[string]bool {
have := make(map[string]bool, len(names))
for _, n := range names {
have[n] = true
}
return have
}
var everyColumn = func() map[string]bool {
names := append([]string{}, catalogueCoreColumns...)
for _, c := range catalogueOptionalColumns {
names = append(names, c.Name)
}
return columnSet(names...)
}()
func TestAFullTableSelectsEveryRealColumn(t *testing.T) {
got := columnsFor(everyColumn)
if strings.Contains(got, "NULL::") {
t.Errorf("a complete table should select no NULL stand-ins:\n%s", got)
}
for _, want := range []string{"product_sku", "providers::text AS providers", "fssai_license"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q from:\n%s", want, got)
}
}
}
// The exact shape that used to vanish: everything but one enrichment column.
func TestATableMissingOneColumnIsStillReadable(t *testing.T) {
have := columnSet()
for name := range everyColumn {
have[name] = true
}
delete(have, "fssai_license")
got := columnsFor(have)
if !strings.Contains(got, "NULL::text AS fssai_license") {
t.Errorf("absent column should be selected as NULL:\n%s", got)
}
if strings.Contains(got, ", fssai_license,") {
t.Errorf("must not select a column the table does not have:\n%s", got)
}
if !strings.Contains(got, "product_name") {
t.Errorf("the rest of the table must still be read:\n%s", got)
}
}
// A NULL stand-in has to carry the same cast as the real column, or the scan
// destination changes type between one brand table and the next.
func TestNullStandInsKeepTheColumnType(t *testing.T) {
bare := columnsFor(columnSet("id", "product_name"))
for _, want := range []string{
"NULL::text AS providers",
"NULL::text AS highlights",
"NULL::text AS nutrients",
"NULL::timestamptz AS created_at",
"NULL::timestamptz AS updated_at",
} {
if !strings.Contains(bare, want) {
t.Errorf("missing %q from:\n%s", want, bare)
}
}
}
// Core columns are never substituted — without an id there is nothing to order
// or address rows by, which is why a table lacking them is skipped outright.
func TestCoreColumnsAreAlwaysSelectedPlainly(t *testing.T) {
got := columnsFor(columnSet("id", "product_name"))
if !strings.HasPrefix(got, "id, product_name") {
t.Errorf("core columns should lead the select list, got:\n%s", got)
}
}
/*
Where product photos come from.
The owning team records what its image search found, per product, in
`image_url` and `image_urls`. Most of those are EXTERNAL — bigbasket, amazon, a
shop's own CDN — and only a subset were ever mirrored into our bucket. Reading
the bucket alone found photos for 2 of britannia's 6 products while they had
URLs for all 6.
*/
func TestJsonImageListIsRead(t *testing.T) {
// The shape their API returns today.
raw := `["https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg",` +
`"https://m.media-amazon.com/images/I/71n1Q3cQL3L.jpg"]`
got := parseImageList(raw)
if len(got) != 2 || got[0] != "https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg" {
t.Fatalf("json list not parsed: %#v", got)
}
}
// The same column stored as a Postgres text[] rather than jsonb. Which one it
// is belongs to the owning team, and this side should not break when it moves.
func TestPostgresArrayImageListIsRead(t *testing.T) {
got := parseImageList(`{"https://a.example/1.jpg","https://b.example/2.jpg"}`)
if len(got) != 2 || got[1] != "https://b.example/2.jpg" {
t.Fatalf("pg array not parsed: %#v", got)
}
}
func TestEmptyImageListsAreNotPhotos(t *testing.T) {
for _, raw := range []string{"", " ", "{}", "[]", "null"} {
if got := parseImageList(raw); len(got) != 0 {
t.Errorf("%q should yield no photos, got %#v", raw, got)
}
}
}
// A malformed value must cost the photos, not the product.
func TestMalformedImageListDoesNotBreakTheRow(t *testing.T) {
if got := parseImageList(`["unterminated`); len(got) != 0 {
t.Errorf("expected no photos from malformed json, got %#v", got)
}
}
func TestTheListedUrlWinsAndDuplicatesCollapse(t *testing.T) {
row := catalogueProductRow{
ImageID: "britannia_britannia_marie_gold_250g",
ImageURL: "https://a.example/1.jpg",
ImageURLs: `["https://a.example/1.jpg","https://a.example/2.jpg"]`,
}
got := imagesFor("britannia", row)
if len(got) != 2 {
t.Fatalf("the primary url repeated in the list should appear once: %#v", got)
}
if got[0] != "https://a.example/1.jpg" || got[1] != "https://a.example/2.jpg" {
t.Errorf("order should follow image_urls: %#v", got)
}
}
// With no URLs recorded, the bucket listing is still consulted — it is the only
// source for anything ingested before these columns existed.
func TestNoRecordedUrlsFallsBackToTheBucket(t *testing.T) {
row := catalogueProductRow{ImageID: "cadbury_cadbury_5_star_5_gm"}
// db.GetImages returns nil when the image store was never initialised,
// which is the case here — the point is that it does not panic and does not
// invent a photo.
if got := imagesFor("cadbury", row); len(got) != 0 {
t.Errorf("expected no photos without an image store, got %#v", got)
}
}
/*
Brand names arrive in two spellings, and only one of them is a key.
The catalogue keys brands by table suffix (`24_mantra`); the ingest service's
run manifest reports the display name for the same brand ("24 Mantra"). Anything
acting on a manifest — the step that puts an uploaded sheet on a shop's shelf,
above all — holds the second and has to be able to look up the first.
Real values, taken from the manifest of the R mart upload on 2026-08-31, which
failed with "Unknown brand: 24 Mantra" and shelved nothing.
*/
func TestNormaliseBrandKeyFoldsDisplayNamesOntoTableKeys(t *testing.T) {
cases := map[string]string{
"24 Mantra": "24_mantra",
"Paper Boat": "paper_boat",
"Too Yumm": "too_yumm",
"Clinic All Clear": "clinic_all_clear",
"hindustan unilever": "hindustan_unilever",
"coca-cola": "coca_cola",
// Single-word brands were never broken; they must stay unbroken.
"Colin": "colin",
"Society": "society",
"itc": "itc",
"Kohinoor": "kohinoor",
// A key that is already in table form passes through untouched, which is
// what makes the fallback safe to apply to every lookup.
"24_mantra": "24_mantra",
// Separators collapse rather than doubling up, and the edges are clean —
// " P&G " must not become "__p_g_".
" P&G ": "p_g",
"Dabur Red": "dabur_red",
}
for input, want := range cases {
if got := normaliseBrandKey(input); got != want {
t.Errorf("normaliseBrandKey(%q) = %q, want %q", input, got, want)
}
}
}
// A brand of nothing but punctuation yields an empty key rather than a stray
// underscore. Empty misses the map and answers "unknown brand", which is the
// right answer; "_" could in principle collide with a real table suffix.
func TestNormaliseBrandKeyRefusesToInventAKey(t *testing.T) {
for _, input := range []string{"", " ", "---", "&&&"} {
if got := normaliseBrandKey(input); got != "" {
t.Errorf("normaliseBrandKey(%q) = %q, want empty", input, got)
}
}
}
// 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

@@ -0,0 +1,85 @@
package repositories
import (
"log"
"strconv"
"sync"
"time"
)
// Telling tills that a shop's shelf has moved.
//
// The broker topic `nearle/pos/{loc}/catalogue` and its publisher have existed
// since the POS integration landed, retained flag and all — and **nothing ever
// called it**. Every terminal's stock figure was therefore whatever it last
// pulled, with no signal that an app order had just sold the last of something.
//
// It could not be called from here, which is why it never was: `repositories`
// does not import `messaging`, and must not — wiring runs the other way, from
// main.go, and reversing it would be an import cycle. So the dependency is
// inverted through this interface. `messaging.PosMqttConsumer` already
// satisfies it; main.go registers it once at startup.
//
// A package-level registration rather than a constructor parameter because the
// two write paths that need it — POS bill ingest and createOrderTx — are
// reached through several layers that would each have to grow a field for a
// notification neither of them cares about the result of.
// CatalogueNotifier tells every till at a store to pull the catalogue again.
type CatalogueNotifier interface {
PublishCatalogueChanged(storeID, revision string) error
}
var (
catalogueNotifierMu sync.RWMutex
catalogueNotifier CatalogueNotifier
)
// SetCatalogueNotifier registers the publisher. Safe to leave unset — the API
// must run with the broker down, and a missed notification only means a till is
// stale until its next periodic pull.
func SetCatalogueNotifier(n CatalogueNotifier) {
catalogueNotifierMu.Lock()
defer catalogueNotifierMu.Unlock()
catalogueNotifier = n
}
// notifyCatalogueChanged asks the tills at one outlet to refresh.
//
// Three rules, all of them learned the hard way elsewhere in this codebase:
//
// 1. **Call it after the transaction commits, never inside.** A rolled-back
// sale must not announce a change that did not happen.
// 2. **It cannot fail a sale.** A publish error is logged and swallowed —
// exactly as syncProductLocationStatus already does for the availability
// flag. Stock is committed; a terminal being told about it late is not
// worth losing a bill over.
// 3. **Only the revision travels, never quantities.** The till pulls. Pushed
// numbers race with concurrent sales, and the catalogue endpoint already
// answers a delta from a revision.
//
// Fire-and-forget on its own goroutine: the publisher waits for the broker's
// acknowledgement, and a bill's response should not sit behind that.
func notifyCatalogueChanged(locationID int) {
if locationID <= 0 {
return
}
catalogueNotifierMu.RLock()
n := catalogueNotifier
catalogueNotifierMu.RUnlock()
if n == nil {
return
}
// Stamped a second in the past for the same reason Catalogue() does it: a
// revision must not claim to include a write that is still landing.
revision := posRevisionFor(locationID, time.Now().Add(-time.Second))
storeID := strconv.Itoa(locationID)
go func() {
if err := n.PublishCatalogueChanged(storeID, revision); err != nil {
log.Printf("catalogue notify: store %s: %v", storeID, err)
}
}()
}

View File

@@ -1,12 +1,15 @@
package repositories package repositories
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"nearle/db" "nearle/db"
"nearle/models" "nearle/models"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -34,10 +37,86 @@ 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`
// catalogueCoreColumns is the minimum a `brand_*` table must have to be worth
// reading: an id to order and address rows by, and a name to show. Everything
// else is enrichment, and a table missing some of it is still a perfectly good
// catalogue of products.
var catalogueCoreColumns = []string{"id", "product_name"}
// catalogueOptionalColumns is selected when present and replaced with NULL when
// it is not, so one absent column costs that column rather than the whole brand.
//
// The expression is carried next to the name because three of these are text[]
// and need casting; the NULL stand-in has to be cast the same way or the scan
// destination changes type from one table to the next.
var catalogueOptionalColumns = []struct{ Name, Present, Absent string }{
{"title", "title", "NULL::text AS title"},
{"description", "description", "NULL::text AS description"},
{"category", "category", "NULL::text AS category"},
{"image_id", "image_id", "NULL::text AS image_id"},
{"size", "size", "NULL::text AS size"},
{"variant_key", "variant_key", "NULL::text AS variant_key"},
{"product_sku", "product_sku", "NULL::text AS product_sku"},
{"sku_source", "sku_source", "NULL::text AS sku_source"},
{"price_range", "price_range", "NULL::text AS price_range"},
{"providers", "providers::text AS providers", "NULL::text AS providers"},
{"fssai_license", "fssai_license", "NULL::text AS fssai_license"},
{"highlights", "highlights::text AS highlights", "NULL::text AS highlights"},
{"nutrients", "nutrients::text AS nutrients", "NULL::text AS nutrients"},
{"search_query", "search_query", "NULL::text AS search_query"},
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
// Where the photos actually are.
//
// The catalogue pipeline records what it found per product: `image_url` for
// the primary and `image_urls` for the rest. Most are EXTERNAL — bigbasket,
// amazon, a shop's own CDN — because stage 6 searches the web and only some
// results get mirrored into our bucket.
//
// This side ignored both columns and instead listed bucket objects under
// `daily/brands/{brand}/{image_id}/`, so a product showed a photo only if it
// happened to have been mirrored. Measured on britannia: 2 of 6 products had
// images here while the owning team had URLs for all 6.
//
// Cast to text and parsed by hand: the column is an array, and GORM's raw
// scan silently drops slice-kind destination fields — the same reason
// providers, highlights and nutrients are read this way.
{"image_url", "image_url", "NULL::text AS image_url"},
{"image_urls", "image_urls::text AS image_urls", "NULL::text AS image_urls"},
}
// columnsFor builds the SELECT list for one table from the columns it has.
//
// This is the fix for brands going missing. Discovery used to demand all
// eighteen columns — `HAVING COUNT(DISTINCT column_name) = 18` — so a brand
// table written by a newer pipeline, with one column renamed or not yet added,
// was not merely degraded but INVISIBLE: absent from `getbrands`, and "Unknown
// brand" on every read. Measured against the live catalogue, 19 of the owning
// team's 35 brands were reachable and the other 16 could not be seen from this
// side at all.
//
// Selecting NULL for what is absent turns that into the smaller, honest
// failure: the brand appears, its products list, and the fields nobody wrote
// come back empty.
func columnsFor(have map[string]bool) string {
parts := make([]string, 0, len(catalogueCoreColumns)+len(catalogueOptionalColumns))
parts = append(parts, catalogueCoreColumns...)
for _, col := range catalogueOptionalColumns {
if have[col.Name] {
parts = append(parts, col.Present)
continue
}
parts = append(parts, col.Absent)
}
return strings.Join(parts, ", ")
}
// catalogueProductRow mirrors catalogueProductColumns for scanning; array // catalogueProductRow mirrors catalogueProductColumns for scanning; array
// columns land here as their raw Postgres text[] literal. // columns land here as their raw Postgres text[] literal.
type catalogueProductRow struct { type catalogueProductRow struct {
@@ -59,6 +138,65 @@ type catalogueProductRow struct {
SearchQuery string SearchQuery string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
ImageURL string
ImageURLs string
}
// imagesFor decides which photos a catalogue product carries.
//
// The pipeline's own `image_urls` first, then its single `image_url`, and the
// bucket listing last. That order is deliberate: the owning team records what
// it found for every product, and only a subset of those files were ever
// mirrored into our bucket — so listing the bucket alone finds photos for a
// minority of the catalogue and reports the rest as having none.
//
// The bucket stays as the fallback rather than being dropped. It is the only
// source for anything ingested before these columns existed, and it is the one
// source that cannot rot: a external URL is somebody else's CDN and will
// eventually 404, at which point the card falls through to the next photo.
func imagesFor(brand string, row catalogueProductRow) []string {
seen := make(map[string]bool)
var out []string
add := func(url string) {
url = strings.TrimSpace(url)
if url == "" || seen[url] {
return
}
seen[url] = true
out = append(out, url)
}
for _, url := range parseImageList(row.ImageURLs) {
add(url)
}
add(row.ImageURL)
if len(out) == 0 {
return db.GetImages(brand, row.ImageID)
}
return out
}
// parseImageList reads the list whichever way it was stored.
//
// A Postgres text[] casts to `{a,b}` and a jsonb column to `["a","b"]`, and the
// column type is the owning team's to change. Handling both here costs four
// lines and removes a whole class of "it worked until they migrated".
func parseImageList(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" || raw == "[]" || raw == "null" {
return nil
}
if strings.HasPrefix(raw, "[") {
var urls []string
if err := json.Unmarshal([]byte(raw), &urls); err == nil {
return urls
}
// Falls through on a malformed value rather than dropping the row.
log.Printf("catalogue: could not parse image_urls as JSON: %.120s", raw)
return nil
}
return models.ParsePGArray(raw)
} }
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct { func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
@@ -70,7 +208,7 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
Description: row.Description, Description: row.Description,
Category: row.Category, Category: row.Category,
ImageID: row.ImageID, ImageID: row.ImageID,
Images: db.GetImages(brand, row.ImageID), Images: imagesFor(brand, row),
Size: row.Size, Size: row.Size,
VariantKey: row.VariantKey, VariantKey: row.VariantKey,
ProductSKU: row.ProductSKU, ProductSKU: row.ProductSKU,
@@ -92,6 +230,7 @@ type CatalogueRepository interface {
GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error)
GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error) GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error)
GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error)
GetProductByImageID(brand, imageID string) (*models.CatalogueProduct, error)
} }
type catalogueRepository struct { type catalogueRepository struct {
@@ -104,12 +243,210 @@ func NewCatalogueRepository(db *gorm.DB) CatalogueRepository {
return &catalogueRepository{db: db} return &catalogueRepository{db: db}
} }
func tableForBrand(brand string) (string, error) { // brandTables is the allowlist actually used, discovered from the catalogue
table, ok := catalogueBrandTables[strings.ToLower(strings.TrimSpace(brand))] // database rather than hardcoded.
if !ok { //
return "", ErrUnknownBrand // The literal map above only ever exposed six brands, so a `brand_*` table
// added to the catalogue DB was invisible to the whole product until somebody
// shipped a release. Discovery removes that, and keeps the property the
// allowlist existed for: **table names still never come from a request.** They
// come from the database's own catalog, and the `brand` parameter is only ever
// looked up in the result.
//
// Two things it checks that a name alone would not:
//
// - the table has the CORE columns — an id and a product name. It used to
// demand all eighteen, which turned "this table is a little different" into
// "this brand does not exist": 16 of 35 brands were unreachable that way.
// Anything beyond the core is now selected as NULL when absent, so a table
// of a different shape is read rather than hidden.
// - discovery returning nothing falls back to the literal map, so a
// permissions problem on information_schema cannot take the catalogue dark.
//
// Cached with a TTL so a brand added today appears without a restart.
var (
brandTablesMu sync.RWMutex
brandTablesData map[string]string
brandTablesAt time.Time
// The columns each discovered table actually has, filled by
// discoverBrandTables and read by columnsForTable. Kept beside the table map
// and refreshed with it, so the two can never describe different schemas.
brandColumnsMu sync.RWMutex
brandColumnsData map[string]map[string]bool
)
// columnsForTable is the SELECT list for one table, or the full fixed list when
// discovery has not run — which is the built-in fallback case, where the tables
// are the known-good ones and every column is present by definition.
func columnsForTable(table string) string {
brandColumnsMu.RLock()
have := brandColumnsData[table]
brandColumnsMu.RUnlock()
if have == nil {
return catalogueProductColumns
} }
return columnsFor(have)
}
const brandTablesTTL = 5 * time.Minute
func (r *catalogueRepository) brandTables() map[string]string {
brandTablesMu.RLock()
if brandTablesData != nil && time.Since(brandTablesAt) < brandTablesTTL {
defer brandTablesMu.RUnlock()
return brandTablesData
}
brandTablesMu.RUnlock()
discovered := catalogueBrandTables
if r.db != nil {
if found, err := r.discoverBrandTables(); err != nil {
log.Printf("catalogue: brand discovery failed, using the built-in list: %v", err)
} else if len(found) > 0 {
discovered = found
} else {
log.Println("catalogue: brand discovery found no usable tables, using the built-in list")
}
}
brandTablesMu.Lock()
brandTablesData, brandTablesAt = discovered, time.Now()
brandTablesMu.Unlock()
return discovered
}
// discoverBrandTables lists every `brand_*` table that can be read at all, and
// records which columns each one actually has.
//
// It asks for the CORE columns only. The previous version demanded all eighteen
// — `HAVING COUNT(DISTINCT column_name) = 18` — which sounds like a safety check
// and behaves like a filter: a table whose pipeline had renamed one column, or
// not written it yet, failed the count and disappeared from the product
// entirely. It was not listed by `getbrands` and every read of it answered
// "Unknown brand", so from this side the brand did not exist.
//
// That is how 19 of the owning team's 35 brands were visible. The missing ones
// were not broken and not empty; they were a column short of a test that had no
// need to be that strict, and nothing anywhere said so.
//
// The per-table column set is read in the same query and handed to columnsFor,
// which substitutes NULL for anything absent — so a table can now be missing
// enrichment without being missing.
func (r *catalogueRepository) discoverBrandTables() (map[string]string, error) {
// `IN (?)` with a slice rather than `= ANY(?)` with a driver array type:
// GORM expands the former itself, and the latter would pull in lib/pq for
// one call in a codebase that has no other use for it.
var rows []struct {
TableName string
ColumnName string
}
err := r.db.Raw(`
SELECT c.table_name, c.column_name
FROM information_schema.columns c
WHERE c.table_schema = 'public'
AND c.table_name LIKE 'brand\_%'
ORDER BY c.table_name`).Scan(&rows).Error
if err != nil {
return nil, err
}
// Group the columns by table, then keep the tables that have the core set.
byTable := make(map[string]map[string]bool)
for _, row := range rows {
if byTable[row.TableName] == nil {
byTable[row.TableName] = make(map[string]bool)
}
byTable[row.TableName][row.ColumnName] = true
}
var names []string
columns := make(map[string]map[string]bool, len(byTable))
for table, have := range byTable {
usable := true
for _, core := range catalogueCoreColumns {
if !have[core] {
usable = false
break
}
}
if !usable {
// Named individually. A brand dropped here is a brand nobody can
// reach, and silence about it is what made the last one take a
// round trip through two teams to find.
log.Printf("catalogue: skipping %q — it lacks one of the core columns %v", table, catalogueCoreColumns)
continue
}
names = append(names, table)
columns[table] = have
}
sort.Strings(names)
brandColumnsMu.Lock()
brandColumnsData = columns
brandColumnsMu.Unlock()
out := make(map[string]string, len(names))
for _, table := range names {
brand := strings.TrimPrefix(table, "brand_")
if brand == "" || brand == table {
continue
}
out[strings.ToLower(brand)] = table
}
return out, nil
}
// tableForBrand resolves a brand to its table, tolerating the display spelling.
//
// The catalogue keys brands by table suffix — `24_mantra`, `paper_boat`,
// `clinic_all_clear` — while the ingest service's run manifest reports the
// DISPLAY name for the same brand: "24 Mantra", "Paper Boat", "Clinic All
// Clear", "hindustan unilever", "coca-cola". They are the same brand written two
// ways, and anything holding a manifest is holding the second.
//
// That mismatch broke shelving outright. Putting an uploaded sheet on a shop's
// shelf reads the catalogue once per brand to resolve `image_id`, and a single
// multi-word brand failed the whole batch with "Unknown brand: 24 Mantra".
// Every brand of more than one word was affected, which in a twenty-product
// sheet is most of them.
//
// The exact key is still tried first, so a brand whose real key genuinely
// contains a separator can never be shadowed by the normalised form.
func (r *catalogueRepository) tableForBrand(brand string) (string, error) {
tables := r.brandTables()
if table, ok := tables[strings.ToLower(strings.TrimSpace(brand))]; ok {
return table, nil return table, nil
}
if table, ok := tables[normaliseBrandKey(brand)]; ok {
return table, nil
}
return "", ErrUnknownBrand
}
// normaliseBrandKey folds a display brand name onto the catalogue's own key.
//
// Anything that is not a letter or a digit becomes an underscore, and runs of
// them collapse — which is exactly how the catalogue builds both its table names
// and the `image_id` prefix: "24 Mantra" becomes `24_mantra`, and the product
// key `24_mantra_24_mantra_organic_moong_dal_500g`. Deliberately not a general
// slugifier; matching that one convention is its whole job.
func normaliseBrandKey(brand string) string {
out := make([]rune, 0, len(brand))
lastWasSep := true // leading separators are dropped rather than kept
for _, r := range strings.ToLower(strings.TrimSpace(brand)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
out = append(out, r)
lastWasSep = false
continue
}
if !lastWasSep {
out = append(out, '_')
lastWasSep = true
}
}
return strings.TrimSuffix(string(out), "_")
} }
func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) { func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
@@ -118,15 +455,29 @@ func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
} }
var brands []models.CatalogueBrand var brands []models.CatalogueBrand
var failures int
for brand, table := range catalogueBrandTables { for brand, table := range r.brandTables() {
var count int64 var count int64
if err := r.db.Table(table).Count(&count).Error; err != nil { if err := r.db.Table(table).Count(&count).Error; err != nil {
return nil, fmt.Errorf("counting %s: %w", table, err) // One brand's table being absent or unreadable must not hide the
// others. It used to abort the whole call, so a single missing
// table emptied the brand filter and — through
// getProductsAllBrands, which had the same flaw — the entire
// import screen, for every brand.
log.Printf("catalogue: skipping brand %q (%s): %v", brand, table, err)
failures++
continue
} }
brands = append(brands, models.CatalogueBrand{Brand: brand, ProductCount: count}) brands = append(brands, models.CatalogueBrand{Brand: brand, ProductCount: count})
} }
// Every table failing is a different thing from every table being empty:
// the first is an outage and must be reported, the second is a fact.
if failures == len(r.brandTables()) {
return nil, fmt.Errorf("no catalogue brand table could be read (%d configured)", failures)
}
return brands, nil return brands, nil
} }
@@ -135,7 +486,7 @@ func (r *catalogueRepository) GetCategories(brand string) ([]string, error) {
return nil, ErrCatalogueDBUnavailable return nil, ErrCatalogueDBUnavailable
} }
table, err := tableForBrand(brand) table, err := r.tableForBrand(brand)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -172,7 +523,7 @@ func (r *catalogueRepository) GetProducts(brand, category, keyword string, pagen
} }
func (r *catalogueRepository) getProductsForBrand(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) { func (r *catalogueRepository) getProductsForBrand(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
table, err := tableForBrand(brand) table, err := r.tableForBrand(brand)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -189,7 +540,7 @@ func (r *catalogueRepository) getProductsForBrand(brand, category, keyword strin
var rows []catalogueProductRow var rows []catalogueProductRow
dataQuery := fmt.Sprintf( dataQuery := fmt.Sprintf(
`SELECT %s FROM %s WHERE %s ORDER BY id LIMIT ? OFFSET ?`, `SELECT %s FROM %s WHERE %s ORDER BY id LIMIT ? OFFSET ?`,
catalogueProductColumns, table, whereClause, columnsForTable(table), table, whereClause,
) )
dataArgs := append(append([]interface{}{}, args...), pagesize, offset) dataArgs := append(append([]interface{}{}, args...), pagesize, offset)
if err := r.db.Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil { if err := r.db.Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil {
@@ -214,25 +565,39 @@ func (r *catalogueRepository) getProductsAllBrands(category, keyword string, pag
whereClause, args := catalogueWhereClause(category, keyword) whereClause, args := catalogueWhereClause(category, keyword)
brands := make([]string, 0, len(catalogueBrandTables)) brands := make([]string, 0, len(catalogueBrandTables))
for brand := range catalogueBrandTables { tables := r.brandTables()
for brand := range tables {
brands = append(brands, brand) brands = append(brands, brand)
} }
sort.Strings(brands) sort.Strings(brands)
var all []models.CatalogueProduct var all []models.CatalogueProduct
var failures int
for _, brand := range brands { for _, brand := range brands {
table := catalogueBrandTables[brand] table := tables[brand]
var rows []catalogueProductRow var rows []catalogueProductRow
dataQuery := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY id`, catalogueProductColumns, table, whereClause) dataQuery := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY id`, columnsForTable(table), table, whereClause)
if err := r.db.Raw(dataQuery, args...).Scan(&rows).Error; err != nil { if err := r.db.Raw(dataQuery, args...).Scan(&rows).Error; err != nil {
return nil, 0, fmt.Errorf("querying %s: %w", table, err) // Skip the brand rather than abandoning the merge. This aborted on
// the first failure, so one absent table returned 500 for a browse
// across all brands — which is what the import screen asks for by
// default, leaving it permanently empty while five of six brands
// were perfectly readable.
log.Printf("catalogue: skipping brand %q (%s) in all-brands browse: %v", brand, table, err)
failures++
continue
} }
for _, row := range rows { for _, row := range rows {
all = append(all, row.toModel(brand)) all = append(all, row.toModel(brand))
} }
} }
if failures == len(brands) {
return nil, 0, fmt.Errorf("no catalogue brand table could be read (%d configured)", failures)
}
sort.Slice(all, func(i, j int) bool { sort.Slice(all, func(i, j int) bool {
return strings.ToLower(all[i].ProductName) < strings.ToLower(all[j].ProductName) return strings.ToLower(all[i].ProductName) < strings.ToLower(all[j].ProductName)
}) })
@@ -274,7 +639,7 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
return nil, ErrCatalogueDBUnavailable return nil, ErrCatalogueDBUnavailable
} }
table, err := tableForBrand(brand) table, err := r.tableForBrand(brand)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -282,7 +647,7 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
var row catalogueProductRow var row catalogueProductRow
query := fmt.Sprintf( query := fmt.Sprintf(
`SELECT %s FROM %s WHERE product_sku = ? LIMIT 1`, `SELECT %s FROM %s WHERE product_sku = ? LIMIT 1`,
catalogueProductColumns, table, columnsForTable(table), table,
) )
result := r.db.Raw(query, sku).Scan(&row) result := r.db.Raw(query, sku).Scan(&row)
if result.Error != nil { if result.Error != nil {
@@ -296,12 +661,53 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
return &product, nil return &product, nil
} }
// GetProductByImageID resolves a catalogue row by the id the ingest pipeline
// calls canonical.
//
// The pipeline reports what it wrote as a manifest of `image_id` values, and
// its own note is blunt about why: image_id is "the primary key every other
// product is deduplicated on", and a product name differing by one character
// is a different product. Importing into a shop needs `catalogueid` — the row
// id — so without this the console would have to match the manifest on NAME,
// which silently creates duplicates instead of updating.
//
// Alongside GetProductBySKU rather than replacing it: a sheet may leave the sku
// column blank, in which case the pipeline mints one and the sku is not a key
// the sender recognises. image_id is derived from brand, name and size and is
// stable across re-uploads.
func (r *catalogueRepository) GetProductByImageID(brand, imageID string) (*models.CatalogueProduct, error) {
if r.db == nil {
return nil, ErrCatalogueDBUnavailable
}
table, err := r.tableForBrand(brand)
if err != nil {
return nil, err
}
var row catalogueProductRow
query := fmt.Sprintf(
`SELECT %s FROM %s WHERE image_id = ? LIMIT 1`,
columnsForTable(table), table,
)
result := r.db.Raw(query, strings.TrimSpace(imageID)).Scan(&row)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, nil
}
product := row.toModel(strings.ToLower(brand))
return &product, nil
}
func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) { func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
if r.db == nil { if r.db == nil {
return nil, ErrCatalogueDBUnavailable return nil, ErrCatalogueDBUnavailable
} }
table, err := tableForBrand(brand) table, err := r.tableForBrand(brand)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -309,7 +715,7 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca
var row catalogueProductRow var row catalogueProductRow
query := fmt.Sprintf( query := fmt.Sprintf(
`SELECT %s FROM %s WHERE id = ? LIMIT 1`, `SELECT %s FROM %s WHERE id = ? LIMIT 1`,
catalogueProductColumns, table, columnsForTable(table), table,
) )
result := r.db.Raw(query, id).Scan(&row) result := r.db.Raw(query, id).Scan(&row)
if result.Error != nil { if result.Error != nil {

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