Compare commits

...

101 Commits

Author SHA1 Message Date
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
Suriya
cd2459dbb6 Let an admin create till staff from the console, through the same code
An admin sets a shop up from a browser; a supervisor adds a cashier at the
counter. Both had to be possible, and only the second one was.

So the console gets createposuser / updateposuser / getposusers /
deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the
same service methods `/pos/users` calls. Not a parallel implementation: a
supervisor created from a browser is the same row, with the same PIN rules, the
same duplicate check and the same identity-column allocation, as one created at
a till. Two paths writing one table is precisely how the two stop matching, and
this codebase already had that happen once.

configid is inferred rather than asked for. It is a number nobody looks up, it
varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting
it wrong creates somebody who cannot sign into the portal their colleagues use
and is invisible to half the platform's queries.

/posroles is served rather than left to the console to hardcode. A console that
knew supervisor was 7 would be wrong the day that changed and would have no way
to find out.

The outlet is the real difference between the two doors. A terminal proves it
with a signed token; the console asserts it, and is checked against the tenant
before anything is written. That is weaker, and it is worth being plain about:
these mint till credentials on an unauthenticated request, exactly like every
other route in the /v1/web and /v1/mob groups, because there is no auth
middleware on the web API at all. Documented as the weakest point in the design
and flagged to move behind a session guard once the console can hold one. The
terminal routes are untouched by it.

Proven in a rolled-back transaction against live data: the console creates a
supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true,
the till's /pos/staff sees them alongside the two created at the counter, and
0451, 1234 and a duplicate PIN are each refused with the same message the
terminal gives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:42:27 +05:30
Suriya
6a62dbb9f3 Hold web-created staff to the same rules the till applies
Two paths write `app_users`: the console's `tenants/createstaff`, and the
terminal's `/pos/users`. Only one of them checked anything.

`createstaff` wrote whatever it was handed. A cashier could be created there
with PIN "0451" — which a bigint column stores as 451 — and would then type four
digits at the counter and be refused for ever, with nothing on either screen to
explain it. Or with 1234, which live data already has on eleven accounts. Or
with a PIN somebody at the same outlet already had, which attributes a bill to
whichever row is read first. Or with no way to sign in at all.

None of that surfaced where it was caused. It surfaced at a counter, days later,
as "the new person cannot log in".

So the rules move into `ValidateStaffUser`, and both paths use it: a name, a
role that is actually a role, a PIN the schema can hold and nobody guesses
first, and at least one way to sign in. The duplicate-PIN check runs too, when
the row names an outlet.

The handler also stops answering 500 with a body claiming 409. Every one of
these is something the person filling in the form can fix, so it is a 400
carrying the reason.

`GetStaffs` now returns `rolename` alongside `roleid`, so a console can show
"Supervisor" without mapping ids itself — `app_roles` has six rows for four
back-office roles and most accounts carry an id absent from it, so any mapping
written client-side would be wrong.

This is what makes the two role systems one. A supervisor or cashier created
from the web behaves at the till exactly like one created at the till, because
there is now a single definition of what those are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:37:28 +05:30
Suriya
f343f4e86e Let the identity column allocate userid, instead of computing it
`app_users.userid` is a `GENERATED BY DEFAULT AS IDENTITY` column. It did not
look like one: `information_schema.columns.column_default` is empty for identity
columns, and reading that as "no default at all" is how this came to compute its
own id with MAX+1.

That worked, and quietly did the wrong thing. An explicit id does not advance
the sequence, so two allocators ended up running in parallel — the sequence sat
at 1447 while MAX(userid) had reached 9189. They cannot collide today, because
almost nothing occupies the range between, but they converge on every insert and
the first collision would be a primary key violation on a live sign-up.

The insert now omits userid and reads it back with RETURNING. The advisory lock
stays, because it was never about the id: two supervisors adding staff at the
same instant could both find a PIN free and both take it, and a duplicate PIN
attributes a bill to whichever row is read first.

Also documents why the email columns go through NULLIF. `app_users_email_unique`
is real, and a second cashier created without an email would otherwise collide
on the empty string — while NULLs do not collide in Postgres. A cashier who
signs in by PIN alone has no email, which is the common case rather than the
edge one.

Verified in a rolled-back transaction against live data: creation allocates
1448, the sequence advances 1447 -> 1448, a second emailless user is accepted,
and a duplicate PIN is still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:30:18 +05:30
Suriya
4b27b84b1f Let a shop run its own counter: supervisor and cashier, created from the till
A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.

Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.

`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.

The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.

PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.

Three things the schema forced:

- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
  451 and reads back as three digits — a cashier would type four and be refused
  for ever. Live data already holds one such account. Rendering refuses to show
  a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
  one transaction behind an advisory lock. Two supervisors creating staff at the
  same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
  accounts and 1111 on nine.

Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:

    created 9188  Store Supervisor  Supervisor  can_manage_staff=true
    created 9189  Counter Cashier   Cashier     can_manage_staff=false
    /pos/staff now returns 2        an unknown PIN is refused

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:22:16 +05:30
Suriya
c696ec3e79 Document terminal sign-in, and prove it against the deployed API
POS_LOGIN.md is for whoever builds and tests the till: the flow in the order it
has to happen, the three endpoints with real request and response shapes, every
error code with its verbatim message, the multi-outlet picker rules, and how
staff and PINs are meant to be handled.

Three things in it are the ones people will otherwise get wrong. `store_id`
comes out of the login response and is never typed by anyone — that is the whole
change. `staff` is usually empty, including at the outlet this build ships
pointed at, so an empty list has to be a no-op and not a wipe. And enforcement
is currently off, which means an untokened request still works today but a token
that *is* sent is still fully checked.

scratch/liveloginproof signs in against the live endpoint with a password read
out of the database — never printed, never passed on a command line where it
would land in a shell history — and then checks the token opens what it should
and refuses what it should not. The token is truncated in its output for the
same reason: it is a bearer credential for a whole trading day.

Run against v1.3.98 in production:

    POST /login                     200   token minted, store_id 1135 resolved
    GET  /session                   200
    GET  /staff                     200
    GET  /catalogue?store_id=1135   200
    GET  /catalogue?store_id=1185   403   this session cannot reach outlet 1185
    POST /health                    202

The 403 is the one worth keeping: a valid token, refused at another tenant's
outlet. That is the hole this work existed to close, shut on live traffic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:26:52 +05:30
Suriya
c4dfcd5387 Serve a shop's own staff to the till, so the built-in PINs can retire
The terminal shipped with three names and three PINs compiled into it. Same
three on every install, readable by anyone with the APK, and permanent —
nothing anywhere could replace them.

`/pos/staff` answers with the people the back office says may ring a bill at an
outlet, and the same list rides down with the session so a till is ready to
trade the moment it signs in. The terminal writes them over its own and
deactivates whatever it had, which is what actually kills the seeded logins.

Two sources are unioned because the schema has two and neither is complete.
`tenantstaffs` is the table built for this and holds 12 rows on the entire
platform; `app_users.locationid` is where staff actually ended up. Either alone
returns nothing for almost every shop.

The endpoint takes no location parameter. The answer carries PINs, so the
outlet comes from the caller's token and a request without one is refused
whatever POS_AUTH_REQUIRED says — a till must not be able to ask who works at
the shop next door.

Rows with no PIN are dropped rather than sent: a name on screen nobody can sign
in as reads as a broken terminal rather than as an unfinished setup. Duplicate
PINs are dropped too, keeping the first — live data has 1234 on eleven accounts
and 1111 on nine, and two people sharing one would make the till attribute a
bill to whichever row it checked first.

The PIN travels in the clear over TLS, deliberately. Four digits are
brute-forceable in microseconds however they are wrapped, so hashing here would
buy the appearance of strength and not the substance — while costing something
real, since the terminal salts every PIN with its own salt before storing it
and could never verify a hash computed here. A PIN is shift attribution, not a
security boundary; the boundary is the session token.

Verified against live data, and it says the fallback still matters: outlet 1135
— the one the POS actually uses — has zero staff, and the only staff row found
anywhere is a delivery rider on PIN 1111.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:01:49 +05:30
Suriya
12165d5e58 Give the POS a real sign-in, and stop believing the store id on the wire
The POS surface was open. A till named its own outlet — `store_id` in a query
or in an ingest batch — and was believed, so one number changed in Settings
read another tenant's catalogue or posted bills into their books. There was no
middleware in the codebase at all, and the `JWT_SECRET_KEY` in the config was
read and never used.

Products were never mis-scoped: `resolvePosStore` already derived the tenant
from the location and the catalogue query already filtered on both. The tenant
was never taken from the wire. What was missing was any check that the caller
was entitled to the location they named.

So the outlet now comes *out* of a sign-in rather than going *in* from the
till. `POST /pos/login` authenticates against the same `app_users` rows the web
console uses — one account store, so deactivating a leaver closes both doors —
and answers with the outlets that account may reach, sealed in an HMAC-SHA256
token the terminal cannot edit.

Two checks then guard everything else, in order: the token verifies, and the
outlet named in the request belongs to the token's tenant. The second is the
one that matters — a valid token is a licence to name *your* outlets, not any.

Notes on the awkward parts:

- The guard reads the outlet from the body as well as the query. The two routes
  that write carry `store_id` in a JSON batch and never in the URL, so a
  query-only check would have left exactly the dangerous call unguarded.
- Three spellings of one thing survive — `store_id`, `locationid`,
  `location_id`. All three are read rather than normalised, because renaming
  them breaks terminals already in the field.
- `POS_AUTH_REQUIRED` defaults to false. Tills are billing real customers
  against the open endpoints right now and enforcing at deploy would stop every
  one mid-trade. A token is still verified when sent, and a wrong-tenant token
  still refused; the flag only governs requests carrying none.
- `POS_TOKEN_SECRET` has no baked-in fallback and fails loudly. A development
  secret in source is the same as no signature at all.
- `configid` is inferred when the till does not send it, because a person at a
  counter has no way to know theirs. `authname` is not unique in this schema —
  live data has one address twice under one configid — so an ambiguous match is
  refused rather than resolved by LIMIT 1, which could bill into the wrong
  tenant's books.

Verified against live data: 58 accounts across 34 tenants can open a till, an
account pinned to a location resolves to it alone, a tenant-level account gets
all six of its outlets, and a cross-tenant outlet request is refused.

Passwords are still plaintext platform-wide. Flagged at the comparison site;
fixing it is a migration touching every login path, not this endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:46:38 +05:30
Suriya
5864204d32 Zero the produce rates, on the owner's instruction
The eight fresh lines at 1135 held 8, 12 and 18. Fresh unbranded fruit and
chilled fish are nil-rated under Indian GST, so those were overcharging.

Held back on the first pass and reported as REVIEW, because every one is a
reduction of a live tax rate and that is a decision for whoever signs the
returns rather than something a script should quietly do. Put to the owner and
released explicitly.

Two readings are assumed and are worth checking against what the counter
actually sells. Maceral and Tuna are taken as fresh or chilled — frozen,
branded or packaged fish is 5%. Hatsun curd was already 0 and stays there as
plain curd; flavoured yoghurt would be 5%.

The undo SQL for all eight is in the tool's output and restores the previous
rates exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:51:56 +05:30
Suriya
d0c3cb751e Document the sale-date contract, and rate the packaged goods
parsePosSaleDate needed no change — RFC3339Nano already accepts the offset the
terminal now sends, and Format("2006-01-02") on a zoned time still yields the
till's own trading day rather than UTC's. But the ordering of those layouts is
load-bearing and nothing said so, and the two bare layouts are a legacy that
should be recognisable as one: they exist for terminals built before the offset,
whose bills record an instant wrong by the offset with nothing in the payload to
recover it from. Two tests pin both halves, including the case that motivated
this — 00:30 IST, where UTC has not yet rolled into the same day.

The GST script writes only the four packaged lines at 1185, which sat at 0 and
were being billed with no tax at all.

It deliberately does not touch the produce at 1135. That was written believing
every rate was 0 — read from a field name that does not exist in the response,
so the check silently returned nothing. The rows in fact hold 8, 12 and 18, and
under Indian GST fresh unbranded fruit and chilled fish are nil-rated, so
several look like overcharging. Every correction there is a reduction of a live
rate, which belongs to whoever signs the returns rather than to a script. They
are reported as REVIEW and left as found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:18:17 +05:30
Suriya
11595ad415 Record the probes that touched live data
seedprices is the only account of what the 15 seeded retail prices were and how
to put the zeros back — products at 1135 and 1185 were all at 0, so nothing was
sellable and the terminal could not be exercised at all. It refuses to overwrite
a price a human already set, so re-running it is safe.

termfixcleanup removes the one bill posted to prove the terminalid fix against
production. Named by its own terminalorderid rather than by date or by "the
newest row": pos_orders holds real takings and is not a table to run an
unbounded DELETE against.

healthproof carries a warning it did not have when it was written. MQTT_USER in
.env is pos_ingest, which the ACL denies publish on the health topic, and
Mosquitto answers an ACL-denied QoS 1 publish with a PUBACK before discarding
it. So the tool connects, reports success, and nothing arrives — indistinguishable
from a dead consumer, which is how it read for an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30
Suriya
ec672a3087 Add an HTTP heartbeat, and file bills under the till that rang them
Two faults found while checking whether today's live bills had landed. They
had — 17 of them, complete — but both of these were sitting in the same data.

**Health existed only over MQTT.** The consumer subscribes to the health topic
and has done since startup, but a terminal on the HTTP route has no way to
reach it. Today's terminal was on HTTP, so it reported nothing and the board
showed "online 0 of 1" while the till was demonstrably alive and selling.
POST /pos/health now takes the same payload the broker carries, into the same
Redis record, so the board cannot tell the two routes apart and does not need
to. It answers 202 and swallows failures: a till that cannot say how it is must
still sell.

**terminalid was empty on 16 of 17 bills.** The consumer backfills a missing
terminal code from the topic, but onto the batch, while the row was built from
the order — the two never met, and importPosOrder was not handed the batch's
value at all. Over HTTP there was no topic to fall back on either. So the
invoice numbers read INV-2608-T5EDD-000NN while the column they should have
matched was blank, and `byterminal` on the sales summary grouped almost
everything under "". The bill's own terminal now wins with the batch's as the
fallback, trimmed, so whitespace is not mistaken for a code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:48:24 +05:30
bddd8fa265 product price 2026-08-04 10:58:26 +05:30
Suriya
8aa4d86eb6 Add a POS integration handover
An API reference and rationale for whoever picks this up next. The
endpoints are the easy half; what is not obvious from reading the code is
why an ack is only published after the commit, why a duplicate counts as
a success, and why is_delta false on a filtered catalogue empties a
shop's shelf. Those are written down here because each one costs a shop
money when someone changes it without knowing.

Covers the GET endpoints in full — request parameters, real response
shapes, and the cases that surprise people: values coming back as
strings from a Redis hash, a quiet till answering 200 rather than 404,
and a bill at another outlet returning 404 even though the reference is
valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:22:11 +05:30
Suriya
b0caacd90a pos 2026-08-03 20:19:04 +05:30
Suriya
b9f389fcdf Process the MQTT ingest on a bounded worker pool
paho delivers on one goroutine, so bills were committed strictly one
after another. Each is a full Postgres transaction — advisory lock,
dedup, stock row locks, availability check, four inserts, commit — which
is 10-30ms, so the ceiling was roughly 30-100 bills a second and a
shop's backlog draining after an outage took minutes to land.

A fixed pool behind a bounded queue, rather than a goroutine per
message. Unbounded concurrency would open a transaction per message and
exhaust the connection pool under a storm, stalling every one of them at
once — a slow minute turned into a dead one. When the queue fills,
submit blocks: paho stops acknowledging, the broker's in-flight window
fills, it stops sending, and the backpressure reaches the till, which
holds its bills and retries. Slow, but nothing is dropped.

Heartbeats get their own pool. Sharing one would let a backlog of bills
delay presence, so every till would appear to go dark at exactly the
moment the system was busiest — the worst time to be blind to which
counters are alive.

Payloads are copied on the way in. paho reuses its buffer once a handler
returns and the work now happens after that, so a queued bill would
otherwise be read as whatever message arrived next — silently, and as
valid JSON often enough to commit the wrong sale.

One bug found by its own test: submit-after-stop selected between a
done-channel and the job channel, and once both were ready Go picks at
random. Picking the send panics on a closed channel. It would have shown
up in production as an occasional crash during shutdown and nowhere
else. Now guarded by an RWMutex held across the send, so the queue
cannot be closed under one in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:53:39 +05:30
Suriya
1e3386fac8 Elect a single MQTT consumer among replicas
Deployed as a StatefulSet with three replicas, and MQTT has no queue
groups — every subscriber receives every message. All three pods would
commit the same bill and publish three acks. Nothing double-counts,
because the ingest deduplicates on the till's UUID and holds an advisory
lock, but it is three times the database work and three times the
traffic for one sale.

Ordinal 0 consumes; the others stay idle. A StatefulSet already
guarantees stable unique ordinals, so this is a deterministic election
with no lock, no lease and no new dependency. If that pod dies the set
recreates it and tills hold their bills meanwhile, which is what they
are built to do.

POS_MQTT_CONSUMER=always/never overrides it for deployments that are not
a StatefulSet. Anything without an ordinal name — a Deployment pod, a
bare container, local development — consumes, because a lone instance
that silently refused to would be a far more confusing failure than one
that did.

The client id now defaults to the pod name rather than a constant. Two
connections sharing an id evict each other in a reconnect loop that
looks exactly like a flapping network, and takes a while to recognise as
anything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:44:47 +05:30
Suriya
fc81df14e4 Answer the catalogue as a delta when the terminal sends a revision
Every pull was a full snapshot, so a shop with a thousand products
re-sent all of them to correct one price. The response now carries a
revision the terminal stores and hands back, and a pull that supplies
one gets only what moved: the product row, its row at that outlet, or
its stock ledger. Stock is included because a shop's count drifts from a
till's on every sale rung at another counter, and a delta that ignored
it would let that drift persist until someone forced a full pull.

The dangerous part is the flag, not the filter. A response marked
is_delta:false tells the terminal to withdraw every product it does not
mention — so a filtered result carrying that label empties the shelf.
Both are now derived from one value, and there is no path through the
function that filters without also setting the flag.

Everything ambiguous resolves toward the snapshot. A revision that is
malformed, empty, or issued to another outlet yields a zero cutoff and a
complete response; the opposite would leave a terminal permanently
missing changes with nothing to show for it. The revision advances only
on the final page, so a terminal that abandons a paginated pull cannot
end up holding one that claims it saw pages it never received. And the
stamp is taken a second in the past, because a product written during
the same second the query ran would otherwise fall on the wrong side of
the next cutoff and be skipped for good.

A delta still cannot withdraw a deleted product — removing a row from
productlocations leaves no tombstone — so a periodic pull without a
revision is what collects those.

Verified against the live outlet: a full pull of 12, a delta returning
only the one product whose price had changed, and pagination that stays
exact now that productid <= 0 is excluded in SQL rather than after the
LIMIT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:26:01 +05:30
Suriya
8709556704 Add read endpoints for counter sales
The ingest only ever wrote. A bill that reached pos_orders was safe and
completely unreachable — no screen in the product could show it, and the
only way to see a day's counter takings was to query the database by
hand.

Three endpoints: a paged bill list, one bill with its lines, and a
summary split the three ways somebody actually asks for — by tender for
reconciling a drawer, by day for a chart, by till for an outlet running
several counters.

locationid is required on all of them and is the authorisation boundary,
so a caller cannot page through another shop's takings by omitting a
parameter. Fetching a bill under the wrong outlet returns 404 even when
the reference is a real one.

Dates match businessdate rather than arrival, because a till that was
offline overnight uploads yesterday's bills this morning and they belong
to yesterday. The list is ordered by billedat for the same reason —
sorting by arrival would interleave a recovered backlog through today.

Unlike the ingest handlers these answer in the usual envelope: they are
read by the web app, not by a terminal, and nothing about them is bound
to the till's contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:17:03 +05:30
Suriya
ef647d3395 Give the POS its own broker accounts
Two scoped users, pos_terminal and pos_ingest, with an ACL that keeps a
till to its own topics: it can publish its bills, registrations and
heartbeats, read its own acks, and nothing else. It cannot reach
nearle/riders/# or doormile/#, and cannot forge an ack — only the ingest
writes those.

admin is deliberately left unrestricted. Its credentials are compiled
into the rider app, so narrowing it would cut off the live fleet without
warning; that change needs someone to confirm nothing else uses it
first. Because admin's entry grants everything, applying the ACL changed
nothing for existing traffic — verified by watching riders 852 and 1114
keep publishing battery, speed and periodic logs throughout.

The scoping was verified by publishing as pos_terminal to four topics
and observing which arrived: the order did, the rider topic, the
doormile topic and its own ack topic did not.

Config and password file were backed up first; the rollback is one cp
and a container restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:01:05 +05:30
Suriya
64a219e7da Stop tracking .env
It has been in the repository since the initial commit carrying the live
database host, user and password. Removing it from the index stops that
getting worse; the credentials are still in history and should be
rotated, which needs coordinating with everything that reads them.

Deployments should pass configuration as container environment rather
than shipping a file — a file on disk is one `git add -f` away from
being committed again.

Also closes the last untested path: a shopper registration published
over the broker rather than posted over HTTP. All three MQTT topics —
order, customer and health — have now been fired against the live
Mosquitto instance and acknowledged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:56:09 +05:30
Suriya
27fbbf0422 Merge remote-tracking branch 'origin/main' 2026-08-03 17:48:21 +05:30
Suriya
e3459a0f1c Ingest counter sales from the POS terminals, over MQTT and HTTP
A till holds every bill in its own SQLite database and keeps it for
seven days after we acknowledge it, marking one synced only when its id
comes back in an ack. Everything here follows from that.

Silence is not acceptance, so a failing ingest publishes nothing at all
and the terminal simply sends again. A duplicate is a success, because
at-least-once delivery means a lost ack legitimately re-delivers bills
we already hold, and calling those failures would strand a day of
takings on the till. Deduplication is a unique index on the terminal's
UUID plus an advisory lock held for the transaction.

Bills land in pos_orders / pos_order_items rather than orders: a counter
bill carries a cashier, a terminal, a rounding adjustment, promos,
loyalty movement and a payment split that orders has nowhere to put, and
forcing one into the other loses whatever does not fit. Stock is *not*
split — a counter sale writes the same productstocks rows an app order
does, through helpers extracted from createOrderTx so the rule that
prevents overselling has one implementation rather than two.
GetRevenueSummary and GetSalesSummary were extended to union the new
table in; any new report has to remember the same.

Terminal health goes to Redis under a 90-second TTL, sharing the
instance the express backend uses. A heartbeat is a fact with an expiry
date: a till that loses power stops refreshing and ages off the board by
itself, where a Postgres row would need ~288k writes a day and a reaper.

Proven end to end against the live estate before commit: a bill over
HTTP and one over the real Mosquitto broker, the same bill three times
producing one row and one stock movement, and a heartbeat arriving on
the health endpoint. All probe data was removed afterwards.

Four things that only surfaced against real data. An unset jsonb column
failed the very first bill. Product SKUs are unusable as barcodes — 6,245
products share 93 SKUs and "1" covers 5,794 of them — against the till's
unique index, so barcodes fall back to the product id. A taxpercent of
-1 exists and would have put negative GST in a filed slab. And a product
with id 0 exists, which can never be billed and is now skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:48:00 +05:30
5d2e1fca9b store based coustmers 2026-08-03 15:12:13 +05:30
481be667db Register the rider push-notification route
/utils/notifyuser has been commented out since the initial commit, so every
rider push the admin consoles have ever sent returned 404. Riders were
assigned deliveries and never told, and the failure surfaced as a generic
"notification failed" that read like a transient network fault.

Nothing else was missing. The handler, the FcmNotification model, the
Firebase service account and the Dockerfile line that copies that account
into the image were all already in place — only the route registration was
absent, which is why the gap survived this long.

Verified against Google: with the route registered, FCM authenticates the
service account and returns a specific rejection for a deliberately invalid
token rather than a 404. No push was sent to a real rider, so the final hop
to a device is still unproven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:48:36 +05:30
c290e1729a Route offline sales by the branch named on each spreadsheet row
The offline-sales import required one workbook per outlet and a store
picked in the UI. A merchant running several branches had to download,
fill and upload a file per branch, and the picker defaulted to the
tenant's first outlet — so an admin who never touched it silently
credited the wrong store, which no validation could catch because the
file and the selection agreed with each other.

One workbook now covers every branch. getsaletemplate takes locationid=0
(the default) to span the tenant, stamping tenantid, locationid and the
store name onto every row, and that row's locationid is what decides
which branch a sale is deducted from. The INNER JOIN on tenantlocations
confines it to outlets the tenant owns, so a template can never disclose
another merchant's catalogue.

uploadofflinesales accordingly takes locationid on each bill. The
locationid on the request itself becomes a scope constraint rather than
a destination: left at 0 the bills go where their rows say, and set to a
branch it pins the upload there and refuses anything else. That is what
holds a store user to their own store — the pin comes from their session,
so editing the locationid column in the spreadsheet changes nothing.
Every branch referenced is checked against the tenant regardless.

Branch context and catalogue are resolved once per branch and reused; a
workbook covering six outlets would otherwise re-run both queries for
every bill in it.

Duplicate detection is now per branch. Bill numbers only have to be
unique within a store, since counter books at different outlets
routinely restart numbering at 1, and treating a shared number as a
repeat would have silently dropped a real sale.

Verified against tenant 1087, whose two branches both stock product
6998 at 100 units: a single upload of two bills moved 1097 to 97 and
1135 to 95 independently; the same bill number at both branches imported
as two separate orders; an upload pinned to 1097 imported its own bill
and refused the 1135 one; a row naming another tenant's outlet was
refused; and re-uploading the file deducted nothing. All five test
orders were cancelled afterwards and both branches confirmed back at 100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:17:52 +05:30
583cd89063 new api for offline sales 2026-07-30 17:25:09 +05:30
a11c4843ca Allocate order numbers atomically instead of read-then-increment
Order ids were duplicating in production: 160 distinct (tenant, orderid) pairs
are shared by more than one order, worst of them "1135-1" on 108 orders, and
every order tenant 1147 has ever placed is numbered "1147-1".

getSequenceno read MAX(seqno)+1 and updateSeqno incremented, both against
r.db rather than the order's transaction and separated by the whole order
insert. Two concurrent orders therefore read the same number before either
wrote, and an order that rolled back still consumed one. Three further
defects made it worse:

  - A NULL orderseqno made COALESCE(MAX(orderseqno) + 1, 1) evaluate
    NULL + 1 = NULL and fall through to a hardcoded "<tenantid>-1". The
    increment then computed NULL + 1 = NULL too, so the counter could never
    leave NULL and every subsequent order reused that same id.

  - Tenants with several ordersequences rows (tenant 1135 has ~25) hit a
    GROUP BY returning multiple rows, of which Scan kept the first
    arbitrarily, while the increment updated all of them.

  - A tenant with no row at all fell back to "<tenantid>-1" indefinitely,
    because nothing ever created one.

nextSequenceNo replaces both functions with a single UPDATE ... RETURNING run
inside the caller's transaction, so the counter row stays locked until the
order commits and concurrent orders queue rather than collide. A NULL seeds
from the tenant's existing order count — at least as high as any number
already issued, so recovery cannot reissue a used id — the counter is pinned
to the tenant's lowest sequenceid so reads and writes address one row, and a
missing row is created on first use.

Verified against production data in rolled-back transactions: tenant 1147
(NULL) now yields 1147-9, 1147-10, ...; tenant 1135 (NULL plus duplicate rows)
1135-356 onward; tenant 916 keeps its 916-2024115209 subprefix format; an
unknown tenant creates its row and starts at 1. Eight concurrent allocations
produced eight distinct ids. Two real orders through the API returned 1147-9
and 1147-10, then were cancelled with stock restoring to its baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:13:43 +05:30
c94ddd34c7 Stop stock receipts clobbering products.productstatus
products.productstatus is a per-product lifecycle field holding
"Active"/"Inactive". CreateProductStock overwrote it with "available" on every
stock receipt — an availability value written into a lifecycle column — which
destroyed the real lifecycle state of the rows it touched. 136 products now
read "available" and 12 "outofstock" with no way to recover what they were.

A single column on products cannot express availability anyway: the same
product can be stocked at one outlet and empty at another. That fact belongs
to productlocations.status, which SyncProductLocationStatus already derives
from the ledger, so the receipt path now updates only that and leaves
productstatus alone. UpdateProductStatus remains available as an explicit
admin operation; it is simply no longer called as a side effect of stock
movement.

GetProductCount counted available/outofstock off the same corrupted column and
returned near-nonsense as a result: across 6245 products it matched
'available' on 136 and 'outofstock' on 12, leaving 6097 — the real answer —
uncounted under "Active". It now derives both from the ledger, counting a
product available when it holds positive stock at any of the tenant's outlets,
so total = available + outofstock (6245 = 22 + 6223).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:08:35 +05:30
3b60a90009 Derive stock and availability from the ledger, not stored fields
Stock shown in the console did not match the productstocks ledger, and two
product endpoints were failing outright. Every cause was on the read side or
in how the availability flag was maintained; the ledger writes themselves
(CreateOrder's "out" entry, cancellation's "in" entry) were already correct.

Read fixes, repositories/productRepository.go:

- GetProductStocks returned SQLSTATE 42803 on every call: bare a.tenantid /
  a.stocktype / a.status under GROUP BY a.productid. The per-ledger-row
  columns are now aggregated and the grouping covers the identity columns.

- FetchFilteredProducts filtered on an alias `e` that no query defines, so
  every /getallproducts call carrying a locationid failed with SQLSTATE 42P01
  instead of returning products.

- FetchFilteredProducts joined productlocations on productid alone and joined
  a (productid, locationid)-grouped stock subquery on productid alone, so a
  product carried by three outlets came back three times, each row showing
  another outlet's quantity and status. Both are now tenant-scoped subqueries
  collapsed to one row per product and scoped to the outlet when one is given.

- GetProductStocks and FetchFilteredProducts compared stocktype = 'in'
  case-sensitively. Production holds 'in' and 'IN' both, so uppercase receipts
  were silently dropped from the balance: one outlet reported 0 for a product
  holding 50, another reported 0 for twelve products holding 200-840.

- GetStockStatement summed opening over stockdate <= CURRENT_DATE, making it
  arithmetically identical to closing. The Inventory ledger showed the same
  number in both columns on every row, which reads as stock never moving.

Availability flag:

productlocations.status was maintained by two different rules — the order path
derived it from the balance, the receiving path set 'available' on any "in"
entry regardless of the resulting balance. A partial restock that left the
balance at or below zero marked a product sellable, and a flag set by an old
order never cleared for stock that arrived by a route the API did not own.

Both paths now derive the flag from the live balance through one rule:
SyncProductLocationStatus (receiving side) and syncProductLocationStatus
(order side, inside the caller's transaction). ReactivateProductLocations is
replaced by the former; the service no longer filters refs by stocktype, since
the direction of the movement is no longer what decides the flag. A row that
has already drifted now repairs itself on its next ledger entry.

Verified against the live database: all four stock endpoints return matching
balances, /getallproducts no longer duplicates rows, and the flag sync was
exercised in both directions inside a rolled-back transaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:14:59 +05:30
a913077da0 Fixed GetProducts 2026-07-28 15:57:46 +05:30
139ce5cda2 product quantity 2026-07-28 15:37:04 +05:30
169 changed files with 32727 additions and 1346 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
# Nothing in here reaches the image.
#
# `.env*` most of all: the Dockerfile does `COPY . .`, and the production
# credentials in `.env.production` were being baked into every image built
# from this folder. The running container gets its environment from the
# platform (Dokploy / Kubernetes), never from a file.
.env
.env.*
!.env.example
.git
.claude
.DS_Store
docs
scratch
init
nearle
server
docker-compose.local.yml

81
.env
View File

@@ -1,21 +1,68 @@
APP_PORT=1009
DB_HOST=66.116.207.225
# 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=admin
DB_PASSWORD="Package@123#"
DB_USER=nearle
DB_PASSWORD=localdev
# --- Catalogue Postgres / pgvector (separate DB, read-only integration) ---
CATALOGUE_DB_HOST=31.97.228.132
CATALOGUE_DB_PORT=6054
CATALOGUE_DB_NAME=pgvector
CATALOGUE_DB_USER=admin
CATALOGUE_DB_PASSWORD="'Package@321#'"
# --- 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
# ── 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=

122
.env.example Normal file
View File

@@ -0,0 +1,122 @@
# 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=

68
.env.local Normal file
View File

@@ -0,0 +1,68 @@
# 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=

80
.env.production Normal file
View File

@@ -0,0 +1,80 @@
# 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

6
.gitignore vendored
View File

@@ -48,3 +48,9 @@ Thumbs.db
*.mov
*.wmv
# Local configuration. Tracked until 2026-08-03, which put the database
# 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
# should be rotated.

View File

@@ -14,6 +14,13 @@ WORKDIR /app
COPY --from=builder /app/server /app
COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json .
# No `.env.*` is copied in (see .dockerignore), so this only decides which
# rules config.Load applies: production insists on a signing secret and never
# falls back to localhost values. Every real value comes from the platform's
# environment settings.
ENV APP_ENV=production
# Must match APP_PORT in the platform's environment (1009 in production).
EXPOSE 1009
CMD ["/app/server"]

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.

View File

@@ -1,49 +1,361 @@
// Package config is the one place the process reads its environment.
//
// Two jobs, in order:
//
// 1. Pick the right `.env` file for the environment we are in and load it.
// 2. Read every setting into a typed Config and refuse to start if anything
// required is missing — all of it, in one message, before a single
// connection is attempted.
//
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
// `.env.production` described an `APP_ENV` switch that did not exist, so the
// only way to run against production was to overwrite `.env` by hand, and the
// only way to find out a variable was missing was a `log.Fatalf` from inside
// `db.Connect()` — one variable per restart.
//
// # Which file loads
//
// `APP_ENV` names the environment and defaults to "local":
//
// go run . → .env.local, then .env
// APP_ENV=production go run . → .env.production, then .env
//
// `.env` is a shared base loaded after the environment file. godotenv never
// overwrites a variable that is already set, so the order of precedence is:
//
// real environment > .env.<APP_ENV> > .env
//
// Neither file has to exist. On the deployed host every value comes from the
// platform's environment settings (Dokploy today, ConfigMaps/Secrets under
// Kubernetes) and there is no file at all — which is exactly why the
// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the
// image carries no `.env.*`, so it cannot fall back to localhost values that
// happen to be lying around in the build context.
package config
import (
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
// Environment names. Anything else is accepted (a staging file works the same
// way) but only these two change behaviour.
const (
EnvLocal = "local"
EnvProduction = "production"
)
// Config is everything the process reads from its environment.
//
// A few settings are still read directly with os.Getenv at the point of use,
// because they are consulted per request or per connection rather than once
// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED
// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_*
// and POS_* settings in package messaging. They are listed and validated here
// so that a misconfiguration is still caught at startup.
type Config struct {
Env string
Port string
DBName string
DBUser string
DBPassword string
DBPort string
DBHost string
UserContextKey string
// AppEnv is the value of APP_ENV: "local" or "production".
AppEnv string
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
Port string
DB DBConfig
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
Redis RedisConfig
S3 S3Config
MQTT MQTTConfig
Embedding EmbeddingConfig
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
// unset, matching utils/postoken.go.
POSTokenSecret string
JWTSecret string
UserContextKey string
GeocoderAPIKey string
}
func Load() *Config {
// DBConfig is one Postgres connection.
type DBConfig struct {
Host string
Port string
Name string
User string
Password string
}
// Enabled reports whether a host was configured at all. Only meaningful for
// the optional catalogue connection; the main database is required.
func (d DBConfig) Enabled() bool { return d.Host != "" }
// RedisConfig is the shared Redis used for POS terminal presence. Optional:
// Host empty means presence is disabled and bills still commit.
type RedisConfig struct {
Host string
Port string
User string
Password string
DB int
}
func (r RedisConfig) Enabled() bool { return r.Host != "" }
// S3Config is the DigitalOcean Spaces bucket holding catalogue product images.
type S3Config struct {
Enabled bool // USE_S3=true
Endpoint string
Bucket string
AccessKey string
SecretKey string
Region string
}
// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty
// means the MQTT ingest and the console live stream stay quiet.
type MQTTConfig struct {
URL string
User string
Password string
ClientID string
}
func (m MQTTConfig) Enabled() bool { return m.URL != "" }
// EmbeddingConfig is the text-embedding model behind the scan-to-product
// search (services/scanService.go). It MUST be the model that filled the
// catalogue's `embedding` column — vectors from two different models are not
// comparable, and pgvector will happily rank garbage. Optional: with no
// provider the search falls back to plain text matching.
type EmbeddingConfig struct {
Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini"
Model string
APIKey string
BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1
Dimensions int // 0 = the model's default
}
func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" }
// IsProduction is true under APP_ENV=production.
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
// Load picks and loads the environment files, reads every setting and
// validates them. The returned error lists every problem at once.
func Load() (*Config, error) {
loadEnvFiles()
cfg := &Config{
Env: getEnv("ENV", "production"),
Port: getEnv("APP_PORT", "1009"),
AppEnv: env("APP_ENV", EnvLocal),
Port: env("APP_PORT", "1122"),
// ✅ STANDARDIZED DB ENV KEYS
DBName: getEnv("DB_NAME", ""),
DBUser: getEnv("DB_USER", ""),
DBPassword: getEnv("DB_PASSWORD", ""),
DBPort: getEnv("DB_PORT", "5432"),
DBHost: getEnv("DB_HOST", "localhost"),
DB: DBConfig{
Host: env("DB_HOST", ""),
Port: env("DB_PORT", "5433"),
Name: env("DB_NAME", ""),
User: env("DB_USER", ""),
Password: env("DB_PASSWORD", ""),
},
Catalogue: DBConfig{
Host: env("CATALOGUE_DB_HOST", ""),
Port: env("CATALOGUE_DB_PORT", "5432"),
Name: env("CATALOGUE_DB_NAME", ""),
User: env("CATALOGUE_DB_USER", ""),
Password: env("CATALOGUE_DB_PASSWORD", ""),
},
Redis: RedisConfig{
Host: env("REDIS_HOST", ""),
Port: env("REDIS_PORT", "6379"),
User: env("REDIS_USER", "default"),
Password: env("REDIS_PASSWORD", ""),
},
S3: S3Config{
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
Endpoint: env("S3_ENDPOINT", ""),
Bucket: env("S3_BUCKET", ""),
AccessKey: env("S3_ACCESS_KEY", ""),
SecretKey: env("S3_SECRET_KEY", ""),
Region: env("S3_REGION", ""),
},
MQTT: MQTTConfig{
URL: env("MQTT_URL", ""),
// MQTT_USERNAME is accepted because livehub.go read that name for
// a while, so an existing deployment may still set it.
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
Password: env("MQTT_PASSWORD", ""),
ClientID: env("MQTT_CLIENT_ID", ""),
},
UserContextKey: getEnv("USER_CONTEXT_KEY", "nearle"),
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
Embedding: EmbeddingConfig{
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
Model: env("EMBEDDING_MODEL", ""),
APIKey: env("EMBEDDING_API_KEY", ""),
BaseURL: env("EMBEDDING_BASE_URL", ""),
},
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
JWTSecret: env("JWT_SECRET_KEY", ""),
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
}
// ✅ Correct validation
if cfg.DBPassword == "" {
log.Println("Warning: DB_PASSWORD is not set")
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
cfg.Redis.DB = db
} else {
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
}
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
n, err := strconv.Atoi(dims)
if err != nil || n < 0 {
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
}
cfg.Embedding.Dimensions = n
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// MustLoad is Load for main(): every problem is printed and the process exits.
func MustLoad() *Config {
cfg, err := Load()
if err != nil {
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
}
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
return cfg
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
// validate collects every problem rather than stopping at the first, so one
// restart is enough to learn everything that is wrong.
func (c *Config) validate() error {
var problems []string
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
if c.DB.Host == "" {
missing("DB_HOST")
}
if c.DB.User == "" {
missing("DB_USER")
}
if c.DB.Password == "" {
missing("DB_PASSWORD")
}
if c.DB.Name == "" {
missing("DB_NAME")
}
// Optional subsystems are either fully configured or absent. Half a
// configuration used to be skipped with a warning, which reads as "fine"
// in a log and turns into "why are there no images" a week later.
if c.Catalogue.Enabled() {
if c.Catalogue.User == "" {
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
}
if c.Catalogue.Password == "" {
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
}
if c.Catalogue.Name == "" {
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
}
}
if c.S3.Enabled {
if c.S3.Endpoint == "" {
missing("S3_ENDPOINT (USE_S3=true)")
}
if c.S3.Bucket == "" {
missing("S3_BUCKET (USE_S3=true)")
}
if c.S3.AccessKey == "" {
missing("S3_ACCESS_KEY (USE_S3=true)")
}
if c.S3.SecretKey == "" {
missing("S3_SECRET_KEY (USE_S3=true)")
}
if c.S3.Region == "" {
missing("S3_REGION (USE_S3=true)")
}
}
if c.Embedding.Enabled() {
switch c.Embedding.Provider {
case "openai", "gemini":
default:
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
}
if c.Embedding.Model == "" {
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
}
if c.Embedding.APIKey == "" {
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
}
}
if c.IsProduction() {
// utils/postoken.go refuses to sign with a short secret at request
// time; catching it here means the first till login is not the first
// anyone hears of it.
secret := c.POSTokenSecret
if secret == "" {
secret = c.JWTSecret
}
if strings.TrimSpace(secret) == "" {
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
} else if len(strings.TrimSpace(secret)) < 16 {
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
}
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
// Not fatal: a dump restored on another machine on the LAN is a valid
// local setup. But `.env.local` pointing at the live host is the
// mistake every comment in that file warns about, so say it out loud.
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
c.AppEnv, c.DB.Host)
}
if len(problems) == 0 {
return nil
}
return errors.New(strings.Join(problems, "\n"))
}
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
//
// APP_ENV is read from the real environment before any file, so a file cannot
// change which environment it is loaded for.
func loadEnvFiles() {
appEnv := env("APP_ENV", EnvLocal)
for _, name := range []string{".env." + appEnv, ".env"} {
if _, err := os.Stat(name); err != nil {
continue
}
if err := godotenv.Load(name); err != nil {
log.Printf("config: could not read %s: %v", name, err)
continue
}
log.Printf("config: loaded %s", name)
}
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func isLocalHost(host string) bool {
switch strings.ToLower(host) {
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
return true
}
return strings.HasPrefix(host, "127.")
}

251
config/config_test.go Normal file
View File

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

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

@@ -146,3 +146,48 @@ func (ctl *CatalogueController) GetProductBySKU(c *fiber.Ctx) error {
"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,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

@@ -1,6 +1,7 @@
package controllers
import (
"fmt"
"log"
"nearle/models"
"net/http"
@@ -349,12 +350,41 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
}
// 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)
if err != nil {
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
if strings.Contains(strings.ToLower(err.Error()), "insufficient stock") {
lowered := strings.ToLower(err.Error())
switch {
case strings.Contains(lowered, "insufficient stock"):
statusCode = http.StatusConflict
case strings.Contains(lowered, "names no outlet"):
statusCode = http.StatusBadRequest
}
return c.Status(statusCode).JSON(fiber.Map{
"code": statusCode,
@@ -371,6 +401,75 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
})
}
// UploadOfflineSales imports a spreadsheet of in-store counter sales.
//
// The response is 200 whenever the batch was processed, even if individual
// bills were rejected, because a partial import is a normal outcome for a
// spreadsheet and the per-bill results carry the detail. A non-200 means
// nothing at all was attempted — a malformed body, or an outlet the caller has
// no claim on.
func (ctl *OrderController) UploadOfflineSales(c *fiber.Ctx) error {
var input models.OfflineSalesUpload
if err := c.BodyParser(&input); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the upload: " + err.Error(),
"status": false,
})
}
// locationid is optional: 0 means the bills carry their own branch, which
// is how one workbook covers every outlet a merchant runs. Supplying it
// pins the upload to that branch and rejects anything else in the file.
if input.Tenantid <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
if len(input.Bills) == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "no sales rows found in the upload",
"status": false,
})
}
result, err := ctl.orderService.UploadOfflineSales(input)
if err != nil {
log.Println("UploadOfflineSales service error:", err)
// An outlet the caller doesn't own is a permission problem, not a
// server fault, and is reported as one so the UI can say so plainly.
statusCode := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "does not belong to tenant") {
statusCode = http.StatusForbidden
}
return c.Status(statusCode).JSON(fiber.Map{
"code": statusCode,
"message": err.Error(),
"status": false,
})
}
message := fmt.Sprintf("%d bill(s) imported", result.Imported)
if result.Duplicate > 0 {
message += fmt.Sprintf(", %d already imported", result.Duplicate)
}
if result.Failed > 0 {
message += fmt.Sprintf(", %d failed", result.Failed)
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"message": message,
"status": true,
"details": result,
})
}
func (ctl *OrderController) GetCustomerOrders(c *fiber.Ctx) error {
customerID := c.Query("customerid")
tenantID := c.Query("tenantid")

View File

@@ -1,6 +1,8 @@
package controllers
import (
"errors"
"nearle/models"
"nearle/services"
"net/http"
"strconv"
@@ -193,3 +195,237 @@ func (ctl *PartnerController) GetRiderInfo(c *fiber.Ctx) error {
"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

@@ -0,0 +1,984 @@
package controllers
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"nearle/middleware"
"nearle/models"
"nearle/repositories"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// HTTP face of the POS terminal ingest.
//
// These handlers break this codebase's house style in one respect, on purpose:
// they answer with a bare ack rather than the usual
// `{code, message, status, details}` envelope. The terminal reads `accepted`
// from the top level of the body and marks a bill synced only if its id is
// there — wrapping the ack would leave every till queueing for ever.
//
// The status code carries the other half of the contract:
//
// - **200** — the batch was processed. Individual bills may still have been
// refused; the ack says which.
// - **4xx** — the request itself is wrong (unreadable body, unknown outlet).
// The terminal treats these as non-retryable and halts, so a person is
// told rather than the broker hammered.
// - **5xx** — the outcome is unknown. The terminal keeps every bill and
// retries with backoff. This is the right answer when the database is
// having a bad minute: *never* ack a batch that did not commit.
type PosController struct {
posService services.PosService
}
func NewPosController(posService services.PosService) *PosController {
return &PosController{posService: posService}
}
// IngestOrders receives a batch of completed counter bills.
func (ctl *PosController) IngestOrders(c *fiber.Ctx) error {
var batch models.PosOrderBatch
if err := c.BodyParser(&batch); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the batch: " + err.Error(),
"status": false,
})
}
if strings.TrimSpace(batch.Storeid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
ack, err := ctl.posService.IngestOrders(batch)
if err != nil {
return posIngestError(c, "IngestOrders", err)
}
return c.Status(http.StatusOK).JSON(ack)
}
// IngestCustomers receives shoppers registered at a till.
func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
var batch models.PosCustomerBatch
if err := c.BodyParser(&batch); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the batch: " + err.Error(),
"status": false,
})
}
if strings.TrimSpace(batch.Storeid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
ack, err := ctl.posService.IngestCustomers(batch)
if err != nil {
return posIngestError(c, "IngestCustomers", err)
}
return c.Status(http.StatusOK).JSON(ack)
}
// IngestHealth records one heartbeat from a till.
//
// The same heartbeat the broker carries, over HTTP, because presence was
// previously reachable *only* over MQTT — a terminal configured for the HTTP
// route reported bills perfectly and never appeared on the fleet board at all,
// with nothing anywhere to say why. A monitoring feature that silently does not
// exist on one of two supported transports is worse than no feature.
//
// Answers 202 rather than 200: nothing is committed, and the till is told not
// to wait on it. Failures are swallowed for the same reason the MQTT path
// swallows them — a terminal that cannot say how it is must still sell, and a
// blank square on a dashboard beats a till that stopped because Redis was busy.
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
var health models.PosHealth
if err := c.BodyParser(&health); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "could not read the heartbeat: " + err.Error(),
"status": false,
})
}
// Over MQTT these come from the topic. There is no topic here, so the body
// is the only source and both are required — a heartbeat that cannot say
// which till it belongs to is unfilable.
if strings.TrimSpace(health.Terminalid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "terminal_id is required",
})
}
if strings.TrimSpace(health.Locationid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "location_id is required",
})
}
// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
// survive as-is, but an unset status from a till that is plainly talking to
// us means online.
if strings.TrimSpace(health.Status) == "" {
health.Status = "online"
}
if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
// Logged, not returned. See above — the till must not slow down for it.
log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
health.Locationid, health.Terminalid, err)
}
return c.Status(http.StatusAccepted).JSON(fiber.Map{
"status": true, "code": http.StatusAccepted,
})
}
// Catalogue answers a terminal's product pull.
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
storeID := strings.TrimSpace(c.Query("store_id"))
if storeID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "store_id is required",
"status": false,
})
}
page, _ := strconv.Atoi(c.Query("page", "0"))
pageSize, _ := strconv.Atoi(c.Query("page_size", "500"))
result, err := ctl.posService.Catalogue(storeID, c.Query("since"), page, pageSize)
if err != nil {
return posIngestError(c, "Catalogue", err)
}
return c.Status(http.StatusOK).JSON(result)
}
// TerminalHealth returns one till's live state, for a support call that starts
// 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 {
terminalID := strings.TrimSpace(c.Query("terminal_id"))
if terminalID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "terminal_id is required", "status": false,
})
}
fields, err := ctl.posService.TerminalHealth(c.Context(), terminalID)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
})
}
if fields == nil {
// 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
// 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{
"code": http.StatusOK,
"status": true,
"details": fiber.Map{
"terminal_id": terminalID,
"status": "offline",
"reason": "no heartbeat received within the presence window",
},
})
}
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})
}
// 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"
// 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.
func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
locationID := strings.TrimSpace(c.Query("location_id"))
if locationID == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "location_id is required", "status": false,
})
}
terminals, err := ctl.posService.LocationHealth(c.Context(), locationID)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
})
}
online := 0
for _, t := range terminals {
if t["status"] == "online" {
online++
}
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"status": true,
"details": fiber.Map{
"location_id": locationID,
"total": len(terminals),
"online": online,
"terminals": terminals,
},
})
}
// ---------------------------------------------------------------- Sales reads
//
// Unlike the ingest handlers above, these answer in the usual
// `{code, message, status, details}` envelope — they are read by the web app,
// not by a terminal, and nothing about them is bound to the till's contract.
// posSalesFilter reads the shared query parameters.
func posSalesFilter(c *fiber.Ctx) (models.PosSalesFilter, error) {
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err != nil || locationID <= 0 {
return models.PosSalesFilter{}, fmt.Errorf("locationid is required")
}
pageno, _ := strconv.Atoi(c.Query("pageno", "0"))
pagesize, _ := strconv.Atoi(c.Query("pagesize", "50"))
return models.PosSalesFilter{
Locationid: locationID,
Fromdate: strings.TrimSpace(c.Query("fromdate")),
Todate: strings.TrimSpace(c.Query("todate")),
Terminalid: strings.TrimSpace(c.Query("terminalid")),
Cashiername: strings.TrimSpace(c.Query("cashiername")),
Paymentmode: strings.TrimSpace(c.Query("paymentmode")),
Pageno: pageno,
Pagesize: pagesize,
}, nil
}
// GetSales lists counter bills for an outlet, newest first.
func (ctl *PosController) GetSales(c *fiber.Ctx) error {
filter, err := posSalesFilter(c)
if err != nil {
return posBadRequest(c, err)
}
page, err := ctl.posService.Sales(filter)
if err != nil {
return posServerError(c, "GetSales", err)
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": page})
}
// GetSaleDetail returns one bill with its lines.
//
// Accepts the terminal's order UUID, the invoice number, or this backend's
// posorderid — a support call starts from whichever the caller is looking at.
func (ctl *PosController) GetSaleDetail(c *fiber.Ctx) error {
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err != nil || locationID <= 0 {
return posBadRequest(c, fmt.Errorf("locationid is required"))
}
reference := strings.TrimSpace(c.Query("reference"))
if reference == "" {
return posBadRequest(c, fmt.Errorf("reference is required — an order id, invoice number or posorderid"))
}
bill, err := ctl.posService.SaleDetail(locationID, reference)
if err != nil {
return posServerError(c, "GetSaleDetail", err)
}
if bill == nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{
"code": http.StatusNotFound,
"message": "no bill matches that reference at this outlet",
"status": false,
})
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": bill})
}
// GetSalesSummary totals a range, split by tender, day and till.
func (ctl *PosController) GetSalesSummary(c *fiber.Ctx) error {
filter, err := posSalesFilter(c)
if err != nil {
return posBadRequest(c, err)
}
summary, err := ctl.posService.SalesSummary(filter)
if err != nil {
return posServerError(c, "GetSalesSummary", err)
}
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": summary})
}
func posBadRequest(c *fiber.Ctx, err error) error {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
})
}
func posServerError(c *fiber.Ctx, op string, err error) error {
log.Printf("pos %s: %v", op, err)
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError, "message": err.Error(), "status": false,
})
}
// posIngestError decides whether the terminal should retry.
//
// The distinction matters more than the message does. A misconfigured store id
// will be just as wrong on the next attempt, so it is reported as a 4xx and the
// till halts and shows a person the reason. Anything else might succeed later,
// so it is a 5xx and the bills stay queued.
func posIngestError(c *fiber.Ctx, op string, err error) error {
log.Printf("pos %s: %v", op, err)
message := err.Error()
lower := strings.ToLower(message)
permanent := strings.Contains(lower, "is not a location id") ||
strings.Contains(lower, "no outlet is registered") ||
strings.Contains(lower, "does not belong to tenant") ||
strings.Contains(lower, "has no products stocked") ||
strings.Contains(lower, "no applocationid configured")
status := http.StatusInternalServerError
if permanent {
status = http.StatusBadRequest
}
return c.Status(status).JSON(fiber.Map{
"code": status,
"message": message,
"status": false,
})
}
// Login signs a terminal in and returns its session.
//
// 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
// 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 {
var req models.PosLoginRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "status": false,
"message": "invalid request body",
})
}
// 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{
"code": http.StatusBadRequest, "status": false,
"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",
})
}
session, err := ctl.posService.Login(req)
if err != nil {
// A rejected credential is 401 and says nothing about which half was
// wrong. Anything else is the deployment's problem, not the caller's,
// and is logged rather than described down the wire.
if repositories.PosLoginRejected(err) {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": err.Error(),
})
}
log.Printf("pos login (%s): %v", identity, err)
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,
"message": "Login successful",
"details": session,
})
}
// Session echoes back who the caller is, per their token.
//
// What a till calls on start-up to find out whether the session it saved
// yesterday is still good, without having to make a real request and interpret
// the failure. Answers 401 through the middleware when it is not.
func (ctl *PosController) Session(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "no session token was presented",
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{
"user_id": claims.Userid,
"tenant_id": claims.Tenantid,
"location_id": claims.Locationid,
"store_id": strconv.Itoa(claims.Locationid),
"role_id": claims.Roleid,
"terminal_id": claims.Terminalid,
"expires_at": time.Unix(claims.Expiresat, 0).UTC().Format(time.RFC3339),
},
})
}
// 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
// asking "who works here" must not be able to ask on behalf of another shop, so
// the outlet comes from the token, and a request without one is refused
// 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 {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "a session token is required to read staff",
})
}
staff, err := ctl.posService.Staff(claims.Tenantid, claims.Locationid)
if err != nil {
return posServerError(c, "Staff", err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": models.PosStaffResponse{
Locationid: claims.Locationid,
Staff: staff,
},
})
}
// ------------------------------------------------------------- Till staff
//
// A shop runs its own counter. A supervisor creates their cashiers from the
// terminal, and every one of these reads the tenant and outlet from the
// caller's session token rather than from the request — so a supervisor at one
// shop cannot create, edit or list staff at another. That is the same inversion
// that stopped a till naming its own store id, applied to people.
// posManager returns the caller's session, provided they may manage staff.
func posManager(c *fiber.Ctx) (utils.PosClaims, error) {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return claims, fiber.NewError(http.StatusUnauthorized,
"a session token is required")
}
if !models.PosRoleCanManageStaff(claims.Roleid) {
// A cashier signing in on the same terminal must not be able to mint
// themselves a supervisor.
return claims, fiber.NewError(http.StatusForbidden,
"only a supervisor can manage till users")
}
return claims, nil
}
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
func (ctl *PosController) CreatePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
var req models.PosUserRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
user, err := ctl.posService.CreateUser(claims.Tenantid, claims.Locationid, claims.Configid, req)
if err != nil {
// Every failure here is something the caller can act on — a bad role, a
// PIN already in use, a name left blank — so it is reported as a 400
// with the reason rather than logged and hidden behind a 500.
return posBadRequest(c, err)
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true,
"message": "User created", "details": user,
})
}
// UpdatePosUser edits one of the caller's own till users.
func (ctl *PosController) UpdatePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
var req models.PosUserRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
user, err := ctl.posService.UpdateUser(claims.Tenantid, claims.Locationid, req)
if err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "User updated", "details": user,
})
}
// ListPosUsers returns the till users at the caller's outlet.
//
// Readable by anyone signed in, not only a supervisor: the terminal needs the
// list to show who is on shift, and a cashier can already see their colleagues
// standing next to them. PINs are the part that matters, and those only go to
// somebody who could set them anyway.
func (ctl *PosController) ListPosUsers(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "a session token is required",
})
}
users, err := ctl.posService.ListUsers(
claims.Tenantid, claims.Locationid,
strings.EqualFold(c.Query("include_inactive"), "true"),
)
if err != nil {
return posServerError(c, "ListPosUsers", err)
}
// A cashier sees who is on shift, not how to sign in as them.
if !models.PosRoleCanManageStaff(claims.Roleid) {
for i := range users {
users[i].Pin = ""
}
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{"location_id": claims.Locationid, "users": users},
})
}
// DeletePosUser retires a till user. Deactivates rather than deletes — bills
// carry the cashier's name and shifts settle against it.
func (ctl *PosController) DeletePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
userID, convErr := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
if convErr != nil || userID <= 0 {
return posBadRequest(c, fmt.Errorf("user_id is required"))
}
if userID == claims.Userid {
// Otherwise the last supervisor at a shop can lock everybody out with
// one tap, and only we can undo it.
return posBadRequest(c, fmt.Errorf("you cannot deactivate the account you are signed in as"))
}
if err := ctl.posService.DeactivateUser(claims.Tenantid, claims.Locationid, userID); err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "User deactivated",
})
}
// PinLogin signs somebody in by PIN at a terminal that is already open.
//
// Requires an existing valid session, and that is the whole security model
// here: four digits is ten thousand guesses, which is no barrier at all to an
// anonymous caller. Tying it to a token means a supervisor has already opened
// the terminal with a real password, and the guesses are confined to one
// outlet's own staff.
//
// The new session is minted fresh rather than derived from the presented one,
// so a cashier taking over from a supervisor drops the supervisor's
// permissions instead of inheriting them.
func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "sign the terminal in with a mobile number and PIN before switching operator",
})
}
var req models.PosLoginRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if strings.TrimSpace(req.Pin) == "" {
return posBadRequest(c, fmt.Errorf("a PIN is required"))
}
session, err := ctl.posService.LoginWithPin(claims.Tenantid, claims.Locationid, req.Pin)
if err != nil {
if repositories.PosLoginRejected(err) {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "that PIN was not recognised",
})
}
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "Signed in", "details": session,
})
}
// posClaimError renders the fiber.Error that posManager returns.
func posClaimError(c *fiber.Ctx, err error) error {
var fe *fiber.Error
if errors.As(err, &fe) {
return c.Status(fe.Code).JSON(fiber.Map{
"code": fe.Code, "status": false, "message": fe.Message,
})
}
return posServerError(c, "posClaims", err)
}
// ------------------------------------------------------- Till staff, from the web
//
// The same staff management as `/pos/users`, for the console an admin actually
// uses. Deliberately the same service calls underneath rather than a parallel
// implementation: a supervisor created from a browser must be the same thing as
// one created at a counter, and two code paths writing one table is exactly how
// that stops being true.
//
// The difference is where the outlet comes from. A terminal proves it with a
// signed token; the console asserts it, because it has no session of its own.
// So it is verified against the tenant before anything is written — which is
// weaker than a signature, and is why these should move behind the same guard
// once the console can hold a session.
// posWebScope reads and checks the tenant and outlet a console request names.
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
if tenantID <= 0 {
return fmt.Errorf("tenantid is required")
}
if locationID <= 0 {
return fmt.Errorf("locationid is required")
}
allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
if err != nil {
return fmt.Errorf("could not verify the outlet")
}
if !allowed {
// Not "no such outlet" — that would confirm which ids exist. It did not
// belong to the tenant asking, and that is all the caller needs.
return fmt.Errorf("outlet %d does not belong to tenant %d", locationID, tenantID)
}
return nil
}
// WebCreatePosUser adds a supervisor or cashier from the console.
func (ctl *PosController) WebCreatePosUser(c *fiber.Ctx) error {
var req models.PosUserWebRequest
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)
}
// The configid the outlet's other people already use, so a new cashier is
// visible to the same portal as their colleagues. Asked for rather than
// derived would mean a console sending a number nobody can look up.
configID := ctl.posService.ConfigidFor(req.Tenantid)
user, err := ctl.posService.CreateUser(req.Tenantid, req.Locationid, configID, req.PosUserRequest)
if err != nil {
return posBadRequest(c, err)
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true,
"message": "User created", "details": user,
})
}
// WebUpdatePosUser edits one of an outlet's till users from the console.
func (ctl *PosController) WebUpdatePosUser(c *fiber.Ctx) error {
var req models.PosUserWebRequest
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)
}
user, err := ctl.posService.UpdateUser(req.Tenantid, req.Locationid, req.PosUserRequest)
if err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "User updated", "details": user,
})
}
// WebListPosUsers lists an outlet's till users for the console.
func (ctl *PosController) WebListPosUsers(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err := ctl.posWebScope(tenantID, locationID); err != nil {
return posBadRequest(c, err)
}
users, err := ctl.posService.ListUsers(tenantID, locationID,
strings.EqualFold(c.Query("include_inactive"), "true"))
if err != nil {
return posServerError(c, "WebListPosUsers", err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{"location_id": locationID, "users": users},
})
}
// WebDeletePosUser retires a till user from the console.
func (ctl *PosController) WebDeletePosUser(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err := ctl.posWebScope(tenantID, locationID); err != nil {
return posBadRequest(c, err)
}
userID, err := strconv.Atoi(strings.TrimSpace(c.Query("userid")))
if err != nil || userID <= 0 {
return posBadRequest(c, fmt.Errorf("userid is required"))
}
if err := ctl.posService.DeactivateUser(tenantID, locationID, userID); err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "User deactivated",
})
}
// WebPosRoles lists the roles a console may offer.
//
// Served rather than hardcoded in the console, because the numbers are this
// backend's business. A console that hardcoded 7 and 8 would be wrong the day
// they change, and would have no way to know.
func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": []fiber.Map{
{
"role_id": models.PosRoleSupervisor, "role": "supervisor",
"label": models.PosRoleName(models.PosRoleSupervisor),
"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",
"label": models.PosRoleName(models.PosRoleCashier),
"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")))
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"))
}
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,
})
}

View File

@@ -1,8 +1,10 @@
package controllers
import (
"fmt"
"net/http"
"strconv"
"strings"
"nearle/models"
"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{
"code": http.StatusInternalServerError,
"message": "Failed to create product",
@@ -201,7 +210,7 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
"code": http.StatusCreated,
"message": "Product created successfully",
"status": true,
"data": product,
"data": created,
})
}
@@ -309,6 +318,42 @@ func (ctl *ProductController) GetLocationProducts(c *fiber.Ctx) error {
})
}
// GetSaleTemplate serves the data the web app turns into the offline-sales
// spreadsheet.
//
// locationid is optional and defaults to 0, meaning every branch the tenant
// runs — one workbook for the whole business, with each row carrying the branch
// its stock belongs to. A store user passes their own locationid to get just
// theirs. tenantid is required: without it there is no scope at all.
func (ctl *ProductController) GetSaleTemplate(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
locationID, _ := strconv.Atoi(c.Query("locationid", "0"))
if tenantID <= 0 {
return c.JSON(fiber.Map{
"status": false,
"code": http.StatusBadRequest,
"message": "tenantid is required",
})
}
result, err := ctl.productService.GetSaleTemplate(tenantID, locationID)
if err != nil {
return c.JSON(fiber.Map{
"status": false,
"code": http.StatusInternalServerError,
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"status": true,
"code": http.StatusOK,
"message": "Success",
"details": result,
})
}
func (ctl *ProductController) GetLocationProductSummary(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
locationID, _ := strconv.Atoi(c.Query("locationid"))
@@ -347,6 +392,41 @@ func (ctl *ProductController) GetAllProducts(c *fiber.Ctx) error {
categoryID, subcategoryID, productID, applocationID, tenantID,
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 {
return c.JSON(fiber.Map{
"status": false,
@@ -368,8 +448,33 @@ func (ctl *ProductController) GetProductByVariant(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
variantid, _ := strconv.Atoi(c.Query("variantid"))
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 {
@@ -488,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 {
var input models.Productvariant
@@ -536,10 +668,54 @@ func (ctl *ProductController) ImportCatalogueProduct(c *fiber.Ctx) error {
}
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{
"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,
})
}
@@ -618,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 {
var input struct {
Tenantid int `json:"tenantid"`
@@ -655,3 +918,93 @@ func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
"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})
}

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,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,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}
}
// 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 {
var input models.StockRequest
if err := c.BodyParser(&input); err != nil {
inputs, err := parseStockRequests(c)
if err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if input.Status == "" {
input.Status = "Pending"
if len(inputs) == 0 {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "send at least one request", "status": false})
}
err := ctl.stockRequestService.CreateStockRequest(&input)
if err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
created := make([]models.StockRequest, 0, len(inputs))
failed := make([]fiber.Map, 0)
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 {
@@ -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})
}
// 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 {
var input struct {
RequestID int `json:"requestid"`
Status string `json:"status"`
RequestID int `json:"requestid"`
RequestIDs []int `json:"requestids"`
Status string `json:"status"`
}
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
err := ctl.stockRequestService.UpdateStockRequest(input.RequestID, input.Status)
if err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
if input.Status == "" {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "status": false,
"message": "status is required"})
}
return c.JSON(fiber.Map{"code": 200, "message": "Stock request updated", "status": true})
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})
}
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

@@ -44,6 +44,25 @@ func (ctl *TenantController) SearchTenant(c *fiber.Ctx) error {
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
pageno, _ := strconv.Atoi(c.Query("pageno"))
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")
aid, _ := strconv.Atoi(c.Query("applocationid"))
tenanttype := c.Query("tenanttype")
@@ -328,8 +347,12 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
}
if err := ctl.tenantService.CreateStaff(data); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusConflict,
// 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 reason. This answered 500 with a body claiming 409, which told a
// console nothing it could act on and told the operator less.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": err.Error(),
"status": false,
})
@@ -440,7 +463,7 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
func (ctl *TenantController) GetTenantInfo(c *fiber.Ctx) error {
log.Printf("[DEBUG] GetTenantInfo OriginalURL: %s, Headers: %v", c.OriginalURL(), c.GetReqHeaders())
// Parse tenant ID
tidStr := c.Query("tenantid")
if tidStr == "" {
@@ -576,3 +599,180 @@ func (ctl *TenantController) GetTenantByKeyword(c *fiber.Ctx) error {
"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",
})
}

View File

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

View File

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

View File

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

78
db/redis.go Normal file
View File

@@ -0,0 +1,78 @@
package db
import (
"context"
"log"
"nearle/config"
"time"
"github.com/redis/go-redis/v9"
)
// Rdb is the shared Redis connection, or nil when Redis is not configured.
//
// Deliberately the *same* instance the express backend uses. POS presence is
// read by the rider app, which talks to that backend, and a second Redis would
// mean either cross-service HTTP calls on every board refresh or two copies of
// the truth about which tills are alive.
//
// Key namespaces do not collide: express owns `delivery:*`, `city:*`,
// `rider_*`; POS owns `pos:*`. Worth keeping that way — a shared datastore only
// stays safe while each writer's keys are obviously its own.
var Rdb *redis.Client
// RedisCtx is the background context for Redis calls made outside a request.
var RedisCtx = context.Background()
// InitRedis connects if REDIS_HOST is set, and does nothing if it is not.
//
// Redis is optional here: without it the POS health board goes dark, but bills
// still arrive and commit. That is the right failure — losing presence is an
// inconvenience, losing a sale is not — so this never aborts startup.
func InitRedis(c config.RedisConfig) {
if !c.Enabled() {
log.Println("redis: REDIS_HOST not set, POS presence disabled")
return
}
host, port, dbIndex := c.Host, c.Port, c.DB
Rdb = redis.NewClient(&redis.Options{
Addr: host + ":" + port,
Username: c.User,
Password: c.Password,
DB: dbIndex,
// Short on purpose. A degraded Redis must fail fast rather than tie up
// a pooled connection for tens of seconds — the express backend learned
// this the hard way, where a 10s x 3-retry config let one stuck call
// hold a connection for ~35s and exhausted the pool under load.
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolTimeout: 4 * time.Second,
})
ctx, cancel := context.WithTimeout(RedisCtx, 5*time.Second)
defer cancel()
if err := Rdb.Ping(ctx).Err(); err != nil {
// Logged, not fatal. A broker that cannot be reached is fatal because
// bills would silently queue; Redis being down only costs the board.
log.Printf("redis: could not reach %s:%s — POS presence will be unavailable: %v", host, port, err)
Rdb = nil
return
}
log.Printf("✅ Redis connected at %s:%s (db %d)", host, port, dbIndex)
}
// CloseRedis releases the pool on shutdown.
func CloseRedis() {
if Rdb == nil {
return
}
if err := Rdb.Close(); err != nil {
log.Printf("redis: close failed: %v", err)
}
}

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:

103
docs/ENVIRONMENT.md Normal file
View File

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

778
docs/PORTFOLIO.md Normal file
View File

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

458
docs/POS_API.md Normal file
View File

@@ -0,0 +1,458 @@
# POS integration — handover
Everything a developer needs to work on, extend or debug the in-store POS
integration. Companion to [`POS_TERMINAL_INGEST.md`](POS_TERMINAL_INGEST.md),
which covers deployment and broker setup.
Base path for everything below: **`/live/api/v1/pos`**
---
## 1. What this is
Retail tills run a Flutter POS app. Each one holds its own SQLite database and
keeps working with no network at all. When a connection is available it
publishes completed bills to an MQTT broker; a consumer in this backend commits
them to Postgres and acknowledges.
```
Cashier completes sale
│
▼
Till's SQLite ───────────────────── one transaction, before any network
sync_status = 0 survives crash, power cut, dead wifi
│
▼
MQTT broker ──────────────────────── transit only, holds nothing you can rely on
nearle/pos/{loc}/{terminal}/order
│
▼
fiesta consumer (messaging/posmqtt.go)
│
▼
POSTGRES ─────────────────────────── the permanent record
pos_orders, pos_order_items
productstocks (stock deducted here)
│
▼
ack → nearle/pos/{loc}/{terminal}/ack
│
▼
Till marks it synced, keeps its copy 7 more days, then purges
```
**Health** takes a separate path: every till publishes a heartbeat every 30
seconds, which lands in **Redis** under a 90-second TTL. Never in Postgres.
---
## 2. The four rules everything rests on
Break any of these and shops lose money. They are not stylistic.
**1. Only an application acknowledgement counts.**
A broker PUBACK means "I hold these bytes". It is not evidence the database
accepted anything. The ack is published *after* the transaction commits, never
from a handler that has merely queued the work.
**2. Silence is not acceptance.**
No ack, an empty ack, a 200 with no body — all leave the bill on the till, and
it is sent again. This is the correct behaviour when we are struggling.
**3. A duplicate is a success.**
Delivery is at-least-once. A lost ack makes a terminal re-send bills we already
hold. Reporting those as failures would strand a day of takings. Deduplication
is a unique index on `pos_orders.terminalorderid` — the UUID minted at the till
— plus a Postgres advisory lock held for the transaction.
**4. Store and terminal come from the topic, never the body.**
A till that could name its own store in a payload could redirect another
counter's acknowledgements.
---
## 3. Endpoints written by a terminal
These answer with a **bare body**, not the usual `{code, message, status}`
envelope — the till reads `accepted` from the top level and marks a bill synced
only if its id is there. Wrapping it would leave every terminal queueing for
ever.
### `POST /orders`
```json
{
"schema": 1,
"batch_id": "9f1c…",
"store_id": "1135",
"terminal_id": "T4A9",
"orders": [{
"id": "99999999-8888-4777-8666-555555555555",
"invoice_number": "INV-2608-T4A9-00002",
"created_at": "2026-08-03T17:29:00Z",
"cashier": "Divya",
"customer": {"id": "…", "mobile": "9840099999", "name": "Ravi"},
"subtotal": 60.0, "discount": 0.0, "tax": 4.44,
"tax_breakdown": {"0.08": 4.44},
"round_off": 0.0, "total": 60.0,
"points_earned": 0, "points_redeemed": 0,
"payments": [{"method": "upi", "amount": 60.0, "reference": "TXN123"}],
"items": [{
"product_id": "6988", "barcode": "6988", "name": "Mysore Banana",
"quantity": 1, "unit_price": 60.0, "discount": 0.0,
"gst_rate": 0.08, "tax": 4.44, "line_total": 60.0
}]
}]
}
```
Response:
```json
{ "batch_id": "9f1c…", "accepted": ["99999999-…"], "rejected": {} }
```
Status codes carry the other half of the contract:
| Code | Meaning | Terminal does |
|---|---|---|
| `200` | batch processed; ack says which bills landed | marks the named ids synced |
| `4xx` | the request is wrong — unknown outlet, bad store id | **halts** and shows a person |
| `5xx` | outcome unknown | keeps everything, retries with backoff |
### `POST /customers`
Same envelope with a `customers` array. **Insert-if-absent on id** — never an
update, so a profile corrected at head office is not reverted by a terminal
replaying an old capture.
The id is a **UUIDv5 over the normalised ten-digit mobile**, so two tills
registering the same shopper independently produce the same row. Do not
reassign it.
No loyalty figures travel upward — points and spend are derived from the bill
stream, which is idempotent and sees every counter.
---
## 4. GET endpoints — for the web app
**These use the normal `{code, message, status, details}` envelope.**
### `GET /sales` — bills for an outlet
```bash
curl "$BASE/sales?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
```
| Parameter | |
|---|---|
| `locationid` | **required** — the authorisation boundary |
| `fromdate`, `todate` | `YYYY-MM-DD`, matched on `businessdate` |
| `terminalid` | e.g. `T4A9` |
| `cashiername` | exact match |
| `paymentmode` | `cash`, `card`, `upi`, `wallet` |
| `pageno` | 0-based, default 0 |
| `pagesize` | default 50, max 500 |
```json
{
"code": 200, "status": true,
"details": {
"total": 137, "pageno": 0, "pagesize": 50,
"bills": [{
"posorderid": 7,
"terminalorderid": "99999999-8888-4777-8666-555555555555",
"invoicenumber": "INV-2608-T4A9-00002",
"tenantid": 1087, "locationid": 1135,
"terminalid": "T4A9", "cashiername": "Divya",
"customerid": 6847, "customermobile": "9840099999", "customername": "Ravi",
"billedat": "2026-08-03T17:29:00Z",
"businessdate": "2026-08-03",
"subtotal": 60, "discount": 0, "taxamount": 4.44,
"roundoff": 0, "total": 60,
"pointsearned": 0, "pointsredeemed": 0,
"itemcount": 1, "paymentmode": "upi",
"paymentsjson": "[{\"method\":\"upi\",\"amount\":60,\"reference\":\"TXN123\"}]",
"promosjson": "[]",
"taxbreakdownjson": "{\"0.08\":4.44}",
"batchid": "batch-mqtt-0001",
"receivedat": "2026-08-03T17:29:11Z"
}]
}
}
```
Line items are **not** included — a page of 50 bills would drag hundreds of rows
behind it and a list screen shows none of them. Use `/sales/detail`.
Ordered by `billedat` descending, not by id: a backlog uploaded after an outage
arrives out of order, and sorting by arrival would interleave yesterday's bills
through today's.
### `GET /sales/detail` — one bill with its lines
```bash
curl "$BASE/sales/detail?locationid=1135&reference=INV-2608-T4A9-00002"
```
`reference` accepts **any of three**: the terminal's order UUID, the invoice
number, or the `posorderid`. A support call starts from whichever the caller
happens to be looking at.
```json
{
"code": 200, "status": true,
"details": {
"posorderid": 7,
"invoicenumber": "INV-2608-T4A9-00002",
"…": "all the fields above, plus:",
"items": [{
"posorderitemid": 12, "posorderid": 7,
"productid": 6988, "productname": "Mysore Banana",
"barcode": "6988", "unitname": "kg",
"quantity": 1, "unitprice": 60,
"discountamount": 0, "gstrate": 0.08,
"taxamount": 4.44, "linetotal": 60
}]
}
}
```
Returns **404** if the reference does not belong to that `locationid` — even
when the reference is a real bill at another outlet.
### `GET /sales/summary` — totals
```bash
curl "$BASE/sales/summary?locationid=1135&fromdate=2026-08-01&todate=2026-08-03"
```
Takes the same filters as `/sales`.
```json
{
"code": 200, "status": true,
"details": {
"locationid": 1135,
"fromdate": "2026-08-01", "todate": "2026-08-03",
"billcount": 137, "itemcount": 402,
"grosssales": 18450.50, "taxcollected": 1204.30,
"discountgiven": 320.00, "roundoff": -1.50,
"averagebill": 134.68,
"bypaymentmode": [
{"paymentmode": "cash", "billcount": 80, "amount": 9200.00},
{"paymentmode": "upi", "billcount": 57, "amount": 9250.50}
],
"byday": [
{"businessdate": "2026-08-01", "billcount": 44, "amount": 5900.00}
],
"byterminal": [
{"terminalid": "T4A9", "billcount": 137, "amount": 18450.50}
]
}
}
```
Three breakdowns because they answer three different questions: **by tender**
for reconciling a drawer, **by day** for a chart, **by till** for an outlet
running several counters.
### `GET /health/terminal` — one till
```bash
curl "$BASE/health/terminal?terminal_id=T4A9"
```
```json
{
"code": 200, "status": true,
"details": {
"terminal_id": "T4A9", "location_id": "1135",
"store_name": "Ragul stores Selvapuram",
"app_version": "1.1.0", "status": "online",
"pending_bills": "0", "pending_registrations": "0",
"oldest_pending_at": "",
"today_bills": "2", "today_amount": "170",
"last_bill_at": "",
"printer_reachable": "0",
"reported_at": "2026-08-03T12:04:21Z",
"received_at": "2026-08-03T12:04:21Z"
}
}
```
Values are **strings** — it is a Redis hash. A till that has not reported inside
its TTL returns `200` with `status: "offline"`, not a 404: it exists, it is
simply quiet.
Fields the till does not collect are **absent, not zero**. A board showing every
terminal at 0% battery is worse than one showing nothing.
`pending_bills` is the number worth watching. A shop quietly accumulating
unsynced takings looks completely normal from the floor.
### `GET /health/location` — the "which counters are dark" board
```bash
curl "$BASE/health/location?location_id=1135"
```
```json
{
"code": 200, "status": true,
"details": {
"location_id": "1135", "total": 3, "online": 2,
"terminals": [
{"terminal_id": "T4A9", "status": "online", "today_bills": "37", "…": "…"},
{"terminal_id": "T7B2", "status": "offline", "reason": "no heartbeat within 90s"}
]
}
}
```
A till whose key expired comes back marked **offline rather than omitted** —
omitting it would make a dead terminal indistinguishable from one that was never
installed, and the dead one is exactly what somebody is looking for.
### `GET /catalogue` — the till's product pull
Bare body, no envelope. Used by terminals, not the web app.
```bash
curl "$BASE/catalogue?store_id=1135&page_size=500"
curl "$BASE/catalogue?store_id=1135&since=loc1135-20260803T135407Z"
```
No `since` → **full snapshot**. With a valid `since` → **change set**.
```json
{
"revision": "loc1135-20260803T135407Z",
"is_delta": false,
"has_more": false,
"products": [{
"id": "6988", "name": "Mysore Banana",
"barcode": "6988", "sku": "",
"category": "grocery", "price": 60, "stock": 750,
"unit": "kilogram", "gst_rate": 0.08, "is_active": true
}],
"customers": [],
"retired_product_ids": []
}
```
**`is_delta` is the dangerous field.** `false` means the terminal withdraws
every product the response does not mention. A filtered result labelled `false`
empties the shelf. In `Catalogue()` the filter and the flag are derived from one
value, so no code path can set one without the other.
Anything ambiguous resolves toward the snapshot: a revision that is malformed,
empty, or issued to a different outlet yields a full response.
The revision **only advances on the final page**, so a terminal that abandons a
paginated pull cannot end up holding one claiming it saw pages it never got.
A delta **cannot withdraw a deleted product** — removing a row from
`productlocations` leaves no tombstone. Only a snapshot collects those, so tills
should pull without a revision periodically.
---
## 5. MQTT topics
| Topic | Direction | Retained |
|---|---|---|
| `nearle/pos/{loc}/{terminal}/order` | till → us | no |
| `nearle/pos/{loc}/{terminal}/customer` | till → us | no |
| `nearle/pos/{loc}/{terminal}/health` | till → us, 30s | no |
| `nearle/pos/{loc}/{terminal}/ack` | us → till | no |
| `nearle/pos/{loc}/{terminal}/status` | till → us | **yes** (Last Will) |
| `nearle/pos/{loc}/catalogue` | us → all tills at a shop | **yes** |
`{loc}` is the numeric `tenantlocations.locationid`. The tenant is resolved from
it server-side and never taken from the wire.
Namespaced under `nearle/` alongside the rider fleet's `nearle/riders/…`.
---
## 6. Database
**`pos_orders`** — one row per counter bill. Separate from `orders` because a
bill carries a cashier, terminal, rounding, promos, loyalty and a payment split
that `orders` has nowhere to put.
**`pos_order_items`** — one row per line.
**`productstocks`** — stock is **not** separate. A counter sale writes the same
`out` rows an app order does, through helpers in `repositories/stockLedger.go`.
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
its own trading.
**`customers`** — registrations, matched on `contactno`.
**Redis** — `pos:terminal:{code}` (hash, 90s TTL) and
`pos:location:{id}:terminals` (set, no TTL). Namespaced `pos:*` so they cannot
collide with express's `delivery:*`, `city:*`, `rider_*`.
> **Reporting:** counter sales are unioned into `GetRevenueSummary` and
> `GetSalesSummary`. **Any new report must do the same**, or it will silently
> understate every shop that runs a till. That is the standing cost of the split.
---
## 7. Code map
| File | |
|---|---|
| `models/pos.go` | wire types — matches the till's JSON exactly |
| `models/posorder.go` | `pos_orders` / `pos_order_items` |
| `models/poshealth.go` | heartbeat |
| `repositories/posRepository.go` | ingest + catalogue |
| `repositories/posSalesRepository.go` | the GET reads |
| `repositories/posPresence.go` | Redis presence |
| `repositories/stockLedger.go` | **shared** stock helpers |
| `messaging/posmqtt.go` | MQTT consumer |
| `messaging/posworkers.go` | bounded worker pools |
| `controllers/posController.go` | HTTP handlers |
| `routes/posroutes.go` | routes |
---
## 8. Operational notes
**Only `fiesta-0` consumes.** MQTT has no queue groups, so all replicas would
receive every message and commit the same bill three times. Ordinal 0 is
elected; the others log *"not the elected consumer"*. Override with
`POS_MQTT_CONSUMER=always|never`.
**Worker pools**: `POS_INGEST_WORKERS` (default 8), `POS_HEALTH_WORKERS`
(default 2). Heartbeats have their own pool so a backlog of bills cannot make
every till look dark at the busiest moment. A full queue **blocks**, pushing
backpressure to the broker and the till — slow, never lossy.
**Startup check**: three `pos: subscribed to nearle/pos/+/+/…` lines on
`fiesta-0`. Without them, MQTT ingest is not running and tills queue silently.
**The broker is not durable storage.** Mosquitto's `max_queued_messages` is 1000
and it flushes every 30 minutes. Fine, because a till keeps its copy until we
acknowledge — but nobody may ever ack on the broker's behalf.
---
## 9. Known gaps
- **No product prices.** Every product at loc 1135 is ₹0, so nothing is
sellable. Unpriced products come down as `is_active: false` so a till cannot
ring up a ₹0 item.
- **The Flutter app has never been run.** All testing used a Go program
impersonating a till.
- **No TLS** on port 1883. Bills carry customer names and mobile numbers.
- **No device authentication.** A terminal is trusted with a location id.
- **Loyalty does not come back down.** Balances at a till are that till's view.
- **`productstocks.quantity` is an integer** but tills sell in kg. POS rounds
**up** so it never under-deducts; the app-order path truncates, which was left
alone rather than silently changed. Making the column numeric is the real fix.
- **Broker credentials in source** — `admin` is in the rider APK and still
unrestricted. The two POS accounts are the only ones not in a source tree.

649
docs/POS_LOGIN.md Normal file
View File

@@ -0,0 +1,649 @@
# Nearle POS — Terminal Sign-In
How a till authenticates, and how it finds out which shop it belongs to.
**Base URL** `https://fiesta.nearle.app/live/api/v1/pos`
**Live since** 6 Aug 2026, `v1.3.98`
---
## What changed, and why it matters
A terminal used to hold a store id typed into Settings and a password compiled
into the app. That made the store id a **claim** rather than a fact: any till
could name any outlet and be believed, so changing one number on one screen
moved a terminal into another tenant's books. The password was identical on
every install of a build.
Now a person signs in with their own back-office account, and the outlet
arrives **as a consequence** — sealed inside a signed token the terminal cannot
edit, and re-checked by the server on every request.
The rule to hold onto: **the till no longer decides which shop it is. It is
told.**
---
## Quickstart
```bash
BASE=https://fiesta.nearle.app/live/api/v1/pos
# 1. Sign in — a mobile number and a 4-digit PIN
curl -s -X POST $BASE/login \
-H 'Content-Type: application/json' \
-d '{"contactno":"9876543210","pin":"4821","terminal_id":"T5EDD"}'
# 2. Use the token on everything else
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
```
---
## The flow
These steps are in order, and the order matters.
**1. Sign in.** `POST /login` with the operator's **mobile number and 4-digit
PIN** — the pair the back office issued them. Both are held on their own
`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
whatever the back office says that account's outlet is.
**3. If `locations` has more than one entry, ask which one.** Only then. A
single-outlet account gets a list of one and must never see a picker.
**4. Save the token.** Platform keystore, not a plain file or SQLite — it is a
bearer credential for a whole trading day. Restore it on launch **before** any
upload or catalogue pull runs.
**5. Send it on every request** as `Authorization: Bearer <token>`.
**6. Import `staff`.** Replace the till's local staff with what came down, and
deactivate anything that wasn't in the list. That is what retires the built-in
PINs.
---
## `POST /login`
The only unauthenticated route. It is where a token comes from.
### Request
```json
{
"contactno": "9876543210",
"pin": "4821",
"terminal_id": "T5EDD",
"device_id": "a5f3…",
"location_id": 1135,
"configid": 1
}
```
| Field | Required | Notes |
|---|---|---|
| `contactno` | yes | Mobile number. Send it as typed — `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account. |
| `pin` | yes | Exactly 4 digits, never starting with `0`. |
| `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. |
| `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. |
| `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). |
`pin` wins if a password is sent as well; `authname` wins over `contactno`.
### Response — `200`
```json
{
"code": 200,
"status": true,
"message": "Login successful",
"details": {
"token": "eyJ1aWQiOjEy….K3p9",
"expires_at": "2026-09-05T10:51:17Z",
"user_id": 1229,
"full_name": "Selvapuram",
"email": "rsselvapuram@gmail.com",
"role_id": 0,
"tenant_id": 1087,
"tenant_name": "Ragul Stores",
"store_id": "1135",
"location_id": 1135,
"location_name": "Ragul stores Selvapuram",
"gstin": "123456",
"address": "…",
"phone": "…",
"locations": [
{ "location_id": 1135, "location_name": "Ragul stores Selvapuram",
"address": "", "city": "", "status": "Active" }
],
"staff": [
{ "user_id": 1148, "full_name": "Ragul Kannan",
"role": "Super admin", "status": "Active" }
]
}
}
```
### The fields that matter
**`store_id`** — a string, because that is the shape every uplink already
sends. Use it verbatim as the `store_id` on `/orders`, `/customers` and
`/catalogue`. It is the same value as `location_id`, handed back in the form it
will be replayed in.
**`token`** — **opaque**. Do not parse it, do not read anything out of it, do
not trust anything it appears to say. Its only correct use is to hand it back.
**`expires_at`** — 30 days out. Long on purpose: a shop signs a terminal in once
and expects it to keep working. Forcing a re-login mid-shift means a queue of
customers waiting while somebody finds the manager.
**`gstin` / `address` / `phone`** — print these on the receipt. They are a legal
requirement on a GST invoice and they used to be compile-time constants, so a
shop correcting its GSTIN had to wait for a rebuild. Write them locally on
sign-in.
**`locations`** — every outlet this account may open a till at. Length 1 is the
normal case.
**`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.
---
## `GET /session`
Answers who the caller is, per their token. What a till calls on launch to
check whether yesterday's session is still good, without making a real request
and interpreting the failure.
Requires the token. Returns `401` when there isn't one.
```json
{
"code": 200,
"status": true,
"details": {
"user_id": 1229,
"tenant_id": 1087,
"location_id": 1135,
"store_id": "1135",
"role_id": 0,
"terminal_id": "PROBE",
"expires_at": "2026-09-05T10:51:17Z"
}
}
```
---
## `GET /staff`
Who may ring a bill at this terminal's outlet. For pulling down somebody hired
mid-shift without signing the terminal out.
**Takes no parameters.** The outlet comes from the caller's own token — a till
must not be able to ask who works at the shop next door. A request without a
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
{
"code": 200,
"status": true,
"details": {
"location_id": 1135,
"staff": []
}
}
```
---
## Roles
Two POS roles, added to `app_roles`:
| roleid | Role | Can |
|---|---|---|
| `7` | **Supervisor** | everything a till does, **plus** creating and editing counter staff |
| `8` | **Cashier** | billing only |
The session carries both, so the terminal never has to map role ids itself:
```json
{ "role_id": 7, "role": "Supervisor", "can_manage_staff": true }
```
Branch on `can_manage_staff`, not on the number. `app_roles` holds six rows for
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
the terminal would be wrong.
### The till and Nearle Daily do not share accounts
`app_users` is the only thing the two products have in common. An account
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."*
---
## `POST /pos/login/pin` — signing on at an open terminal
For a cashier taking over a counter a supervisor has already opened.
**Requires an existing valid token.** A PIN alone is four digits — ten thousand
guesses, and no barrier to an anonymous caller. Tying it to a session confines
the guesses to one outlet's staff, at a terminal somebody has already opened.
That is why this route takes a bare PIN and `/login` does not: there, the PIN is
checked against one mobile number, and the number is what makes the pair worth
anything.
```bash
curl -s -X POST $BASE/login/pin \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"pin":"1602"}'
```
Returns a **new** session, with the same shape as `/login`. New rather than
reused, because the token carries the role — a cashier taking over from a
supervisor must drop their permissions, not inherit them.
`401` if the PIN is not recognised. `400` if two people at the outlet share it,
which creation refuses but older data may contain.
---
## `/pos/users` — the shop's own counter staff
A supervisor creates their own cashiers, from the terminal.
**The outlet is never in the request.** Tenant and location come from the
caller's token, so a supervisor at Selvapuram cannot create staff at R mart by
sending a different number — the same inversion that stopped a till naming its
own store id.
### `POST /pos/users`
```json
{
"full_name": "Asha Kumar",
"role": "cashier",
"pin": "4821",
"authname": "asha@shop.test",
"password": "…"
}
```
| Field | Notes |
|---|---|
| `full_name` | required; split across `firstname`/`lastname` |
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
| `pin` | optional, 4 digits. See the rules below |
| `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 |
**Everyone gets a username and a password, cashiers included**, because a PIN
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**
- **Exactly 4 digits, and cannot start with `0`.** `app_users.pin` is a
`bigint`, so `"0451"` would be stored as `451` and read back as three digits —
a cashier would type four and be refused for ever. One such account already
exists in live data.
- **`1234`, `1111`, `2345`, `4321`, `9999`, `2222`, `3456`, `0000` are refused.**
Live data has `1234` on eleven accounts and `1111` on nine.
- **Unique within the outlet**, not globally. A PIN only distinguishes people at
one counter; making it platform-unique would exhaust the space fast.
Answers `201` with the created user. Every failure is a `400` carrying the
reason, because all of them are things the caller can fix.
### `GET /pos/users`
Readable by anyone signed in — the terminal needs it to show who is on shift.
**A cashier gets the list with `pin` blanked**; only somebody who could set a
PIN gets to see one. `?include_inactive=true` to see leavers.
### `PUT /pos/users`
Same fields plus `user_id`. Send only what changes. Supervisor only.
### `DELETE /pos/users?user_id=9189`
Deactivates — never deletes, because bills carry the cashier's name and shifts
settle against it. Supervisor only, and you cannot deactivate the account you
are signed in as: otherwise the last supervisor at a shop can lock everyone out
with one tap.
---
## Creating staff from the web console
The same staff management, for the screen an admin actually uses. Registered
under both `/v1/web/tenants` and `/v1/mob/tenants`.
```
GET /v1/web/tenants/posroles
GET /v1/web/tenants/getposusers?tenantid=1087&locationid=1135
POST /v1/web/tenants/createposuser
PUT /v1/web/tenants/updateposuser
DELETE /v1/web/tenants/deleteposuser?tenantid=1087&locationid=1135&userid=9189
```
`createposuser` takes the same body as `/pos/users`, plus the outlet — the
console has no session token, so it has to name one:
```json
{
"tenantid": 1087,
"locationid": 1135,
"full_name": "Asha Kumar",
"role": "cashier",
"pin": "4821"
}
```
**These run the same service calls as `/pos/users`.** A supervisor created from
a browser is the same row, with the same rules applied, as one created at a
counter — same PIN validation, same duplicate check, same identity-column
allocation. That is the point of them: two paths writing one table is how the
two stop matching.
`configid` is never asked for. It is inferred from whichever value the tenant's
existing accounts carry — a number nobody looks up, that varies per tenant (1087
is spread across 1, 6 and 15), and that silently creates an account nobody can
find if it is wrong.
`GET /posroles` returns the two roles with their ids and labels, so a console
offering the choice never has to know that supervisor is `7`.
### :red_circle: These are unauthenticated
Like every other route in the `/v1/web` and `/v1/mob` groups — there is no auth
middleware anywhere on the web API. The outlet is checked against the tenant
before anything is written, so a caller cannot create staff at a shop that is
not theirs *given a tenant id* — but nothing proves the caller is that tenant.
So this mints till credentials on an unauthenticated request. It is consistent
with the rest of the platform, and it is still the weakest point in this design.
They should move behind a session guard as soon as the console can hold one.
The terminal routes are not affected: `/pos/users` proves its outlet with a
signed token.
---
## Using the token
```
Authorization: Bearer eyJ1aWQiOjEy….K3p9
```
`X-Pos-Token: <token>` is accepted as a fallback, because some shop routers
strip `Authorization` headers over plain HTTP. A bare token with no `Bearer `
prefix is tolerated too.
Send it on **every** POS call: `/orders`, `/customers`, `/catalogue`, `/health`,
`/sales*`, `/session`, `/staff`.
### What the server checks
1. The token verifies against our signing key and has not expired.
2. The outlet named in the request belongs to the token's tenant.
The second is the one that matters. A valid token is a licence to name **your**
outlets, not any outlet. The outlet is read from the query string *and* from the
JSON body, because `/orders` and `/customers` carry `store_id` in the batch and
never in the URL.
```
GET /catalogue?store_id=1135 → 200 your outlet
GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach outlet 1185"}
```
---
## Errors
### Sign-in
| Code | Meaning | What the till should do |
|---|---|---|
| `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. **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 |
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 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 is not attached to a tenant and cannot open a till`
- `no active outlet is registered for this account`
- `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`
That last one is real, not theoretical: neither `authname` nor `contactno` is
unique in this schema. Live data has the same address twice, and 34 mobile
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
| Code | Meaning |
|---|---|
| `401` | No token, malformed token, bad signature, or expired — sign in again |
| `403` | Valid token naming an outlet the tenant doesn't own |
---
## Multi-outlet accounts
An account pinned to one location gets that location. An account with no
location — a proprietor with several shops — gets all of the tenant's active
outlets.
```
rsselvapuram@gmail.com → 1 outlet (1135, Selvapuram)
raguladmin@gmail.com → 6 outlets (1097, 1135, 1137, 1138, 1139, 885536644)
```
When `locations.length > 1`:
1. Show a picker. **Don't make it dismissable** — a terminal has to be standing
somewhere, and silently defaulting to the first outlet is how a day's takings
get filed against the wrong shop.
2. Sign in **again** with `location_id` set to their choice.
Re-signing-in is not laziness. The outlet is inside the signed token, so only
the server can issue one for a different shop — and re-checking entitlement at
that moment is the point.
---
## Staff and PINs
Two different credentials, easily confused:
| | Says | Checked by |
|---|---|---|
| **Sign-in** (email + password) | which **shop** this terminal is | the server |
| **PIN** | which **person** rang this bill | the terminal, offline |
The PIN stamps `cashiername` and is what shifts settle against. It is **shift
attribution, not a security boundary** — the boundary is the token.
### The PIN comes down in the clear
Over TLS, and that's considered rather than sloppy. Four digits are
brute-forceable in microseconds whatever they're wrapped in, so hashing
server-side would buy the appearance of strength and not the substance — while
costing something real, because the terminal salts every PIN with its own random
salt before storing it and could never verify a hash computed on the server.
**Store it hashed on the device.** It arrives in the clear; it must not sit that
way.
### Importing
Write everyone in `staff`, keyed on `user_id` so a re-sync updates rather than
duplicates. Then **deactivate everything you didn't just import** — that is what
kills the built-in PINs. Deactivate, never delete: bills carry the cashier's
name.
### :warning: `staff` is usually empty today
Only 116 of 596 accounts on the platform have a PIN set. Outlet 1135 — the one
the terminal ships pointed at — has **zero**.
So:
- **An empty list is not a failure.** Do nothing and leave the till exactly as
it was.
- **A list where every PIN is unusable** (`0`, blank) must behave the same way.
Deactivating the local accounts because the back office isn't filled in yet
would leave a counter nobody can sign in to.
The terminal still ships with three seeded logins for exactly this reason. They
retire automatically the moment real staff exist. Filling in real PINs in the
back office is what makes that happen.
---
## Current state
| | |
|---|---|
| Endpoints | live on `v1.3.98`, all three pods |
| Signing key | set in `app-secrets` |
| **Enforcement** | **OFF** — `POS_AUTH_REQUIRED` is unset |
Enforcement being off means a request carrying **no** token is still allowed
through, so terminals already trading don't stop the day this ships. It does
**not** mean tokens are ignored:
- a token that's present and invalid is **always** refused;
- a valid token naming another tenant's outlet is **always** refused.
Once the fleet is on a build that signs in, `POS_AUTH_REQUIRED=true` closes the
door on untokened requests.
---
## Known limitations
- **Passwords are stored in plaintext** across the whole platform, not just
here. Fixing it is a migration touching every login path.
- **No role check.** Any active account with a tenant, a password and an active
outlet can open a till — including `roleid 0`, which isn't in `app_roles` at
all and currently includes a delivery rider. The damage is bounded by the
token: they can only reach their own tenant's books.
- **`1135` means two different things.** It's a *location* (Ragul stores
Selvapuram, under tenant 1087) and separately a *tenant* (Suriya Store). Same
number, different tables. Watch for it in logs.

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

362
docs/POS_TERMINAL_INGEST.md Normal file
View File

@@ -0,0 +1,362 @@
# POS terminal ingest
How an in-store Nearle POS till reaches this backend. The terminal side of the
contract is specified in the POS repository at `docs/sync-contract.md`; this
covers what was built here and how to turn it on.
## What a terminal expects, and why it matters
A till stores every bill in its own SQLite database the moment a sale
completes, and keeps it for **seven days after we acknowledge it**. It marks a
bill synced if — and only if — the bill's id appears in the `accepted` list of
our reply.
That single rule drives every decision below:
- **Silence is not acceptance.** No reply, an empty reply, a 200 with no body:
all leave the bill on the till, and it is sent again. This is the correct
behaviour when we are struggling, and it is why a failing ingest never
acknowledges.
- **A duplicate is a success.** Delivery is at-least-once. A lost ack makes a
terminal re-send bills we already hold, and calling those failures would
strand a day of takings. The ingest recognises them and accepts them without
touching stock again.
- **A rejection is a decision.** Naming an id in `rejected` stops the till
retrying it and waits for a person. Right for "this bill is malformed", wrong
for "the database is having a bad minute".
## Two ways in, one code path
Both transports call `services.PosService`, so a bill arriving over MQTT and
one arriving over HTTP cannot diverge.
### Where a bill lands
Counter sales are written to **`pos_orders` / `pos_order_items`**, not to
`orders`. A bill is a different document from an app order: it carries a
cashier, a terminal, a rounding adjustment, promo campaigns, loyalty movement
and a payment split across several tenders, none of which `orders` has anywhere
to put. Forcing one into the other's shape loses whichever fields do not fit,
silently.
**Stock is not separate.** A counter sale writes the same `productstocks`
"out" rows an app order does, through the shared helpers in `stockLedger.go` —
the same row locks, the same availability check, the same availability re-sync.
Two stock ledgers would mean the catalogue pull sends a till figures that ignore
the till's own trading, and it would oversell.
Existing revenue queries were extended to include `pos_orders`
(`GetRevenueSummary`, `GetSalesSummary`), so dashboards do not understate a shop
that runs a counter. **Any new report has to remember to do the same** — that is
the standing cost of the split.
### HTTP
Base path: `/live/api/v1/pos`
**Written by a terminal** — bare-ack responses, see below.
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/orders` | Completed bills |
| `POST` | `/customers` | Shoppers registered at a till |
| `GET` | `/catalogue` | Product pull. Query: `store_id`, `since`, `page`, `page_size` |
**Read by the web app** — normal `{code, message, status, details}` envelope.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/sales` | Bills for an outlet, newest first |
| `GET` | `/sales/detail` | One bill with its lines |
| `GET` | `/sales/summary` | Totals by tender, day and till |
| `GET` | `/health/terminal` | One till's live state |
| `GET` | `/health/location` | Every till at a shop |
`/sales` and `/sales/summary` take: **`locationid` (required)**, `fromdate`,
`todate` (YYYY-MM-DD, matched on `businessdate`), `terminalid`, `cashiername`,
`paymentmode`, `pageno`, `pagesize`.
`/sales/detail` takes `locationid` and `reference` — the terminal's order UUID,
the invoice number, or the `posorderid`, whichever the caller happens to have.
**`locationid` is the authorisation boundary.** Every read is scoped to one
outlet; omitting it is an error rather than a page through every shop's takings,
and asking for a bill under the wrong outlet returns 404 even when the reference
is valid.
Dates match `businessdate` — the day the sale was rung, not the day it reached
us. A till that was offline overnight uploads yesterday's bills this morning and
they belong to yesterday.
These answer with a **bare ack**, not the usual `{code, message, status}`
envelope — the terminal reads `accepted` from the top level of the body:
```json
{ "batch_id": "9f1c…", "accepted": ["order-uuid"], "rejected": {} }
```
Status codes carry the rest of the contract:
- **200** — batch processed. Individual bills may still be refused; the ack says which.
- **4xx** — the request is wrong (unknown outlet, bad store id). The till halts and shows a person.
- **5xx** — outcome unknown. The till keeps everything and retries with backoff.
### MQTT
The broker is **Eclipse Mosquitto 2.1.2** at `66.116.225.226:1883`, shared with
the rider app (`nearle/riders/#`) and the doormile project (`doormile/riders/#`).
There is **no NATS** in this deployment. NATS servers exist for other projects
on other hosts, but their ports are closed from here and their configs carry no
`mqtt {}` block, so they expose no MQTT gateway. The NATS consumer that briefly
lived in this package has been deleted rather than left to rot.
| Env | Purpose |
|---|---|
| `MQTT_URL` | `tcp://66.116.225.226:1883`. **Unset disables MQTT ingest** — HTTP still works |
| `MQTT_USER` / `MQTT_PASSWORD` | Broker credentials |
| `MQTT_CLIENT_ID` | Defaults to `nearle-pos-ingest`. **Must be unique per replica** — a second connection with the same id evicts the first, and the two would fight in a loop |
| Topic | Direction |
|---|---|
| `nearle/pos/+/+/order` | till → us |
| `nearle/pos/+/+/customer` | till → us |
| `nearle/pos/+/+/health` | till → us, every 30s |
| `nearle/pos/{loc}/{terminal}/ack` | us → till |
| `nearle/pos/{loc}/catalogue` | us → every till at a shop |
Subscribed with `CleanSession(false)` and a stable client id, so a brief restart
resumes rather than missing what arrived meanwhile. Re-subscribes on every
reconnect, because a broker that did not persist the session would otherwise
come back subscribed to nothing.
**Do not treat the broker as durable storage.** Two measured facts make that
unsafe:
- `max_queued_messages` is at its default of **1000**. If this backend is down
long enough for a hundred tills to exceed that, Mosquitto silently drops the
overflow.
- Mosquitto's `autosave_interval` defaults to **30 minutes**, so a hard kill can
lose up to half an hour of persisted state.
Neither loses a bill, and that is the whole point of the acknowledgement design:
a dropped message is simply never acked, so the terminal keeps its copy and
sends it again. The broker is a transport, not a ledger.
**The store and terminal are read from the topic, never from the body.** A till
that could name its own store in the payload could redirect another counter's
acknowledgements.
## Configuring a terminal
Settings → Connectivity & sync → Configure.
| Field | Value |
|---|---|
| Store ID | **The numeric `locationid`.** Not a name — the tenant is resolved from it |
| Terminal name | Whatever staff call the till |
| Transport | `HTTP` or `MQTT` |
| Base URL (HTTP) | `https://your-host/live/api/v1/pos` |
| Broker host / port (MQTT) | `66.116.225.226`, port `1883`, **TLS off** (8883 is not configured) |
`store_id` carrying the locationid is load-bearing: `resolvePosStore` looks the
tenant up from it and refuses a location that is not registered. A terminal
cannot name its tenant.
## Mapping decisions
Worth knowing before the first bill lands.
- **Idempotency** is a unique index on `pos_orders.terminalorderid` — the UUID
minted at the till — plus a Postgres advisory lock held for the life of the
transaction, so a redelivery arriving concurrently waits and then sees the
committed row rather than racing past the check.
- **`businessdate` is the day the sale was rung**, not the day it arrived. A
till that was offline overnight uploads yesterday's bills this morning, and
they belong to yesterday. Every daily figure keys on this.
- **Line amounts are scaled onto the bill total.** The till sends each line at
its pre-apportionment value while the header carries the total after
bill-level discounts. Left alone the item rows would sum to the subtotal and
every report that adds up lines would disagree with the one reading the
header.
- **Payment mode** is the largest tender on a split bill; the full split is
kept verbatim in `paymentsjson` for drawer reconciliation.
- **Fractional quantities round *up* for stock.** `productstocks.quantity` is an
integer column, so 1.5 kg of onions cannot be recorded exactly. Rounding up
never under-deducts, so recorded stock is never higher than the shelf. The app
order path truncates instead (1.5 → 1), which under-deducts; that behaviour was
left untouched rather than silently changed for live traffic. **Making the
column numeric is the real fix.**
- **Customers** match on `contactno` within the outlet's `applocationid`, so a
shopper registered at a till and one who installed the app become one row.
Registrations are **insert-if-absent** — never an update, so a profile
corrected at head office is not reverted by a terminal replaying an old
capture.
- **Catalogue** answers a snapshot or a change set, decided by the `since`
revision — see below.
- **Barcodes** come from `products.productsku` — there is no barcode column.
Scanning at the till matches on it, so SKUs must be the scannable code for
barcode scanning to work.
## Catalogue: snapshots and deltas
`GET /catalogue?store_id=1135` with no `since` returns a **full snapshot**. The
response carries a `revision`; the terminal stores it and sends it back next
time as `since=`, and then gets only what changed.
A product is included in a change set when any of three things moved: the
product row (name, tax, brand), its row at this outlet (price, availability), or
its stock ledger. Stock counts because a shop's figure drifts from a till's on
every sale rung at another counter, and a delta that ignored it would let that
drift persist until someone forced a full pull.
**The one rule that matters.** A response marked `is_delta: false` is treated as
a snapshot, and the terminal **withdraws every product it does not mention**. A
filtered result labelled `false` therefore empties the shop's shelf. The filter
and the flag are computed from a single value in `Catalogue()` — there is no
path that filters without also setting the flag, and that is deliberate.
**A revision that cannot be read falls back to a full snapshot.** Malformed,
empty, or issued to a different outlet — all yield a zero cutoff and a complete
response. The other direction would leave a terminal permanently missing every
change it had not already seen, with nothing to indicate it.
**The revision only advances on the final page.** A terminal that abandons a
paginated pull half way gets back the revision it already had — or an empty one,
meaning the next pull is a snapshot. Both are recoverable; a prematurely
advanced revision is not.
**A delta cannot withdraw a deleted product.** A row removed from
`productlocations` leaves no tombstone, so nothing tells the change set to
retire it. Only a snapshot collects those, which is why a terminal should pull
without a revision periodically — the morning import is the natural moment.
## Terminal health
Every till publishes a heartbeat to `nearle/pos/{loc}/{terminal}/health` every
**30 seconds**. It is stored in Redis, never in Postgres.
| Env | Purpose |
|---|---|
| `REDIS_HOST` / `REDIS_PORT` | **Unset disables presence.** Point at the same Redis the express backend uses |
| `REDIS_USER` / `REDIS_PASSWORD` / `REDIS_DB` | Defaults `default`, empty, `0` |
```
pos:terminal:{terminalcode} HASH, TTL 90s
pos:location:{locationid}:terminals SET, no TTL
```
The TTL is the whole design. A heartbeat is a fact with an expiry date: a till
that loses power stops refreshing, the key expires, and it disappears from the
board with nothing having to notice. In Postgres this would need ~288,000 writes
a day across a hundred tills *and* a reaper job, because a row saying "online"
cannot age out by itself.
90 seconds is three missed beats. Two would make an ordinary GPRS hiccup look
like a dead till; five would take two and a half minutes to notice a real one.
The set has **no TTL**, mirroring `city:{tenantid}:active_deliveries` in the
express backend: it is an index of what exists, not a claim that any of it is
alive. Membership means "this till has been seen here"; liveness is whether the
hash still exists.
Keys are namespaced `pos:*` and do not collide with express's `delivery:*`,
`city:*` or `rider_*`. **Worth keeping that way** — a shared datastore only stays
safe while each writer's keys are obviously its own.
A heartbeat is **never acknowledged**. Presence is fire-and-forget: a till that
stopped selling because a dashboard was busy would be a self-inflicted outage.
Read it back:
| Method | Path |
|---|---|
| `GET` | `/live/api/v1/pos/health/terminal?terminal_id=T4A9` |
| `GET` | `/live/api/v1/pos/health/location?location_id=12` |
A till whose key has expired comes back marked `offline` rather than being
omitted — omitting it would make a dead terminal indistinguishable from one that
was never installed, and the dead one is exactly what somebody is looking for.
What a heartbeat carries: identity and app version; **queue depth**
(`pending_bills`, `pending_registrations`, `oldest_pending_at`) — the numbers
that make a silent sync failure visible; **today's trading** (`today_bills`,
`today_amount`, `last_bill_at`) — a till that is connected but has rung nothing
in three hours is usually a jammed printer or an absent cashier; and device
state.
## Not built
- **Loyalty coming back down.** The uplink deliberately carries no points or
spend — those belong to the bill stream, which is idempotent and sees every
counter. Nothing yet computes them centrally and sends them to the tills, so
a shopper's balance at a till is that till's view.
- **Device authentication.** A terminal is trusted with a locationid. Signed
device tokens are the obvious next step before this is exposed publicly.
- **Battery and free storage in the heartbeat.** The reporter has a hook for
them, but this build collects neither — they need platform packages a desktop
build has no use for. Fields that are not collected are **omitted**, not sent
as zero: a board showing every till at 0% battery is worse than one showing
nothing.
## Broker accounts
Applied 2026-08-03 on `66.116.225.226`. Two scoped accounts now exist alongside
`admin`, with an ACL at `/mosquitto/config/acl` referenced from
`mosquitto.conf`.
| User | May publish | May subscribe |
|---|---|---|
| `pos_terminal` | `nearle/pos/+/+/{order,customer,status,health}` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` |
| `pos_ingest` | `nearle/pos/+/+/{ack,command}`, `nearle/pos/+/catalogue` | `nearle/pos/+/+/{order,customer,health,status}` |
| `admin` | everything — **deliberately unchanged** | everything |
A till therefore cannot publish to `nearle/riders/#` or `doormile/#`, and cannot
write its own ack topic — only the ingest may do that. Verified by publishing as
`pos_terminal` to all four and watching which arrived: the order did, the other
three did not.
**`admin` was left unrestricted on purpose.** Its credentials are compiled into
the rider app, so narrowing it here would cut off the live rider fleet without
warning. The right next step is:
```conf
user admin
topic readwrite nearle/riders/#
topic readwrite doormile/#
```
but only once someone has confirmed nothing else authenticates as `admin`.
Until then the ACL changes nothing for it — which is why applying it was safe.
Rollback, if ever needed:
```bash
cp /root/Mqtt/backup-<timestamp>/{mosquitto.conf,passwd} /root/Mqtt/config/
docker restart mqtt_broker
```
**Still outstanding on the broker:**
- **No TLS.** Port 8883 is not configured. Bills carry customer names and mobile
numbers, and they travel in the clear. Traefik on the same host already
terminates 443, so certificates exist to borrow from.
- **`passwd` is world-readable.** Mosquitto warns about it and future versions
will refuse to load it. Tightening it means `chown 1883:1883` as well as
`chmod`, because the broker runs as uid 1883 and a root-owned 0600 file would
stop it starting.
- **Credentials in source.** `admin` is in the rider APK, Redis is hardcoded in
the express backend, and Postgres was in this repository's git history until
2026-08-03. The POS accounts above are the only ones not in any source tree —
keep it that way.
## Capacity
Current load, measured: **~0.9 msg/s inbound**, 5 connected clients, 112
retained messages totalling 8 KB.
A hundred tills add roughly 3.3 msg/s steady (a 1 KB heartbeat each per 30s)
plus bursts of up to ~50 KB when a sale batch goes up. That is 3–4× current
traffic and well within what Mosquitto handles on any VPS. The broker will not
be the bottleneck; Postgres write throughput on bill ingest is the thing to
watch instead.

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.

95
docs/SECURITY_HANDOFF.md Normal file
View File

@@ -0,0 +1,95 @@
# Handoff: Broken Access Control (IDOR) audit & fixes — Fiesta backend
Repo: `backend_fiesta` (Go + Fiber + GORM), consumed by `nearledaily/daily_merchant_web` (React/TS) and a mobile app (not in this repo).
## 1. The root problem (still not fully fixed — read this first)
**There is no authentication system in this backend.** Grep confirms:
- No JWT/session token is ever issued. `Login`, `TenantLogin`, `TenantWebLogin`, `AppLogin` (in `controllers/userController.go`) just look up the user/tenant and return their info in the JSON body — no token.
- No auth middleware exists anywhere. `routes/routes.go` / `main.go` only wire up CORS middleware. Every route is wide open — anyone who can reach the API can call any endpoint with any query params.
Because of that, every endpoint trusts client-supplied query params (`tenantid`, `customerid`, `partnerid`, etc.) as the sole source of "who is asking." There is currently **nothing stopping a logged-in store admin for tenant 1135 from just requesting `?tenantid=1136`** and getting another tenant's data — the frontend happens to always send the logged-in user's own tenantid, but the backend never checks it.
**This session's fixes only close one specific hole**, described below. The real fix — deriving identity server-side from a verified token instead of trusting query params — has not been started. Whoever picks this up should treat that as the actual next milestone.
## 2. The specific bug that was found and fixed this session
Pattern found repeatedly across the codebase: repository functions build SQL dynamically, e.g.
```go
query := "SELECT ... FROM orders WHERE 1=1"
if tenantID != 0 {
query += " AND tenantid = ?"
params = append(params, tenantID)
}
// ...similar optional blocks for partnerid, customerid, etc.
```
**If none of the scoping params were supplied (0 / empty), the query silently fell through to "no WHERE clause" and returned every row in the table across every tenant.** This was directly reachable — e.g. `orders/getorders` with no `tenantid` returned all ~300 orders in the DB rather than 400ing, which is how the user first noticed this (logged in as a store admin, expected only their store's orders, saw everyone's).
### Fix pattern applied
Rather than rewriting every repository query (large surface area, higher regression risk), a **controller-level guard** was added to each affected endpoint: if none of the valid scoping ids are present in the query string, return `400` immediately instead of calling the service/repo at all.
Standard error shape used everywhere:
```json
{ "status": false, "code": 400, "message": "<specific message>" }
```
## 3. Endpoints fixed (8 total)
| # | Endpoint | File / function | Guard added |
|---|---|---|---|
| 1 | `GET /v1/web/orders/getorders` (+ mob) | `controllers/orderController.go` `GetOrders` (line 24) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `appuserid` — else 400 (line ~102-110). Previously the `else` branch called `GetAllOrders` (unscoped). |
| 2 | `GET /v1/web/orders/getordersummary` | `controllers/orderController.go` `GetOrderSummary` (line 129) | requires one of `tenantid`, `partnerid`, `customerid`, `locationid` (line 137-143) |
| 3 | `GET /v1/web/orders/getlocationsummary` | `controllers/orderController.go` `GetlocationOrderSummary` (line 163) | requires `tenantid` (line 167-173) |
| 4 | `GET /v1/web/users/getallusers` | `controllers/userController.go` `GetAllUsers` (line 22) | requires `tenantid` (line 29-35). Note: this endpoint's query selects `a.pin` (login PIN) — this was a high-severity leak (PINs across all tenants) before the fix. |
| 5 | `GET /v1/web/deliveries/getdeliveries` (+ mob) | `controllers/deliveriesController.go` `GetDeliveries` (line 194) | requires one of `tenantid`, `partnerid`, `customerid`, `applocationid`, `userid`, `appuserid` (line 212-218) |
| 6 | `GET /v1/web/partners/getriders` (+ mob) | `controllers/partnerController.go` `GetActiveRiders` (line 19) | requires one of `tenantid`, `partnerid`, `applocationid`, `userid` (line 25-31). Lower severity — underlying repo query defaults to `userid = 0` rather than a full dump, but fixed for consistency. |
| 7 | `GET /v1/web/partners/getriderlogs` (+ mob) | `controllers/partnerController.go` `GetRiderLogs` (line 121) | requires one of `partnerid`, `applocationid` (line 127-133). **Also fixed an unrelated bug in the same function**: `tdate` was reading `c.Query("fromdate")` (copy-paste error) so the end of any date range was always silently overwritten with the start date. Now correctly reads `c.Query("todate")` (line 125). |
| 8 | `POST /v1/mob/orders/getcustomerorders` | `controllers/orderController.go` `GetCustomerOrders` (line 374) | requires `customerid` (line 394-400) |
### Also fixed alongside #2: SQL injection in `GetOrderSummary`
`repositories/orderRepository.go` `GetOrderSummary` previously built the date filter by **string-concatenating** `fdate`/`tdate` directly into raw SQL. Rewritten to use parameterized `?` placeholders passed through `r.db.Raw(query, params...)`. The `strconv` import was removed from that file since it became unused after the rewrite (verified via grep no other usage remained).
## 4. Reviewed and explicitly NOT changed (don't re-flag these)
Same `WHERE 1=1` pattern exists elsewhere but was judged not to be a bug, or already safe:
- **`repositories/tenantRepository.go` `GetAllTenants`** — intentionally lists all tenants for a platform/super-admin console. The gap here is "no RBAC to restrict who can call this," which is the same root-cause auth gap from section 1, not a scoping bug to patch individually.
- **`repositories/productRepository.go` `GetProductSubCategory`** — has an explanatory comment: subcategories are intentionally shared/global master data plus tenant-owned overrides. Not a bug.
- **`repositories/productRepository.go` `GetProductCount`** — returns aggregate counts only (no PII), low severity, left as-is.
- **`repositories/utilsRepository.go` `GetSubcategories`** — global taxonomy/reference data; the model has no tenant field at all.
- **`repositories/orderRepository.go` `GetAdminOrders`** — has `WHERE 1=1` internally but is safe because its only caller (`GetOrders` controller) only invokes it when `applocationid != 0`.
- **`repositories/orderRepository.go` `GetAllOrders`** — now dead code (unreachable) after fix #1 above; confirmed via grep it's no longer called anywhere. Could be deleted as cleanup but left in place.
- **`repositories/tenantRepository.go` `GetTenantLocations`** — already always filters `WHERE tenantid = ?`. Safe, unchanged.
## 5. Known pre-existing bug found during this audit, NOT yet fixed anywhere
**Frontend/backend path mismatch on rider logs.** In `nearledaily/daily_merchant_web/src/services/fiestaApi.ts`, `getRiderLogs()` (~line 1132) calls:
```ts
fiestaGet('riders/getriderlogs', {...})
```
`FIESTA_BASE` is `https://fiesta.nearle.app/live/api/v1/web`, so this resolves to `.../v1/web/riders/getriderlogs`. But the backend only registers this route under the `partners` group (`routes/partnerroutes.go`): `partner.Get("/getriderlogs", ...)` on `api.Group("/v1/web/partners")`, i.e. the real path is `.../v1/web/partners/getriderlogs`. Confirmed via grep there is no `/v1/web/riders` route group anywhere in the backend.
**This means `getRiderLogs()` in the web console has likely been 404ing already, independent of anything fixed this session.** Fix is a one-line FE change: `'riders/getriderlogs'` → `'partners/getriderlogs'`. Not fixed yet because it's a frontend-repo change and wasn't the scope of this backend security pass — flagging it here so it isn't lost.
## 6. Frontend compatibility check (already done, no FE changes needed for the 8 fixes above)
Checked `daily_merchant_web/src/services/fiestaApi.ts` against every fix — the web console already sends the now-required params in all cases:
- `getOrders`, `getAllUsers`, `getDeliveries`, `getOrderSummary`, `getLocationSummary` — all declare `tenantid: number` as a **required** (non-optional) TS field already.
- `getRiders` — always sends `applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID` (never zero/undefined) plus required `tenantid`.
- `getRiderLogs` — sends `tenantid`/`applocationid` when available, but see the path bug in section 5 — worth re-verifying once that's fixed.
- **`mob/orders/getcustomerorders`** (fix #8) is called from the **mobile app**, which is not in this repo — whoever owns that codebase needs to verify every call site always sends `customerid`. Not verified in this session.
## 7. Environment note
**No Go toolchain is available in the sandbox this session ran in** (`command not found: go`). All edits above were manually reviewed (imports, syntax, call sites checked via Read/grep) but **never compiled**. Run `go build ./...` and the existing test suite (if any) before deploying any of this.
## 8. Suggested next steps for whoever picks this up
1. `go build ./...` and smoke-test all 8 changed endpoints (call with and without the required param, confirm 200 vs 400).
2. Fix the `riders/getriderlogs` → `partners/getriderlogs` path bug in `fiestaApi.ts` (section 5).
3. Verify the mobile app always sends `customerid` to `mob/orders/getcustomerorders` before this ships, since that's the one fixed endpoint not verified from a frontend contract.
4. Scope and plan the real fix: JWT/session auth issuance + middleware, so `tenantid`/`customerid`/etc. are derived from a verified server-side identity instead of trusted from query params. Until that lands, the 8 fixes in this doc only prevent the "forgot to pass an id → get everything" failure mode — they do **not** prevent a malicious or buggy client from passing a *different* tenant's/customer's/partner's real id and getting their data.

View File

@@ -4,28 +4,38 @@ import (
"nearle/controllers"
"nearle/repositories"
"nearle/services"
"nearle/utils"
"gorm.io/gorm"
)
type Facade struct {
UserController *controllers.UserController
ProductController *controllers.ProductController
OrderController *controllers.OrderController
DeliveriesController *controllers.DeliveriesController
UtilsController *controllers.UtilsController
TenantController *controllers.TenantController
PartnerController *controllers.PartnerController
CustomerController *controllers.CustomerController
StockRequestController *controllers.StockRequestController
CatalogueController *controllers.CatalogueController
UserController *controllers.UserController
ProductController *controllers.ProductController
OrderController *controllers.OrderController
DeliveriesController *controllers.DeliveriesController
UtilsController *controllers.UtilsController
TenantController *controllers.TenantController
PartnerController *controllers.PartnerController
CustomerController *controllers.CustomerController
StockRequestController *controllers.StockRequestController
CatalogueController *controllers.CatalogueController
PosController *controllers.PosController
LiveController *controllers.LiveController
CatalogueUploadController *controllers.CatalogueUploadController
ScanController *controllers.ScanController
// Held so the NATS consumer can reach the ingest without going through
// HTTP. Unexported: everything else should use the controller.
posService services.PosService
}
// NewFacade wires up modules against the main (nearledb) connection.
// catalogueDB is a separate connection to the pgvector catalogue database;
// it may be nil if catalogue env vars are not configured, in which case
// catalogue endpoints will error at query time rather than at startup.
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
// embedder may be nil too: scan-to-order then matches on words alone.
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Facade {
// User Module
userRepo := repositories.NewUserRepository(db)
@@ -79,16 +89,56 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
stockRequestService := services.NewStockRequestService(stockRequestRepo, productService)
stockRequestController := controllers.NewStockRequestController(stockRequestService)
// POS Module — ingest from the in-store terminals.
//
// Presence has no *gorm.DB: terminal health lives in Redis under a TTL, so
// a till that loses power ages out of the board by itself instead of
// leaving a Postgres row claiming it is online.
posRepo := repositories.NewPosRepository(db)
posPresence := repositories.NewPosPresenceRepository()
posService := services.NewPosService(posRepo, posPresence)
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)
return &Facade{
UserController: userController,
ProductController: productController,
OrderController: orderController,
DeliveriesController: deliveriesController,
UtilsController: utilsController,
TenantController: tenantController,
PartnerController: partnerController,
CustomerController: customerController,
StockRequestController: stockRequestController,
CatalogueController: catalogueController,
UserController: userController,
ProductController: productController,
OrderController: orderController,
DeliveriesController: deliveriesController,
UtilsController: utilsController,
TenantController: tenantController,
PartnerController: partnerController,
CustomerController: customerController,
StockRequestController: stockRequestController,
CatalogueController: catalogueController,
PosController: posController,
LiveController: liveController,
CatalogueUploadController: catalogueUploadController,
ScanController: scanController,
posService: posService,
}
}
// PosService exposes the ingest to callers outside the HTTP layer — the NATS
// consumer runs the same code path a POST does, so a bill arriving over MQTT
// and one arriving over HTTP cannot diverge.
func (f *Facade) PosService() services.PosService { return f.posService }

62
go.mod
View File

@@ -2,9 +2,22 @@ module nearle
go 1.24
toolchain go1.24.0
require gorm.io/gorm v1.25.10
require (
firebase.google.com/go v3.13.0+incompatible
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.30
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/gofiber/fiber v1.14.6
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0
github.com/valyala/fasthttp v1.50.0
golang.org/x/oauth2 v0.12.0
google.golang.org/api v0.143.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.25.10
)
require (
cloud.google.com/go v0.110.7 // indirect
@@ -14,12 +27,8 @@ require (
cloud.google.com/go/iam v1.1.1 // indirect
cloud.google.com/go/longrunning v0.5.1 // indirect
cloud.google.com/go/storage v1.30.1 // indirect
firebase.google.com/go v3.13.0+incompatible // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
@@ -28,76 +37,57 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.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/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/gofiber/fiber v1.14.6 // 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/gofiber/utils v0.0.10 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/gorilla/schema v1.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/klauspost/compress v1.17.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.50.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/oauth2 v0.12.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.143.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect
google.golang.org/grpc v1.58.2 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
)
require (
github.com/gofiber/fiber/v2 v2.50.0
github.com/jinzhu/copier v0.4.0
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/spf13/viper v1.17.0
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.5.2
)

440
go.sum
View File

@@ -1,59 +1,21 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.13.0 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk=
cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8=
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
@@ -93,34 +55,27 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSI
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
@@ -128,64 +83,34 @@ github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmh
github.com/gofiber/utils v0.0.10 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U=
github.com/gofiber/utils v0.0.10/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -193,19 +118,12 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -222,22 +140,11 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
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/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -247,50 +154,25 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
@@ -299,308 +181,74 @@ github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.143.0 h1:o8cekTkqhywkbZT6p1UHJPZ9+9uuCAJs/KYomxZB8fA=
google.golang.org/api v0.143.0/go.mod h1:FoX9DO9hT7DLNn97OuoZAGSDuNAXdJRuGK98rSUgurk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA=
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI=
@@ -608,21 +256,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I=
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -633,39 +270,18 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

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;

377
main.go
View File

@@ -3,10 +3,14 @@ package main
import (
"fmt"
"log"
"nearle/config"
"nearle/db"
"nearle/facade"
"nearle/messaging"
"nearle/models"
"nearle/repositories"
"nearle/routes"
"nearle/utils"
"os"
"os/signal"
"strings"
@@ -16,15 +20,14 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/joho/godotenv"
"gorm.io/gorm"
)
func init() {
godotenv.Load()
}
func main() {
// Loads `.env.<APP_ENV>` (default `.env.local`) and `.env`, then checks
// every required setting at once. Nothing below runs against a half
// configured environment — see config/config.go for the precedence rules.
cfg := config.MustLoad()
app := fiber.New()
@@ -36,24 +39,371 @@ func main() {
}))
fmt.Println("🌐 Connecting to databases...")
db.Connect()
db.Connect(cfg)
fmt.Println("✅ Database connections established!")
// Shared with the express backend. POS terminal presence lives here under a
// TTL; optional, because losing the health board is an inconvenience and
// losing a sale is not.
db.InitRedis(cfg.Redis)
// Ensure schema is updated
db.DB.AutoMigrate(&models.StockRequest{})
f := facade.NewFacade(db.DB, db.CatalogueDB)
// Counter sales from the in-store terminals. Separate tables from `orders`
// because a bill carries a cashier, a terminal, rounding, promos, loyalty
// and a payment split that `orders` has nowhere to put.
if err := db.DB.AutoMigrate(&models.PosOrders{}, &models.PosOrderItems{}); err != nil {
log.Fatal("POS schema migration failed:", 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)
}
// 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)
}
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder)
routes.RegisterRoutes(app, f)
// Start server
// POS terminals reach the ingest over MQTT when MQTT_URL is set, and over
// HTTP otherwise. Both land on the same service, so a bill cannot behave
// differently depending on how it arrived.
//
// A broker that is configured but unreachable is fatal on purpose: coming
// 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())
if err != nil {
log.Fatal("POS MQTT consumer failed to start:", err)
}
if posMqtt != nil {
repositories.SetCatalogueNotifier(posMqtt)
}
// 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() {
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)
}
}()
gracefulShutdown()
gracefulShutdown(posMqtt)
}
func selectDBMiddleware(c *fiber.Ctx) error {
@@ -78,13 +428,18 @@ func selectDBMiddleware(c *fiber.Ctx) error {
return c.Next()
}
func gracefulShutdown() {
func gracefulShutdown(posMqtt *messaging.PosMqttConsumer) {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
<-c
fmt.Println("\nShutting down gracefully...")
// Drained before anything else: a bill mid-commit still gets its ack, and
// without one the terminal would hold it and send it again on restart.
posMqtt.Close()
db.CloseRedis()
// Normally: close db.DB_DEV and db.DB_LIVE
// Example:
// closeDB(db.DB_DEV)

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
}

340
messaging/posmqtt.go Normal file
View File

@@ -0,0 +1,340 @@
package messaging
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"nearle/models"
"nearle/services"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// MQTT ingest for the Nearle POS terminals.
//
// The broker is Eclipse Mosquitto, shared with the rider fleet. An audit of the
// estate found no reachable NATS and no MQTT gateway on the NATS boxes that do
// exist, so a NATS consumer that briefly lived here was deleted rather than
// left to rot — a client for a protocol nothing speaks is worse than none.
//
// Enabled with MQTT_URL. Unset, the terminals reach the same service over HTTP
// instead, and this file does nothing.
//
// Only one replica consumes: see posConsumerElected.
const (
// Namespaced under `nearle/` alongside the rider app's
// `nearle/riders/{riderId}/...`, so one broker ACL rule covers each system
// and it is obvious from a topic which one it belongs to.
//
// Wildcards for MQTT are `+` per level, where NATS uses `*`.
topicOrders = "nearle/pos/+/+/order"
topicCustomers = "nearle/pos/+/+/customer"
topicHealth = "nearle/pos/+/+/health"
)
type PosMqttConsumer struct {
client mqtt.Client
svc services.PosService
// Bills and registrations share a pool; heartbeats get their own, so a
// backlog of sales cannot make every till look dark at the moment the
// system is busiest.
ingest *posPool
health *posPool
}
// StartPosMqttConsumer connects and subscribes.
//
// Returns (nil, nil) when MQTT_URL is unset — a deployment without a broker is
// supported, and the caller carries on with the HTTP endpoints.
func StartPosMqttConsumer(svc services.PosService) (*PosMqttConsumer, error) {
url := strings.TrimSpace(os.Getenv("MQTT_URL"))
if url == "" {
log.Println("pos: MQTT_URL not set, plain-MQTT ingest disabled")
return nil, nil
}
// Only one replica consumes.
//
// MQTT has no queue groups — every subscriber receives 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 three times
// the database work and three times the traffic for one sale.
//
// A StatefulSet gives pods stable ordinal names, so ordinal 0 is a
// deterministic election with no coordination and no extra dependency. If
// that pod dies the set recreates it; tills hold their bills and re-send in
// the meantime, which is exactly what they are built to do.
if !posConsumerElected() {
log.Printf("pos: replica %q is not the elected consumer, MQTT ingest idle here",
os.Getenv("HOSTNAME"))
return nil, nil
}
// Each ingest worker holds a database transaction while it runs, so the
// real ceiling is the Postgres connection pool rather than the CPU. The
// queue is deep enough to absorb a burst and shallow enough that a genuine
// overload is felt as backpressure rather than hidden as latency.
c := &PosMqttConsumer{
svc: svc,
ingest: newPosPool("ingest", posPoolSize("POS_INGEST_WORKERS", 8), 256),
health: newPosPool("health", posPoolSize("POS_HEALTH_WORKERS", 2), 512),
}
opts := mqtt.NewClientOptions().
AddBroker(url).
// Stable, so the broker resumes this session and redelivers anything
// in flight rather than treating every restart as a new subscriber.
// Defaults to the pod name so replicas can never collide: a second
// connection with the same client id evicts the first, and the two then
// fight in a reconnect loop that looks like a flapping network.
SetClientID(getEnvDefault("MQTT_CLIENT_ID",
getEnvDefault("HOSTNAME", "nearle-pos-ingest"))).
SetCleanSession(false).
// Ordered delivery keeps paho on one goroutine, which is what lets a
// full queue push back on the broker. With concurrent delivery paho
// would keep reading no matter how far behind the workers were.
SetOrderMatters(posOrderedDelivery).
SetAutoReconnect(true).
SetMaxReconnectInterval(30 * time.Second).
SetKeepAlive(30 * time.Second).
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Printf("pos: MQTT connection lost: %v", err)
})
if user := os.Getenv("MQTT_USER"); user != "" {
opts.SetUsername(user).SetPassword(os.Getenv("MQTT_PASSWORD"))
}
// Re-subscribed on every (re)connect rather than once at startup: with a
// broker that did not persist the session, a reconnect would otherwise come
// back silently subscribed to nothing.
opts.SetOnConnectHandler(func(client mqtt.Client) {
log.Printf("pos: connected to MQTT broker %s", url)
for topic, handler := range map[string]mqtt.MessageHandler{
topicOrders: wrapHandler(c.ingest, c.handleOrders),
topicCustomers: wrapHandler(c.ingest, c.handleCustomers),
topicHealth: wrapHandler(c.health, c.handleHealth),
} {
if token := client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil {
log.Printf("pos: could not subscribe to %s: %v", topic, token.Error())
continue
}
log.Printf("pos: subscribed to %s", topic)
}
})
client := mqtt.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
return nil, fmt.Errorf("could not connect to the MQTT broker at %s: %w", url, token.Error())
}
c.client = client
return c, nil
}
func (c *PosMqttConsumer) handleOrders(_ mqtt.Client, msg mqtt.Message) {
var batch models.PosOrderBatch
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
// Dropped rather than retried: there is no batch id to answer with, and
// the till will time out and re-send anyway.
log.Printf("pos: discarding unreadable order batch on %s: %v", msg.Topic(), err)
return
}
store, terminal := topicIdentity(msg.Topic())
if batch.Storeid == "" {
batch.Storeid = store
}
if batch.Terminalid == "" {
batch.Terminalid = terminal
}
ack, err := c.svc.IngestOrders(batch)
if err != nil {
// Nothing committed, so nothing is acknowledged. The terminal keeps
// every bill and retries — which is the entire point of the design.
log.Printf("pos: order batch %s from %s/%s failed, not acking: %v",
batch.Batchid, store, terminal, err)
return
}
c.publishAck(store, terminal, ack)
log.Printf("pos: order batch %s from %s/%s — %d accepted, %d rejected",
batch.Batchid, store, terminal, len(ack.Accepted), len(ack.Rejected))
}
func (c *PosMqttConsumer) handleCustomers(_ mqtt.Client, msg mqtt.Message) {
var batch models.PosCustomerBatch
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
log.Printf("pos: discarding unreadable customer batch on %s: %v", msg.Topic(), err)
return
}
store, terminal := topicIdentity(msg.Topic())
if batch.Storeid == "" {
batch.Storeid = store
}
if batch.Terminalid == "" {
batch.Terminalid = terminal
}
ack, err := c.svc.IngestCustomers(batch)
if err != nil {
log.Printf("pos: customer batch %s from %s/%s failed, not acking: %v",
batch.Batchid, store, terminal, err)
return
}
c.publishAck(store, terminal, ack)
}
// handleHealth records one heartbeat.
//
// Never acknowledged. Presence is fire-and-forget: a till whose heartbeat
// failed must carry on selling, and a blank square on a dashboard is a far
// better outcome than a terminal that stopped because Redis was busy.
func (c *PosMqttConsumer) handleHealth(_ mqtt.Client, msg mqtt.Message) {
var health models.PosHealth
if err := json.Unmarshal(msg.Payload(), &health); err != nil {
log.Printf("pos: discarding unreadable heartbeat on %s: %v", msg.Topic(), err)
return
}
// From the topic, not the body — the same rule bills follow.
store, terminal := topicIdentity(msg.Topic())
if health.Locationid == "" {
health.Locationid = store
}
if health.Terminalid == "" {
health.Terminalid = terminal
}
// The broker's Last Will arrives here too, as a bare {"status":"offline"}
// with no other fields, which is exactly what should be recorded when a
// till loses power mid-shift.
if health.Status == "" {
health.Status = "online"
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.svc.RecordHealth(ctx, health); err != nil {
log.Printf("pos: could not record heartbeat from %s/%s: %v", store, terminal, err)
}
}
// publishAck answers the till that sent the batch, and only that till.
func (c *PosMqttConsumer) publishAck(store, terminal string, ack *models.PosAck) {
if store == "" || terminal == "" {
log.Printf("pos: cannot ack batch %s — the topic named no terminal", ack.Batchid)
return
}
payload, err := json.Marshal(ack)
if err != nil {
log.Printf("pos: could not encode ack for batch %s: %v", ack.Batchid, err)
return
}
topic := fmt.Sprintf("nearle/pos/%s/%s/ack", store, terminal)
// QoS 1: losing an ack means the till re-sends bills that are already
// banked. Harmless, because the ingest deduplicates — but wasted traffic on
// a shop line that may not have much to spare.
token := c.client.Publish(topic, 1, false, payload)
if !token.WaitTimeout(10*time.Second) || token.Error() != nil {
log.Printf("pos: could not publish ack to %s: %v", topic, token.Error())
}
}
// topicIdentity reads the store and terminal out of
// `nearle/pos/<store>/<terminal>/<kind>`.
//
// Taken from the topic rather than the body on purpose: a till that could name
// a store in its payload could post sales into another shop's books.
func topicIdentity(topic string) (store, terminal string) {
parts := strings.Split(topic, "/")
if len(parts) < 5 {
return "", ""
}
return parts[2], parts[3]
}
// PublishCatalogueChanged tells every till in a store to pull now.
//
// Retained, so a terminal that was switched off during the change still hears
// about it when it comes back.
func (c *PosMqttConsumer) PublishCatalogueChanged(storeID, revision string) error {
payload, err := json.Marshal(map[string]string{"revision": revision})
if err != nil {
return err
}
token := c.client.Publish(fmt.Sprintf("nearle/pos/%s/catalogue", storeID), 1, true, payload)
token.Wait()
return token.Error()
}
// Close disconnects, allowing a moment for in-flight acks to leave.
func (c *PosMqttConsumer) Close() {
if c == nil || c.client == nil {
return
}
// Workers drain before the connection closes, so a bill mid-commit still
// gets its ack out. Disconnecting first would strand it: committed here,
// unacknowledged there, and sent again on the till's next attempt.
c.ingest.stop()
c.health.stop()
quiesce, err := strconv.Atoi(getEnvDefault("MQTT_QUIESCE_MS", "2000"))
if err != nil || quiesce < 0 {
quiesce = 2000
}
c.client.Disconnect(uint(quiesce))
}
// posConsumerElected decides whether this replica runs the MQTT ingest.
//
// Rules, in order:
//
// - POS_MQTT_CONSUMER=always or =never settles it outright, for deployments
// that are not a StatefulSet or that want the consumer somewhere specific.
// - A StatefulSet pod name ending in `-0` is elected. Ordinals are stable and
// unique, so this needs no lock, no lease and no coordination.
// - Anything else — a bare container, a Deployment, local development —
// is elected, because a single instance that refused to consume would be a
// far more confusing failure than one that did.
func posConsumerElected() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("POS_MQTT_CONSUMER"))) {
case "always", "true", "yes":
return true
case "never", "false", "no":
return false
}
host := strings.TrimSpace(os.Getenv("HOSTNAME"))
if i := strings.LastIndex(host, "-"); i >= 0 {
if ordinal := host[i+1:]; ordinal != "" && strings.Trim(ordinal, "0123456789") == "" {
// A StatefulSet ordinal. Only the first replica consumes.
return ordinal == "0"
}
}
// Not an ordinal-named pod, so there is nothing to elect against.
return true
}
func getEnvDefault(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}

474
messaging/posmqtt_test.go Normal file
View File

@@ -0,0 +1,474 @@
package messaging
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"nearle/models"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// These drive the real handlers through paho's own interfaces, so what is under
// test is the code that runs in production rather than a parallel
// reimplementation of it.
//
// No embedded broker: the infrastructure audit established that the broker is
// Mosquitto 2.1.2 and that it works. What was never established is whether
// *this* code acks the right terminal, and refuses to ack when the ingest
// failed — which is where a bug would cost a shop its takings.
// fakePosService lets a test decide what the ingest did.
type fakePosService struct {
ack *models.PosAck
err error
batches []models.PosOrderBatch
custBatch []models.PosCustomerBatch
heartbeats []models.PosHealth
healthErr error
}
func (f *fakePosService) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
f.batches = append(f.batches, batch)
return f.ack, f.err
}
func (f *fakePosService) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
f.custBatch = append(f.custBatch, batch)
return f.ack, f.err
}
func (f *fakePosService) Catalogue(string, string, int, int) (*models.PosCatalogueResponse, error) {
return nil, nil
}
func (f *fakePosService) RecordHealth(_ context.Context, health models.PosHealth) error {
f.heartbeats = append(f.heartbeats, health)
return f.healthErr
}
func (f *fakePosService) TerminalHealth(context.Context, string) (map[string]string, error) {
return nil, nil
}
func (f *fakePosService) Sales(models.PosSalesFilter) (*models.PosSalesPage, error) {
return nil, nil
}
func (f *fakePosService) SaleDetail(int, string) (*models.PosOrders, error) {
return nil, nil
}
func (f *fakePosService) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
return nil, nil
}
// Sign-in plays no part over the broker: a terminal on MQTT authenticates to
// the broker itself, and the topic it publishes on already names its store.
// These exist to satisfy the interface, and returning "denied" is the safer
// stub — a fake that waved authorisation through could hide a real regression.
func (f *fakePosService) Login(models.PosLoginRequest) (*models.PosSession, error) {
return nil, nil
}
func (f *fakePosService) LocationAllowed(int, int) (bool, error) {
return false, nil
}
func (f *fakePosService) Staff(int, int) ([]models.PosStaffMember, error) {
return nil, nil
}
// Staff management plays no part over the broker — a terminal on MQTT publishes
// bills and nothing else. Denied rather than permitted, so a fake cannot hide a
// regression by waving authorisation through.
func (f *fakePosService) CreateUser(int, int, int, models.PosUserRequest) (*models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) UpdateUser(int, int, models.PosUserRequest) (*models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) ListUsers(int, int, bool) ([]models.PosUser, error) {
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) LoginWithPin(int, int, string) (*models.PosSession, error) {
return nil, nil
}
func (f *fakePosService) ConfigidFor(int) int { return 0 }
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
return nil, nil
}
// ---------------------------------------------------------------- paho fakes
type published struct {
topic string
qos byte
retained bool
payload []byte
}
// fakeClient records what was published and nothing else.
type fakeClient struct {
mu sync.Mutex
sent []published
}
func (c *fakeClient) Publish(topic string, qos byte, retained bool, payload any) mqtt.Token {
c.mu.Lock()
defer c.mu.Unlock()
body, _ := payload.([]byte)
c.sent = append(c.sent, published{topic: topic, qos: qos, retained: retained, payload: body})
return doneToken{}
}
func (c *fakeClient) publishes() []published {
c.mu.Lock()
defer c.mu.Unlock()
return append([]published(nil), c.sent...)
}
func (c *fakeClient) IsConnected() bool { return true }
func (c *fakeClient) IsConnectionOpen() bool { return true }
func (c *fakeClient) Connect() mqtt.Token { return doneToken{} }
func (c *fakeClient) Disconnect(uint) {}
func (c *fakeClient) Subscribe(string, byte, mqtt.MessageHandler) mqtt.Token {
return doneToken{}
}
func (c *fakeClient) SubscribeMultiple(map[string]byte, mqtt.MessageHandler) mqtt.Token {
return doneToken{}
}
func (c *fakeClient) Unsubscribe(...string) mqtt.Token { return doneToken{} }
func (c *fakeClient) AddRoute(string, mqtt.MessageHandler) {}
func (c *fakeClient) OptionsReader() mqtt.ClientOptionsReader { return mqtt.ClientOptionsReader{} }
type doneToken struct{}
func (doneToken) Wait() bool { return true }
func (doneToken) WaitTimeout(time.Duration) bool { return true }
func (doneToken) Done() <-chan struct{} {
ch := make(chan struct{})
close(ch)
return ch
}
func (doneToken) Error() error { return nil }
type fakeMessage struct {
topic string
payload []byte
}
func (m fakeMessage) Duplicate() bool { return false }
func (m fakeMessage) Qos() byte { return 1 }
func (m fakeMessage) Retained() bool { return false }
func (m fakeMessage) Topic() string { return m.topic }
func (m fakeMessage) MessageID() uint16 { return 1 }
func (m fakeMessage) Payload() []byte { return m.payload }
func (m fakeMessage) Ack() {}
func consumerFor(svc *fakePosService) (*PosMqttConsumer, *fakeClient) {
client := &fakeClient{}
return &PosMqttConsumer{client: client, svc: svc}, client
}
func orderBatch(batchID string, ids ...string) []byte {
orders := make([]models.PosOrder, 0, len(ids))
for _, id := range ids {
orders = append(orders, models.PosOrder{Id: id, Invoicenumber: "INV-" + id})
}
body, _ := json.Marshal(models.PosOrderBatch{Schema: 1, Batchid: batchID, Orders: orders})
return body
}
// ---------------------------------------------------------------------- tests
func TestAckGoesBackToTheTerminalThatSent(t *testing.T) {
ack := models.NewPosAck("batch-1")
ack.Accept("order-a")
c, client := consumerFor(&fakePosService{ack: ack})
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/order",
payload: orderBatch("batch-1", "order-a"),
})
sent := client.publishes()
if len(sent) != 1 {
t.Fatalf("published %d messages, want exactly 1", len(sent))
}
// Addressed to the till that sent it. A store-wide ack would tell every
// other counter that bills they never sent had landed.
if sent[0].topic != "nearle/pos/12/T4A9/ack" {
t.Errorf("ack topic = %q, want nearle/pos/12/T4A9/ack", sent[0].topic)
}
if sent[0].qos != 1 {
t.Errorf("ack qos = %d, want 1", sent[0].qos)
}
if sent[0].retained {
t.Error("the ack was retained; a stale ack replayed to a new session would retire bills that were never sent")
}
var got models.PosAck
if err := json.Unmarshal(sent[0].payload, &got); err != nil {
t.Fatalf("decode ack: %v", err)
}
if got.Batchid != "batch-1" {
t.Errorf("batch_id = %q, want batch-1", got.Batchid)
}
if len(got.Accepted) != 1 || got.Accepted[0] != "order-a" {
t.Errorf("accepted = %v, want [order-a]", got.Accepted)
}
}
func TestAFailedIngestIsNotAcked(t *testing.T) {
// The single most important behaviour here. An ack the ingest did not earn
// tells a terminal to delete a bill that was never banked.
c, client := consumerFor(&fakePosService{err: errors.New("database is having a bad minute")})
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/order",
payload: orderBatch("batch-2", "order-a"),
})
if sent := client.publishes(); len(sent) != 0 {
t.Fatalf("a batch that failed to commit was acknowledged: %s", sent[0].payload)
}
}
func TestStoreAndTerminalComeFromTheTopic(t *testing.T) {
// The body is authoritative for nothing about identity. A till that could
// name a store in its payload could post sales into another shop's books.
svc := &fakePosService{ack: models.NewPosAck("batch-3")}
c, _ := consumerFor(svc)
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/44/T0001/order",
payload: orderBatch("batch-3", "order-x"),
})
if len(svc.batches) != 1 {
t.Fatalf("the batch never reached the ingest")
}
if got := svc.batches[0].Storeid; got != "44" {
t.Errorf("store_id = %q, want 44 (from the topic)", got)
}
if got := svc.batches[0].Terminalid; got != "T0001" {
t.Errorf("terminal_id = %q, want T0001", got)
}
}
func TestABodyCannotOverrideTheTopicIdentity(t *testing.T) {
// A till claiming to be somewhere else must not be believed.
svc := &fakePosService{ack: models.NewPosAck("batch-4")}
c, client := consumerFor(svc)
body, _ := json.Marshal(models.PosOrderBatch{
Schema: 1,
Batchid: "batch-4",
Storeid: "99", // a shop this till has no claim on
Orders: []models.PosOrder{{Id: "order-a"}},
})
c.handleOrders(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: body})
// The ingest still resolves the store it was *told*, which is a known gap —
// but the ack must go back to the real terminal, so a forged store id
// cannot redirect another till's acknowledgements.
sent := client.publishes()
if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" {
t.Fatalf("ack went to %v, want nearle/pos/12/T4A9/ack", sent)
}
}
func TestAMalformedBatchIsDroppedWithoutAcking(t *testing.T) {
// Nothing to key an ack on, and nothing committed. Silence is correct: the
// till times out and re-sends.
svc := &fakePosService{ack: models.NewPosAck("x")}
c, client := consumerFor(svc)
c.handleOrders(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/order",
payload: []byte("not json at all"),
})
if len(svc.batches) != 0 {
t.Error("an unreadable batch reached the ingest")
}
if sent := client.publishes(); len(sent) != 0 {
t.Errorf("an unreadable batch was acknowledged: %s", sent[0].payload)
}
}
func TestRegistrationsAckOnTheSameTopic(t *testing.T) {
ack := models.NewPosAck("cust-1")
ack.Accept("customer-a")
c, client := consumerFor(&fakePosService{ack: ack})
body, _ := json.Marshal(models.PosCustomerBatch{
Schema: 1,
Batchid: "cust-1",
Customers: []models.PosCustomer{{Id: "customer-a", Mobile: "9840012345", Name: "Meena"}},
})
c.handleCustomers(nil, fakeMessage{topic: "nearle/pos/12/T4A9/customer", payload: body})
sent := client.publishes()
if len(sent) != 1 || sent[0].topic != "nearle/pos/12/T4A9/ack" {
t.Fatalf("registration ack went to %v", sent)
}
}
func TestAHeartbeatIsRecordedAndNeverAcked(t *testing.T) {
// Presence is fire-and-forget. A till waiting on an ack for its heartbeat
// would be a till that a busy dashboard can block.
svc := &fakePosService{}
c, client := consumerFor(svc)
body, _ := json.Marshal(models.PosHealth{Status: "online", Pendingbills: 4, Todaybills: 37})
c.handleHealth(nil, fakeMessage{topic: "nearle/pos/12/T4A9/health", payload: body})
if len(svc.heartbeats) != 1 {
t.Fatalf("the heartbeat never reached the presence store")
}
got := svc.heartbeats[0]
if got.Terminalid != "T4A9" || got.Locationid != "12" {
t.Errorf("identity = %s/%s, want 12/T4A9 (from the topic)", got.Locationid, got.Terminalid)
}
if got.Pendingbills != 4 {
t.Errorf("pending_bills = %d, want 4", got.Pendingbills)
}
if sent := client.publishes(); len(sent) != 0 {
t.Error("a heartbeat was acknowledged; presence must be fire-and-forget")
}
}
func TestALastWillIsRecordedAsOffline(t *testing.T) {
// The broker publishes this on the till's behalf when it loses power. It
// carries nothing but a status, and that is the point — it is the only way
// to tell "closed for the night" from "unplugged".
svc := &fakePosService{}
c, _ := consumerFor(svc)
c.handleHealth(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/health",
payload: []byte(`{"status":"offline"}`),
})
if len(svc.heartbeats) != 1 {
t.Fatal("the will never reached the presence store")
}
if got := svc.heartbeats[0].Status; got != "offline" {
t.Errorf("status = %q, want offline — a will must not be defaulted to online", got)
}
}
func TestAFailedPresenceWriteDoesNotStopTheTill(t *testing.T) {
// Redis being unreachable must cost the board, never a sale.
svc := &fakePosService{healthErr: errors.New("redis is down")}
c, client := consumerFor(svc)
c.handleHealth(nil, fakeMessage{
topic: "nearle/pos/12/T4A9/health",
payload: []byte(`{"status":"online"}`),
})
if sent := client.publishes(); len(sent) != 0 {
t.Error("a failed heartbeat produced a message back to the till")
}
}
func TestAnEmptyAckSerialisesAsAListNotNull(t *testing.T) {
// A terminal reading `null` for accepted treats the whole batch as
// unconfirmed and sends it again for ever.
body, err := json.Marshal(models.NewPosAck("batch-5"))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if want := `"accepted":[]`; !strings.Contains(string(body), want) {
t.Errorf("ack serialised as %s, want it to contain %s", body, want)
}
}
func TestTopicIdentityRejectsShortTopics(t *testing.T) {
// A topic that names no terminal must yield nothing rather than a
// plausible-looking wrong answer that sends an ack to the wrong place.
for _, topic := range []string{"nearle/pos/order", "pos/12/T4A9/order", "", "nearle"} {
store, terminal := topicIdentity(topic)
if store != "" || terminal != "" {
t.Errorf("topicIdentity(%q) = %q/%q, want empty", topic, store, terminal)
}
}
store, terminal := topicIdentity("nearle/pos/12/T4A9/order")
if store != "12" || terminal != "T4A9" {
t.Errorf("topicIdentity = %q/%q, want 12/T4A9", store, terminal)
}
}
// MQTT has no queue groups: every subscriber gets every message. Three replicas
// all consuming would commit the same bill three times and publish three acks —
// harmless, because the ingest is idempotent, but three times the work.
func TestOnlyTheFirstStatefulSetReplicaConsumes(t *testing.T) {
cases := []struct {
name string
hostname string
override string
want bool
}{
{"statefulset ordinal 0", "fiesta-0", "", true},
{"statefulset ordinal 1", "fiesta-1", "", false},
{"statefulset ordinal 2", "fiesta-2", "", false},
{"double-digit ordinal", "fiesta-10", "", false},
// A Deployment pod has a random suffix, not an ordinal. Refusing to
// consume there would be a far more confusing failure than consuming.
{"deployment pod", "fiesta-7d4f9c8b6d-x2k9p", "", true},
{"bare container", "a1b2c3d4e5f6", "", true},
{"no hostname", "", "", true},
// The override settles it outright either way.
{"forced on", "fiesta-2", "always", true},
{"forced off", "fiesta-0", "never", false},
{"forced on via true", "fiesta-5", "true", true},
{"forced off via false", "fiesta-0", "false", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Setenv("HOSTNAME", c.hostname)
t.Setenv("POS_MQTT_CONSUMER", c.override)
if got := posConsumerElected(); got != c.want {
t.Errorf("posConsumerElected() = %v, want %v (hostname %q, override %q)",
got, c.want, c.hostname, c.override)
}
})
}
}

174
messaging/posworkers.go Normal file
View File

@@ -0,0 +1,174 @@
package messaging
import (
"log"
"os"
"strconv"
"sync"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// Concurrency for the MQTT ingest.
//
// paho delivers messages on a single goroutine, so without this every bill is
// committed one after another. A bill is a full Postgres transaction — advisory
// lock, dedup check, stock row locks, availability check, four inserts, commit —
// which realistically costs 10–30ms. Serially that is 30–100 bills a second,
// and a shop-wide backlog draining after an outage would take minutes to land.
//
// ### Why a bounded pool rather than a goroutine per message
//
// paho can be told to call handlers concurrently, but it spawns without limit.
// A storm would then open a database transaction per message, exhaust the
// connection pool, and stall every one of them at once — turning a slow minute
// into a dead one.
//
// A fixed pool behind a bounded queue does the opposite. When the queue fills,
// submitting **blocks**, which is the point: paho stops acknowledging, the
// broker's in-flight window fills, and it stops sending. Backpressure travels
// all the way back to the till, which holds its bills and retries. Slow, but
// nothing is dropped and nothing is lost.
//
// ### Why bills and heartbeats have separate pools
//
// A heartbeat is one Redis write and a bill is a transaction. Sharing a queue
// would let a backlog of bills delay presence, and every till would appear to
// go dark at exactly the moment the system was busiest — the worst possible
// time to be blind to which counters are alive.
// posPool is a fixed set of workers reading a bounded queue.
type posPool struct {
name string
jobs chan func()
wg sync.WaitGroup
once sync.Once
// Guards the transition to closed. A plain `select` over a done-channel and
// the job channel is not enough: once both are ready Go picks between them
// at random, and picking the send panics on a closed channel. Held for
// reading across the whole of submit, so stop cannot close the queue out
// from under a send already in progress.
mu sync.RWMutex
closed bool
}
func newPosPool(name string, workers, queue int) *posPool {
p := &posPool{
name: name,
jobs: make(chan func(), queue),
}
p.wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer p.wg.Done()
for job := range p.jobs {
job()
}
}()
}
log.Printf("pos: %s pool started with %d workers, queue %d", name, workers, queue)
return p
}
// submit queues work, blocking when the queue is full.
//
// Blocking is deliberate. Dropping would lose a bill outright; the terminal
// would eventually re-send it, but only after its ack timeout, and meanwhile we
// would have thrown away work we had already accepted. Blocking instead pushes
// back through paho to the broker to the till, which is exactly where the
// decision to slow down belongs.
func (p *posPool) submit(job func()) {
p.mu.RLock()
if p.closed {
p.mu.RUnlock()
// Shutting down. Running it inline still gets the work done and its ack
// published, rather than discarding a bill that already reached us.
job()
return
}
// The read lock is held across the send. Blocking here while the queue is
// full cannot deadlock against stop: the workers only exit once the channel
// is closed, and that happens under the write lock this send is holding
// off — so they stay alive and keep draining until this send completes.
p.jobs <- job
p.mu.RUnlock()
}
// stop drains the queue and waits for in-flight work.
//
// Every job already accepted runs to completion, so a bill mid-commit still
// gets its ack. Without one the terminal would hold it and send it again on
// restart — harmless, but avoidable.
func (p *posPool) stop() {
p.once.Do(func() {
// The write lock waits for every submit already in progress, so the
// channel is never closed while something is mid-send.
p.mu.Lock()
p.closed = true
close(p.jobs)
p.mu.Unlock()
p.wg.Wait()
log.Printf("pos: %s pool drained", p.name)
})
}
// posPoolSize reads a worker count from the environment.
//
// The default is deliberately modest. Each worker holds a database transaction
// while it runs, so the useful ceiling is the Postgres connection pool, not the
// CPU — set this above what the database can serve and the workers simply queue
// inside the driver instead, where there is no backpressure to feel.
func posPoolSize(key string, fallback int) int {
v, err := strconv.Atoi(os.Getenv(key))
if err != nil || v <= 0 {
return fallback
}
if v > 128 {
return 128
}
return v
}
// posOrderedDelivery reports whether paho should preserve message order.
//
// Left on: paho then delivers on one goroutine, which hands work to the pool
// and blocks when it is full. That single delivery goroutine is what makes
// backpressure reach the broker at all — with concurrent delivery paho would
// keep reading regardless of how far behind the workers were.
const posOrderedDelivery = true
// wrapHandler puts a paho message handler behind a pool.
//
// The payload is copied because paho reuses its buffer once the handler
// returns, and the work now happens after that.
func wrapHandler(pool *posPool, h mqtt.MessageHandler) mqtt.MessageHandler {
return func(client mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := make([]byte, len(msg.Payload()))
copy(payload, msg.Payload())
pool.submit(func() {
h(client, copiedMessage{topic: topic, payload: payload})
})
}
}
// copiedMessage carries a payload that outlives paho's buffer.
type copiedMessage struct {
topic string
payload []byte
}
func (m copiedMessage) Duplicate() bool { return false }
func (m copiedMessage) Qos() byte { return 1 }
func (m copiedMessage) Retained() bool { return false }
func (m copiedMessage) Topic() string { return m.topic }
func (m copiedMessage) MessageID() uint16 { return 0 }
func (m copiedMessage) Payload() []byte { return m.payload }
func (m copiedMessage) Ack() {}

View File

@@ -0,0 +1,247 @@
package messaging
import (
"sync"
"sync/atomic"
"testing"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
func TestEveryJobRuns(t *testing.T) {
pool := newPosPool("test", 4, 16)
var done int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
pool.submit(func() {
defer wg.Done()
atomic.AddInt64(&done, 1)
})
}
wg.Wait()
pool.stop()
if got := atomic.LoadInt64(&done); got != 100 {
t.Errorf("ran %d jobs, want 100", got)
}
}
func TestConcurrencyIsBounded(t *testing.T) {
// The reason the pool exists. Unbounded concurrency would open a database
// transaction per message and exhaust the connection pool under a storm,
// stalling every one of them at once.
const workers = 4
pool := newPosPool("test", workers, 64)
var inFlight, peak int64
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
pool.submit(func() {
defer wg.Done()
now := atomic.AddInt64(&inFlight, 1)
for {
was := atomic.LoadInt64(&peak)
if now <= was || atomic.CompareAndSwapInt64(&peak, was, now) {
break
}
}
time.Sleep(time.Millisecond)
atomic.AddInt64(&inFlight, -1)
})
}
wg.Wait()
pool.stop()
if got := atomic.LoadInt64(&peak); got > workers {
t.Errorf("peak concurrency %d exceeded the %d workers", got, workers)
}
}
func TestSubmitBlocksRatherThanDroppingWork(t *testing.T) {
// A full queue must slow the caller down, not discard a bill. Dropping
// would throw away work already accepted from the broker, and the terminal
// would only find out at its ack timeout.
pool := newPosPool("test", 1, 1)
release := make(chan struct{})
var ran int64
// Occupy the single worker.
pool.submit(func() {
<-release
atomic.AddInt64(&ran, 1)
})
// Fill the queue, then a third submit must block until the worker frees up.
pool.submit(func() { atomic.AddInt64(&ran, 1) })
blocked := make(chan struct{})
go func() {
pool.submit(func() { atomic.AddInt64(&ran, 1) })
close(blocked)
}()
select {
case <-blocked:
t.Fatal("submit returned while the queue was full; work would be dropped under load")
case <-time.After(100 * time.Millisecond):
// Correctly blocked.
}
close(release)
select {
case <-blocked:
case <-time.After(3 * time.Second):
t.Fatal("submit never unblocked after the worker freed up")
}
pool.stop()
if got := atomic.LoadInt64(&ran); got != 3 {
t.Errorf("ran %d jobs, want 3 — none may be lost", got)
}
}
func TestStopDrainsAcceptedWork(t *testing.T) {
// A bill mid-commit must still get its ack. Without one the terminal holds
// it and sends it again on restart — harmless, but avoidable.
pool := newPosPool("test", 2, 64)
var done int64
for i := 0; i < 50; i++ {
pool.submit(func() {
time.Sleep(time.Millisecond)
atomic.AddInt64(&done, 1)
})
}
pool.stop()
if got := atomic.LoadInt64(&done); got != 50 {
t.Errorf("only %d of 50 jobs completed before shutdown finished", got)
}
}
func TestSubmitAfterStopStillRunsTheWork(t *testing.T) {
// A message that arrived during shutdown has already been taken from the
// broker. Discarding it would lose a bill we accepted responsibility for.
pool := newPosPool("test", 2, 8)
pool.stop()
var ran int64
pool.submit(func() { atomic.AddInt64(&ran, 1) })
if got := atomic.LoadInt64(&ran); got != 1 {
t.Error("work submitted during shutdown was discarded")
}
}
func TestStopIsIdempotent(t *testing.T) {
// Close() may be reached twice on a shutdown path; a second close of the
// jobs channel would panic and take the process down mid-drain.
pool := newPosPool("test", 2, 8)
pool.stop()
pool.stop()
pool.stop()
}
func TestPoolSizeFallsBackAndClamps(t *testing.T) {
t.Setenv("POS_TEST_WORKERS", "")
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
t.Errorf("unset = %d, want the fallback 8", got)
}
t.Setenv("POS_TEST_WORKERS", "not a number")
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
t.Errorf("garbage = %d, want the fallback 8", got)
}
t.Setenv("POS_TEST_WORKERS", "0")
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
t.Errorf("zero = %d, want the fallback 8", got)
}
t.Setenv("POS_TEST_WORKERS", "-4")
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
t.Errorf("negative = %d, want the fallback 8", got)
}
t.Setenv("POS_TEST_WORKERS", "24")
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 24 {
t.Errorf("explicit = %d, want 24", got)
}
// Clamped: more workers than the database can serve just moves the queue
// inside the driver, where there is no backpressure to feel.
t.Setenv("POS_TEST_WORKERS", "100000")
if got := posPoolSize("POS_TEST_WORKERS", 8); got != 128 {
t.Errorf("absurd = %d, want the 128 clamp", got)
}
}
func TestAWrappedHandlerCopiesThePayload(t *testing.T) {
// paho reuses its buffer once a handler returns, and with a pool the work
// now happens *after* that. Without a copy a queued bill would be read as
// whatever message happened to arrive next — silently, and as valid JSON
// often enough to commit the wrong sale.
pool := newPosPool("test", 1, 4)
seen := make(chan string, 1)
wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
seen <- string(msg.Payload())
})
// A buffer paho would reuse.
buffer := []byte(`{"batch_id":"original"}`)
wrapped(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: buffer})
// Overwrite it the instant the handler returns, exactly as paho would.
for i := range buffer {
buffer[i] = 'X'
}
select {
case got := <-seen:
if got != `{"batch_id":"original"}` {
t.Errorf("handler saw %q — the payload was not copied before queueing", got)
}
case <-time.After(3 * time.Second):
t.Fatal("the wrapped handler never ran")
}
pool.stop()
}
func TestAWrappedHandlerKeepsTheTopic(t *testing.T) {
// Store and terminal are read from the topic, never the body. Losing it in
// the hand-off would leave the ack with nowhere to go.
pool := newPosPool("test", 1, 4)
seen := make(chan string, 1)
wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
seen <- msg.Topic()
})
wrapped(nil, fakeMessage{topic: "nearle/pos/1135/T4A9/order", payload: []byte("{}")})
select {
case got := <-seen:
if got != "nearle/pos/1135/T4A9/order" {
t.Errorf("topic = %q, want nearle/pos/1135/T4A9/order", got)
}
case <-time.After(3 * time.Second):
t.Fatal("the wrapped handler never ran")
}
pool.stop()
}

219
middleware/posauth.go Normal file
View File

@@ -0,0 +1,219 @@
package middleware
import (
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"time"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
// Authorisation for the POS terminal.
//
// Before this, the whole POS surface was open. A till named its own outlet on
// the wire and was believed, so `store_id=1185` in a URL was enough to read
// another tenant's catalogue or post bills into their books. There was no
// middleware in the codebase at all and the `JWT_SECRET_KEY` in the config was
// read and never used.
//
// The fix is two checks, in this order:
//
// 1. the caller holds a token this server signed, and
// 2. the outlet they are naming belongs to the tenant inside that token.
//
// The second is the one that matters. A valid token is not a licence to name
// any location — it is a licence to name *your* locations, and without the
// cross-check a real terminal at one shop could still read the shop next door.
// PosLocalsKey names where the verified claims are parked for handlers.
const PosLocalsKey = "posclaims"
// posAuthRequired reports whether a request without a valid token is refused.
//
// Defaults to OFF, and that is a deliberate, temporary choice rather than an
// oversight. Terminals are already in shops billing real customers against the
// unauthenticated endpoints; switching enforcement on at deploy would stop
// every one of them mid-trade. So the endpoint ships first, tills adopt it, and
// `POS_AUTH_REQUIRED=true` closes the door once the fleet is carrying tokens.
//
// While it is off a token is still *verified* when one is sent, and a request
// carrying a token for the wrong tenant is still refused — the flag only
// decides what happens to a request carrying none.
func posAuthRequired() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("POS_AUTH_REQUIRED")), "true")
}
// PosAuth verifies the session token and pins the request to its outlet.
func PosAuth(pos services.PosService) fiber.Handler {
return func(c *fiber.Ctx) error {
token := bearerToken(c)
if token == "" {
if posAuthRequired() {
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
}
// Legacy till. Allowed through un-pinned, which is exactly the state
// this middleware exists to end — see posAuthRequired.
return c.Next()
}
claims, err := utils.ParsePosToken(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 posUnauthorized(c, err.Error())
}
// The outlet named in the request, if it named one. Every POS route
// spells this differently — `store_id` on catalogue, `locationid` on
// sales, `location_id` on health — so all three are read rather than
// the caller being asked to change.
requested := requestedLocation(c)
if requested > 0 && requested != claims.Locationid {
// A different outlet than the token was issued for. Permitted only
// if the tenant genuinely owns it — a proprietor with six shops
// should be able to look at all six from one signed-in session.
allowed, err := pos.LocationAllowed(claims.Tenantid, requested)
if err != nil {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
"code": http.StatusServiceUnavailable, "status": false,
"message": "could not verify outlet access",
})
}
if !allowed {
return c.Status(http.StatusForbidden).JSON(fiber.Map{
"code": http.StatusForbidden, "status": false,
"message": "this session cannot reach outlet " + strconv.Itoa(requested),
})
}
}
c.Locals(PosLocalsKey, claims)
return c.Next()
}
}
// bearerToken reads the session out of the request.
//
// `Authorization: Bearer …` is the form to use. `X-Pos-Token` is accepted as
// well because some of the 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.
func bearerToken(c *fiber.Ctx) string {
header := strings.TrimSpace(c.Get("Authorization"))
if header != "" {
if after, found := strings.CutPrefix(header, "Bearer "); found {
return strings.TrimSpace(after)
}
if !strings.Contains(header, " ") {
// Tolerates a bare token. Terminals in the field get this wrong and
// the alternative is a shop that cannot sell.
return header
}
}
return strings.TrimSpace(c.Get("X-Pos-Token"))
}
// requestedLocation reads whichever outlet parameter this route happens to use.
//
// The three spellings are a wart — `store_id`, `locationid` and `location_id`
// all mean the same thing across the POS routes. Normalising them is a breaking
// change for terminals already in the field, so this reads all three instead
// and leaves the naming alone.
//
// The body is searched as well as the query, and that is not an optional extra:
// the two routes that *write* — order and customer ingest — carry `store_id` in
// a JSON batch and never in the URL. Checking only the query string would leave
// the exact call that posts bills into another tenant's books unguarded, which
// is the hole this middleware exists to close.
func requestedLocation(c *fiber.Ctx) int {
for _, key := range []string{"store_id", "locationid", "location_id"} {
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
return id
}
}
}
return bodyLocation(c)
}
// bodyLocation pulls the outlet out of a JSON request body.
//
// Decoded into a loose map rather than the batch type on purpose. This runs
// before the handler and must not reject anything the handler would have
// accepted — a body that fails to parse here is left to the handler to refuse
// with its own message, and a batch shape that changes later must not silently
// stop being authorised.
//
// `c.Body()` returns the buffered bytes, so reading it here does not consume
// the stream the handler goes on to parse.
func bodyLocation(c *fiber.Ctx) int {
body := c.Body()
if len(body) == 0 || len(body) > 8<<20 {
return 0
}
var probe struct {
Storeid json.RawMessage `json:"store_id"`
Locationid json.RawMessage `json:"location_id"`
}
if err := json.Unmarshal(body, &probe); err != nil {
return 0
}
for _, raw := range []json.RawMessage{probe.Storeid, probe.Locationid} {
if id := asLocationID(raw); id > 0 {
return id
}
}
return 0
}
// asLocationID reads an id that may have been sent as a number or as a string.
//
// The till sends `"store_id": "1135"` and the health payload sends
// `"location_id": "1135"`, both quoted, while other callers send it bare.
// Accepting only one shape would silently skip the check for the other — and a
// skipped check here reads exactly like a passed one.
func asLocationID(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
if id, err := strconv.Atoi(strings.TrimSpace(asString)); err == nil && id > 0 {
return id
}
return 0
}
var asNumber int
if err := json.Unmarshal(raw, &asNumber); err == nil && asNumber > 0 {
return asNumber
}
return 0
}
func posUnauthorized(c *fiber.Ctx, message string) error {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false, "message": message,
})
}
// PosClaimsFrom returns the verified session on a request, if it carried one.
//
// The second return distinguishes "no token" from "a token claiming tenant 0",
// which a caller acting on the tenant id must not confuse.
func PosClaimsFrom(c *fiber.Ctx) (utils.PosClaims, bool) {
claims, ok := c.Locals(PosLocalsKey).(utils.PosClaims)
return claims, ok
}

151
middleware/posauth_test.go Normal file
View File

@@ -0,0 +1,151 @@
package middleware
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// The outlet a request names has to be found wherever the route happens to put
// it. These cover the extraction alone — it is the part that decides whether
// the authorisation check runs at all, and a miss here reads exactly like a
// pass.
func locationFor(t *testing.T, method, target, body string) int {
t.Helper()
app := fiber.New()
found := -1
app.All("/probe", func(c *fiber.Ctx) error {
found = requestedLocation(c)
return c.SendStatus(fiber.StatusOK)
})
var reader *strings.Reader
if body == "" {
reader = strings.NewReader("")
} else {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if _, err := app.Test(req); err != nil {
t.Fatalf("probing: %v", err)
}
return found
}
func TestTheOutletIsFoundUnderEveryNameTheRoutesUse(t *testing.T) {
// Three spellings for one thing across the POS routes. Missing any of them
// leaves that route unguarded.
cases := []struct {
name string
target string
}{
{"catalogue says store_id", "/probe?store_id=1135"},
{"sales say locationid", "/probe?locationid=1135"},
{"health says location_id", "/probe?location_id=1135"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := locationFor(t, "GET", tc.target, ""); got != 1135 {
t.Fatalf("wanted outlet 1135, got %d", got)
}
})
}
}
// The two routes that *write* carry the outlet in a JSON batch and never in the
// URL. Checking only the query string would leave the exact call that posts
// bills into another tenant's books unguarded.
func TestTheOutletIsFoundInAnIngestBody(t *testing.T) {
body := `{"batch_id":"b1","terminal_id":"T5EDD","store_id":"1135","orders":[]}`
if got := locationFor(t, "POST", "/probe", body); got != 1135 {
t.Fatalf("wanted outlet 1135 from the batch body, got %d", got)
}
}
// The till quotes its ids; other callers send them bare. Accepting only one
// shape silently skips the check for the other.
func TestAnOutletIsReadWhetherQuotedOrNot(t *testing.T) {
quoted := `{"store_id":"1135"}`
bare := `{"store_id":1135}`
if got := locationFor(t, "POST", "/probe", quoted); got != 1135 {
t.Fatalf("quoted store_id: wanted 1135, got %d", got)
}
if got := locationFor(t, "POST", "/probe", bare); got != 1135 {
t.Fatalf("bare store_id: wanted 1135, got %d", got)
}
}
func TestAHealthBodyNamesItsOutlet(t *testing.T) {
body := `{"terminal_id":"T5EDD","location_id":"1135","status":"online"}`
if got := locationFor(t, "POST", "/probe", body); got != 1135 {
t.Fatalf("wanted outlet 1135 from the health body, got %d", got)
}
}
// A request naming no outlet is not an error — /session names none — so it must
// come back as "nothing to check" rather than as outlet zero.
func TestARequestNamingNoOutletReportsNone(t *testing.T) {
if got := locationFor(t, "GET", "/probe", ""); got != 0 {
t.Fatalf("wanted 0 for a request naming no outlet, got %d", got)
}
if got := locationFor(t, "POST", "/probe", `{"batch_id":"b1"}`); got != 0 {
t.Fatalf("wanted 0 for a body naming no outlet, got %d", got)
}
}
// A body this middleware cannot parse must not be treated as naming an outlet.
// The handler will refuse it on its own terms; guessing here would either
// reject a good request or wave a bad one through.
func TestAnUnparseableBodyNamesNoOutlet(t *testing.T) {
if got := locationFor(t, "POST", "/probe", `{not json at all`); got != 0 {
t.Fatalf("wanted 0 for an unparseable body, got %d", got)
}
}
func TestABearerTokenIsReadInEveryFormTheFieldSends(t *testing.T) {
app := fiber.New()
var got string
app.Get("/probe", func(c *fiber.Ctx) error {
got = bearerToken(c)
return c.SendStatus(fiber.StatusOK)
})
cases := []struct {
name string
header string
value string
want string
}{
{"the standard form", "Authorization", "Bearer abc.def", "abc.def"},
{"a bare token, which terminals send", "Authorization", "abc.def", "abc.def"},
{"the fallback header", "X-Pos-Token", "abc.def", "abc.def"},
{"a scheme we do not issue", "Authorization", "Basic dXNlcjpwdw==", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got = ""
req := httptest.NewRequest("GET", "/probe", nil)
req.Header.Set(tc.header, tc.value)
if _, err := app.Test(req); err != nil {
t.Fatalf("probing: %v", err)
}
if got != tc.want {
t.Fatalf("wanted %q, got %q", tc.want, got)
}
})
}
}

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

@@ -3,37 +3,40 @@ package models
import "time"
type Customers struct {
Customerid int `json:"customerid" gorm:"Primary_Key"`
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Profileimage string `json:"profileimage"`
Gender string `json:"gender"`
Dob string `json:"dob"`
Dialcode string `json:"dialcode"`
Contactno string `json:"contactno"`
Email string `json:"email"`
Deviceid string `json:"deviceid"`
Devicetype string `json:"devicetype"`
Authmode int `json:"authmode"`
Configid int `json:"configid"`
Customertoken string `json:"customertoken"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Landmark string `json:"landmark"`
Doorno string `json:"doorno"`
Postcode string `json:"postcode"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
Applocationid int `json:"applocationid"`
Locationid int `json:"locationid,omitempty" gorm:"-"`
Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"`
Primaryaddress int `json:"primaryaddress,omitempty" gorm:"-"`
Tenantid int `json:"tenantid,omitempty" gorm:"-"`
Status int `json:"status"`
Intro string `json:"intro"`
Qrmode int `json:"qrmode,omitempty" gorm:"-"`
Customerid int `json:"customerid" gorm:"Primary_Key"`
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Profileimage string `json:"profileimage"`
Gender string `json:"gender"`
Dob string `json:"dob"`
Dialcode string `json:"dialcode"`
Contactno string `json:"contactno"`
Email string `json:"email"`
Deviceid string `json:"deviceid"`
Devicetype string `json:"devicetype"`
Authmode int `json:"authmode"`
Configid int `json:"configid"`
Customertoken string `json:"customertoken"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Landmark string `json:"landmark"`
Doorno string `json:"doorno"`
Postcode string `json:"postcode"`
// Numbers from a map picker, strings from a text field — see
// 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"`
Locationid int `json:"locationid,omitempty" gorm:"-"`
Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"`
Primaryaddress int `json:"primaryaddress,omitempty" gorm:"-"`
Tenantid int `json:"tenantid,omitempty" gorm:"-"`
Status int `json:"status"`
Intro string `json:"intro"`
Qrmode int `json:"qrmode,omitempty" gorm:"-"`
}
type CustomerInfo struct {
@@ -82,20 +85,37 @@ type CustomerLocationResult struct {
}
type Customerlocations struct {
Locationid int `json:"locationid" gorm:"Primary_Key"`
Customerid int `json:"customerid"`
Applocationid int `json:"applocationid" gorm:"-"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Landmark string `json:"landmark"`
Doorno string `json:"doorno"`
Postcode string `json:"postcode"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
Primaryaddress int `json:"primaryaddress"`
Status int `json:"status"`
Locationid int `json:"locationid" gorm:"Primary_Key"`
Customerid int `json:"customerid"`
// 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"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Landmark string `json:"landmark"`
Doorno string `json:"doorno"`
Postcode string `json:"postcode"`
// Latitude and longitude arrive as NUMBERS from a map picker and as strings
// from a text field. A strict type rejected the first, and BodyParser fails
// the WHOLE request on one unreadable field — so a shopper who set their
// 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 {

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

View File

@@ -70,39 +70,45 @@ func (fs FlexibleString) String() string {
}
type OrderInfo struct {
Orderheaderid int `json:"orderheaderid"`
Applocationid int `json:"applocationid"`
Applocation string `json:"applocation"`
Tenantid int `json:"tenantid"`
Partnerid int `json:"partnerid"`
Locationid int `json:"locationid"`
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Moduleid int `json:"moduleid"`
Configid int `json:"configid"`
Orderid string `json:"orderid"`
Orderdate string `json:"orderdate"`
Deliverydate string `json:"deliverydate"`
Orderstatus string `json:"orderstatus"`
Deliverystatus string `json:"deliverystatus"`
Deliveryamt float64 `json:"deliveryamt"`
Itemcount int `json:"itemcount"`
Ordernotes string `json:"ordernotes"`
Kms FlexibleString `json:"kms"`
Actualkms FlexibleString `json:"actualkms"`
Pending string `json:"Pending"`
Processing string `json:"processing"`
Ready string `json:"ready"`
Cancelled string `json:"cancelled"`
Delivered string `json:"delivered"`
Assigntime string `json:"assigntime"`
Starttime string `json:"starttime"`
Arrivaltime string `json:"arrivaltime"`
Pickuptime string `json:"pickuptime"`
Deliverytime string `json:"deliverytime"`
Canceltime string `json:"canceltime"`
Deliverycharge float32 `json:"deliverycharge"`
Orderamount float32 `json:"orderamount"`
Orderheaderid int `json:"orderheaderid"`
Applocationid int `json:"applocationid"`
Applocation string `json:"applocation"`
Tenantid int `json:"tenantid"`
Partnerid int `json:"partnerid"`
Locationid int `json:"locationid"`
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Moduleid int `json:"moduleid"`
Configid int `json:"configid"`
Orderid string `json:"orderid"`
Orderdate string `json:"orderdate"`
Deliverydate string `json:"deliverydate"`
Orderstatus string `json:"orderstatus"`
Deliverystatus string `json:"deliverystatus"`
Deliveryamt float64 `json:"deliveryamt"`
Itemcount int `json:"itemcount"`
Ordernotes string `json:"ordernotes"`
Kms FlexibleString `json:"kms"`
Actualkms FlexibleString `json:"actualkms"`
Pending string `json:"Pending"`
Processing string `json:"processing"`
Ready string `json:"ready"`
Cancelled string `json:"cancelled"`
Delivered string `json:"delivered"`
Assigntime string `json:"assigntime"`
Starttime string `json:"starttime"`
Arrivaltime string `json:"arrivaltime"`
Pickuptime string `json:"pickuptime"`
Deliverytime string `json:"deliverytime"`
Canceltime string `json:"canceltime"`
Deliverycharge float32 `json:"deliverycharge"`
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"`
Pickupcustomer string `json:"pickupcustomer"`
Pickupcontactno string `json:"pickupcontactno"`
@@ -439,6 +445,97 @@ type Ordersequences struct {
Paymentprefix string `json:"paymentprefix" gorm:"default:PAY"`
}
// ── Offline (in-store) sales import ───────────────────────────────────────────
//
// A sale rung up at the counter never passes through the app, so nothing
// deducts its stock. These types carry a spreadsheet of such sales into the
// same order path online orders use, so one ledger remains the single source
// of truth for stock and one revenue figure covers both channels.
// OfflineSaleItem is one spreadsheet row. Only Productid and Qtysold are
// required; the rest fall back to the product's own pricing when left blank.
// Productname is carried for verification against Productid, not for matching
// (see SaleTemplateRow for why a name can't be a key).
type OfflineSaleItem struct {
Productid int `json:"productid"`
Productname string `json:"productname"`
Qtysold float64 `json:"qtysold"`
Unitprice float64 `json:"unitprice"`
Discountamount float64 `json:"discountamount"`
Taxpercent float64 `json:"taxpercent"`
}
// OfflineSaleBill is one counter bill — the rows of a spreadsheet grouped by
// their branch and bill number.
//
// Locationid is the branch the bill was rung up at, taken from the spreadsheet
// row rather than from a store the operator picked in the UI. One workbook can
// therefore carry sales for every branch a merchant runs, and each bill's stock
// comes out of its own outlet. Bills are grouped per branch, so the same bill
// number at two outlets is two separate sales, not a duplicate.
//
// Billno is what makes a re-upload of the same file safe: it is recorded on the
// order and refused if already present for that branch.
type OfflineSaleBill struct {
Locationid int `json:"locationid"`
Billno string `json:"billno"`
Saledate string `json:"saledate"`
Paymentmode string `json:"paymentmode"`
Customername string `json:"customername"`
Customermobile string `json:"customermobile"`
Remarks string `json:"remarks"`
Items []OfflineSaleItem `json:"items"`
}
// OfflineSalesUpload is the request body.
//
// Locationid here is a scope constraint, not the destination. Left at 0 the
// bills go to whichever branch each one names, which is what a multi-branch
// owner uploads. Set to a branch it pins the whole upload to that outlet and
// any bill naming a different one is refused — that is how a store user is
// held to their own store no matter what the spreadsheet says.
//
// Every branch referenced is checked against Tenantid regardless, so no upload
// can reach an outlet the merchant does not own.
type OfflineSalesUpload struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Userid int `json:"userid"`
Bills []OfflineSaleBill `json:"bills"`
}
// Outcomes a single bill can have. A bill is all-or-nothing: it either commits
// with its stock movement or it leaves nothing behind.
const (
OfflineSaleImported = "imported"
OfflineSaleDuplicate = "duplicate"
OfflineSaleFailed = "failed"
)
// OfflineSaleResult reports one bill's fate. Bills are independent, so a file
// with one bad bill still imports the rest and names exactly what it skipped.
// The branch is echoed back because a single upload spans several, and "bill 7
// failed" is not actionable without knowing which store it belonged to.
type OfflineSaleResult struct {
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Billno string `json:"billno"`
Status string `json:"status"`
Orderid string `json:"orderid"`
Orderheaderid int `json:"orderheaderid"`
Itemcount int `json:"itemcount"`
Amount float64 `json:"amount"`
Message string `json:"message"`
}
type OfflineSalesUploadResponse struct {
Imported int `json:"imported"`
Duplicate int `json:"duplicate"`
Failed int `json:"failed"`
Totalamount float64 `json:"totalamount"`
Results []OfflineSaleResult `json:"results"`
}
type TenantRevenueSummary struct {
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`

View File

@@ -61,6 +61,15 @@ type Partnerinfo struct {
Postcode string `json:"postcode"`
Partnerinfo string `json:"partnerinfo"`
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 {
@@ -129,3 +138,189 @@ type RiderlogDetails struct {
Breakhours float32 `json:"breakhours"`
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"`
}

519
models/pos.go Normal file
View File

@@ -0,0 +1,519 @@
package models
import "strings"
// Wire format for the Nearle POS terminal.
//
// These types mirror what the till actually publishes, field for field. The
// terminal is the fixed side of this contract: it is installed on a hundred
// machines that cannot all be updated at once, so the names here follow its
// JSON rather than this codebase's usual Go casing.
//
// The authoritative description lives in the terminal repository at
// docs/sync-contract.md.
// PosOrderItem is one line of a counter bill.
//
// Productid arrives as a string because the till stores catalogue ids as text.
// It carries the numeric products.productid this backend issued during a
// catalogue pull, so it parses back to an int on arrival.
type PosOrderItem struct {
Productid string `json:"product_id"`
Barcode string `json:"barcode"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unitprice float64 `json:"unit_price"`
Discount float64 `json:"discount"`
Gstrate float64 `json:"gst_rate"`
Tax float64 `json:"tax"`
Linetotal float64 `json:"line_total"`
}
// PosOrderCustomer is the shopper snapshot carried on the bill itself.
//
// Deliberately thin. The full profile travels on its own uplink; this exists so
// a bill can be attached to somebody even when their registration has not
// arrived yet.
type PosOrderCustomer struct {
Id string `json:"id"`
Mobile string `json:"mobile"`
Name string `json:"name"`
}
// PosOrderPayment is one tender against a bill. A bill may be split across
// several.
type PosOrderPayment struct {
Method string `json:"method"`
Amount float64 `json:"amount"`
Reference string `json:"reference"`
}
// PosOrderPromo records a campaign that fired, as an amount rather than a rule.
// A bill read back years later must show what was actually given, not what
// today's rules would give.
type PosOrderPromo struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Amount float64 `json:"amount"`
}
// PosOrder is one completed sale.
//
// Id is a UUID minted at the till and is the only thing that identifies this
// bill. It is what deduplication keys on, because at-least-once delivery means
// the same bill legitimately arrives more than once.
type PosOrder struct {
Id string `json:"id"`
Invoicenumber string `json:"invoice_number"`
Createdat string `json:"created_at"`
Terminalid string `json:"terminal_id"`
Cashier string `json:"cashier"`
Customer *PosOrderCustomer `json:"customer"`
Subtotal float64 `json:"subtotal"`
Discount float64 `json:"discount"`
Promos []PosOrderPromo `json:"promos"`
Tax float64 `json:"tax"`
Roundoff float64 `json:"round_off"`
Total float64 `json:"total"`
Pointsearned int `json:"points_earned"`
Pointsredeemed int `json:"points_redeemed"`
Payments []PosOrderPayment `json:"payments"`
Items []PosOrderItem `json:"items"`
// GST per slab, as printed on the tax invoice: {"0.05": 12.30, "0.18": 4.50}.
// Absent from terminals built before this field existed, which is why every
// consumer of it has to tolerate an empty map.
Taxbreakdown map[string]float64 `json:"tax_breakdown"`
}
// PosOrderBatch is the envelope a terminal publishes.
//
// Storeid carries the numeric tenantlocations.locationid as a string. The
// tenant is resolved from it server-side and never taken from the terminal — a
// till must not be able to name the tenant it posts into.
type PosOrderBatch struct {
Schema int `json:"schema"`
Batchid string `json:"batch_id"`
Storeid string `json:"store_id"`
Terminalid string `json:"terminal_id"`
Sentat string `json:"sent_at"`
Orders []PosOrder `json:"orders"`
}
// PosCustomer is a shopper registered at a till.
//
// No loyalty figures. Points, lifetime spend and visit counts are derived from
// the bill stream, which is idempotent and sees every counter; accepting a
// terminal's local balance would make the last till to sync win.
type PosCustomer struct {
Id string `json:"id"`
Mobile string `json:"mobile"`
Name string `json:"name"`
Email string `json:"email"`
Gender string `json:"gender"`
Dateofbirth string `json:"date_of_birth"`
Registeredat string `json:"registered_at"`
Registeredbyterminal string `json:"registered_by_terminal"`
}
type PosCustomerBatch struct {
Schema int `json:"schema"`
Batchid string `json:"batch_id"`
Storeid string `json:"store_id"`
Terminalid string `json:"terminal_id"`
Sentat string `json:"sent_at"`
Customers []PosCustomer `json:"customers"`
}
// PosAck is the only thing that retires a bill on the terminal.
//
// The rule the whole design rests on: 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
// is sent again.
//
// Naming an id in Rejected is a decision, not a fault: the terminal stops
// retrying that record and waits for a person. Use it for "this bill is
// malformed", never for "the database is having a bad minute" — for the latter,
// do not ack at all and let the till back off and retry.
type PosAck struct {
Batchid string `json:"batch_id"`
Accepted []string `json:"accepted"`
Rejected map[string]string `json:"rejected,omitempty"`
}
// NewPosAck returns an ack with non-nil members, so it serialises as `[]` and
// `{}` rather than `null`. A terminal reading null for accepted would treat the
// whole batch as unconfirmed.
func NewPosAck(batchID string) *PosAck {
return &PosAck{
Batchid: batchID,
Accepted: make([]string, 0),
Rejected: make(map[string]string),
}
}
func (a *PosAck) Accept(id string) {
a.Accepted = append(a.Accepted, id)
}
func (a *PosAck) Reject(id, reason string) {
a.Rejected[id] = reason
}
// PosCatalogueProduct is one product as the till stores it.
type PosCatalogueProduct struct {
Id string `json:"id"`
Name string `json:"name"`
Barcode string `json:"barcode"`
Sku string `json:"sku"`
Category string `json:"category"`
Price float64 `json:"price"`
Mrp float64 `json:"mrp,omitempty"`
Stock float64 `json:"stock"`
Unit string `json:"unit"`
Gstrate float64 `json:"gst_rate"`
Hsncode string `json:"hsn_code,omitempty"`
Brand string `json:"brand,omitempty"`
Isactive bool `json:"is_active"`
}
// PosCatalogueCustomer is a shopper travelling *down* to a terminal.
//
// The mirror of PosCustomer, and the difference is the point: the uplink
// carries no loyalty figures because a till's local balance is only its own
// view, while the downlink carries them because the back office has seen every
// counter and is the only thing that can total them.
type PosCatalogueCustomer struct {
Id string `json:"id"`
Name string `json:"name"`
Mobile string `json:"mobile"`
Email string `json:"email,omitempty"`
Gender string `json:"gender,omitempty"`
Dateofbirth string `json:"date_of_birth,omitempty"`
Loyaltypoints int `json:"loyalty_points"`
Lifetimespend float64 `json:"lifetime_spend"`
Visitcount int `json:"visit_count"`
Createdat string `json:"created_at,omitempty"`
Lastvisitat string `json:"last_visit_at,omitempty"`
}
// PosCatalogueResponse answers a terminal's catalogue pull.
//
// Isdelta is load-bearing. A response marked false is treated as a full
// snapshot and the terminal withdraws every product it does not mention — so
// answering a change set with false empties the shelf.
type PosCatalogueResponse struct {
Revision string `json:"revision"`
Isdelta bool `json:"is_delta"`
Hasmore bool `json:"has_more"`
Products []PosCatalogueProduct `json:"products"`
Customers []PosCatalogueCustomer `json:"customers"`
Retiredids []string `json:"retired_product_ids"`
}
// ---------------------------------------------------------------- Sign-in
//
// A terminal used to hold a store id typed into Settings and a password
// compiled into the app. That made the store id a *claim* rather than a fact:
// any till could name any outlet and be believed, and one leaked build opened
// every tenant on the platform.
//
// These types replace it with the account model the web console already uses.
// A person signs in with their own `app_users` credentials, and the outlet
// comes out of their record instead of going in from the wire.
// PosLoginRequest is what a till sends to sign in.
//
// A mobile number and a four-digit PIN. That is what a person standing at a
// 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
// 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.
type PosLoginRequest struct {
// 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"`
// 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"`
Locationid int `json:"location_id"`
// Which physical till is asking. Recorded on the session so a stolen token
// can be told apart from the terminal it was issued to.
Terminalid string `json:"terminal_id"`
Deviceid string `json:"device_id"`
}
// PosLoginLocation is one outlet a signed-in user may bill for.
type PosLoginLocation struct {
Locationid int `json:"location_id"`
Locationname string `json:"location_name"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
Status string `json:"status,omitempty"`
}
// PosSession is what a till holds for the rest of the trading day.
//
// Storeid is returned as a string because that is the shape the terminal's
// configuration already stores and sends — handing it back in the form it will
// be replayed in removes a conversion, and a conversion is where a store id
// gets mangled.
type PosSession struct {
Token string `json:"token"`
Expiresat string `json:"expires_at"`
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Email string `json:"email,omitempty"`
Roleid int `json:"role_id"`
// What the role is called, and the one thing the terminal actually branches
// on. Sent as a flag rather than leaving the till to map role ids itself:
// `app_roles` has six rows for four roles and most accounts carry an id
// absent from it, so any mapping written on the terminal would be wrong.
Role string `json:"role"`
Canmanagestaff bool `json:"can_manage_staff"`
// Which portal this account belongs to. Carried so a supervisor creating a
// cashier gives them the same configid — an account created under the wrong
// one cannot sign into the web console and is invisible to half the
// platform's queries. Not sent to the terminal: it has no use for it and it
// is one more number to get wrong.
Configid int `json:"-"`
Tenantid int `json:"tenant_id"`
Tenantname string `json:"tenant_name"`
Storeid string `json:"store_id"`
Locationid int `json:"location_id"`
Locationname string `json:"location_name"`
Gstin string `json:"gstin,omitempty"`
Address string `json:"address,omitempty"`
Phone string `json:"phone,omitempty"`
// Every outlet this account may sign a terminal into. A single-outlet user
// gets a list of one, so the till has no special case: it shows a picker
// when there is a choice and skips it when there is not.
Locations []PosLoginLocation `json:"locations"`
// The people who may ring a bill at the chosen outlet.
//
// Sent with the session so a terminal is ready to trade the moment it signs
// in, rather than needing a second call before the first customer. May be
// empty — most tenants have no staff recorded yet — and the terminal has to
// cope with that rather than treat it as a failure.
Staff []PosStaffMember `json:"staff"`
}
// PosStaffMember is one person who may ring a bill at an outlet.
//
// Distinct from the account that signs the *terminal* in. The sign-in says
// which shop this till belongs to; this says who is standing at it, and it is
// what gets stamped on a bill as `cashiername` and settled against at the end
// of a shift.
//
// The PIN used to travel down with this list, on the reasoning that a PIN was
// *shift attribution* rather than a security boundary: the token decided which
// books a till could reach, and the PIN only decided which of the people
// already inside a shop got credited with a sale.
//
// That reasoning ended when the PIN became half of the sign-in. A list of PINs
// is now a list of working credentials for the outlet — including the
// supervisor's, which carries `can_manage_staff` — so a cashier handed this
// array could sign back in as their own manager. Hence `json:"-"`: the field is
// 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 {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Role string `json:"role"`
Pin string `json:"-"`
Status string `json:"status,omitempty"`
}
// PosStaffResponse answers a request for an outlet's people.
type PosStaffResponse struct {
Locationid int `json:"location_id"`
Staff []PosStaffMember `json:"staff"`
}
// ------------------------------------------------------------ POS staff roles
//
// `app_roles` is keyed by roleid and carries a configid, so the same name
// appears more than once — Admin is both 3 and 5, Manager both 4 and 6, one per
// portal. These two are deliberately not per-portal: a till is a till whichever
// tenant owns it, and a role that had to be duplicated per config would be one
// more thing to remember when a tenant is onboarded.
//
// The ids are fixed rather than allocated, because they are referenced from the
// terminal and from this source. `app_roles.roleid` has no sequence and no
// default — every id in that table was assigned by hand — so nothing is being
// worked around here.
const (
// PosRoleSupervisor runs the terminal: settings, imports, price overrides,
// voids, and creating the people below.
PosRoleSupervisor = 7
// PosRoleCashier bills, and nothing else.
PosRoleCashier = 8
)
// PosRoleName maps a role id to what a person calls it.
func PosRoleName(roleID int) string {
switch roleID {
case PosRoleSupervisor:
return "Supervisor"
case PosRoleCashier:
return "Cashier"
}
return ""
}
// PosRoleFromName reads the role off a request.
//
// Accepts the name rather than the number, so a caller never has to hardcode 7
// or 8 — and returns 0 for anything unrecognised, which every caller treats as
// a refusal rather than as a default.
func PosRoleFromName(name string) int {
switch strings.ToLower(strings.TrimSpace(name)) {
case "supervisor":
return PosRoleSupervisor
case "cashier":
return PosRoleCashier
}
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.
//
// Supervisors, and nobody else.
//
// This used to include the back office's own roles 1 to 6, on the reasoning
// that somebody who can already administer a shop from a browser is not made
// 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 {
return roleID == PosRoleSupervisor
}
// PosUser is a person who signs in at a till.
type PosUser struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Firstname string `json:"first_name,omitempty"`
Lastname string `json:"last_name,omitempty"`
Authname string `json:"authname,omitempty"`
Contactno string `json:"contactno,omitempty"`
Roleid int `json:"role_id"`
Role string `json:"role"`
Pin string `json:"pin,omitempty"`
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"`
Status string `json:"status"`
}
// PosUserRequest creates or edits a till user.
//
// Note what is absent: tenant and location. Both come from the caller's own
// session token. A supervisor creating staff can only ever create them at their
// own outlet, and no field in this struct can say otherwise — which is the same
// inversion that stopped a till naming its own shop.
type PosUserRequest struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Role string `json:"role"`
Pin string `json:"pin"`
Password string `json:"password"`
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"`
// 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"`
}
// PosUserWebRequest is a staff change made from the web console.
//
// Identical to [PosUserRequest] but for the two fields a terminal never needs
// to send: the console has no session token, so it has to name the outlet it is
// working on. That is the one real difference between the two doors into this,
// and it is also the weaker one — the till's outlet is proved by a signature,
// while this is asserted. The handler checks the outlet belongs to the tenant
// before writing anything, which is as far as it can go without the console
// holding a session of its own.
type PosUserWebRequest struct {
PosUserRequest
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
}

58
models/poshealth.go Normal file
View File

@@ -0,0 +1,58 @@
package models
// PosHealth is what a till reports about itself every 30 seconds.
//
// This is a liveness signal, not a record. It lives in Redis under a TTL and is
// never written to Postgres: a terminal that dies simply stops refreshing and
// disappears from the board on its own, with no reaper job and no row left
// claiming "online" three days after the shop closed.
//
// The fields exist to answer questions a person actually asks when a shop
// phones in: is the till on, is it reaching us, is it selling anything, and is
// the hardware in the way.
type PosHealth struct {
// Identity. Terminalid is the short code printed on invoices — the thing a
// support call starts with.
Terminalid string `json:"terminal_id"`
Locationid string `json:"location_id"`
Storename string `json:"store_name"`
Appversion string `json:"app_version"`
// "online" while the till is refreshing this. The broker's Last Will
// overwrites it with "offline" if the terminal loses power mid-shift, which
// is the only way to tell *closed for the night* from *unplugged*.
Status string `json:"status"`
// Queue depth — the number that matters most. A shop quietly accumulating
// unsynced takings looks completely normal from the shop floor, and this is
// the only thing that makes it visible before someone reconciles a till and
// finds a day missing.
Pendingbills int `json:"pending_bills"`
Pendingregistrations int `json:"pending_registrations"`
Oldestpendingat string `json:"oldest_pending_at"`
// Today's trading. A till that is connected but has rung nothing in three
// hours usually means a jammed printer or an absent cashier, and neither
// shows up in a plain online/offline board.
Todaybills int `json:"today_bills"`
Todayamount float64 `json:"today_amount"`
Lastbillat string `json:"last_bill_at"`
// Device state, for pre-emptive support.
//
// Pointers so that *not reported* is distinguishable from *reported as
// zero*. Not every build collects these — battery and free storage need
// platform packages a desktop till has no use for — and writing an
// uncollected reading as 0 would show a board full of terminals on a flat
// battery with an unreachable printer. A nil field is skipped entirely.
Batterylevel *int `json:"battery_level,omitempty"`
Batterycharging *bool `json:"battery_charging,omitempty"`
Storagefreemb *int `json:"storage_free_mb,omitempty"`
Printerreachable *bool `json:"printer_reachable,omitempty"`
Drawerstatus *string `json:"drawer_status,omitempty"`
// Stamped by the till. The consumer also stamps its own arrival time, and
// the two disagreeing is itself a signal — a till whose clock is wrong
// writes bills under the wrong business date.
Reportedat string `json:"reported_at"`
}

209
models/posorder.go Normal file
View File

@@ -0,0 +1,209 @@
package models
import "time"
// Counter sales, stored at the fidelity the till actually rang them.
//
// Separate from `orders` on purpose. An app order and a counter bill are
// different documents: a bill carries a cashier, a terminal, a rounding
// adjustment, promo campaigns, loyalty movement and a payment split across
// several tenders, none of which `orders` has anywhere to put. Forcing one into
// the other's shape loses whichever fields do not fit, and the loss is silent.
//
// The cost of the split is that existing revenue queries do not see these rows
// until they are extended to union them in — done in orderRepository's summary
// queries, and the thing to remember when adding a new report.
//
// Stock is *not* separate: a counter sale writes the same productstocks "out"
// rows an app order does, through the same helper. Two stock ledgers would mean
// the catalogue pull sends a till figures that ignore its own sales.
// PosOrders is one counter bill.
type PosOrders struct {
Posorderid int `json:"posorderid" gorm:"primaryKey;autoIncrement;column:posorderid"`
// The UUID minted at the till. Globally unique by construction and the only
// thing that identifies this bill, so it carries a unique index: delivery is
// at-least-once and the same bill legitimately arrives more than once.
Terminalorderid string `json:"terminalorderid" gorm:"column:terminalorderid;uniqueIndex;not null"`
// Human-facing, and unique only per terminal — a till that was replaced
// restarts its own series, so gaps are normal and duplicates across
// terminals are expected.
Invoicenumber string `json:"invoicenumber" gorm:"column:invoicenumber;index"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
// Which physical till, e.g. "T4A9". Free text: nothing keys on it, but a
// support call starts with it.
Terminalid string `json:"terminalid" gorm:"column:terminalid;index"`
Cashiername string `json:"cashiername" gorm:"column:cashiername"`
// Resolved against the customers table. Zero for a walk-in.
Customerid int `json:"customerid" gorm:"column:customerid;index"`
Customermobile string `json:"customermobile" gorm:"column:customermobile"`
Customername string `json:"customername" gorm:"column:customername"`
// When the sale was rung, not when it reached us — a till that was offline
// for a day uploads bills whose Billedat is yesterday, and every daily
// figure must use this rather than Receivedat.
Billedat time.Time `json:"billedat" gorm:"column:billedat;index"`
// YYYY-MM-DD of Billedat, denormalised so a day's takings are one indexed
// equality match rather than a range scan with timezone arithmetic.
Businessdate string `json:"businessdate" gorm:"column:businessdate;index"`
Subtotal float64 `json:"subtotal" gorm:"column:subtotal"`
Discount float64 `json:"discount" gorm:"column:discount"`
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
// The paise adjustment printed on the bill. Kept because total is not
// derivable from the other columns without it.
Roundoff float64 `json:"roundoff" gorm:"column:roundoff"`
// What the shopper actually paid. The figure every revenue report sums.
Total float64 `json:"total" gorm:"column:total"`
Pointsearned int `json:"pointsearned" gorm:"column:pointsearned"`
Pointsredeemed int `json:"pointsredeemed" gorm:"column:pointsredeemed"`
Itemcount int `json:"itemcount" gorm:"column:itemcount"`
// The largest tender, for the common "how did they pay" grouping.
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode;index"`
// The full split, verbatim. A bill can be part cash, part card, part
// loyalty, and collapsing that to one mode would lose the reconciliation a
// cashier settles their drawer against.
Paymentsjson string `json:"paymentsjson" gorm:"column:paymentsjson;type:jsonb"`
// Campaigns that fired, stored as amounts rather than rules — a bill read
// back years later must show what was given, not what today's rules give.
Promosjson string `json:"promosjson" gorm:"column:promosjson;type:jsonb"`
// GST per slab, as printed on the tax invoice.
Taxbreakdownjson string `json:"taxbreakdownjson" gorm:"column:taxbreakdownjson;type:jsonb"`
// Which upload carried this bill, and when it landed. Kept for tracing a
// terminal's complaint back to a specific batch.
Batchid string `json:"batchid" gorm:"column:batchid;index"`
Receivedat time.Time `json:"receivedat" gorm:"column:receivedat"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
Items []PosOrderItems `json:"items" gorm:"-"`
}
func (PosOrders) TableName() string {
return "pos_orders"
}
// PosOrderItems is one line of a counter bill.
type PosOrderItems struct {
Posorderitemid int `json:"posorderitemid" gorm:"primaryKey;autoIncrement;column:posorderitemid"`
Posorderid int `json:"posorderid" gorm:"column:posorderid;index"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
Productid int `json:"productid" gorm:"column:productid;index"`
// Snapshotted rather than joined. A product renamed or withdrawn next month
// must not change what a bill from today says it sold.
Productname string `json:"productname" gorm:"column:productname"`
Barcode string `json:"barcode" gorm:"column:barcode"`
Unitname string `json:"unitname" gorm:"column:unitname"`
// Fractional: a counter sells 1.5 kg of onions. Note that productstocks
// cannot represent that — see roundStockQty.
Quantity float64 `json:"quantity" gorm:"column:quantity"`
Unitprice float64 `json:"unitprice" gorm:"column:unitprice"`
Discountamount float64 `json:"discountamount" gorm:"column:discountamount"`
// Stored as a fraction (0.18), matching how the till holds it.
Gstrate float64 `json:"gstrate" gorm:"column:gstrate"`
Taxamount float64 `json:"taxamount" gorm:"column:taxamount"`
// What this line contributed to the bill total, after its share of every
// discount. The lines sum to the bill's Total less Roundoff.
Linetotal float64 `json:"linetotal" gorm:"column:linetotal"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
}
func (PosOrderItems) TableName() string {
return "pos_order_items"
}
// PosSalesFilter scopes a query over counter sales.
//
// Locationid is required and is the authorisation boundary — every read is
// scoped to one outlet, so a caller cannot page through another shop's takings
// by omitting a parameter.
type PosSalesFilter struct {
Locationid int
Fromdate string // YYYY-MM-DD, matched against businessdate
Todate string
Terminalid string
Cashiername string
Paymentmode string
Pageno int
Pagesize int
}
// PosSalesPage is one page of bills, with the total so a caller can paginate
// without a second request.
type PosSalesPage struct {
Total int64 `json:"total"`
Pageno int `json:"pageno"`
Pagesize int `json:"pagesize"`
Bills []PosOrders `json:"bills"`
}
// PosSalesSummary totals a range of counter sales.
//
// Deliberately separate from the bill list: a shop settling a till wants the
// figures, not five hundred rows, and computing them client-side would mean
// fetching every page first.
type PosSalesSummary struct {
Locationid int `json:"locationid"`
Fromdate string `json:"fromdate"`
Todate string `json:"todate"`
Billcount int `json:"billcount"`
Itemcount int `json:"itemcount"`
Grosssales float64 `json:"grosssales"`
Taxcollected float64 `json:"taxcollected"`
Discount float64 `json:"discountgiven"`
Roundoff float64 `json:"roundoff"`
Averagebill float64 `json:"averagebill"`
// What a cashier reconciles the drawer against.
Bypaymentmode []PosPaymentTotal `json:"bypaymentmode"`
// One row per trading day, for a chart.
Byday []PosDayTotal `json:"byday"`
// Which tills contributed, so an outlet with several counters can see them
// apart without a second query.
Byterminal []PosTerminalTotal `json:"byterminal"`
}
type PosPaymentTotal struct {
Paymentmode string `json:"paymentmode"`
Billcount int `json:"billcount"`
Amount float64 `json:"amount"`
}
type PosDayTotal struct {
Businessdate string `json:"businessdate"`
Billcount int `json:"billcount"`
Amount float64 `json:"amount"`
}
type PosTerminalTotal struct {
Terminalid string `json:"terminalid"`
Billcount int `json:"billcount"`
Amount float64 `json:"amount"`
}

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"`
}
// 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 {
Categoryid int `json:"categoryid"`
Moduleid int `json:"moduleid"`
@@ -36,53 +53,167 @@ type ProductCategory struct {
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 {
Variantid int `json:"variantid" gorm:"Primary_Key"`
Tenantid int `json:"tenantid"`
Variantname string `json:"variantname"`
Variantid int `json:"variantid" gorm:"primaryKey;autoIncrement"`
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"`
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"`
Categoryname string `json:"categoryname" gorm:"-"`
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 {
Productid int `json:"productid" gorm:"primaryKey;autoIncrement"`
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
Productlocationid int `json:"productlocationid" gorm:"->"`
Tenantid int `json:"tenantid,omitempty"`
Categoryid int `json:"categoryid"`
Categoryname string `json:"categoryname" gorm:"->"`
Subcategoryid int `json:"subcategoryid,omitempty"`
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
Catalogueid int `json:"catalogueid,omitempty"`
Addonid int `json:"addonid,omitempty"`
Discountid int `json:"discountid"`
Discountvalue float64 `json:"discountvalue"`
Pricingid int `json:"pricingid,omitempty"`
Productname string `json:"productname,omitempty"`
Productimage string `json:"productimage,omitempty"`
Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit,omitempty"`
Unitvalue string `json:"unitvalue,omitempty"`
Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"`
Taxpercent float64 `json:"taxpercent,omitempty"`
Producttax int `json:"producttax" gorm:"default:0"`
Productstock int `json:"productstock" gorm:"default:0"`
Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"`
Quantity int `json:"quantity"`
Retailprice float64 `json:"retailprice,omitempty"`
Diffprice float64 `json:"diffprice,omitempty"`
Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"`
Approve int `json:"approve"`
Productstatus string `json:"productstatus" `
Productid int `json:"productid" gorm:"primaryKey;autoIncrement"`
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
Productlocationid int `json:"productlocationid" gorm:"->"`
Tenantid int `json:"tenantid,omitempty"`
Categoryid int `json:"categoryid"`
Categoryname string `json:"categoryname" gorm:"->"`
Subcategoryid int `json:"subcategoryid,omitempty"`
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
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"`
Discountid int `json:"discountid"`
Discountvalue float64 `json:"discountvalue"`
Pricingid int `json:"pricingid,omitempty"`
Productname string `json:"productname,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"`
Productdesc string `json:"productdesc,omitempty"`
Productsku string `json:"productsku,omitempty"`
Brandid int `json:"brandid,omitempty"`
Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"`
Taxpercent float64 `json:"taxpercent,omitempty"`
Producttax int `json:"producttax" gorm:"default:0"`
Productstock int `json:"productstock" gorm:"default:0"`
Productcombo int `json:"productcombo" gorm:"default:0"`
Variants int `json:"variants" gorm:"default:0"`
// 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"`
// Price is the EFFECTIVE selling price at the location a query was scoped
// to: productlocations.price when the store has set one, otherwise the
// master Retailprice below. Read-only — it is computed by the query, never
// written through this struct. Location-scoped endpoints must expose it, or
// a price the admin sets per store can never reach the customer app: they
// returned only Retailprice, which the admin catalogue never writes.
// Same meaning as Locationproducts.Price, so both product feeds agree.
Price float64 `json:"price" gorm:"->"`
Retailprice float64 `json:"retailprice"`
Diffprice float64 `json:"diffprice,omitempty"`
Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"`
Approve int `json:"approve"`
Productstatus string `json:"productstatus" `
// Populated only by queries scoped to a specific location (e.g.
// GetProductByVariant when locationid is passed): Productstock becomes
// the live SUM(in)-SUM(out) balance from productstocks — the same
@@ -111,10 +242,32 @@ type Locationproducts struct {
Productimage string `json:"productimage,omitempty"`
Productdesc string `json:"productdesc,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"`
Productbrand string `json:"productbrand,omitempty"`
Productunit string `json:"productunit,omitempty"`
Unitvalue string `json:"unitvalue,omitempty"`
Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue"`
Toppicks string `json:"toppicks,omitempty"`
Productcost float64 `json:"productcost,omitempty"`
Taxamount float64 `json:"taxamount,omitempty"`
@@ -129,13 +282,18 @@ type Locationproducts struct {
// 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 float64 `json:"price" gorm:"->"`
Retailprice float64 `json:"retailprice,omitempty"`
Retailprice float64 `json:"retailprice"`
Diffprice float64 `json:"diffprice,omitempty"`
Diffpercent float64 `json:"diffpercent,omitempty"`
Othercost float64 `json:"othercost,omitempty"`
Approve int `json:"approve" gorm:"default:0"`
// Productstatus string `json:"productstatus" gorm:"default:available"`
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 {
@@ -283,6 +441,13 @@ type TenantCategory struct {
type ImportedCatalogueRef struct {
Brand string `json:"brand"`
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
@@ -316,6 +481,20 @@ type Productlocations struct {
Quantity int `json:"quantity" gorm:"<-:false"`
Stocktype string `json:"stocktype" gorm:"<-:false"`
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
@@ -327,6 +506,57 @@ type ProductLocationRef struct {
Productid int
}
// SaleTemplateRow is one line of the downloadable offline-sales spreadsheet: a
// product stocked at one branch, with the numbers the person at the till needs
// to see before typing a sold quantity against it.
//
// Tenantid and Locationid ride on every row because one workbook covers every
// branch a merchant runs. The row's own Locationid decides which branch's stock
// its sale comes out of — a tenant-level import would be wrong, since the same
// product is held separately at each outlet.
//
// Productid is the only field that identifies the product. It cannot be
// productsku: across the live catalogue 6,245 products share just 93 distinct
// sku values (one tenant has 463 products all carrying sku "1"), and 154 are
// blank, so a sku is not a key. Productname is nearly unique per tenant but not
// reliably ("rice" appears 6 times for one tenant), so it travels as a
// human-readable confirmation only and is never matched on. That is why the
// spreadsheet has to be generated from this endpoint rather than typed from
// scratch — productid and locationid are filled in for the user.
type SaleTemplateRow struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Productid int `json:"productid"`
Productname string `json:"productname"`
Productunit string `json:"productunit"`
Unitvalue string `json:"unitvalue"`
Categoryname string `json:"categoryname"`
Currentstock int `json:"currentstock"`
Price float64 `json:"price"`
Taxpercent float64 `json:"taxpercent"`
}
// SaleTemplateLocation is one branch covered by the workbook, so the sheet can
// list what it spans and the UI can summarise it without walking every row.
type SaleTemplateLocation struct {
Locationid int `json:"locationid"`
Locationname string `json:"locationname"`
Productcount int `json:"productcount"`
}
// SaleTemplate is the payload the web app turns into an .xlsx workbook.
//
// Locationid is 0 when the template spans every branch of the tenant, which is
// the normal case for an owner or admin. A store user gets a template for their
// own branch only, and it is then the single entry in Locations.
type SaleTemplate struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Locations []SaleTemplateLocation `json:"locations"`
Products []SaleTemplateRow `json:"products"`
}
type ProductSubcategory struct {
Subcatid int `json:"subcatid"`
Categoryid int `json:"categoryid"`

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

172
models/scan.go Normal file
View File

@@ -0,0 +1,172 @@
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 {
Productid int `json:"productid"`
Productname string `json:"productname"`
Size string `json:"size"` // "500 g", "1 kg" — unitvalue + productunit
Price float64 `json:"price"`
Stock int `json:"stock"`
Available bool `json:"available"`
Image string `json:"image,omitempty"`
// Is this the product that matched, or a size hanging under it?
IsVariant bool `json:"is_variant"`
Variantname string `json:"variantname,omitempty"`
// How the row was tied back to the catalogue: "imageid",
// "brand+catalogueid", "name" or "variant-of:<productid>".
MatchedBy string `json:"matched_by"`
}
// ScanStoreOffer is one store and what it can sell.
type ScanStoreOffer struct {
ScanStore
// Nearest store with at least one option in stock. Exactly one offer
// carries this, and only when something is in stock somewhere.
Recommended bool `json:"recommended"`
// Any option in stock here.
Available bool `json:"available"`
Options []ScanOption `json:"options"`
}
// ScanCatalogueMatch is what the catalogue search settled on.
type ScanCatalogueMatch struct {
Brand string `json:"brand"`
Catalogueid int64 `json:"catalogueid"`
Imageid string `json:"imageid,omitempty"`
ProductName string `json:"product_name"`
Title string `json:"title,omitempty"`
Category string `json:"category,omitempty"`
Size string `json:"size,omitempty"`
VariantKey string `json:"variant_key,omitempty"`
Image string `json:"image,omitempty"`
Score float64 `json:"score"`
// "vector+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

@@ -3,28 +3,49 @@ package models
import "time"
type Tenantinfo struct {
Tenantid int `json:"tenantid" gorm:"Primary_Key"`
Locationid int `json:"locationid"`
Tenantname string `json:"tenantname"`
Locationname string `json:"locationname"`
Tenanttype string `json:"tenanttype"`
Registrationno string `json:"registrationno"`
Tenanttoken string `json:"tenanttoken"`
Companyname string `json:"companyname"`
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
Locationcatact string `json:"locationcontact"`
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Postcode string `json:"postcode"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
Tenantimage string `json:"tenantimage"`
Tenantinfo string `json:"tenantinfo"`
Tenantid int `json:"tenantid" gorm:"Primary_Key"`
Locationid int `json:"locationid"`
Tenantname string `json:"tenantname"`
Locationname string `json:"locationname"`
Tenanttype string `json:"tenanttype"`
Registrationno string `json:"registrationno"`
Tenanttoken string `json:"tenanttoken"`
Companyname string `json:"companyname"`
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
Locationcatact string `json:"locationcontact"`
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Address string `json:"address"`
Suburb string `json:"suburb"`
City string `json:"city"`
State string `json:"state"`
Postcode string `json:"postcode"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
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"`
// 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"`
Paymode1 int `json:"paymode1"`
Paymode2 int `json:"paymode2"`
@@ -36,6 +57,13 @@ type Tenantinfo struct {
Approved int `json:"approved"`
Moduleid int `json:"moduleid"`
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"`
Lastname string `json:"lastname"`
Accountname string `json:"Accountname"`
@@ -43,6 +71,14 @@ type Tenantinfo struct {
Allocationid int `json:"allocationid"`
Allocationtype string `json:"allocationtype"`
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 {
@@ -68,6 +104,21 @@ type Tenantlocations struct {
Deliverymins int `json:"deliverymins"`
Cancelsecs int `json:"cancelsecs"`
Status string `json:"status" gorm:"default:Active"`
// 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 {
@@ -120,7 +171,11 @@ type Tenantpricing struct {
}
type StaffInfo struct {
Userid int `json:"userid"`
Userid int `json:"userid"`
// What the role is called, so a console does not have to map ids itself.
// `app_roles` holds six rows for four back-office roles and most accounts
// carry an id absent from it, so any mapping written client-side is wrong.
Rolename string `json:"rolename"`
Authname string `json:"authname"`
Configid int `json:"configid"`
Authmode int `json:"authmode"`
@@ -143,6 +198,10 @@ type StaffInfo struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
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"`
}
type Tenantuser struct {

View File

@@ -101,4 +101,11 @@ type TenantUserInfo struct {
Categoryname string `json:"categoryname"`
Subcategoryid int `json:"subcategoryid"`
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,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
import (
"encoding/json"
"errors"
"fmt"
"log"
"nearle/db"
"nearle/models"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
@@ -38,6 +41,81 @@ const catalogueProductColumns = `id, product_name, title, description, category,
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`
// 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
// columns land here as their raw Postgres text[] literal.
type catalogueProductRow struct {
@@ -59,6 +137,65 @@ type catalogueProductRow struct {
SearchQuery string
CreatedAt 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 {
@@ -70,7 +207,7 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
Description: row.Description,
Category: row.Category,
ImageID: row.ImageID,
Images: db.GetImages(brand, row.ImageID),
Images: imagesFor(brand, row),
Size: row.Size,
VariantKey: row.VariantKey,
ProductSKU: row.ProductSKU,
@@ -92,6 +229,7 @@ type CatalogueRepository interface {
GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error)
GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error)
GetProductByID(brand string, id int64) (*models.CatalogueProduct, error)
GetProductByImageID(brand, imageID string) (*models.CatalogueProduct, error)
}
type catalogueRepository struct {
@@ -104,12 +242,210 @@ func NewCatalogueRepository(db *gorm.DB) CatalogueRepository {
return &catalogueRepository{db: db}
}
func tableForBrand(brand string) (string, error) {
table, ok := catalogueBrandTables[strings.ToLower(strings.TrimSpace(brand))]
if !ok {
return "", ErrUnknownBrand
// brandTables is the allowlist actually used, discovered from the catalogue
// database rather than hardcoded.
//
// 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 table, nil
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
}
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) {
@@ -118,15 +454,29 @@ func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
}
var brands []models.CatalogueBrand
var failures int
for brand, table := range catalogueBrandTables {
for brand, table := range r.brandTables() {
var count int64
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})
}
// 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
}
@@ -135,7 +485,7 @@ func (r *catalogueRepository) GetCategories(brand string) ([]string, error) {
return nil, ErrCatalogueDBUnavailable
}
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, err
}
@@ -172,7 +522,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) {
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, 0, err
}
@@ -189,7 +539,7 @@ func (r *catalogueRepository) getProductsForBrand(brand, category, keyword strin
var rows []catalogueProductRow
dataQuery := fmt.Sprintf(
`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)
if err := r.db.Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil {
@@ -214,25 +564,39 @@ func (r *catalogueRepository) getProductsAllBrands(category, keyword string, pag
whereClause, args := catalogueWhereClause(category, keyword)
brands := make([]string, 0, len(catalogueBrandTables))
for brand := range catalogueBrandTables {
tables := r.brandTables()
for brand := range tables {
brands = append(brands, brand)
}
sort.Strings(brands)
var all []models.CatalogueProduct
var failures int
for _, brand := range brands {
table := catalogueBrandTables[brand]
table := tables[brand]
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 {
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 {
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 {
return strings.ToLower(all[i].ProductName) < strings.ToLower(all[j].ProductName)
})
@@ -274,7 +638,7 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
return nil, ErrCatalogueDBUnavailable
}
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, err
}
@@ -282,7 +646,7 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
var row catalogueProductRow
query := fmt.Sprintf(
`SELECT %s FROM %s WHERE product_sku = ? LIMIT 1`,
catalogueProductColumns, table,
columnsForTable(table), table,
)
result := r.db.Raw(query, sku).Scan(&row)
if result.Error != nil {
@@ -296,12 +660,53 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
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) {
if r.db == nil {
return nil, ErrCatalogueDBUnavailable
}
table, err := tableForBrand(brand)
table, err := r.tableForBrand(brand)
if err != nil {
return nil, err
}
@@ -309,7 +714,7 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca
var row catalogueProductRow
query := fmt.Sprintf(
`SELECT %s FROM %s WHERE id = ? LIMIT 1`,
catalogueProductColumns, table,
columnsForTable(table), table,
)
result := r.db.Raw(query, id).Scan(&row)
if result.Error != nil {

View File

@@ -0,0 +1,177 @@
package repositories
import (
"errors"
"strings"
"nearle/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type CatalogueUploadRepository interface {
Record(upload *models.CatalogueUpload) error
List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error)
UpdateStatus(upload *models.CatalogueUpload) error
MarkShelved(batchID string, shelved, skipped int) error
AttachSheet(batchID, sheetRows string) error
}
type catalogueUploadRepository struct {
db *gorm.DB
}
func NewCatalogueUploadRepository(db *gorm.DB) CatalogueUploadRepository {
return &catalogueUploadRepository{db: db}
}
// Record stores the receipt, or returns the one already stored.
//
// Idempotent on `batchid`, which matters more than it looks. The console writes
// this the instant the drop is accepted and then polls; a refresh, a retry after
// a dropped response, or two tabs watching the same upload would otherwise each
// insert a row, and the operator would see one upload three times.
//
// `DoNothing` rather than an update, because everything a second caller could
// supply here is the same data it supplied the first time. Progress arrives
// through UpdateStatus, which is the call that is allowed to overwrite.
func (r *catalogueUploadRepository) Record(upload *models.CatalogueUpload) error {
if strings.TrimSpace(upload.Batchid) == "" {
// Without the id the row is worthless: the id is the ONLY credential
// for reading the result back, and nothing else in the receipt can
// stand in for it.
return errors.New("batchid is required — it is the only way to read the result back")
}
if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "batchid"}},
DoNothing: true,
}).Create(upload).Error; err != nil {
return err
}
// A conflict leaves Uploadid zero and the caller with nothing to show, so
// the existing row is read back and returned as if it had just been made.
if upload.Uploadid == 0 {
return r.db.Where("batchid = ?", upload.Batchid).First(upload).Error
}
return nil
}
// List returns receipts newest first, scoped to whoever is asking.
//
// A tenant of 0 means "every tenant" and is how a Nearle Admin sees the whole
// platform; a store login always sends its own. The scoping is deliberately the
// same shape as the import itself — see the note on ImportScope in the console —
// because a receipt that could be read by the wrong shop would leak what another
// merchant stocks.
//
// The two names are LEFT joined. A tenant or branch can be deleted after the
// upload, and a receipt that vanishes with it would take the audit trail with
// it; joined this way the row survives, just unnamed.
func (r *catalogueUploadRepository) List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) {
var uploads []models.CatalogueUpload
query := r.db.Table("catalogueuploads").
Select("catalogueuploads.*, tenants.tenantname, tenantlocations.locationname").
Joins("left join tenants on tenants.tenantid = catalogueuploads.tenantid").
Joins("left join tenantlocations on tenantlocations.locationid = catalogueuploads.locationid")
if tenantID > 0 {
query = query.Where("catalogueuploads.tenantid = ?", tenantID)
}
if locationID > 0 {
query = query.Where("catalogueuploads.locationid = ?", locationID)
}
if pageNo < 1 {
pageNo = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
err := query.
Order("catalogueuploads.created DESC").
Offset((pageNo - 1) * pageSize).
Limit(pageSize).
Find(&uploads).Error
return uploads, err
}
// UpdateStatus caches what the ingest service last said about a drop.
//
// Only the fields the service owns are written, and they are written by column
// rather than by struct: a zero-valued struct field would otherwise be skipped
// by GORM's non-zero update, and "0 inserted" is a real answer that has to be
// storable. Everything the console owns — tenant, branch, filename, who sent it
// — is deliberately not touched, so a stale browser cannot rewrite the identity
// of a receipt while reporting progress on it.
func (r *catalogueUploadRepository) UpdateStatus(upload *models.CatalogueUpload) error {
if strings.TrimSpace(upload.Batchid) == "" {
return errors.New("batchid is required")
}
fields := map[string]any{
"laststatus": upload.Laststatus,
"inserted": upload.Inserted,
"backfilled": upload.Backfilled,
"skipped": upload.Skipped,
"rejected": upload.Rejected,
}
// Never blanked. A drop reports `released_to` once and then the drop itself
// is spent; a later poll of the RUN carries no run id of its own, and
// writing that empty value back would lose the only pointer from the id we
// hold to the results.
if strings.TrimSpace(upload.Runid) != "" {
fields["runid"] = upload.Runid
}
return r.db.Model(&models.CatalogueUpload{}).
Where("batchid = ?", upload.Batchid).
Updates(fields).Error
}
// MarkShelved records the half of the story the ingest service knows nothing
// about: that the products were priced, put on a branch's shelf and given their
// opening stock.
//
// Kept apart from UpdateStatus because the two answer different questions and
// fail independently. "The catalogue has them" and "this shop can sell them"
// are not the same claim, and a receipt that showed only the first would report
// success for products no customer can buy.
func (r *catalogueUploadRepository) MarkShelved(batchID string, shelved, skipped int) error {
if strings.TrimSpace(batchID) == "" {
return errors.New("batchid is required")
}
return r.db.Model(&models.CatalogueUpload{}).
Where("batchid = ?", batchID).
Updates(map[string]any{
"shelvedcount": shelved,
"skippedcount": skipped,
"shelvedat": gorm.Expr("CURRENT_TIMESTAMP"),
}).Error
}
// AttachSheet stores the sheet's rows against a receipt that has none.
//
// The rescue path for a receipt filed before the rows were kept, and for one
// whose upload predates this column. The prices and opening stock exist only in
// the spreadsheet; if the receipt was written without them, the only way back is
// for somebody to hand the file over again.
//
// Deliberately not part of UpdateStatus. That call is made by any browser
// polling the ingest service and must never touch what the console owns; this
// one is a person supplying missing data, and the two should not be able to
// happen by accident in each other's place.
func (r *catalogueUploadRepository) AttachSheet(batchID, sheetRows string) error {
if strings.TrimSpace(batchID) == "" {
return errors.New("batchid is required")
}
if strings.TrimSpace(sheetRows) == "" {
// Empty would be stored as `[]` and read back as "this sheet had no
// rows", which is a different claim from "nobody has supplied them
// yet" — and the second is what makes the shelve button appear.
return errors.New("sheetrows is required")
}
return r.db.Model(&models.CatalogueUpload{}).
Where("batchid = ?", batchID).
Update("sheetrows", sheetRows).Error
}

View File

@@ -188,10 +188,21 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
var args []interface{}
searchLike := "%" + keyword + "%"
// DISTINCT ON collapses to one row per customer BEFORE the LIMIT is applied.
// Without it the store-scoped branch below paginated the joined
// customerlocations rows — one per saved address — so `pagesize` bought a
// page of addresses, not of customers. Live example: locationid 1185 returned
// 12 rows that were only 2 people, 11 of them one customer's addresses. A
// store with a page size of 20 therefore listed roughly three customers and
// gave no hint that the rest existed.
//
// The ORDER BY must lead with the DISTINCT ON expression, so customerid sorts
// first; the trailing keys only decide WHICH address represents a customer,
// preferring the one flagged primary.
if lid != 0 {
q1 = `SELECT a.customerid,a.firstname,a.lastname,a.contactno,a.email,
q1 = `SELECT DISTINCT ON (a.customerid) a.customerid,a.firstname,a.lastname,a.contactno,a.email,
b.locationid as deliverylocationid,b.address,b.suburb,b.city,b.state,b.landmark,b.doorno,b.postcode,
b.latitude,b.longitude,a.applocationid,c.locationid as tenantlocationid,a.status
b.latitude,b.longitude,a.applocationid,c.locationid as tenantlocationid,a.status
FROM customers a
LEFT JOIN customerlocations b ON a.customerid=b.customerid
INNER JOIN tenantcustomers c ON a.customerid=c.customerid
@@ -204,13 +215,17 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
args = append(args, searchLike, searchLike, searchLike)
}
q1 += ` ORDER BY a.customerid DESC LIMIT ? OFFSET ?`
q1 += ` ORDER BY a.customerid DESC, b.primaryaddress DESC NULLS LAST, b.locationid ASC
LIMIT ? OFFSET ?`
args = append(args, pagesize, offset)
} else {
q1 = `SELECT a.customerid,a.firstname,a.lastname,a.contactno,a.email,
// A customer linked to several outlets of the same tenant has one
// tenantcustomers row per outlet, so this branch double-counted them
// against the LIMIT too.
q1 = `SELECT DISTINCT ON (a.customerid) a.customerid,a.firstname,a.lastname,a.contactno,a.email,
a.address,a.suburb,a.city,a.state,a.landmark,a.doorno,a.postcode,
a.latitude,a.longitude,a.applocationid,c.locationid as tenantlocationid,a.status
a.latitude,a.longitude,a.applocationid,c.locationid as tenantlocationid,a.status
FROM customers a
INNER JOIN tenantcustomers c ON a.customerid=c.customerid
WHERE c.tenantid = ?`
@@ -223,12 +238,10 @@ func (r *customerRepository) GetTenantCustomers(tid, lid, pageno, pagesize int,
args = append(args, searchLike, searchLike, searchLike)
}
q1 += ` ORDER BY a.customerid DESC LIMIT ? OFFSET ?`
q1 += ` ORDER BY a.customerid DESC, c.locationid ASC LIMIT ? OFFSET ?`
args = append(args, pagesize, offset)
}
print(q1)
r.db.Raw(q1, args...).Find(&data)
return data
}

View File

@@ -1,11 +1,13 @@
package repositories
import (
"errors"
"fmt"
"log"
"nearle/models"
"strconv"
"strings"
"time"
"github.com/jinzhu/copier"
"gorm.io/gorm"
@@ -59,8 +61,22 @@ const (
SUM(CASE WHEN c.orderstatus = 'cancelled' THEN 1 ELSE 0 END) AS deliveriescancelled,
SUM(CASE WHEN c.paymenttype = 64 THEN c.deliveryamt ELSE 0 END) AS paylater,
SUM(CASE WHEN c.paymenttype = 43 THEN c.deliveryamt ELSE 0 END) AS payondelivery,
ROUND(SUM(c.kms), 2) AS kms,
ROUND(SUM(c.actualkms), 2) AS actualkms,
-- Guarded cast: both are TEXT columns holding whatever was written to them.
--
-- Summed raw this is "function sum(text) does not exist". Casting them
-- unconditionally is not enough either: the rider summary then answered
-- "invalid input syntax for type numeric: null" on production 2026-09-05,
-- because some rows hold the four-character string "null" rather than an
-- empty one. NULLIF caught the empty case and not that one.
--
-- So only a value that IS a number is cast; everything else scores 0. That
-- covers the empty string, the literal "null", and any future junk, without
-- one bad row aborting the whole statement.
-- Raw, this is "function sum(text) does not exist" and the endpoint 500s on
-- every call — verified on production 2026-09-04. Same defect as the rider
-- summary above, which is the other place these two columns are added up.
ROUND(SUM(CASE WHEN c.kms ~ '^[0-9]+([.][0-9]+)?$' THEN c.kms::numeric ELSE 0 END), 2) AS kms,
ROUND(SUM(CASE WHEN c.actualkms ~ '^[0-9]+([.][0-9]+)?$' THEN c.actualkms::numeric ELSE 0 END), 2) AS actualkms,
SUM(c.deliveryamt) AS charges
FROM
tenants b
@@ -75,8 +91,19 @@ const (
SUM(CASE WHEN b.orderstatus = 'arrived' THEN 1 ELSE 0 END) AS arrived,
SUM(CASE WHEN b.orderstatus = 'picked' THEN 1 ELSE 0 END) AS picked,
SUM(CASE WHEN b.orderstatus = 'delivered' THEN 1 ELSE 0 END) AS delivered,
SUM(CASE WHEN b.orderstatus = 'delivered' THEN b.actualkms ELSE 0 END) AS actualkms,
SUM(CASE WHEN b.orderstatus = 'delivered' THEN b.kms ELSE 0 END) AS kms,
-- kms and actualkms are TEXT columns, not numbers.
--
-- Summed raw they produce
-- "CASE types integer and text cannot be matched", because the ELSE arm is
-- the integer 0 — so this endpoint answered 500 on every call. Verified on
-- production 2026-09-04. Every other SUM here is over a real numeric
-- (deliveryamt) or a literal 1, which is why only these two broke it.
--
-- NULLIF handles the empty string, which is what an unridden job carries
-- and what ::numeric would choke on; a value that is not a number at all
-- would still raise, but nothing writes one.
SUM(CASE WHEN b.orderstatus = 'delivered' THEN CASE WHEN b.actualkms ~ '^[0-9]+([.][0-9]+)?$' THEN b.actualkms::numeric ELSE 0 END ELSE 0 END) AS actualkms,
SUM(CASE WHEN b.orderstatus = 'delivered' THEN CASE WHEN b.kms ~ '^[0-9]+([.][0-9]+)?$' THEN b.kms::numeric ELSE 0 END ELSE 0 END) AS kms,
SUM(CASE WHEN b.paymenttype = 64 THEN b.deliveryamt ELSE 0 END) AS paylater,
SUM(CASE WHEN b.paymenttype = 43 THEN b.deliveryamt ELSE 0 END) AS payondelivery,
SUM(CASE WHEN b.orderstatus = 'delivered' THEN b.deliveryamt ELSE 0 END) AS deliveryamt
@@ -128,9 +155,36 @@ func (r *deliveriesRepository) CreateDeliveries(data []models.Deliveries) error
ord.Orderstatus = data[i].Orderstatus
ord.Pending = data[i].Deliverydate
res := tx.Table("orders").
Where("orderheaderid=?", data[i].Orderheaderid).
Updates(&ord)
if res.Error != nil {
tx.Rollback()
return res.Error
}
// An UPDATE matching no rows is not an SQL error, so a delivery could
// be created against an orderheaderid that does not exist and the whole
// call would still report success — leaving a job in the rider's queue
// for an order nobody can open. Same shape as the stock-request reject
// that reported "4 updated" when only 3 existed.
if res.RowsAffected == 0 {
tx.Rollback()
return fmt.Errorf("order %d not found", data[i].Orderheaderid)
}
// Link the order back to its delivery.
//
// Nothing wrote this column. `orders.deliveryid` was NULL on every row
// in production, which meant there was no way to ask an order whether
// it had been assigned — the console had to cross-reference the whole
// deliveries list to find out. Written here, in the same transaction as
// the delivery itself, so the two can never disagree.
//
// `data[i].Deliveryid` is filled in by the Create above: it is the
// primary key GORM reads back after the insert.
if err := tx.Table("orders").
Where("orderheaderid=?", data[i].Orderheaderid).
Updates(&ord).Error; err != nil {
Update("deliveryid", data[i].Deliveryid).Error; err != nil {
tx.Rollback()
return err
}
@@ -143,30 +197,139 @@ func (r *deliveriesRepository) CreateDeliveries(data []models.Deliveries) error
return nil
}
// stampNow is the one clock these tables are written from: server local time,
// in the layout every lifecycle column already uses.
func stampNow() string {
return time.Now().Format("2006-01-02 15:04:05")
}
func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus) error {
var ord models.Updateorderstatus
var cloc models.Customerlocations
if data.Deliveryid == 0 {
return errors.New("deliveryid is required")
}
tx := r.db.Begin()
if tx.Error != nil {
return tx.Error
}
// The lifecycle timestamp on the DELIVERY row, stamped when the caller does
// not send one.
//
// This is why the journey timings were blank. `Updates` with a struct skips
// zero-valued fields, and the rider app sends {deliveryid, orderstatus} and
// nothing else — so starttime, arrivaltime, pickuptime and deliverytime were
// only ever written by a client that volunteered them, and none does.
// Measured on live tenant 1147: every delivery row carries an assigntime
// (written at assign) and an empty string in all five of the others, so the
// step-by-step timings in the console had nothing to show at any stage.
//
// The order side of this was already fixed — see `stamp` below, which does
// exactly this for the mirrored order columns. The delivery's own row was
// left out, which is the half an operator actually looks at.
//
// Same format as the column already holds ("2006-01-02 15:04:05", server
// local): a second convention in one column would be worse than none.
if now := stampNow(); now != "" {
switch data.Orderstatus {
case "pending":
if strings.TrimSpace(data.Assigntime) == "" {
data.Assigntime = now
}
case "accepted":
if strings.TrimSpace(data.Starttime) == "" {
data.Starttime = now
}
case "arrived":
if strings.TrimSpace(data.Arrivaltime) == "" {
data.Arrivaltime = now
}
case "picked":
if strings.TrimSpace(data.Pickuptime) == "" {
data.Pickuptime = now
}
case "delivered":
if strings.TrimSpace(data.Deliverytime) == "" {
data.Deliverytime = now
}
case "cancelled":
if strings.TrimSpace(data.Canceltime) == "" {
data.Canceltime = now
}
}
}
if err := tx.Table("deliveries").Where("deliveryid = ?", data.Deliveryid).Updates(&data).Error; err != nil {
tx.Rollback()
return err
}
// The parent order is resolved from the delivery row rather than taken from
// the request. Every status branch below writes the order with
// "WHERE orderheaderid = ?", and a client that omits orderheaderid made that
// "WHERE orderheaderid = 0", matching nothing. GORM reports no error for an
// update that affects no rows, so the handler still answered 201 Success
// while the order silently kept its old status — 635 deliveries are marked
// delivered against an order still reading pending because of this.
//
// deliveryid is the one field every caller must send (it is how the row
// above is found), so deriving the link from it makes the sync independent
// of how complete the client's payload is.
orderHeaderID := data.Orderheaderid
if orderHeaderID == 0 {
if err := tx.Table("deliveries").
Select("orderheaderid").
Where("deliveryid = ?", data.Deliveryid).
Scan(&orderHeaderID).Error; err != nil {
tx.Rollback()
return err
}
}
if orderHeaderID == 0 {
tx.Rollback()
return fmt.Errorf("delivery %d has no order attached", data.Deliveryid)
}
// syncOrder applies the status to the parent order and fails loudly if the
// row is not there, instead of reporting success for a write that landed
// nowhere.
syncOrder := func() error {
res := tx.Table("orders").Where("orderheaderid = ?", orderHeaderID).Updates(&ord)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("order %d not found for delivery %d", orderHeaderID, data.Deliveryid)
}
return nil
}
// The lifecycle timestamp mirrored onto the order. Clients frequently send
// the status without one, and because Updates() skips zero-valued struct
// fields the order's own column was left blank while its status moved on.
stamp := func(supplied string) string {
if strings.TrimSpace(supplied) != "" {
return supplied
}
return stampNow()
}
switch data.Orderstatus {
case "pending":
ord.Orderstatus = data.Orderstatus
ord.Pending = data.Assigntime
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
ord.Pending = stamp(data.Assigntime)
if err := syncOrder(); err != nil {
tx.Rollback()
return err
}
case "picked":
if data.Pickuplocationid != 0 && data.Deliverytype != "B" {
cloc.Latitude = data.Riderslat
cloc.Longitude = data.Riderslon
cloc.Latitude = models.FlexibleString(data.Riderslat)
cloc.Longitude = models.FlexibleString(data.Riderslon)
cloc.Address = data.Address
cloc.Suburb = data.Suburb
cloc.City = data.City
@@ -181,15 +344,15 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
case "delivered":
ord.Orderstatus = data.Orderstatus
ord.Delivered = data.Deliverytime
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
ord.Delivered = stamp(data.Deliverytime)
if err := syncOrder(); err != nil {
tx.Rollback()
return err
}
if data.Deliverylocationid != 0 && data.Deliverytype != "B" {
cloc.Latitude = data.Deliverylat
cloc.Longitude = data.Deliverylong
cloc.Latitude = models.FlexibleString(data.Deliverylat)
cloc.Longitude = models.FlexibleString(data.Deliverylong)
cloc.Address = data.Address
cloc.Suburb = data.Suburb
cloc.City = data.City
@@ -204,8 +367,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
case "cancelled":
ord.Orderstatus = data.Orderstatus
ord.Cancelled = data.Canceltime
if err := tx.Table("orders").Where("orderheaderid = ?", data.Orderheaderid).Updates(&ord).Error; err != nil {
ord.Cancelled = stamp(data.Canceltime)
if err := syncOrder(); err != nil {
tx.Rollback()
return err
}
@@ -374,40 +537,40 @@ func (r *deliveriesRepository) GetReportSummary(tid, pid, uid, aid int, fdate, t
case tid != 0:
if fdate != "" && tdate != "" {
q1 = reports + ` WHERE a.tenantid=` + strconv.Itoa(tid) +
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY a.tenantid, b.tenantname`
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY b.tenantid, b.tenantname`
} else {
q1 = reports + ` WHERE a.tenantid=` + strconv.Itoa(tid) + ` GROUP BY a.tenantid, b.tenantname`
q1 = reports + ` WHERE a.tenantid=` + strconv.Itoa(tid) + ` GROUP BY b.tenantid, b.tenantname`
}
case pid != 0:
if fdate != "" && tdate != "" {
q1 = reports + ` WHERE a.partnerid=` + strconv.Itoa(pid) +
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY a.tenantid, b.tenantname`
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY b.tenantid, b.tenantname`
} else {
q1 = reports + ` WHERE a.partnerid=` + strconv.Itoa(pid) + ` GROUP BY a.tenantid, b.tenantname`
q1 = reports + ` WHERE a.partnerid=` + strconv.Itoa(pid) + ` GROUP BY b.tenantid, b.tenantname`
}
case uid != 0:
if fdate != "" && tdate != "" {
q1 = reports + ` WHERE c.userid=` + strconv.Itoa(uid) +
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY a.tenantid, b.tenantname`
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY b.tenantid, b.tenantname`
} else {
q1 = reports + ` WHERE c.userid=` + strconv.Itoa(uid) + ` GROUP BY a.tenantid, b.tenantname`
q1 = reports + ` WHERE c.userid=` + strconv.Itoa(uid) + ` GROUP BY b.tenantid, b.tenantname`
}
case aid != 0:
if fdate != "" && tdate != "" {
q1 = reports + ` WHERE c.applocationid=` + strconv.Itoa(aid) +
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY a.tenantid, b.tenantname`
` AND a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY b.tenantid, b.tenantname`
} else {
q1 = reports + ` WHERE c.applocationid=` + strconv.Itoa(aid) + ` GROUP BY a.tenantid, b.tenantname`
q1 = reports + ` WHERE c.applocationid=` + strconv.Itoa(aid) + ` GROUP BY b.tenantid, b.tenantname`
}
default:
if fdate != "" && tdate != "" {
q1 = reports + ` WHERE a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY a.tenantid, b.tenantname`
q1 = reports + ` WHERE a.orderdate::date BETWEEN '` + fdate + `' AND '` + tdate + `' GROUP BY b.tenantid, b.tenantname`
} else {
q1 = reports + ` GROUP BY a.tenantid, b.tenantname`
q1 = reports + ` GROUP BY b.tenantid, b.tenantname`
}
}
@@ -474,11 +637,14 @@ func (r *deliveriesRepository) GetTenantDeliveries(input models.DeliveryQuery) [
offset := (input.Pageno - 1) * input.Pagesize
// LEFT JOIN on the rider, for the same reason as the branch-scoped read
// below: the rider's row supplies a name and a phone number, and a delivery
// whose rider is later deactivated should lose the name, not the job.
baseQuery := `
SELECT a.*, b.tenantname, c.firstname AS ridername, c.contactno AS ridercontact
FROM deliveries a
JOIN tenants b ON a.tenantid = b.tenantid
JOIN app_users c ON a.userid = c.userid
LEFT JOIN app_users c ON a.userid = c.userid
WHERE a.tenantid = ?
`
queryBuilder.WriteString(baseQuery)
@@ -844,10 +1010,15 @@ func (r *deliveriesRepository) GetDeliveryQueues(uid int, fdate, tdate string) (
return nil, err
}
} else {
q1 = deliveries + `
WHERE a.orderstatus='pending'
AND a.userid=?
AND DATE(a.deliverydate)=CURDATE()
// CURRENT_DATE, not CURDATE(). The latter is MySQL and this is
// Postgres, so this branch answered
// `500 function curdate() does not exist` every time it was taken —
// which is every call a rider's app makes without a date range, i.e.
// "show me today's jobs". Verified locally 2026-09-03.
q1 = deliveries + `
WHERE a.orderstatus='pending'
AND a.userid=?
AND DATE(a.deliverydate)=CURRENT_DATE
ORDER BY a.deliveryid ASC`
if err := r.db.Raw(q1, uid).Find(&data).Error; err != nil {
return nil, err
@@ -865,13 +1036,32 @@ func (r *deliveriesRepository) GetTenantLocationDeliveries(input models.Delivery
offset := (input.Pageno - 1) * input.Pagesize
// LEFT JOIN on the rider and the customer; INNER only on the tenant.
//
// All three were inner joins and two of them were filtering the result to
// nothing. `deliverycustomerid` is 0 on every delivery this system has
// written — neither the app nor the old console sets it — so joining
// `customers` on it matched no row, and the branch-scoped read returned an
// empty list for every branch of every tenant.
//
// Measured on production 2026-09-03: tenant 1135 has 7 deliveries, 6 of
// them at location 1166, and ?tenantid=1135&locationid=1166 returned 0.
// That is the entire deliveries page for a store user, who is pinned to one
// branch and so always sends a locationid.
//
// The rider join is the same shape and would hide a delivery whose rider
// was later deactivated — the accidental-filter pattern already fixed in
// GetStaffs, GetUserById, GetTenantByID and the order listings.
//
// `customertoken` is the only thing the customer join contributes: a push
// token, worth having and not worth losing the row over.
qb.WriteString(`
SELECT a.*, b.tenantname, c.firstname AS ridername,
SELECT a.*, b.tenantname, c.firstname AS ridername,
c.contactno AS ridercontact, d.customertoken
FROM deliveries a
JOIN tenants b ON a.tenantid = b.tenantid
JOIN app_users c ON a.userid = c.userid
JOIN customers d ON a.deliverycustomerid = d.customerid
LEFT JOIN app_users c ON a.userid = c.userid
LEFT JOIN customers d ON a.deliverycustomerid = d.customerid
WHERE a.tenantid = ? AND a.locationid = ?
`)
@@ -889,11 +1079,15 @@ func (r *deliveriesRepository) GetTenantLocationDeliveries(input models.Delivery
if input.Keyword != "" {
like := "%" + input.Keyword + "%"
// A stray `m` sat after the third OR — a typed character that reached
// production. Postgres rejects the whole statement, so searching within
// a branch's deliveries failed outright rather than returning nothing,
// which is why it was invisible behind the empty-list bug above.
qb.WriteString(`
AND (
a.pickupcustomer LIKE ? OR
b.tenantname LIKE ? OR
a.deliverycustomer LIKE ? OR m
a.deliverycustomer LIKE ? OR
a.pickupcontactno LIKE ? OR
a.deliverycontactno LIKE ? OR
a.orderid LIKE ?

View File

@@ -0,0 +1,62 @@
package repositories
import (
"strings"
"testing"
)
// The block that stopped orders being placed: no outlet on the order.
//
// Stock is per-outlet, so a missing locationid read as zero available and the
// order was refused as "insufficient stock" — for Cadbury Dairy Milk at Suriya
// Peelamedu, which had 25 on the shelf. These pin the message on the actual
// cause.
func TestAnOrderWithNoOutletIsRefusedForTheRightReason(t *testing.T) {
err := assertOutletNamed([]stockLine{
{Productid: 7086, Productname: "Cadbury Dairy Milk 100g", Locationid: 0, Quantity: 1},
})
if err == nil {
t.Fatal("an order with no outlet was accepted")
}
if strings.Contains(strings.ToLower(err.Error()), "insufficient stock") {
t.Errorf("still blames the stock: %v", err)
}
if !strings.Contains(err.Error(), "locationid") {
t.Errorf("does not say what to send: %v", err)
}
if !strings.Contains(err.Error(), "Cadbury Dairy Milk 100g") {
t.Errorf("does not name the line: %v", err)
}
}
// One good line does not excuse a bad one — a mixed order would deduct part of
// the basket at a real outlet and the rest at nowhere.
func TestOneLineWithoutAnOutletFailsTheWholeOrder(t *testing.T) {
err := assertOutletNamed([]stockLine{
{Productid: 7086, Productname: "Dairy Milk", Locationid: 1170},
{Productid: 7085, Productname: "5 Star", Locationid: 0},
})
if err == nil {
t.Fatal("a mixed order was accepted")
}
if !strings.Contains(err.Error(), "5 Star") {
t.Errorf("blamed the wrong line: %v", err)
}
}
func TestAnOrderThatNamesItsOutletPasses(t *testing.T) {
if err := assertOutletNamed([]stockLine{
{Productid: 7086, Productname: "Dairy Milk", Locationid: 1170},
}); err != nil {
t.Errorf("a valid order was refused: %v", err)
}
}
// An unnamed product still has to produce a usable message — the id is all
// there is to go on.
func TestAnUnnamedLineIsIdentifiedById(t *testing.T) {
err := assertOutletNamed([]stockLine{{Productid: 7086, Locationid: 0}})
if err == nil || !strings.Contains(err.Error(), "7086") {
t.Errorf("want the product id in the message, got: %v", err)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
package repositories
import (
"testing"
"nearle/models"
)
/*
One partner, one district.
The form used to ask for a home region AND a coverage set, which put the same
partner in `partnerinfo.applocationid` and several `partnerlocations` rows. A
partner works one district — that is the operating rule — so the two writes
carry the same single id and `regionsOf` is what guarantees they cannot
disagree.
*/
func TestRegionsOfIsTheOneDistrict(t *testing.T) {
got := regionsOf(models.NewPartner{Applocationid: 2})
if len(got) != 1 || got[0] != 2 {
t.Fatalf("got %v, want [2]", got)
}
}
// Zero is not a district. It arrives from a form field nobody filled in, and a
// partnerlocations row pointing at region 0 would join to nothing for ever.
func TestRegionsOfIgnoresZero(t *testing.T) {
if got := regionsOf(models.NewPartner{Applocationid: 0}); len(got) != 0 {
t.Fatalf("got %v, want none", got)
}
}
// A district sent only by NAME has not been resolved yet — CreatePartner opens
// it and sets the id before this is reached. Until then there is no region, and
// saying so beats guessing one.
func TestRegionsOfDoesNotInventOneFromAName(t *testing.T) {
if got := regionsOf(models.NewPartner{District: "Erode"}); len(got) != 0 {
t.Fatalf("got %v, want none until the district is opened", got)
}
}

View File

@@ -1,11 +1,14 @@
package repositories
import (
"errors"
"fmt"
"nearle/models"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PartnerRepository interface {
@@ -16,6 +19,13 @@ type PartnerRepository interface {
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
GetRiderInfo(userid int) (models.RiderInfo, error)
GetFleetSummary(aid, tid int, fdate, tdate string) (models.FleetSummary, error)
CreateRider(rider models.NewRider) (int, error)
UpdateRider(rider models.NewRider) error
GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error)
CreatePartner(input models.NewPartner) (int, error)
UpdatePartner(input models.NewPartner) error
GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error)
EnsureRegion(district string) (int, error)
}
type partnerRepository struct {
@@ -77,30 +87,67 @@ func (r *partnerRepository) GetPartners(aid, pid, uid int) ([]models.Partnerinfo
var q1 string
var args []interface{}
// Every variant joins partnerlocations, and that join is the whole point.
//
// ── It is what separates our partners from somebody else's ──────────────
//
// `partnerinfo` is shared. It has no column saying which product a row
// belongs to — no configid, no appid — so a partner created by another app
// on this database is indistinguishable from ours by its own fields, and
// this read used to return every Active row on the platform. The console
// made that worse rather than better: it asks `getapplocations` for EVERY
// region and then fetches partners region by region, so the applocationid
// filter below never narrowed anything.
//
// `partnerlocations` is the difference. Only `CreatePartner` writes it —
// one row per region, in the same transaction as the partner — so a row in
// that table means "registered through this console". The partners that
// predate it were inserted by hand and have none, which is why two of them
// are called "Test".
//
// ── The region filter reads the link table, not the home region ─────────
//
// `partnerinfo.applocationid` is the HOME region — CreatePartner writes
// `regions[0]` there — while partnerlocations holds every region covered.
// Those are not the same thing, and not only in theory: partner 44,
// Xpress-Cbe-Main, has a home region of 1 and link rows for 1 AND 2, so
// filtering on the partner row hid them from every Madurai query. That is
// the case the link table exists for.
//
// DISTINCT because such a partner has one row per region in the join and is
// still one partner. Only partnerinfo columns are selected, so there is
// nothing per-region for it to fail to collapse.
//
// A caller fanning out over regions and concatenating the answers still has
// to dedupe — the same partner is legitimately in two of them. The console's
// `useAllPartners` does; it listed Xpress-Cbe-Main twice until it did.
const columns = `select distinct p.partnerid,p.applocationid,p.partnertypeid,p.partnername,
p.primarycontact,p.primaryemail,p.contactno,p.address,p.suburb,p.state,p.city,p.partnerimage
from partnerinfo p
inner join partnerlocations l on l.partnerid = p.partnerid
where p.status='Active'`
if pid != 0 {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active' and partnerid=?`
// Scoped the same way on purpose: asking for a partner by id must not
// be a way round the separation above.
q1 = columns + ` and p.partnerid=?`
args = append(args, pid)
} else if aid != 0 {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active' and applocationid=?`
q1 = columns + ` and l.applocationid=?`
args = append(args, aid)
} else {
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
contactno,address,suburb,state,city,partnerimage
from partnerinfo where status='Active'`
q1 = columns
}
q1 += ` order by p.partnername, p.partnerid`
err := r.db.Raw(q1, args...).Find(&data).Error
if err != nil {
return nil, err
}
print(q1)
return data, nil
}
@@ -168,7 +215,20 @@ func (r *partnerRepository) GetRiderLogs(pid, aid int, fdate, tdate string) ([]m
baseQuery += " AND a.logdate::date = CURRENT_DATE"
}
baseQuery += " GROUP BY a.userid ORDER BY logid ASC"
// GROUP BY the two PRIMARY KEYS, not by a.userid.
//
// The select is "a.*, b.*" — every column of riderlogs and app_users — and
// grouping by a.userid leaves all of them unaggregated: userid is not
// riderlogs' key (logid is), so it does not functionally determine a.logid
// or anything else. Postgres refuses the whole statement, so this endpoint
// answered 500 "column a.logid must appear in the GROUP BY clause" on every
// call it has ever received. Verified on production 2026-09-04.
//
// Grouping by both primary keys is what the query means: one row per LOG
// (the model is RiderlogDetails and the sort is by logid), carrying that
// log's break hours summed. Postgres treats a PK in GROUP BY as determining
// the rest of its table's columns, so "a.*, b.*" is then legal.
baseQuery += " GROUP BY a.logid, b.userid ORDER BY a.logid ASC"
if err := r.db.Raw(baseQuery, args...).Find(&data).Error; err != nil {
return nil, err
@@ -295,3 +355,675 @@ func (r *partnerRepository) GetFleetSummary(aid, tid int, fdate, tdate string) (
return summary, nil
}
// CreateRider hires one rider, in one transaction.
//
// Three tables, all or nothing. Before this existed the only way to make a
// rider was POST /users/create, which writes `app_users` and stops — so it
// answered 201 Created and produced somebody every rider query ignored forever,
// because `getriders` INNER JOINs `ridersettings` and `app_userpools` as well.
// The old console papered over that by generating an SQL script for an operator
// to run by hand; the script named three columns that do not exist, so it never
// worked either.
//
// The guards below all catch the same class of fault: a write that succeeds and
// then cannot be seen. Postgres will not complain about any of them — a
// `shiftid` pointing nowhere is a perfectly good integer — but each one produces
// a rider who is invisible the moment the transaction commits.
func (r *partnerRepository) CreateRider(rider models.NewRider) (int, error) {
if strings.TrimSpace(rider.Firstname) == "" {
return 0, errors.New("the rider needs a name")
}
if strings.TrimSpace(rider.Contactno) == "" {
return 0, errors.New("the rider needs a contact number")
}
if rider.Applocationid == 0 {
return 0, errors.New("the rider needs a delivery region")
}
if rider.Shiftid == 0 {
return 0, errors.New("the rider needs a shift")
}
// A shift that does not exist takes the rider out of every listing:
// `getriders` joins ridershifts through ridersettings.shiftid.
var shifts int64
if err := r.db.Table("ridershifts").Where("shiftid = ?", rider.Shiftid).Count(&shifts).Error; err != nil {
return 0, err
}
if shifts == 0 {
return 0, fmt.Errorf("shift %d does not exist", rider.Shiftid)
}
// Same again for the region, which is joined twice — app_location AND
// app_locationconfig. A region with no config row hides every rider in it.
var configs int64
if err := r.db.Table("app_locationconfig").Where("applocationid = ?", rider.Applocationid).Count(&configs).Error; err != nil {
return 0, err
}
if configs == 0 {
return 0, fmt.Errorf("delivery region %d is not configured, so a rider added to it would not appear anywhere", rider.Applocationid)
}
// Riders are looked up by phone more than by anything else, and two accounts
// on one number is how the wrong person gets the job.
var clash int64
if err := r.db.Table("app_users").
Where("contactno = ? AND configid = 6", strings.TrimSpace(rider.Contactno)).
Count(&clash).Error; err != nil {
return 0, err
}
if clash > 0 {
return 0, fmt.Errorf("a rider already uses %s", strings.TrimSpace(rider.Contactno))
}
status := strings.TrimSpace(rider.Status)
if status == "" {
status = "Active"
}
tx := r.db.Begin()
if tx.Error != nil {
return 0, tx.Error
}
// configid 6 is what identifies a rider — there is no Rider row in
// app_roles, and `getriders` keys on the configid rather than on a role.
user := models.User{
Firstname: strings.TrimSpace(rider.Firstname),
Lastname: strings.TrimSpace(rider.Lastname),
Contactno: strings.TrimSpace(rider.Contactno),
Email: strings.TrimSpace(rider.Email),
Password: rider.Password,
Address: rider.Address,
Suburb: rider.Suburb,
City: rider.City,
State: rider.State,
Postcode: rider.Postcode,
Configid: 6,
Tenantid: rider.Tenantid,
Locationid: rider.Locationid,
Applocationid: rider.Applocationid,
Partnerid: rider.Partnerid,
Shiftid: rider.Shiftid,
Status: status,
}
if err := tx.Table("app_users").Create(&user).Error; err != nil {
tx.Rollback()
return 0, err
}
if user.Userid == 0 {
tx.Rollback()
return 0, errors.New("the rider account was written without an id")
}
settings := models.Ridersettings{
Userid: user.Userid,
Partnerid: rider.Partnerid,
Shiftid: rider.Shiftid,
Identificationno: rider.Identificationno,
Vehiclename: rider.Vehiclename,
Vehicleno: rider.Vehicleno,
Licenseno: rider.Licenseno,
Registrationno: rider.Registrationno,
}
if err := tx.Table("ridersettings").Create(&settings).Error; err != nil {
tx.Rollback()
return 0, err
}
// onduty 1 means "available for work", not "on shift now" — that second
// question is answered by riderlogs, which the rider's own app writes when
// they clock on. A rider created here is therefore correctly absent from
// getriders until they start a shift.
pool := models.Appuserpools{
Userid: user.Userid,
Partnerid: rider.Partnerid,
Onduty: 1,
Status: status,
}
if err := tx.Table("app_userpools").Create(&pool).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return user.Userid, nil
}
// GetRiderRoster lists every rider, on duty or not.
//
// Distinct from GetActiveRiders, and the difference is the whole point. That
// one INNER JOINs a riderlog dated today with logstatus 0 — it answers "who can
// I give this delivery to right now", which is correct for an assignment picker
// and useless for a staff directory: somebody hired this morning, or simply not
// working today, is absent from it. A directory that hides the person you just
// created reads as a failed save.
//
// So every join here is LEFT except the rider's own settings, and the duty
// state is returned as facts — `onduty`, the last log date, and whether that
// log is today — rather than used as a filter.
func (r *partnerRepository) GetRiderRoster(tid, aid, pid int) ([]models.RiderRosterRow, error) {
var data []models.RiderRosterRow
q1 := `SELECT a.userid, a.firstname, a.lastname,
CONCAT(a.firstname, ' ', a.lastname) AS fullname,
a.contactno, a.email, a.tenantid, a.locationid, a.applocationid, a.partnerid, a.status,
f.locationname AS applocation,
COALESCE(g.locationname, '') AS locationname,
p.partnername,
c.identificationno, c.vehiclename, c.vehicleno, c.licenseno, c.registrationno,
c.shiftid, CONCAT(d.starttime, ' - ', d.endtime) AS shiftname,
COALESCE(b.onduty, 0) AS onduty,
e.logdate AS lastlogdate,
COALESCE(e.logdate::date = CURRENT_DATE AND e.logstatus = 0, false) AS isonduty
FROM app_users a
INNER JOIN ridersettings c ON a.userid = c.userid
LEFT JOIN app_userpools b ON a.userid = b.userid
LEFT JOIN ridershifts d ON c.shiftid = d.shiftid
LEFT JOIN app_location f ON a.applocationid = f.applocationid
LEFT JOIN partnerinfo p ON a.partnerid = p.partnerid
LEFT JOIN tenantlocations g ON a.locationid = g.locationid
LEFT JOIN (
SELECT r1.userid, r1.logdate, r1.logstatus
FROM riderlogs r1
INNER JOIN (
SELECT userid, MAX(logdate) AS max_logdate FROM riderlogs GROUP BY userid
) r2 ON r1.userid = r2.userid AND r1.logdate = r2.max_logdate
) e ON a.userid = e.userid
WHERE a.configid = 6`
var args []interface{}
// Scoped by whichever id the caller has. Tenant first: it is the narrowest,
// and it is the scope a merchant's own directory wants.
if tid != 0 {
q1 += ` AND a.tenantid = ?`
args = append(args, tid)
} else if aid != 0 {
q1 += ` AND a.applocationid = ?`
args = append(args, aid)
} else if pid != 0 {
q1 += ` AND a.partnerid = ?`
args = append(args, pid)
}
q1 += ` ORDER BY a.firstname, a.lastname`
if err := r.db.Raw(q1, args...).Scan(&data).Error; err != nil {
return nil, err
}
return data, nil
}
// UpdateRider edits a rider across both of their tables.
//
// Only the fields a person can change from the console: their contact details,
// their vehicle and licence, their shift, and their status. The identity
// columns and the tenant are not editable — moving a rider between merchants is
// not an edit, and doing it silently through a profile form is how a rider ends
// up on somebody else's books.
func (r *partnerRepository) UpdateRider(rider models.NewRider) error {
if rider.Userid == 0 {
return errors.New("userid is required")
}
if rider.Shiftid != 0 {
var shifts int64
if err := r.db.Table("ridershifts").Where("shiftid = ?", rider.Shiftid).Count(&shifts).Error; err != nil {
return err
}
if shifts == 0 {
return fmt.Errorf("shift %d does not exist", rider.Shiftid)
}
}
tx := r.db.Begin()
if tx.Error != nil {
return tx.Error
}
// Built as a map rather than a struct: Updates() with a struct skips every
// zero value, so clearing a licence number or blanking an email would
// silently do nothing. A map says exactly what to write.
user := map[string]interface{}{
"firstname": strings.TrimSpace(rider.Firstname),
"lastname": strings.TrimSpace(rider.Lastname),
"contactno": strings.TrimSpace(rider.Contactno),
"email": strings.TrimSpace(rider.Email),
"address": rider.Address,
"suburb": rider.Suburb,
"city": rider.City,
"state": rider.State,
"postcode": rider.Postcode,
}
if rider.Shiftid != 0 {
user["shiftid"] = rider.Shiftid
}
// The branch an own rider works out of. Moving them between a merchant's
// outlets is an ordinary edit; moving them between MERCHANTS is not, which
// is why tenantid and partnerid stay out of this map.
if rider.Locationid != 0 {
user["locationid"] = rider.Locationid
}
if strings.TrimSpace(rider.Status) != "" {
user["status"] = strings.TrimSpace(rider.Status)
}
res := tx.Table("app_users").Where("userid = ? AND configid = 6", rider.Userid).Updates(user)
if res.Error != nil {
tx.Rollback()
return res.Error
}
// An UPDATE matching no rows is not an SQL error, so editing a userid that
// is not a rider would report success and change nothing.
if res.RowsAffected == 0 {
tx.Rollback()
return fmt.Errorf("rider %d not found", rider.Userid)
}
settings := map[string]interface{}{
"identificationno": rider.Identificationno,
"vehiclename": rider.Vehiclename,
"vehicleno": rider.Vehicleno,
"licenseno": rider.Licenseno,
"registrationno": rider.Registrationno,
}
if rider.Shiftid != 0 {
settings["shiftid"] = rider.Shiftid
}
if err := tx.Table("ridersettings").Where("userid = ?", rider.Userid).Updates(settings).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
/* ── Onboarding a delivery partner ────────────────────────────────────────────
A partner is a company that supplies riders, and until now the platform could
only READ them: `getpartners` has always existed and nothing could create one.
The five partners live today were inserted by hand, which is also why two of
them are named "Test".
Where a partner works is recorded twice, on purpose and not by accident:
partnerinfo.applocationid their home region — the rider app reads it
partnerlocations every region they cover
Both are kept in step here. Writing only the first would confine a partner to
one city, and writing only the second would hide them from the rider app.
`GetPartners` reads the SECOND: it joins partnerlocations, which both scopes a
region query to every city a partner actually covers and — because only this
function writes that table — separates partners registered here from the ones
another product put in the shared `partnerinfo`. So the link rows are not
bookkeeping; they are what makes a partner ours. */
// CreatePartner onboards a delivery partner and records the regions they cover.
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
if strings.TrimSpace(input.Partnername) == "" {
return 0, errors.New("the partner needs a name")
}
if strings.TrimSpace(input.Primarycontact) == "" {
return 0, errors.New("the partner needs a contact number")
}
// A district that is not open yet is opened here rather than refused — the
// form offers all 38 and this is what makes that true. Resolved before the
// checks below, so everything after works on a region that exists.
if input.Applocationid == 0 && strings.TrimSpace(input.District) != "" {
opened, err := r.EnsureRegion(input.District)
if err != nil {
return 0, err
}
input.Applocationid = opened
}
if input.Applocationid == 0 {
return 0, errors.New("the partner needs a district to work in")
}
// A region with no config row hides every rider placed in it — the same
// guard `CreateRider` applies, for the same reason.
regions := regionsOf(input)
for _, aid := range regions {
var configs int64
if err := r.db.Table("app_locationconfig").Where("applocationid = ?", aid).Count(&configs).Error; err != nil {
return 0, err
}
if configs == 0 {
return 0, fmt.Errorf("region %d is not configured, so riders placed in it would not appear anywhere", aid)
}
}
// Two partners on one number is how the wrong company gets the work.
var clash int64
if err := r.db.Table("partnerinfo").
Where("primarycontact = ?", strings.TrimSpace(input.Primarycontact)).
Count(&clash).Error; err != nil {
return 0, err
}
if clash > 0 {
return 0, fmt.Errorf("a partner already uses %s", strings.TrimSpace(input.Primarycontact))
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "Active"
}
tx := r.db.Begin()
if tx.Error != nil {
return 0, tx.Error
}
row := map[string]any{
"partnername": strings.TrimSpace(input.Partnername),
"companyname": strings.TrimSpace(input.Companyname),
"registrationno": strings.TrimSpace(input.Registrationno),
"primarycontact": strings.TrimSpace(input.Primarycontact),
"primaryemail": strings.TrimSpace(input.Primaryemail),
"contactno": strings.TrimSpace(input.Contactno),
"address": input.Address,
"suburb": input.Suburb,
"city": input.City,
"state": input.State,
"postcode": input.Postcode,
"partnerinfo": input.Partnerinfo,
"partnerimage": input.Partnerimage,
"applocationid": regions[0],
"status": status,
}
var partnerid int
if err := tx.Table("partnerinfo").
Clauses(clause.Returning{Columns: []clause.Column{{Name: "partnerid"}}}).
Create(&row).Error; err != nil {
tx.Rollback()
return 0, err
}
if id, ok := row["partnerid"]; ok {
partnerid = toInt(id)
}
if partnerid == 0 {
tx.Rollback()
return 0, errors.New("the partner was written without an id")
}
if err := replaceLocations(tx, partnerid, regions, input); err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return partnerid, nil
}
// UpdatePartner edits a partner and, when regions are supplied, re-states them.
//
// Regions are replaced rather than merged: the console sends the whole set it
// is showing, and a merge would make removing a region impossible. Sending none
// leaves them alone, so an edit that only changes a phone number cannot empty
// the list by omission.
func (r *partnerRepository) UpdatePartner(input models.NewPartner) error {
if input.Partnerid == 0 {
return errors.New("partnerid is required")
}
if input.Applocationid == 0 && strings.TrimSpace(input.District) != "" {
opened, err := r.EnsureRegion(input.District)
if err != nil {
return err
}
input.Applocationid = opened
}
fields := map[string]any{}
set := func(key, value string) {
if strings.TrimSpace(value) != "" {
fields[key] = strings.TrimSpace(value)
}
}
set("partnername", input.Partnername)
set("companyname", input.Companyname)
set("registrationno", input.Registrationno)
set("primarycontact", input.Primarycontact)
set("primaryemail", input.Primaryemail)
set("contactno", input.Contactno)
set("address", input.Address)
set("suburb", input.Suburb)
set("city", input.City)
set("state", input.State)
set("partnerinfo", input.Partnerinfo)
set("partnerimage", input.Partnerimage)
set("status", input.Status)
if input.Postcode > 0 {
fields["postcode"] = input.Postcode
}
if input.Applocationid > 0 {
fields["applocationid"] = input.Applocationid
}
regions := regionsOf(input)
if len(fields) == 0 && len(regions) == 0 {
return errors.New("nothing to update")
}
tx := r.db.Begin()
if tx.Error != nil {
return tx.Error
}
if len(fields) > 0 {
fields["updated"] = gorm.Expr("NOW()")
res := tx.Table("partnerinfo").Where("partnerid = ?", input.Partnerid).Updates(fields)
if res.Error != nil {
tx.Rollback()
return res.Error
}
if res.RowsAffected == 0 {
tx.Rollback()
return fmt.Errorf("no partner with partnerid %d", input.Partnerid)
}
}
if len(regions) > 0 {
if err := replaceLocations(tx, input.Partnerid, regions, input); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
// GetPartnerLocations lists the regions a partner covers, named.
func (r *partnerRepository) GetPartnerLocations(partnerid int) ([]models.PartnerLocation, error) {
var data []models.PartnerLocation
err := r.db.Raw(`
SELECT a.partnerlocationid, a.partnerid, a.applocationid,
COALESCE(b.locationname, '') AS applocation
FROM partnerlocations a
LEFT JOIN app_location b ON a.applocationid = b.applocationid
WHERE a.partnerid = ?
ORDER BY b.locationname`, partnerid).Scan(&data).Error
return data, err
}
/* ── Helpers ─────────────────────────────────────────────────────────────── */
// regionsOf is every region the partner covers, home region first and no
// duplicates. One list, so the row and the link table cannot disagree.
func regionsOf(input models.NewPartner) []int {
if input.Applocationid > 0 {
return []int{input.Applocationid}
}
return []int{}
}
// replaceLocations re-states a partner's regions inside the caller's tx.
func replaceLocations(tx *gorm.DB, partnerid int, regions []int, input models.NewPartner) error {
if len(regions) == 0 {
return nil
}
if err := tx.Table("partnerlocations").Where("partnerid = ?", partnerid).Delete(nil).Error; err != nil {
return err
}
rows := make([]map[string]any, 0, len(regions))
for _, aid := range regions {
rows = append(rows, map[string]any{
"partnerid": partnerid,
"applocationid": aid,
"address": input.Address,
"suburb": input.Suburb,
"city": input.City,
"state": input.State,
"postcode": strconv.Itoa(input.Postcode),
})
}
return tx.Table("partnerlocations").Create(&rows).Error
}
// toInt reads the id a RETURNING clause handed back, whatever numeric type the
// driver chose for it.
func toInt(value any) int {
switch n := value.(type) {
case int:
return n
case int32:
return int(n)
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}
/*
Opening a district.
`app_location` is not a geography table — it is the list of places Nearle
actually runs, each with a radius, opening hours and an image. Three rows exist.
That is why a partner could only be placed in three of Tamil Nadu's thirty-eight
districts: `partnerinfo.applocationid` has to point at one of these rows, every
rider query joins through it, and `CreateRider` refuses a region with no
`app_locationconfig`.
So onboarding a partner in a new district OPENS the district: it writes both
rows, copying the operating defaults from a region already running rather than
inventing them. The alternative was a form that lists 38 districts and accepts
3, which is a form that lies.
── Why the id is computed rather than defaulted ────────────────────────────
`app_location.applocationid` is a plain bigint: no identity, no default, no
sequence — the same shape as `productcategories.categoryid`. Every insert has to
supply one, so two people onboarding partners at the same moment would both read
the same MAX and write the same id. The advisory lock serialises that, and the
name lookup inside it makes a repeat call return the existing row instead of a
second Erode.
*/
// EnsureRegion returns the applocationid for a district, opening it if needed.
func (r *partnerRepository) EnsureRegion(district string) (int, error) {
name := strings.TrimSpace(district)
if name == "" {
return 0, errors.New("a district name is required")
}
if id := r.regionByName(r.db, name); id > 0 {
return id, nil
}
tx := r.db.Begin()
if tx.Error != nil {
return 0, tx.Error
}
// One writer at a time. The key is arbitrary and constant — it names this
// operation, not a row.
if err := tx.Exec(`SELECT pg_advisory_xact_lock(?)`, 8412771).Error; err != nil {
tx.Rollback()
return 0, err
}
// Checked again INSIDE the lock: the request that was waiting for it may
// have been opening the same district.
if id := r.regionByName(tx, name); id > 0 {
tx.Rollback()
return id, nil
}
var nextID int
if err := tx.Raw(`SELECT COALESCE(MAX(applocationid), 0) + 1 FROM app_location`).
Scan(&nextID).Error; err != nil {
tx.Rollback()
return 0, err
}
// Operating defaults copied from a region already running, so a new
// district behaves like the ones that work rather than like a blank row.
var template struct {
Countryid int
Radius int
Opentime string
Closetime string
}
if err := tx.Raw(`
SELECT COALESCE(countryid, 0) AS countryid, COALESCE(radius, 18) AS radius,
COALESCE(opentime, '08:00:00') AS opentime,
COALESCE(closetime, '23:59:00') AS closetime
FROM app_location WHERE status = 'Active' ORDER BY applocationid LIMIT 1`).
Scan(&template).Error; err != nil {
tx.Rollback()
return 0, err
}
if template.Radius == 0 {
template.Radius = 18
}
if err := tx.Exec(`
INSERT INTO app_location
(applocationid, countryid, locationname, city, state, radius,
deliveryradius, opentime, closetime, status)
VALUES (?, ?, ?, ?, 'Tamil Nadu', ?, ?, ?, ?, 'Active')`,
nextID, template.Countryid, name, name, template.Radius, template.Radius,
template.Opentime, template.Closetime).Error; err != nil {
tx.Rollback()
return 0, err
}
// Without this row every rider in the district is invisible — `getriders`
// joins it, and `CreateRider` refuses a region that lacks it. Opening a
// district means both rows or neither.
var nextConfig int
if err := tx.Raw(`SELECT COALESCE(MAX(applocationconfigid), 0) + 1 FROM app_locationconfig`).
Scan(&nextConfig).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Exec(`
INSERT INTO app_locationconfig (applocationconfigid, applocationid, configid, status)
VALUES (?, ?, 1, 'Active')`, nextConfig, nextID).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return nextID, nil
}
// regionByName finds a district by name, case-insensitively and space-tolerant.
func (r *partnerRepository) regionByName(db *gorm.DB, name string) int {
var id int
db.Raw(`SELECT applocationid FROM app_location
WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id)
return id
}

View File

@@ -0,0 +1,554 @@
package repositories
import (
"fmt"
"strings"
"nearle/models"
)
// Sign-in for the POS terminal.
//
// Deliberately reads the same `app_users` rows the web console authenticates
// against rather than introducing a terminal-specific credential table. A shop
// manager who can sign into the back office should be able to open the till
// with the same details, and one account store means deactivating a leaver
// closes both doors at once instead of one and a half.
//
// Kept in its own file because the rest of posRepository is about moving bills
// and stock, and mixing authorisation into that made the one thing nobody
// should have to hunt for the hardest thing to find.
// posLoginRow is the credential check's raw answer.
type posLoginRow struct {
Userid int
Password string
Pin int64
Status string
Roleid int
Configid int
Tenantid int
Locationid int
Firstname string
Lastname string
Email string
}
// posLoginSecret is the credential a sign-in offered.
//
// Resolved once, up front, so that the eligible-row check and the wrong-role
// diagnostic ask the same question of a row. Two places deciding separately
// what counts as a correct PIN is how one of them ends up admitting an account
// the other refuses.
type posLoginSecret struct {
// byPin says which of the two ways in this is. A till signs in with a
// mobile number and a PIN; a password is only still read so that terminals
// which have not shipped the new screen keep working through the backfill.
byPin bool
pin int64
password string
}
// newPosLoginSecret reads the credential out of a request.
//
// A malformed PIN is the same answer as a wrong one. Saying "a PIN is four
// digits" to an unauthenticated caller would confirm that the *number* they
// typed exists, which is the one thing this endpoint must not do.
func newPosLoginSecret(req models.PosLoginRequest) (posLoginSecret, error) {
if pin := strings.TrimSpace(req.Pin); pin != "" {
value, err := posLoginPin(pin)
if err != nil {
return posLoginSecret{}, errPosLoginRejected
}
return posLoginSecret{byPin: true, pin: value}, nil
}
if strings.TrimSpace(req.Password) == "" {
return posLoginSecret{}, fmt.Errorf("a PIN is required")
}
return posLoginSecret{password: req.Password}, nil
}
// set reports whether the account carries a credential of the kind offered.
//
// Distinguished from a wrong one so that somebody provisioned without a PIN is
// told to go and get one, rather than left retyping four digits that were never
// going to work.
func (s posLoginSecret) set(row posLoginRow) bool {
if s.byPin {
return row.Pin >= PosPinMin && row.Pin <= PosPinMax
}
return strings.TrimSpace(row.Password) != ""
}
// missing names the credential this account has not been given.
func (s posLoginSecret) missing() error {
if s.byPin {
return fmt.Errorf("this account has no PIN set; ask your supervisor to set one in the web console first")
}
return fmt.Errorf("this account has no password set; set one in the web console first")
}
// matches checks the offered credential against the account's own.
//
// The PIN is compared as an integer because that is what the column holds, and
// there is nothing to leak through timing: the value was already reduced to a
// number by [posLoginPin], so the comparison sees a machine word rather than
// the digits somebody typed.
func (s posLoginSecret) matches(row posLoginRow) bool {
if s.byPin {
return s.set(row) && row.Pin == s.pin
}
// Matches the web console's plaintext comparison, which is what the stored
// column holds today. Constant-time so this endpoint at least does not add
// a timing oracle on top.
return s.set(row) && constantTimeEqual(row.Password, s.password)
}
// PosLogin authenticates a user and returns the session they are entitled to.
//
// The outlet is resolved here, from the user's own row and the tenant's list of
// locations — never from anything the caller sent. That inversion is the whole
// point of the endpoint.
func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession, error) {
field, value, err := posLoginIdentity(req)
if err != nil {
return nil, err
}
secret, err := newPosLoginSecret(req)
if err != nil {
return nil, err
}
rows, err := r.posLoginCandidates(field, value, req.Configid, true)
if err != nil {
return nil, err
}
// One message for "no such account" and for "wrong password", on purpose.
// Distinguishing them turns the login into a directory of who banks here.
if len(rows) == 0 {
// Nothing eligible under that credential. Before answering with the
// deliberately vague rejection, look again without the role filter — a
// back-office account typing its own password at a till deserves to be
// told that is the problem, rather than sent hunting for a password
// that was never wrong.
//
// Only ever reached after that account's own password verifies, so it
// discloses nothing the caller has not just proved. An ambiguous match
// falls through to the vague answer rather than naming anything.
if others, oerr := r.posLoginCandidates(field, value, req.Configid, false); oerr == nil &&
len(others) == 1 && secret.matches(others[0]) {
return nil, errPosRoleIneligible
}
return nil, errPosLoginRejected
}
// `authname` is not unique in this schema — live data has the same address
// twice under one configid — so more than one row can come back. Resolving
// that by taking the first would let the account a person *meant* be
// shadowed by a stranger's, and on a POS that means billing into the wrong
// tenant's books. Refused instead, with the fix the caller can act on.
if len(rows) > 1 {
return nil, fmt.Errorf(
"more than one account uses these sign-in details; ask your administrator for the configid and send it with the login")
}
// Inactive accounts never reach here — posLoginCandidates excludes them, so
// that a deactivated duplicate cannot make a live login ambiguous.
row := rows[0]
// TODO: the password column is plaintext across the whole platform, and the
// PIN column is a bare integer. Hashing either is a migration touching every
// login path, not something this endpoint can fix alone — but a POS token
// minted off them is only ever as good as those columns.
if !secret.set(row) {
return nil, secret.missing()
}
if !secret.matches(row) {
return nil, errPosLoginRejected
}
return r.sessionFor(row, req.Locationid)
}
// posLoginIdentity decides which column a sign-in is naming an account by.
//
// A mobile number is normalised to the ten digits the row holds before it is
// matched, because that is the only form the console ever stores. Without this
// a cashier certain of their own number types "+91 98765 43210" and is refused
// — the row says "9876543210" and the comparison is exact.
func posLoginIdentity(req models.PosLoginRequest) (string, string, error) {
if name := strings.TrimSpace(req.Authname); name != "" {
return "authname", name, nil
}
phone, err := normalisePosPhone(req.Contactno)
if err != nil {
// A number that cannot be reduced to ten digits matches no row, so this
// is a rejection rather than a hint about who banks here.
return "", "", errPosLoginRejected
}
if phone == "" {
return "", "", fmt.Errorf("a mobile number is required")
}
return "contactno", phone, nil
}
// sessionFor turns an authenticated account into the session it is entitled to.
//
// Shared by both ways in — an email and password, or a PIN at an already-open
// terminal. Extracted rather than duplicated because everything after the
// credential check is authorisation, and two copies of an authorisation rule
// is one copy too many.
//
// [requestedLocation] is optional and only means anything for an account that
// reaches more than one outlet. It is checked against that set, never trusted
// on its own.
func (r *posRepository) sessionFor(row posLoginRow, requestedLocation int) (*models.PosSession, error) {
// The till is not the back office, and one account is never both. An
// account reaches a terminal only by having been provisioned for one —
// Supervisor or Cashier, created from the console — and never by carrying a
// Nearle Daily role that happens to sound senior.
//
// Checked here rather than in PosLogin so that the PIN route is covered by
// the same line. Both ways in build their session through this function, and
// a gate on only one of them would be a gate on neither.
if !models.PosRoleEligible(row.Roleid) {
return nil, errPosRoleIneligible
}
if row.Tenantid <= 0 {
return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till")
}
locations, err := r.posLoginLocations(row.Tenantid, row.Locationid)
if err != nil {
return nil, err
}
if len(locations) == 0 {
return nil, fmt.Errorf("no active outlet is registered for this account")
}
// Which outlet this terminal is standing in. A request may ask for one, but
// only from the set the account already reaches.
chosen := locations[0]
if requestedLocation > 0 {
match := false
for _, loc := range locations {
if loc.Locationid == requestedLocation {
chosen, match = loc, true
break
}
}
if !match {
return nil, fmt.Errorf("this account cannot open a till at outlet %d", requestedLocation)
}
}
session := &models.PosSession{
Userid: row.Userid,
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
Email: row.Email,
Roleid: row.Roleid,
Role: posRoleLabel(row.Roleid),
Configid: row.Configid,
Canmanagestaff: models.PosRoleCanManageStaff(row.Roleid),
Tenantid: row.Tenantid,
Storeid: fmt.Sprintf("%d", chosen.Locationid),
Locationid: chosen.Locationid,
Locationname: chosen.Locationname,
Address: chosen.Address,
Locations: locations,
}
r.decoratePosSession(session)
// Staff come down with the session so a till is ready to trade the moment
// it signs in. A failure here is not a failed sign-in: a shop with no staff
// recorded — which is almost all of them today — must still be able to open
// its terminal.
if staff, err := r.PosStaff(session.Tenantid, session.Locationid); err == nil {
session.Staff = staff
}
return session, nil
}
// posRoleLabel names a role for the terminal.
//
// Prefers the two POS roles this codebase defines, then falls back to whatever
// `app_roles` calls it — which is blank for a great many accounts, because most
// carry a roleid that is not in that table at all.
func posRoleLabel(roleID int) string {
if name := models.PosRoleName(roleID); name != "" {
return name
}
switch roleID {
case 1:
return "Super admin"
case 2:
return "Operations"
case 3, 5:
return "Admin"
case 4, 6:
return "Manager"
}
return ""
}
// posLoginCandidates finds the accounts matching a set of sign-in details.
//
// Returns a list rather than a row because `app_users` does not constrain
// `authname` to be unique — not globally and not per configid. The caller
// decides what an ambiguous match means; silently picking one here would bury
// the decision in a LIMIT 1.
//
// The configid handling is the part worth explaining. The web console asks for
// it because the browser knows which tenant portal it is on. A till does not:
// somebody is standing at a counter typing an email and a password, and
// demanding a number they have never seen would make the login unusable. So it
// is honoured when sent and inferred when not — and inference that finds more
// than one candidate is reported, never guessed.
func (r *posRepository) posLoginCandidates(field, value string, configID int, tillOnly bool) ([]posLoginRow, error) {
rows := make([]posLoginRow, 0, 2)
query := fmt.Sprintf(`
SELECT userid, COALESCE(password, '') AS password, COALESCE(pin, 0) AS pin,
COALESCE(status, '') AS status,
COALESCE(roleid, 0) AS roleid, COALESCE(configid, 0) AS configid,
COALESCE(tenantid, 0) AS tenantid, COALESCE(locationid, 0) AS locationid,
COALESCE(firstname, '') AS firstname, COALESCE(lastname, '') AS lastname,
COALESCE(email, '') AS email
FROM app_users
WHERE LOWER(TRIM(%s)) = LOWER(TRIM(?))`, field)
params := []interface{}{value}
if configID > 0 {
query += ` AND configid = ?`
params = append(params, configID)
}
// Only till accounts are candidates.
//
// This matters most for signing in by phone. `contactno` is not unique in
// this schema — 34 numbers are shared by 104 active accounts, one of them
// by eleven — and the caller refuses any lookup returning more than one
// row, because choosing between them could bill into the wrong tenant's
// books. Without this clause a cashier whose number also sits on a tenant
// admin's record simply cannot log in.
//
// Narrowing here means a till phone number only has to be unique among
// till accounts, not across all 608 users. An eligible-but-wrong-role
// account still gets the specific errPosRoleIneligible answer, because the
// caller checks the role again after the password verifies — this clause
// removes ambiguity, it does not replace that check.
if tillOnly {
query += fmt.Sprintf(` AND COALESCE(roleid, 0) IN (%d, %d)`,
models.PosRoleSupervisor, models.PosRoleCashier)
}
// Inactive accounts are excluded from the match rather than matched and
// then refused. A deactivated duplicate would otherwise make a working
// login ambiguous, which turns "this person left" into "nobody can open
// the till".
query += ` AND LOWER(COALESCE(status, 'active')) <> 'inactive' ORDER BY userid`
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// posLoginLocations lists the outlets an account may open a till at.
//
// A user pinned to one location gets that one alone; a tenant-level account
// with locationid 0 — a proprietor with several shops — gets all of the
// tenant's active outlets and picks at sign-in.
//
// Inactive outlets are excluded rather than listed and disabled: a till cannot
// usefully trade at a closed shop, and offering it is an invitation to a
// support call.
func (r *posRepository) posLoginLocations(tenantID, pinned int) ([]models.PosLoginLocation, error) {
rows := make([]models.PosLoginLocation, 0)
query := `
SELECT locationid,
COALESCE(locationname, '') AS locationname,
COALESCE(address, '') AS address,
COALESCE(city, '') AS city,
COALESCE(status, '') AS status
FROM tenantlocations
WHERE tenantid = ? AND LOWER(COALESCE(status, 'active')) <> 'inactive'`
params := []interface{}{tenantID}
if pinned > 0 {
query += ` AND locationid = ?`
params = append(params, pinned)
}
query += ` ORDER BY locationid`
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// decoratePosSession fills in what a receipt needs.
//
// The store name, GSTIN and address printed on a bill are a legal requirement
// on a GST invoice, and the till had them as compile-time constants. Sending
// them down with the session means a shop that corrects its GSTIN in the back
// office sees the correction on its next receipt rather than at the next
// rebuild.
//
// Failures here are swallowed: a missing tenant name is a cosmetic problem, and
// refusing a sign-in over it would close a shop.
func (r *posRepository) decoratePosSession(session *models.PosSession) {
var tenant struct {
Tenantname string
Gstin string
Contactno string
Address string
}
// `registrationno` is where this schema keeps the GST number — there is no
// `gstin` column. Aliased rather than renamed through the stack so the till
// receives it under the name it prints on a receipt.
err := r.db.Raw(`
SELECT COALESCE(tenantname, '') AS tenantname,
COALESCE(registrationno, '') AS gstin,
COALESCE(primarycontact, '') AS contactno,
COALESCE(address, '') AS address
FROM tenants WHERE tenantid = ? LIMIT 1`, session.Tenantid).Scan(&tenant).Error
if err != nil {
return
}
session.Tenantname = tenant.Tenantname
session.Gstin = tenant.Gstin
session.Phone = tenant.Contactno
// The outlet's own address wins — a chain's receipts must name the shop the
// customer is standing in, not head office. The tenant address is only a
// fallback for an outlet that has none recorded.
if strings.TrimSpace(session.Address) == "" {
session.Address = tenant.Address
}
}
// PosLocationAllowed reports whether a tenant owns an outlet.
//
// The check the whole session model rests on. Everything a terminal asks for
// names a location, and this is what stops a valid token for one shop being
// replayed against another.
func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, error) {
if tenantID <= 0 || locationID <= 0 {
return false, nil
}
var count int64
err := r.db.Raw(
`SELECT COUNT(1) FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
tenantID, locationID,
).Scan(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// errPosLoginRejected is the single answer to a bad email and a bad password.
var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised")
// errPosRoleIneligible is the answer to a correct credential on an account that
// is not a till account.
//
// Deliberately specific, where a bad password is deliberately vague. By the
// time this 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. It
// names the fix, because the fix is somebody else's screen.
var errPosRoleIneligible = fmt.Errorf(
"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")
// PosLoginRejected reports whether an error is a failed credential check, so
// the controller can answer 401 for those and 500 for a database fault without
// matching on message text.
func PosLoginRejected(err error) bool { return err == errPosLoginRejected }
// constantTimeEqual compares two secrets without leaking their contents through
// how long it took.
//
// Length is compared first and is deliberately allowed to leak — a password's
// length is not the secret, and hashing to a fixed width just to hide it would
// be more machinery than the exposure justifies.
func constantTimeEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := 0; i < len(a); i++ {
diff |= a[i] ^ b[i]
}
return diff == 0
}
// PosStaff lists the people who may ring a bill at an outlet.
//
// Two sources, unioned, because the schema has two and neither is complete.
// `tenantstaffs` is the table built for this and holds 12 rows on the entire
// platform; `app_users.locationid` is where staff actually ended up. Reading
// only the purpose-built table would return nothing for almost every shop, and
// reading only `app_users` would miss anyone assigned through the back office's
// staff screen. So both.
//
// Only people with a PIN come back. A row with `pin = 0` cannot ring anything —
// offering it to the till would put a name on screen that no one can sign in
// as, which reads as a broken terminal rather than as an unfinished setup.
func (r *posRepository) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) {
rows := make([]models.PosStaffMember, 0)
query := `
SELECT DISTINCT
a.userid,
TRIM(CONCAT(COALESCE(a.firstname,''), ' ', COALESCE(a.lastname,''))) AS fullname,
COALESCE(r.rolename, '') AS role,
CAST(a.pin AS TEXT) AS pin,
COALESCE(a.status, '') AS status
FROM app_users a
LEFT JOIN app_roles r ON r.roleid = a.roleid
WHERE a.tenantid = ?
AND COALESCE(a.pin, 0) > 0
AND LOWER(COALESCE(a.status, 'active')) <> 'inactive'
AND (
a.locationid = ?
OR EXISTS (SELECT 1 FROM tenantstaffs s
WHERE s.userid = a.userid
AND s.tenantid = a.tenantid
AND s.locationid = ?
AND LOWER(COALESCE(s.status, 'active')) <> 'inactive')
)
ORDER BY fullname`
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
return nil, err
}
// A PIN shared by two people at one outlet would make the till attribute a
// bill to whichever row it happened to check first — so the second one is
// dropped rather than sent. Live data has 1234 on eleven accounts and 1111
// on nine, so this is not hypothetical.
seen := make(map[string]bool, len(rows))
unique := rows[:0]
for _, row := range rows {
if seen[row.Pin] {
continue
}
seen[row.Pin] = true
unique = append(unique, row)
}
return unique, nil
}

View File

@@ -0,0 +1,197 @@
package repositories
import (
"encoding/json"
"strings"
"testing"
"nearle/models"
)
// Sign-in at a till is a mobile number and a four-digit PIN. These cover the
// two halves separately — which account, and which secret — because the failure
// that matters is not "a wrong PIN is refused" but "a right one is refused",
// and every way that happens is a shop that cannot open.
// The rule that decides whether a PIN may be *issued* is not the rule that
// decides whether one may be *typed*. Live data holds 1234 on eleven accounts
// and 1111 on nine; running the creation rule at sign-in would lock all twenty
// out of the terminal this system signed them up to.
func TestAPinTooWeakToIssueStillSignsIn(t *testing.T) {
for _, pin := range []string{"1234", "1111", "9999", "4321"} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q may now be issued; this test is checking the wrong rule", pin)
}
value, err := posLoginPin(pin)
if err != nil {
t.Errorf("PIN %q was refused at sign-in: %v — that account is locked out", pin, err)
}
if got := strings.TrimSpace(pin); value == 0 {
t.Errorf("PIN %q parsed to 0", got)
}
}
}
func TestAPinOfferedAtSignInIsFourDigitsTheColumnCanHold(t *testing.T) {
// Empty is a refusal here, unlike at creation where it means "this person
// gets a password instead". An empty PIN reaching the comparison would ask
// the database for `pin = 0`, which is what every account without one holds.
for _, pin := range []string{"", " ", "123", "12345", "abcd", "12a4", "0451"} {
if _, err := posLoginPin(pin); err == nil {
t.Errorf("PIN %q was accepted at sign-in", pin)
}
}
value, err := posLoginPin(" 4821 ")
if err != nil {
t.Fatalf("a good PIN was refused: %v", err)
}
if value != 4821 {
t.Fatalf("PIN parsed to %d, want 4821", value)
}
}
// A number is typed by a person, not generated. It has to match the ten digits
// the console stored however they wrote it down.
func TestAMobileNumberIsMatchedInTheFormItIsStored(t *testing.T) {
for _, typed := range []string{"9876543210", "+91 98765 43210", "098765-43210", " 91-9876543210 "} {
field, value, err := posLoginIdentity(models.PosLoginRequest{Contactno: typed, Pin: "4821"})
if err != nil {
t.Errorf("number %q was refused: %v", typed, err)
continue
}
if field != "contactno" {
t.Errorf("number %q was looked up by %q", typed, field)
}
if value != "9876543210" {
t.Errorf("number %q normalised to %q, want 9876543210", typed, value)
}
}
}
func TestAnUnusableNumberIsAPlainRejection(t *testing.T) {
// Not "that is not a mobile number" — this endpoint is unauthenticated, and
// a distinct answer for a well-formed number is the first half of a
// directory of who banks here.
for _, typed := range []string{"12345", "98765432101234", "9876543210123"} {
_, _, err := posLoginIdentity(models.PosLoginRequest{Contactno: typed, Pin: "4821"})
if err != errPosLoginRejected {
t.Errorf("number %q answered %v, want the standard rejection", typed, err)
}
}
// A field holding no digits at all is not a wrong number, it is an empty
// one — and saying so is more use to somebody who fumbled the keyboard than
// "those sign-in details were not recognised".
for _, typed := range []string{"", " ", "abcdefghij"} {
_, _, err := posLoginIdentity(models.PosLoginRequest{Contactno: typed, Pin: "4821"})
if err == nil || err == errPosLoginRejected {
t.Errorf("number %q answered %v, want a request error naming the missing field", typed, err)
}
}
}
// A username still resolves an account, so terminals that have not shipped the
// new screen keep working through the backfill.
func TestAUsernameStillNamesAnAccount(t *testing.T) {
field, value, err := posLoginIdentity(models.PosLoginRequest{
Authname: " supervisor.1135@pos.nearle.in ", Contactno: "9876543210",
})
if err != nil {
t.Fatalf("a username was refused: %v", err)
}
if field != "authname" || value != "supervisor.1135@pos.nearle.in" {
t.Fatalf("looked up by %q = %q, want authname", field, value)
}
}
func TestThePinIsCheckedAgainstTheAccountsOwn(t *testing.T) {
secret, err := newPosLoginSecret(models.PosLoginRequest{Contactno: "9876543210", Pin: "4821"})
if err != nil {
t.Fatalf("a well-formed PIN was refused: %v", err)
}
if !secret.byPin {
t.Fatal("a request carrying a PIN was read as a password sign-in")
}
if !secret.matches(posLoginRow{Pin: 4821}) {
t.Error("the right PIN was refused")
}
if secret.matches(posLoginRow{Pin: 4822}) {
t.Error("a wrong PIN was accepted")
}
// The one that would matter most: an account with no PIN holds 0 in that
// column, and every account on the platform did until this shipped.
if secret.set(posLoginRow{Pin: 0}) {
t.Error("an account with no PIN was treated as having one")
}
if secret.matches(posLoginRow{Pin: 0}) {
t.Error("an account with no PIN was signed in")
}
// A password on the row is not a PIN, and must not stand in for one.
if secret.matches(posLoginRow{Pin: 0, Password: "4821"}) {
t.Error("a password was accepted as a PIN")
}
if !strings.Contains(secret.missing().Error(), "PIN") {
t.Errorf("an account without a PIN was told %q", secret.missing())
}
}
func TestAPasswordStillOpensATillWhileNumbersAreBackfilled(t *testing.T) {
secret, err := newPosLoginSecret(models.PosLoginRequest{
Authname: "supervisor.1135@pos.nearle.in", Password: "xHegDaH55ccWic",
})
if err != nil {
t.Fatalf("a password sign-in was refused: %v", err)
}
if secret.byPin {
t.Fatal("a request carrying no PIN was read as a PIN sign-in")
}
if !secret.matches(posLoginRow{Password: "xHegDaH55ccWic"}) {
t.Error("the right password was refused")
}
if secret.matches(posLoginRow{Password: "xHegDaH55ccWid"}) {
t.Error("a wrong password was accepted")
}
if secret.matches(posLoginRow{Password: ""}) {
t.Error("an account with no password was signed in")
}
// A PIN on the row is not a password. Symmetric to the check above, and the
// reason both live in one function: two credentials checked in two places
// is how one of them ends up satisfying the other.
if secret.matches(posLoginRow{Pin: 4821}) {
t.Error("a PIN was accepted as a password")
}
}
func TestASignInWithNoCredentialAtAllIsRefused(t *testing.T) {
if _, err := newPosLoginSecret(models.PosLoginRequest{Contactno: "9876543210"}); err == nil {
t.Fatal("a sign-in offering neither a PIN nor a password was accepted")
}
// A malformed PIN answers the same as a wrong one, rather than confirming
// that the number it was sent with exists.
if _, err := newPosLoginSecret(models.PosLoginRequest{Contactno: "9876543210", Pin: "12"}); err != errPosLoginRejected {
t.Errorf("a malformed PIN answered %v, want the standard rejection", err)
}
}
// The session hands the terminal its outlet's staff so it can trade at once.
// Now that a PIN is half of the sign-in, that list must not carry them: it
// would hand every cashier their supervisor's credentials, and a supervisor
// carries can_manage_staff.
func TestTheStaffListDoesNotCarryPins(t *testing.T) {
body, err := json.Marshal(models.PosSession{
Staff: []models.PosStaffMember{{Userid: 42, Fullname: "Priya Raman", Role: "Cashier", Pin: "4821"}},
})
if err != nil {
t.Fatalf("session did not marshal: %v", err)
}
if strings.Contains(string(body), "4821") || strings.Contains(string(body), `"pin"`) {
t.Fatalf("the login response carried a staff PIN: %s", body)
}
}

195
repositories/posPresence.go Normal file
View File

@@ -0,0 +1,195 @@
package repositories
import (
"context"
"fmt"
"strconv"
"time"
"nearle/db"
"nearle/models"
"github.com/redis/go-redis/v9"
)
// POS terminal presence, in Redis.
//
// ### Why Redis and not a table
//
// A heartbeat is a fact with an expiry date. Written to Postgres it needs a
// row per till updated twice a minute — around 288,000 writes a day across a
// hundred terminals — and a reaper job to mark a till offline once it stops,
// because a row that says "online" has no way of ageing out on its own.
//
// A Redis key with a TTL does the ageing for free. A till that loses power
// stops refreshing, the key expires, and it disappears from the board without
// anything having to notice. That is the whole design.
//
// ### Keys
//
// pos:terminal:{terminalcode} HASH, TTL 90s — one till's state
// pos:location:{locationid}:terminals SET, no TTL — which tills a shop has
//
// The set has no TTL on purpose, mirroring how `city:{tenantid}:active_deliveries`
// is treated in the express backend: it is an index of what exists, not a claim
// that any of it is alive right now. Membership means "this till has been seen
// here"; liveness is whether the hash still exists.
const (
// Three missed heartbeats. Two would make an ordinary GPRS hiccup look like
// a dead till; five would take two and a half minutes to notice a real one.
posPresenceTTL = 90 * time.Second
posTerminalKeyFmt = "pos:terminal:%s"
posLocationKeyFmt = "pos:location:%s:terminals"
)
type PosPresenceRepository interface {
Record(ctx context.Context, health models.PosHealth) error
Terminal(ctx context.Context, terminalID string) (map[string]string, error)
Location(ctx context.Context, locationID string) ([]map[string]string, error)
}
type posPresenceRepository struct{}
func NewPosPresenceRepository() PosPresenceRepository { return &posPresenceRepository{} }
// Record writes one heartbeat and refreshes its TTL.
func (r *posPresenceRepository) Record(ctx context.Context, health models.PosHealth) error {
if db.Rdb == nil {
return fmt.Errorf("redis is not configured")
}
if health.Terminalid == "" {
return fmt.Errorf("heartbeat has no terminal id")
}
terminalKey := fmt.Sprintf(posTerminalKeyFmt, health.Terminalid)
fields := map[string]any{
"terminal_id": health.Terminalid,
"location_id": health.Locationid,
"store_name": health.Storename,
"app_version": health.Appversion,
"status": health.Status,
"pending_bills": health.Pendingbills,
"pending_registrations": health.Pendingregistrations,
"oldest_pending_at": health.Oldestpendingat,
"today_bills": health.Todaybills,
"today_amount": health.Todayamount,
"last_bill_at": health.Lastbillat,
"reported_at": health.Reportedat,
// Stamped here as well as at the till. The two disagreeing by more than
// a few seconds means the terminal's clock is wrong — which matters,
// because bills are filed under the business date the till decided.
"received_at": time.Now().UTC().Format(time.RFC3339),
}
// Device readings only when the till actually reported them. A build that
// does not collect battery level must not leave one behind saying 0%.
if health.Batterylevel != nil {
fields["battery_level"] = *health.Batterylevel
}
if health.Batterycharging != nil {
fields["battery_charging"] = *health.Batterycharging
}
if health.Storagefreemb != nil {
fields["storage_free_mb"] = *health.Storagefreemb
}
if health.Printerreachable != nil {
fields["printer_reachable"] = *health.Printerreachable
}
if health.Drawerstatus != nil {
fields["drawer_status"] = *health.Drawerstatus
}
// HSet leaves untouched fields in place, so a reading that stops being
// reported would otherwise linger for ever at its last value. Clearing the
// absent ones keeps the hash honest about what this till currently knows.
stale := make([]string, 0, 5)
for field, reported := range map[string]bool{
"battery_level": health.Batterylevel != nil,
"battery_charging": health.Batterycharging != nil,
"storage_free_mb": health.Storagefreemb != nil,
"printer_reachable": health.Printerreachable != nil,
"drawer_status": health.Drawerstatus != nil,
} {
if !reported {
stale = append(stale, field)
}
}
pipe := db.Rdb.TxPipeline()
pipe.HSet(ctx, terminalKey, fields)
if len(stale) > 0 {
pipe.HDel(ctx, terminalKey, stale...)
}
pipe.Expire(ctx, terminalKey, posPresenceTTL)
if health.Locationid != "" {
// No TTL: this is the list of tills a shop has, not a claim that any of
// them is alive. Liveness is whether the hash above still exists.
pipe.SAdd(ctx, fmt.Sprintf(posLocationKeyFmt, health.Locationid), health.Terminalid)
}
_, err := pipe.Exec(ctx)
return err
}
// Terminal returns one till's last known state, or nil if it has gone quiet.
func (r *posPresenceRepository) Terminal(ctx context.Context, terminalID string) (map[string]string, error) {
if db.Rdb == nil {
return nil, fmt.Errorf("redis is not configured")
}
fields, err := db.Rdb.HGetAll(ctx, fmt.Sprintf(posTerminalKeyFmt, terminalID)).Result()
if err != nil && err != redis.Nil {
return nil, err
}
if len(fields) == 0 {
// Expired or never seen. Both mean "not reporting", which is what the
// caller needs to know; distinguishing them would need a durable record
// this deliberately does not keep.
return nil, nil
}
return fields, nil
}
// Location returns every till registered at a shop, live or dark.
//
// A till whose key has expired comes back as a stub with status "offline"
// rather than being omitted. Omitting it would make a dead terminal
// indistinguishable from one that was never installed — and the dead one is
// precisely what somebody is looking for.
func (r *posPresenceRepository) Location(ctx context.Context, locationID string) ([]map[string]string, error) {
if db.Rdb == nil {
return nil, fmt.Errorf("redis is not configured")
}
members, err := db.Rdb.SMembers(ctx, fmt.Sprintf(posLocationKeyFmt, locationID)).Result()
if err != nil && err != redis.Nil {
return nil, err
}
out := make([]map[string]string, 0, len(members))
for _, terminalID := range members {
fields, err := r.Terminal(ctx, terminalID)
if err != nil {
return nil, err
}
if fields == nil {
fields = map[string]string{
"terminal_id": terminalID,
"location_id": locationID,
"status": "offline",
// Says why it is being reported offline, rather than leaving a
// reader to guess whether the till said so or simply vanished.
"reason": "no heartbeat within " + strconv.Itoa(int(posPresenceTTL.Seconds())) + "s",
}
}
out = append(out, fields)
}
return out, nil
}

View File

@@ -0,0 +1,869 @@
package repositories
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"nearle/models"
"gorm.io/gorm"
)
// Ingestion for the Nearle POS terminal.
//
// Bills arrive here already rung up and paid for — the till is the system of
// record until we say otherwise, and it holds its own copy for a week on the
// strength of our acknowledgement. Two consequences shape everything below.
//
// **A duplicate is a success.** Delivery is at-least-once: a lost ack makes a
// terminal re-send bills that are already banked. Reporting those as failures
// would strand a day of takings on the till for ever. So a bill we already hold
// is accepted, silently, without touching stock again.
//
// **Acknowledge only after the commit.** A bill named in the ack is one the
// terminal is entitled to delete. Saying so before the transaction lands would
// trade a real sale for a queue position.
//
// The commit itself is deliberately not reimplemented here. Each bill runs
// through createOrderTx, the same path an app order and a spreadsheet import
// take, so stock deduction, the per-product row locks that prevent overselling,
// the ledger entries and sequence allocation stay shared rather than forked.
type PosRepository interface {
IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)
// Sign-in. The outlet a terminal bills for is decided here, from the user's
// own record, rather than being named by the till and believed.
PosLogin(req models.PosLoginRequest) (*models.PosSession, error)
PosLocationAllowed(tenantID, locationID int) (bool, error)
PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error)
// Till staff, managed by the shop. Tenant and location are always the
// caller's own, taken from their session token — no argument here can name
// somebody else's outlet.
CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
// Shift windows for till staff.
ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error)
CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
DeactivatePosUser(tenantID, locationID, userID int) error
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
PosConfigidFor(tenantID int) int
// Reading counter sales back out. Without these a committed bill is
// unreachable from every screen in the product.
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
SaleDetail(locationID int, reference string) (*models.PosOrders, error)
SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
}
type posRepository struct {
db *gorm.DB
// Held rather than embedded so the order machinery is reached explicitly.
orders *orderRepository
}
func NewPosRepository(db *gorm.DB) PosRepository {
return &posRepository{db: db, orders: &orderRepository{db: db}}
}
// resolvePosStore turns the terminal's store_id into an authorised outlet.
//
// The till sends a location and nothing else. The tenant is looked up from it
// here and never accepted from the wire: a terminal that could name its own
// tenant could post sales into somebody else's books.
func (r *posRepository) resolvePosStore(storeID string) (*offlineLocationContext, error) {
locationID, err := strconv.Atoi(strings.TrimSpace(storeID))
if err != nil || locationID <= 0 {
return nil, fmt.Errorf("store_id %q is not a location id; configure the terminal's Store ID with the numeric locationid", storeID)
}
var tenantID int
err = r.db.Raw(
`SELECT COALESCE(MIN(tenantid), 0) FROM tenantlocations WHERE locationid = ?`,
locationID,
).Scan(&tenantID).Error
if err != nil {
return nil, err
}
if tenantID <= 0 {
return nil, fmt.Errorf("no outlet is registered with locationid %d", locationID)
}
return r.orders.resolveOfflineLocationContext(tenantID, locationID)
}
// IngestOrders commits a batch of counter bills and reports what landed.
//
// A failure to resolve the outlet at all returns an error rather than an ack,
// so the terminal treats the outcome as unknown and retries. A failure on one
// bill is reported against that bill alone and the rest still commit.
func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
ack := models.NewPosAck(batch.Batchid)
if len(batch.Orders) == 0 {
return ack, nil
}
ctx, err := r.resolvePosStore(batch.Storeid)
if err != nil {
return nil, err
}
products, err := r.orders.loadOfflineProducts(ctx.Tenantid, ctx.Locationid)
if err != nil {
return nil, err
}
if len(products) == 0 {
return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
}
for _, order := range batch.Orders {
if strings.TrimSpace(order.Id) == "" {
// Nothing to key on, so it can never be deduplicated. Refusing it
// is safer than admitting a bill that would double on every retry.
ack.Reject("", "order is missing its id")
continue
}
if reason := r.importPosOrder(ctx, products, batch.Batchid, batch.Terminalid, order); reason != "" {
ack.Reject(order.Id, reason)
continue
}
ack.Accept(order.Id)
}
return ack, nil
}
// importPosOrder commits one bill, or leaves nothing behind.
//
// Returns an empty string on success — including the case where the bill was
// already held, which is a success from the terminal's point of view.
//
// The bill lands in pos_orders at full fidelity, and the stock it consumed goes
// through the same productstocks ledger an app order uses. Those two facts pull
// in opposite directions and both matter: the bill is its own kind of document
// and deserves its own table, but stock is one number per shelf and must not be
// tracked twice.
// batchTerminal is the terminal the whole batch came from, used when a bill
// does not name one itself. Over MQTT the consumer fills it in from the topic;
// over HTTP the terminal sends it once at the top of the batch rather than
// repeating it on every bill.
func (r *posRepository) importPosOrder(
ctx *offlineLocationContext,
products map[int]offlineProduct,
batchID string,
batchTerminal string,
order models.PosOrder,
) string {
if len(order.Items) == 0 {
return "bill has no items"
}
saleDate, err := parsePosSaleDate(order.Createdat)
if err != nil {
return err.Error()
}
// The till has already apportioned bill-level discounts across its lines to
// get the tax right, but it sends each line at its own pre-apportionment
// value. Left alone, the item rows would sum to the subtotal while the
// header carried the total, and every report that adds up lines would
// disagree with the one that reads the header.
//
// So the lines are scaled onto what was actually collected. The till's own
// figures stay authoritative for the bill as a whole; this only decides how
// that whole is attributed across the lines inside it.
netAmount := order.Total - order.Roundoff
lineSum := 0.0
taxSum := 0.0
for _, item := range order.Items {
lineSum += item.Linetotal
taxSum += item.Tax
}
amountFactor := 1.0
if lineSum > 0 && netAmount > 0 {
amountFactor = netAmount / lineSum
}
taxFactor := 1.0
if taxSum > 0 && order.Tax > 0 {
taxFactor = order.Tax / taxSum
}
items := make([]models.PosOrderItems, 0, len(order.Items))
lines := make([]stockLine, 0, len(order.Items))
var taxTotal float64
for _, raw := range order.Items {
productID, err := strconv.Atoi(strings.TrimSpace(raw.Productid))
if err != nil || productID <= 0 {
return fmt.Sprintf("line '%s' has product_id %q, which is not a catalogue id", raw.Name, raw.Productid)
}
// Membership of this map is the ownership check. A product absent from
// it is either another tenant's or not stocked here, and either way the
// bill is refused rather than posted against a catalogue it has no
// claim on.
product, ok := products[productID]
if !ok {
return fmt.Sprintf("product %d is not stocked at %s", productID, ctx.Locationname)
}
if raw.Quantity <= 0 {
return fmt.Sprintf("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Quantity)
}
landing := raw.Linetotal * amountFactor
taxAmount := raw.Tax * taxFactor
gross := raw.Unitprice * raw.Quantity
discount := gross - landing
if discount < 0 {
discount = 0
}
taxTotal += taxAmount
items = append(items, models.PosOrderItems{
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
Productid: productID,
Productname: product.Productname,
Barcode: raw.Barcode,
Unitname: product.Productunit,
Quantity: raw.Quantity,
Unitprice: raw.Unitprice,
Discountamount: discount,
Gstrate: raw.Gstrate,
Taxamount: taxAmount,
Linetotal: landing,
})
lines = append(lines, stockLine{
Productid: productID,
Locationid: ctx.Locationid,
Productname: product.Productname,
Quantity: raw.Quantity,
})
}
paymentMode := "cash"
if len(order.Payments) > 0 {
// The largest tender names the bill. A split paid mostly by card with
// ten rupees of change in cash is a card sale in every report anyone
// actually reads — the full split is kept in Paymentsjson regardless.
largest := order.Payments[0]
for _, p := range order.Payments[1:] {
if p.Amount > largest.Amount {
largest = p
}
}
if m := strings.ToLower(strings.TrimSpace(largest.Method)); m != "" {
paymentMode = m
}
}
tx := r.db.Begin()
if tx.Error != nil {
return fmt.Sprintf("could not start a transaction: %v", tx.Error)
}
// Held for the life of the transaction, so a redelivery arriving at the
// same moment waits here and then sees the committed row rather than racing
// past the check below and banking the sale twice. The unique index on
// terminalorderid would catch it either way; this turns a constraint
// violation into an orderly "already held".
lockKey := "possale:" + strings.ToUpper(strings.TrimSpace(order.Id))
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not lock bill %s: %v", order.Invoicenumber, err)
}
var already int
err = tx.Raw(
`SELECT COALESCE(COUNT(*), 0) FROM pos_orders WHERE terminalorderid = ?`,
strings.TrimSpace(order.Id),
).Scan(&already).Error
if err != nil {
tx.Rollback()
return fmt.Sprintf("could not check whether bill %s was already held: %v", order.Invoicenumber, err)
}
if already > 0 {
// Already banked. Accepted, not rejected — this is the ordinary result
// of a lost ack, and calling it a failure would leave the till holding
// a bill we have had all along. Stock is deliberately untouched.
tx.Rollback()
return ""
}
// Locks first, then availability, then the writes — the same order an app
// order takes, and the reason two tills selling the last unit cannot both
// succeed.
if err := lockStockRows(tx, ctx.Tenantid, lines); err != nil {
tx.Rollback()
return err.Error()
}
if err := assertStockAvailable(tx, ctx.Tenantid, lines, func(l stockLine) int {
return roundStockQty(l.Quantity)
}); err != nil {
tx.Rollback()
return err.Error()
}
customerID, err := r.orders.resolveOfflineCustomer(tx, ctx, posCustomerName(order), posCustomerMobile(order))
if err != nil {
tx.Rollback()
return fmt.Sprintf("could not resolve the customer: %v", err)
}
bill := models.PosOrders{
Terminalorderid: strings.TrimSpace(order.Id),
Invoicenumber: order.Invoicenumber,
Tenantid: ctx.Tenantid,
Locationid: ctx.Locationid,
// The bill's own terminal wins; the batch's is the fallback. Without
// this the column was empty on every bill that arrived over HTTP —
// the invoice number carried the code and the column did not, so
// per-terminal reconciliation had nothing to group on.
Terminalid: posTerminalFor(order.Terminalid, batchTerminal),
Cashiername: order.Cashier,
Customerid: customerID,
Customermobile: posCustomerMobile(order),
Customername: posCustomerName(order),
Billedat: saleDate,
// The day the sale was rung, not the day it arrived. A till that was
// offline overnight uploads yesterday's bills this morning, and every
// daily figure has to follow the sale rather than the upload.
Businessdate: saleDate.Format("2006-01-02"),
Subtotal: order.Subtotal,
Discount: order.Discount,
Taxamount: taxTotal,
Roundoff: order.Roundoff,
Total: order.Total,
Pointsearned: order.Pointsearned,
Pointsredeemed: order.Pointsredeemed,
Itemcount: len(items),
Paymentmode: paymentMode,
Paymentsjson: posJSON(order.Payments),
Promosjson: posJSON(order.Promos),
// Every jsonb column must carry valid JSON. Left at Go's zero value an
// empty string reaches Postgres and the whole insert fails with
// "invalid input syntax for type json" — taking the bill down with it.
Taxbreakdownjson: posJSON(order.Taxbreakdown),
Batchid: batchID,
Receivedat: time.Now(),
}
if err := tx.Create(&bill).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not write bill %s: %v", order.Invoicenumber, err)
}
for i := range items {
items[i].Posorderid = bill.Posorderid
if err := tx.Create(&items[i]).Error; err != nil {
tx.Rollback()
return fmt.Sprintf("could not write a line of bill %s: %v", order.Invoicenumber, err)
}
// The same ledger an app order writes to. A second stock ledger for
// counter sales would mean the catalogue pull sends a till figures that
// ignore the till's own trading.
if err := recordStockOut(tx, ctx.Tenantid, lines[i], roundStockQty(lines[i].Quantity)); err != nil {
tx.Rollback()
return fmt.Sprintf("could not deduct stock for bill %s: %v", order.Invoicenumber, err)
}
}
if err := tx.Commit().Error; err != nil {
return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err)
}
// Stock has moved at this outlet, so every other till standing at the same
// counter is now holding a figure that is one sale out of date. After the
// commit, never before — a rolled-back bill must not announce itself.
notifyCatalogueChanged(ctx.Locationid)
return ""
}
// posJSON encodes a payload column.
//
// Falls back to a JSON null rather than failing the bill: these columns exist
// to be read back later, and losing one is not a reason to refuse a sale the
// shopper has already paid for.
func posJSON(v any) string {
body, err := json.Marshal(v)
if err != nil || len(body) == 0 {
return "null"
}
return string(body)
}
// posTerminalFor picks which terminal code to file a bill under.
//
// Trimmed before the emptiness test: a terminal sending `" "` is saying nothing,
// and treating that as a real code would file bills under a blank that looks
// identical to the missing value this exists to fix.
func posTerminalFor(orderTerminal, batchTerminal string) string {
if t := strings.TrimSpace(orderTerminal); t != "" {
return t
}
return strings.TrimSpace(batchTerminal)
}
func posCustomerName(order models.PosOrder) string {
if order.Customer == nil {
return ""
}
return order.Customer.Name
}
func posCustomerMobile(order models.PosOrder) string {
if order.Customer == nil {
return ""
}
return order.Customer.Mobile
}
// parsePosSaleDate reads the till's timestamp.
//
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
// one is refused, because importing a sale under the wrong date corrupts every
// daily revenue figure that reads it.
// parsePosSaleDate reads the moment a bill was rung.
//
// The order of these layouts is load-bearing, and the two zoned ones must stay
// first. A terminal that sends its offset — `2026-08-05T00:30:00+05:30` — gets
// both readings right: the instant is correct, and Format("2006-01-02") still
// yields the till's own trading day rather than UTC's.
//
// The two bare layouts exist for terminals built before the offset was added,
// which are still in the field. `time.Parse` fills an absent zone with UTC, so
// those bills record an instant wrong by the offset — a Coimbatore wall clock
// read as though it were London. That is not recoverable here: nothing in the
// payload says which zone it came from. Their business date is still right,
// which is why the daily figures held up while billedat did not, and why these
// are tolerated rather than refused.
func parsePosSaleDate(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Now(), nil
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999999",
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, raw); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unrecognised created_at %q", raw)
}
// IngestCustomers records shoppers registered at a till.
//
// Insert-if-absent, never an update. A registration is replayed freely, and a
// profile corrected at head office must not be reverted by a terminal replaying
// what it captured months ago.
func (r *posRepository) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
ack := models.NewPosAck(batch.Batchid)
if len(batch.Customers) == 0 {
return ack, nil
}
ctx, err := r.resolvePosStore(batch.Storeid)
if err != nil {
return nil, err
}
for _, customer := range batch.Customers {
mobile := strings.TrimSpace(customer.Mobile)
if strings.TrimSpace(customer.Id) == "" || mobile == "" {
ack.Reject(customer.Id, "registration is missing its id or mobile number")
continue
}
if err := r.upsertPosCustomer(ctx, customer, mobile); err != nil {
ack.Reject(customer.Id, err.Error())
continue
}
ack.Accept(customer.Id)
}
return ack, nil
}
// upsertPosCustomer attaches the shopper to this outlet's app location.
//
// Matched on contactno, which is what the rest of the system already keys a
// shopper on, so a shopper registered at a till and one who installed the app
// end up as one row rather than two.
func (r *posRepository) upsertPosCustomer(
ctx *offlineLocationContext,
customer models.PosCustomer,
mobile string,
) error {
name := strings.TrimSpace(customer.Name)
if name == "" {
name = "Counter Customer"
}
var existing int
err := r.db.Raw(
`SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`,
mobile, ctx.Applocationid,
).Scan(&existing).Error
if err != nil {
return err
}
if existing > 0 {
// Already known. Accepted without a write — the terminal's copy is not
// newer than ours in any way we can establish.
return nil
}
// status 0 mirrors every other customer row in production, including ones
// actively placing orders. A different value here would make a shopper
// registered at the counter behave unlike all the others.
var created int
err = r.db.Raw(`
INSERT INTO customers (configid, firstname, lastname, contactno, email, gender, dob, applocationid, locationid, status, created, updated)
VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())
RETURNING customerid`,
ctx.Configid, name, mobile,
strings.TrimSpace(customer.Email),
strings.TrimSpace(customer.Gender),
strings.TrimSpace(customer.Dateofbirth),
ctx.Applocationid, ctx.Locationid,
).Scan(&created).Error
if err != nil {
return err
}
if created <= 0 {
return errors.New("failed to create the customer row")
}
return nil
}
// posRevisionLayout is the timestamp inside a catalogue revision.
//
// The revision is the terminal's memory of when it last pulled: it stores what
// we send and hands it back on the next request, and the time encoded in it is
// the cutoff for what has changed since. Colons are avoided so the whole string
// stays safe in a URL query without escaping.
const posRevisionLayout = "20060102T150405Z"
// posRevisionFor mints the revision a terminal will send back to us.
func posRevisionFor(locationID int, at time.Time) string {
return fmt.Sprintf("loc%d-%s", locationID, at.UTC().Format(posRevisionLayout))
}
// posRevisionCutoff reads the timestamp back out of a revision.
//
// Returns the zero time when the revision is missing, malformed, or belongs to
// a different outlet — and a zero cutoff means "send everything". Falling back
// to a full snapshot is the only safe direction: answering an unreadable
// revision with a *delta* would leave the terminal quietly missing every change
// it had not already seen, with nothing to indicate it.
func posRevisionCutoff(locationID int, revision string) time.Time {
revision = strings.TrimSpace(revision)
prefix := fmt.Sprintf("loc%d-", locationID)
if !strings.HasPrefix(revision, prefix) {
return time.Time{}
}
at, err := time.Parse(posRevisionLayout, strings.TrimPrefix(revision, prefix))
if err != nil {
return time.Time{}
}
return at
}
// Catalogue answers a terminal's pull, as a snapshot or as a change set.
//
// ### The rule this function exists to keep
//
// A response with `is_delta: false` is treated as a full snapshot, and the
// terminal **withdraws every product the response does not mention**. So a
// filtered result labelled `false` empties the shop's shelf.
//
// The two are therefore decided together, from one value: a zero cutoff means
// no filter and `is_delta: false`; a non-zero cutoff means filtered and
// `is_delta: true`. There is no path through this function that filters without
// also setting the flag.
//
// ### What counts as a change
//
// A product is included when any of three things moved since the cutoff: the
// product row itself (name, tax, brand), its row at this location (price,
// availability), or its stock ledger. Stock is included because a shop's count
// drifts from the till's on every sale rung elsewhere, and a delta that omitted
// it would let that drift persist until someone forced a full pull.
//
// ### What a delta cannot do
//
// A product *deleted* from productlocations leaves no tombstone, so a change set
// cannot know to withdraw it. Only a full snapshot collects those. A terminal
// should pull without a revision periodically — the morning import is the
// natural moment — and this is why.
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
ctx, err := r.resolvePosStore(storeID)
if err != nil {
return nil, err
}
if pageSize <= 0 || pageSize > 1000 {
pageSize = 500
}
if page < 0 {
page = 0
}
// The single decision. Everything downstream reads this rather than
// re-deriving it, so the filter and the flag cannot disagree.
cutoff := posRevisionCutoff(ctx.Locationid, since)
isDelta := !cutoff.IsZero()
type row struct {
Productid int
Productname string
Productsku string
Categoryname string
Productunit string
Productbrand string
Price float64
Retailprice float64
Taxpercent float64
Stock float64
Status string
}
// A product counts as changed if the product row, its row at this location,
// or its stock ledger moved. Written as one predicate so a delta cannot
// miss a price change simply because the product row was untouched.
changed := ""
params := []interface{}{ctx.Tenantid, ctx.Locationid}
if isDelta {
changed = `AND (
a.updated >= ?
OR b.updated >= ?
OR EXISTS (SELECT 1 FROM productstocks s2
WHERE s2.productid = a.productid AND s2.tenantid = a.tenantid
AND s2.locationid = b.locationid
AND (s2.stockdate >= ? OR s2.updated >= ?))
)`
params = append(params, cutoff, cutoff, cutoff, cutoff)
}
rows := make([]row, 0)
query := fmt.Sprintf(`
SELECT a.productid,
COALESCE(a.productname, '') AS productname,
COALESCE(a.productsku, '') AS productsku,
COALESCE(c.categoryname, '') AS categoryname,
COALESCE(a.productunit, '') AS productunit,
COALESCE(a.productbrand, '') AS productbrand,
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
COALESCE(a.retailprice, 0) AS retailprice,
COALESCE(a.taxpercent, 0) AS taxpercent,
COALESCE((
SELECT SUM(CASE WHEN LOWER(s.stocktype) = 'in' THEN s.quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(s.stocktype) = 'out' THEN s.quantity ELSE 0 END)
FROM productstocks s
WHERE s.productid = a.productid AND s.tenantid = a.tenantid AND s.locationid = b.locationid
), 0) AS stock,
COALESCE(b.status, '') AS status
FROM products a
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
LEFT JOIN productcategories c ON a.categoryid = c.categoryid
WHERE a.tenantid = ? AND b.locationid = ? AND a.productid > 0 %s
ORDER BY a.productid
LIMIT ? OFFSET ?`, changed)
// One row past the page, purely so has_more can be answered without a
// second count query.
params = append(params, pageSize+1, page*pageSize)
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
// One row beyond the page was requested purely to answer has_more without a
// second count query.
hasMore := len(rows) > pageSize
if hasMore {
rows = rows[:pageSize]
}
products := make([]models.PosCatalogueProduct, 0, len(rows))
for _, p := range rows {
// Rows with productid <= 0 are excluded in SQL rather than here. Live
// data has at least one — almost certainly an insert that never got a
// sequence value — and it can never be billed, because the ingest
// refuses any line whose id is not positive. Filtering it in the query
// also keeps pagination exact: skipped after the LIMIT, it would eat a
// slot and hand back a short page.
mrp := p.Retailprice
if mrp <= p.Price {
mrp = 0
}
// Indian GST is 0/5/12/18/28, but the column holds 3, 4, 6, 7, 9, 10,
// 15 and even -1 across live data. A negative rate would put negative
// tax on a bill and a negative figure in a slab on a filed return, so
// it is floored here rather than trusted.
gstRate := p.Taxpercent / 100
if gstRate < 0 {
gstRate = 0
}
// Withdrawn from sale when there is no selling price. Neither
// productlocations.price nor retailprice is set on much of the estate —
// only productcost is — and a till that can ring an item up at ₹0 is
// worse than one that cannot ring it up at all. Pricing the product
// makes it sellable; nothing here needs changing.
sellable := p.Price > 0 &&
!strings.EqualFold(strings.TrimSpace(p.Status), "outofstock")
products = append(products, models.PosCatalogueProduct{
Id: strconv.Itoa(p.Productid),
Name: p.Productname,
Barcode: posBarcode(p.Productid, p.Productsku),
Sku: p.Productsku,
Category: posCategory(p.Categoryname),
Price: p.Price,
Mrp: mrp,
Stock: p.Stock,
Unit: posUnit(p.Productunit),
Gstrate: gstRate,
Brand: p.Productbrand,
Isactive: sellable,
})
}
// The revision only advances on the final page.
//
// A terminal that gives up half way through a paginated pull — a dropped
// connection, a till switched off — must not be left holding a revision
// that claims it has seen pages it never received. Every one of those
// products would then be excluded from the next delta and stay stale
// indefinitely, with nothing anywhere to indicate it.
//
// So mid-pull we echo back whatever the terminal already had: unchanged if
// it sent one, empty if it did not, and empty means the next pull is a full
// snapshot. Both are recoverable; a prematurely advanced revision is not.
//
// The stamp is taken a second in the past. A product written during the
// same second this query ran could otherwise land on the wrong side of the
// next cutoff and be skipped for good — overlapping by a second costs one
// redundant row and cannot lose one.
revision := strings.TrimSpace(since)
if !hasMore {
revision = posRevisionFor(ctx.Locationid, time.Now().Add(-time.Second))
}
return &models.PosCatalogueResponse{
Revision: revision,
// Decided with the filter, never separately. False here would tell the
// terminal to withdraw every product this response omits.
Isdelta: isDelta,
Hasmore: hasMore,
Products: products,
Customers: make([]models.PosCatalogueCustomer, 0),
// A product deleted from productlocations leaves no tombstone, so a
// change set cannot know to withdraw it. Only a full snapshot collects
// those, which is why a terminal should pull without a revision
// periodically.
Retiredids: make([]string, 0),
}, nil
}
// posBarcode decides what the till scans this product by.
//
// The terminal holds a **unique** index on barcode, so whatever this returns has
// to be distinct across the whole catalogue or the import fails outright.
//
// `products.productsku` cannot be trusted for that. Measured against live data:
// 6,245 products carry only 93 distinct SKUs, and the single value "1" is used
// by 5,794 of them. Mapping SKU straight to barcode would collapse most of the
// catalogue onto one row.
//
// So a SKU is used only when it looks like a real scannable code — 8 to 14
// digits, the shape of an EAN-8, UPC-A or EAN-13 — and otherwise the product id
// stands in. The id is unique by construction, which keeps the import working
// today; the day real barcodes are populated, scanning starts working on its own
// with no change here.
//
// Until then, scanning a physical barcode at the till will not find anything.
// That is a data problem, not a code one.
func posBarcode(productID int, sku string) string {
sku = strings.TrimSpace(sku)
if len(sku) >= 8 && len(sku) <= 14 {
digitsOnly := true
for _, r := range sku {
if r < '0' || r > '9' {
digitsOnly = false
break
}
}
if digitsOnly {
return sku
}
}
return strconv.Itoa(productID)
}
// posCategory maps a category name onto one of the terminal's fixed buckets.
//
// The till ships a closed enum, so anything unrecognised has to land somewhere;
// grocery is the catch-all it already uses for uncategorised stock.
func posCategory(name string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "dairy":
return "dairy"
case "fruits", "fruit":
return "fruits"
case "vegetables", "vegetable":
return "vegetables"
case "beverages", "beverage", "drinks":
return "beverages"
case "snacks", "snack":
return "snacks"
case "personal care", "personalcare":
return "personalCare"
case "household", "home care", "homecare":
return "household"
default:
return "grocery"
}
}
// posUnit maps a unit of measure onto the terminal's enum, defaulting to pieces.
func posUnit(unit string) string {
switch strings.ToLower(strings.TrimSpace(unit)) {
case "kg", "kilogram", "kilo":
return "kilogram"
case "g", "gram", "grams":
return "gram"
case "l", "litre", "liter":
return "litre"
case "ml", "millilitre", "milliliter":
return "millilitre"
case "pack", "packet":
return "pack"
default:
return "piece"
}
}

View File

@@ -0,0 +1,253 @@
package repositories
import (
"testing"
"time"
)
// The terminal holds a unique index on barcode, so this rule decides whether a
// catalogue import succeeds at all. Measured against live data when it was
// written: 6,245 products, 93 distinct SKUs, and "1" used by 5,794 of them.
func TestPosBarcodeFallsBackToProductIdWhenTheSkuIsNotScannable(t *testing.T) {
cases := []struct {
name string
productID int
sku string
want string
}{
{"the SKU almost every product shares", 844, "1", "844"},
{"blank SKU", 845, "", "845"},
{"whitespace only", 846, " ", "846"},
{"too short to be a barcode", 847, "1234567", "847"},
{"too long to be a barcode", 848, "123456789012345", "848"},
{"not digits", 849, "SKU-ABC-123", "849"},
{"digits with a space", 850, "1234 5678", "850"},
// Real scannable codes are used as-is, so the day the catalogue carries
// them scanning starts working with no code change.
{"EAN-8", 851, "12345678", "12345678"},
{"UPC-A", 852, "012345678905", "012345678905"},
{"EAN-13", 853, "8901030865278", "8901030865278"},
{"padded EAN-13", 854, " 8901030865278 ", "8901030865278"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posBarcode(c.productID, c.sku); got != c.want {
t.Errorf("posBarcode(%d, %q) = %q, want %q", c.productID, c.sku, got, c.want)
}
})
}
}
func TestPosBarcodesAreUniqueAcrossACatalogueOfSharedSkus(t *testing.T) {
// The failure this exists to prevent: a whole catalogue collapsing onto one
// barcode and the import being rejected by the terminal's unique index.
seen := make(map[string]int)
for id := 844; id < 844+500; id++ {
barcode := posBarcode(id, "1")
if first, clash := seen[barcode]; clash {
t.Fatalf("products %d and %d both produced barcode %q", first, id, barcode)
}
seen[barcode] = id
}
}
func TestRoundStockQtyNeverUnderDeducts(t *testing.T) {
// productstocks.quantity is an integer column and a counter sells 1.5 kg of
// onions. Rounding up keeps recorded stock at or below what is on the shelf;
// truncating would let the shop oversell a little more with every sale.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 2},
{0.25, 1},
{2.0, 2},
{2.01, 3},
{0, 1},
{-1, 1},
}
for _, c := range cases {
if got := roundStockQty(c.quantity); got != c.want {
t.Errorf("roundStockQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}
func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
// App orders have always truncated, and that behaviour is deliberately
// preserved rather than corrected — changing it would silently alter stock
// deduction for every order already flowing through createOrderTx.
cases := []struct {
quantity float64
want int
}{
{1, 1},
{1.5, 1},
{0.5, 1},
{3.9, 3},
{0, 1},
}
for _, c := range cases {
if got := legacyOrderQty(c.quantity); got != c.want {
t.Errorf("legacyOrderQty(%g) = %d, want %d", c.quantity, got, c.want)
}
}
}
// A catalogue revision is the terminal's memory of when it last pulled. If it
// does not survive a round trip, every pull silently becomes a full snapshot —
// or worse, a filtered result gets labelled as one and the shop's shelf empties.
func TestPosRevisionRoundTrips(t *testing.T) {
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
revision := posRevisionFor(1135, at)
if revision != "loc1135-20260803T123045Z" {
t.Fatalf("revision = %q, want loc1135-20260803T123045Z", revision)
}
got := posRevisionCutoff(1135, revision)
if !got.Equal(at) {
t.Errorf("cutoff = %v, want %v", got, at)
}
}
func TestAnUnusableRevisionFallsBackToAFullSnapshot(t *testing.T) {
// A zero cutoff means "send everything", and the caller turns that into
// is_delta:false. Falling back the other way — answering an unreadable
// revision with a change set — would leave a terminal permanently missing
// every change it had not already seen, with nothing to show for it.
cases := []struct {
name string
location int
revision string
}{
{"empty", 1135, ""},
{"whitespace", 1135, " "},
{"no prefix", 1135, "20260803T123045Z"},
{"malformed timestamp", 1135, "loc1135-not-a-time"},
{"truncated timestamp", 1135, "loc1135-20260803"},
{"another outlet's revision", 1135, "loc1097-20260803T123045Z"},
{"prefix collision", 113, "loc1135-20260803T123045Z"},
{"garbage", 1135, "../../etc/passwd"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posRevisionCutoff(c.location, c.revision); !got.IsZero() {
t.Errorf("cutoff = %v, want zero (full snapshot) for %q", got, c.revision)
}
})
}
}
func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
// loc1135 and loc113 share a textual prefix. Matching loosely would let one
// shop's cutoff silently scope another shop's delta.
at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
revision := posRevisionFor(1135, at)
if got := posRevisionCutoff(1135, revision); got.IsZero() {
t.Error("the issuing outlet could not read back its own revision")
}
for _, other := range []int{113, 11350, 1097, 1} {
if got := posRevisionCutoff(other, revision); !got.IsZero() {
t.Errorf("outlet %d accepted outlet 1135's revision (cutoff %v)", other, got)
}
}
}
// The bug this covers reached production and stayed invisible for a day.
//
// The MQTT consumer backfills a missing terminal code from the topic, but it
// wrote it onto the *batch* while the row was built from the *order*, so the
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
// The result: 16 of 17 live bills carried an empty terminalid while their own
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
// summary grouped almost everything under "".
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
cases := []struct {
name string
orderTerminal string
batchTerminal string
want string
}{
{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
{"neither knows", "", "", ""},
// Whitespace is not a terminal code. Treating it as one would file
// bills under a blank that reads identically to the missing value
// this fallback exists to prevent.
{"bill sends whitespace", " ", "T5EDD", "T5EDD"},
{"batch sends whitespace", "", " ", ""},
{"codes are trimmed", " T5EDD ", "", "T5EDD"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
c.orderTerminal, c.batchTerminal, got, c.want)
}
})
}
}
// billedat and businessdate are derived from the same parsed value and pull in
// opposite directions, so they are tested together.
//
// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a
// half hours after it was received. The till was sending a naive local
// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall
// clock was recorded as though read in London.
//
// The daily figures survived it by luck: businessdate comes off the wall clock
// either way, and the wall clock was always the till's own. Anything comparing
// billedat against real time did not.
func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) {
// Coimbatore, late enough that UTC has not yet rolled into the same day.
const ist = "2026-08-05T00:30:00+05:30"
at, err := parsePosSaleDate(ist)
if err != nil {
t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err)
}
// The instant. 00:30 IST is 19:00 UTC the previous evening.
wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC)
if !at.UTC().Equal(wantInstant) {
t.Errorf("instant = %v, want %v", at.UTC(), wantInstant)
}
// The trading day. This is the one that must NOT follow UTC — the shop rang
// this sale on the 5th and its takings belong to the 5th. Deriving the
// business date from UTC would file it under the 4th and leave two days
// wrong: one short, one over.
if got := at.Format("2006-01-02"); got != "2026-08-05" {
t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got)
}
}
// Terminals built before the offset was added send a bare local timestamp, and
// they are still in the field. Parsing must not start refusing them.
//
// The instant such a bill records is wrong by the offset and cannot be
// recovered — there is nothing in the payload that says which zone it was read
// in. Its business date is still right, which is why the daily figures held up,
// and why this stays a tolerated legacy rather than a rejection.
func TestANaiveSaleDateIsStillAccepted(t *testing.T) {
at, err := parsePosSaleDate("2026-08-05T12:49:28.245")
if err != nil {
t.Fatalf("a pre-offset terminal must not be refused: %v", err)
}
if got := at.Format("2006-01-02"); got != "2026-08-05" {
t.Errorf("businessdate = %s, want 2026-08-05", got)
}
}

View File

@@ -0,0 +1,223 @@
package repositories
import (
"fmt"
"strings"
"nearle/models"
)
// Reading counter sales back out.
//
// The ingest side of this package only ever writes. Without these, a bill that
// reached pos_orders was invisible to every screen in the product — the data
// was safe and unreachable, which is its own kind of lost.
//
// Every query is scoped to one locationid. That is the authorisation boundary:
// a caller who omits it gets an error rather than a page through somebody
// else's takings.
// posSalesWhere builds the shared filter, so the list, the detail and the
// summary can never disagree about what "this outlet in this range" means.
func posSalesWhere(f models.PosSalesFilter) (string, []interface{}) {
where := "locationid = ?"
params := []interface{}{f.Locationid}
// Matched on businessdate — the day the sale was rung, not the day it
// reached us. A till that was offline overnight uploads yesterday's bills
// this morning and they belong to yesterday.
if f.Fromdate != "" && f.Todate != "" {
where += " AND businessdate BETWEEN ? AND ?"
params = append(params, f.Fromdate, f.Todate)
} else if f.Fromdate != "" {
where += " AND businessdate >= ?"
params = append(params, f.Fromdate)
} else if f.Todate != "" {
where += " AND businessdate <= ?"
params = append(params, f.Todate)
}
if t := strings.TrimSpace(f.Terminalid); t != "" {
where += " AND terminalid = ?"
params = append(params, t)
}
if c := strings.TrimSpace(f.Cashiername); c != "" {
where += " AND cashiername = ?"
params = append(params, c)
}
if p := strings.TrimSpace(f.Paymentmode); p != "" {
where += " AND LOWER(paymentmode) = ?"
params = append(params, strings.ToLower(p))
}
return where, params
}
// Sales returns a page of bills, newest first, with the total count.
//
// Line items are deliberately not included: a page of fifty bills would drag
// several hundred rows behind it, and a list screen shows none of them. Use
// SaleDetail for one bill.
func (r *posRepository) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) {
if f.Locationid <= 0 {
return nil, fmt.Errorf("locationid is required")
}
if f.Pagesize <= 0 || f.Pagesize > 500 {
f.Pagesize = 50
}
if f.Pageno < 0 {
f.Pageno = 0
}
where, params := posSalesWhere(f)
var total int64
if err := r.db.Raw(
fmt.Sprintf(`SELECT COUNT(*) FROM pos_orders WHERE %s`, where),
params...,
).Scan(&total).Error; err != nil {
return nil, err
}
bills := make([]models.PosOrders, 0)
// Ordered by billedat rather than by id: a batch uploaded after an outage
// arrives out of order, and a list sorted by arrival would interleave
// yesterday's bills through today's.
query := fmt.Sprintf(
`SELECT * FROM pos_orders WHERE %s
ORDER BY billedat DESC, posorderid DESC
LIMIT ? OFFSET ?`, where)
if err := r.db.Raw(query,
append(params, f.Pagesize, f.Pageno*f.Pagesize)...,
).Scan(&bills).Error; err != nil {
return nil, err
}
return &models.PosSalesPage{
Total: total,
Pageno: f.Pageno,
Pagesize: f.Pagesize,
Bills: bills,
}, nil
}
// SaleDetail returns one bill with its lines.
//
// Accepts either the terminal's own order UUID or this backend's posorderid,
// because a support call starts from whichever the caller happens to be looking
// at — a receipt carries the invoice number, a log carries the UUID.
func (r *posRepository) SaleDetail(locationID int, reference string) (*models.PosOrders, error) {
if locationID <= 0 {
return nil, fmt.Errorf("locationid is required")
}
reference = strings.TrimSpace(reference)
if reference == "" {
return nil, fmt.Errorf("an order id, invoice number or posorderid is required")
}
var bill models.PosOrders
err := r.db.Raw(`
SELECT * FROM pos_orders
WHERE locationid = ?
AND (terminalorderid = ? OR invoicenumber = ?
OR CAST(posorderid AS TEXT) = ?)
LIMIT 1`,
locationID, reference, reference, reference,
).Scan(&bill).Error
if err != nil {
return nil, err
}
if bill.Posorderid == 0 {
return nil, nil
}
items := make([]models.PosOrderItems, 0)
if err := r.db.Raw(
`SELECT * FROM pos_order_items WHERE posorderid = ? ORDER BY posorderitemid`,
bill.Posorderid,
).Scan(&items).Error; err != nil {
return nil, err
}
bill.Items = items
return &bill, nil
}
// SalesSummary totals a range, broken out the three ways somebody actually
// asks for: by tender, by day, and by till.
func (r *posRepository) SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error) {
if f.Locationid <= 0 {
return nil, fmt.Errorf("locationid is required")
}
where, params := posSalesWhere(f)
summary := &models.PosSalesSummary{
Locationid: f.Locationid,
Fromdate: f.Fromdate,
Todate: f.Todate,
Bypaymentmode: make([]models.PosPaymentTotal, 0),
Byday: make([]models.PosDayTotal, 0),
Byterminal: make([]models.PosTerminalTotal, 0),
}
var head struct {
Billcount int
Itemcount int
Grosssales float64
Taxcollected float64
Discount float64
Roundoff float64
}
if err := r.db.Raw(fmt.Sprintf(`
SELECT COUNT(*) AS billcount,
COALESCE(SUM(itemcount), 0) AS itemcount,
COALESCE(SUM(total), 0) AS grosssales,
COALESCE(SUM(taxamount), 0) AS taxcollected,
COALESCE(SUM(discount), 0) AS discount,
COALESCE(SUM(roundoff), 0) AS roundoff
FROM pos_orders WHERE %s`, where), params...).Scan(&head).Error; err != nil {
return nil, err
}
summary.Billcount = head.Billcount
summary.Itemcount = head.Itemcount
summary.Grosssales = head.Grosssales
summary.Taxcollected = head.Taxcollected
summary.Discount = head.Discount
summary.Roundoff = head.Roundoff
if head.Billcount > 0 {
summary.Averagebill = head.Grosssales / float64(head.Billcount)
}
if err := r.db.Raw(fmt.Sprintf(`
SELECT COALESCE(paymentmode,'') AS paymentmode,
COUNT(*) AS billcount, COALESCE(SUM(total),0) AS amount
FROM pos_orders WHERE %s
GROUP BY paymentmode ORDER BY amount DESC`, where),
params...).Scan(&summary.Bypaymentmode).Error; err != nil {
return nil, err
}
if err := r.db.Raw(fmt.Sprintf(`
SELECT businessdate, COUNT(*) AS billcount,
COALESCE(SUM(total),0) AS amount
FROM pos_orders WHERE %s
GROUP BY businessdate ORDER BY businessdate`, where),
params...).Scan(&summary.Byday).Error; err != nil {
return nil, err
}
if err := r.db.Raw(fmt.Sprintf(`
SELECT COALESCE(terminalid,'') AS terminalid, COUNT(*) AS billcount,
COALESCE(SUM(total),0) AS amount
FROM pos_orders WHERE %s
GROUP BY terminalid ORDER BY amount DESC`, where),
params...).Scan(&summary.Byterminal).Error; err != nil {
return nil, err
}
return summary, nil
}

View File

@@ -0,0 +1,170 @@
package repositories
import (
"fmt"
"regexp"
"strings"
"nearle/models"
)
// Shift windows for till staff.
//
// Scoped by tenant *and* outlet in every statement rather than checked first,
// the same shape the till-user queries use: a console naming somebody else's
// shift id updates no rows and is told so, instead of quietly editing another
// shop's hours.
var posTimeOfDay = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
// normaliseShiftTime accepts what a time input actually sends.
//
// `<input type="time">` gives "07:00", some browsers and most hand-typed values
// give "07:00:00", and `ridershifts` already stores the seconds form. Both are
// reduced to `HH:MM` so a shift written by one client reads the same to another.
func normaliseShiftTime(raw string) (string, error) {
t := strings.TrimSpace(raw)
if t == "" {
return "", fmt.Errorf("a start and end time are required")
}
if len(t) == 8 && strings.Count(t, ":") == 2 {
t = t[:5]
}
if !posTimeOfDay.MatchString(t) {
return "", fmt.Errorf("time must be 24-hour HH:MM; got %q", raw)
}
return t, nil
}
// ListStaffShifts returns an outlet's shifts, newest last so a picker reads in
// the order they were created rather than alphabetically by name.
func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required")
}
shifts := make([]models.StaffShifts, 0)
query := `SELECT * FROM staffshifts WHERE tenantid = ? AND locationid = ?`
if !includeInactive {
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
}
query += ` ORDER BY staffshiftid`
if err := r.db.Raw(query, tenantID, locationID).Scan(&shifts).Error; err != nil {
return nil, err
}
return shifts, nil
}
// CreateStaffShift adds a window at one outlet.
func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("a shift name is required")
}
start, err := normaliseShiftTime(req.Starttime)
if err != nil {
return nil, err
}
end, err := normaliseShiftTime(req.Endtime)
if err != nil {
return nil, err
}
// An end before a start is allowed on purpose — a night shift runs 22:00 to
// 06:00 and wrapping midnight is ordinary in retail. Only the equal case is
// refused, because a zero-length window cannot be what anyone meant.
if start == end {
return nil, fmt.Errorf("a shift cannot start and end at the same time")
}
weekdays := strings.TrimSpace(req.Weekdays)
if weekdays != "" && !regexp.MustCompile(`^[01]{7}$`).MatchString(weekdays) {
return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday, e.g. 1111100")
}
shift := models.StaffShifts{
Tenantid: tenantID,
Locationid: locationID,
Name: name,
Starttime: start,
Endtime: end,
Weekdays: weekdays,
Status: "Active",
}
if err := r.db.Table("staffshifts").Create(&shift).Error; err != nil {
return nil, err
}
return &shift, nil
}
// UpdateStaffShift edits a window. Every field is optional; only the ones sent
// are written, matching how the till-user update behaves.
func (r *posRepository) UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
if req.Staffshiftid <= 0 {
return nil, fmt.Errorf("staff_shift_id is required")
}
sets := []string{}
args := []interface{}{}
if name := strings.TrimSpace(req.Name); name != "" {
sets = append(sets, "name = ?")
args = append(args, name)
}
if strings.TrimSpace(req.Starttime) != "" {
t, err := normaliseShiftTime(req.Starttime)
if err != nil {
return nil, err
}
sets = append(sets, "starttime = ?")
args = append(args, t)
}
if strings.TrimSpace(req.Endtime) != "" {
t, err := normaliseShiftTime(req.Endtime)
if err != nil {
return nil, err
}
sets = append(sets, "endtime = ?")
args = append(args, t)
}
if w := strings.TrimSpace(req.Weekdays); w != "" {
if !regexp.MustCompile(`^[01]{7}$`).MatchString(w) {
return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday")
}
sets = append(sets, "weekdays = ?")
args = append(args, w)
}
if s := strings.TrimSpace(req.Status); s != "" {
sets = append(sets, "status = ?")
args = append(args, s)
}
if len(sets) == 0 {
return nil, fmt.Errorf("nothing to change")
}
sets = append(sets, "updated = NOW()")
args = append(args, req.Staffshiftid, tenantID, locationID)
res := r.db.Exec(
fmt.Sprintf(`UPDATE staffshifts SET %s WHERE staffshiftid = ? AND tenantid = ? AND locationid = ?`,
strings.Join(sets, ", ")), args...)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, fmt.Errorf("no shift %d at this outlet", req.Staffshiftid)
}
var out models.StaffShifts
if err := r.db.Raw(`SELECT * FROM staffshifts WHERE staffshiftid = ?`, req.Staffshiftid).Scan(&out).Error; err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -0,0 +1,812 @@
package repositories
import (
"crypto/rand"
"fmt"
"math/big"
"strconv"
"strings"
"nearle/models"
"gorm.io/gorm"
)
// Till staff, managed by the shop rather than by us.
//
// A supervisor creates their own cashiers, at their own outlet, from the
// terminal. Everything here follows one rule: **the tenant and the outlet come
// from the caller's session token and never from the request body.** A
// supervisor at Selvapuram cannot create a cashier at R mart by sending a
// different number, for the same reason a till cannot bill into another shop.
// PosPinMin and PosPinMax bound an acceptable PIN.
//
// Four digits, and never starting with a zero — because `app_users.pin` is a
// `bigint`. A PIN of "0451" would be stored as 451 and read back as three
// digits, so a cashier would type four and be refused for ever. Live data
// already holds one such account.
//
// Refusing the leading zero costs a shop 1000 of 10000 combinations and buys a
// PIN that means the same thing on the way in and on the way out.
const (
PosPinMin = 1000
PosPinMax = 9999
)
// posDefaultAuthname is the username a till account gets when nobody names one.
//
// Keyed on the outlet and the role rather than on the person, so it survives
// staff turnover: a shop replacing its cashier reissues one password instead of
// re-teaching a new address. `nth` disambiguates a second account of the same
// role at the same counter and is omitted for the first, so the common case
// stays the readable one.
//
// The domain is deliberately not a real one. These are till credentials, never
// a mailbox, and an address that looks deliverable invites somebody to try
// sending a reset to it.
func posDefaultAuthname(roleID, locationID, nth int) string {
role := strings.ToLower(models.PosRoleName(roleID))
if role == "" {
role = "staff"
}
if nth > 1 {
return fmt.Sprintf("%s%d.%d@pos.nearle.in", role, nth, locationID)
}
return fmt.Sprintf("%s.%d@pos.nearle.in", role, locationID)
}
// newPosPassword generates a till password.
//
// From crypto/rand, and returned to the caller exactly once — at creation —
// because the column it lands in is plaintext and reading it back later should
// take a deliberate query rather than an ordinary list call.
//
// The alphabet drops l, I, O, 0 and 1. These get read off one screen and typed
// on another by somebody with a queue in front of them.
func newPosPassword() string {
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
out := make([]byte, 14)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
// crypto/rand failing is not a condition to paper over with a
// weaker source; a guessable till password is worse than no till.
panic(fmt.Sprintf("generating a till password: %v", err))
}
out[i] = alphabet[n.Int64()]
}
return string(out)
}
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
roleID := models.PosRoleFromName(req.Role)
if roleID == 0 {
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
}
name := strings.TrimSpace(req.Fullname)
if name == "" {
return nil, fmt.Errorf("a name is required")
}
first, last := splitName(name)
pin, err := validatePosPin(req.Pin)
if err != nil {
return nil, err
}
// The number this person signs in with, and required.
//
// Optional once, on the reasoning that a shop could be provisioned before it
// had collected everybody's number. That stopped being defensible when the
// number became half of the sign-in: an account created without one cannot
// reach the new login screen at all, so "optional" meant the console could
// quietly keep manufacturing accounts nobody can sign into — and the failure
// surfaces at a counter, in front of a queue, rather than here.
//
// The column stays nullable and [UpdatePosUser] still treats an empty value
// as "leave alone", so the accounts that predate this keep working through
// the backfill and cannot have their number cleared. This closes the door on
// new ones only.
if strings.TrimSpace(req.Contactno) == "" {
return nil, fmt.Errorf("a mobile number is required; it is what this person signs in with at the till")
}
phone, err := normalisePosPhone(req.Contactno)
if err != nil {
return nil, err
}
if phone == "" {
// A different case from the check above, not a repeat of it.
// [normalisePosPhone] answers
// ("", nil) rather than an error when a value holds no digits at all, so
// "not a number" arrives here looking exactly like "no number" — and
// without this the insert would write a blank and skip the uniqueness
// check below, which is the hole this whole change is closing.
return nil, fmt.Errorf("mobile number must be 10 digits; got %q", req.Contactno)
}
password := strings.TrimSpace(req.Password)
authname := strings.ToLower(strings.TrimSpace(req.Authname))
// Every till account gets a username and a password, cashiers included.
//
// A PIN cannot open a *closed* terminal — the PIN route needs a session that
// already exists — so a PIN-only cashier can work only while a supervisor is
// standing there to unlock the till first. That is not how a shop opens: the
// person who arrives at seven is as often the cashier as the supervisor.
//
// Generated when the console does not supply them, so provisioning is one
// call and nobody has to invent a scheme. An explicit value always wins: a
// shop that wants its people signing in as themselves just sends one.
//
// Whether the name was generated is remembered, because the two cases want
// opposite handling on a collision — see the uniqueness check below.
nameWasGenerated := authname == ""
if nameWasGenerated {
authname = posDefaultAuthname(roleID, locationID, 0)
}
if password == "" {
password = newPosPassword()
}
// A PIN stays optional. It switches operator at an open counter, which not
// every shop does, and it is the one credential the till keeps in plaintext
// to hand around — so it is set deliberately, never by default.
var created *models.PosUser
err = r.db.Transaction(func(tx *gorm.DB) error {
// The advisory lock is for the PIN check below, not for the id.
//
// `userid` is an identity column — `information_schema.column_default`
// is empty for those, which is easy to misread as "no default at all"
// and was misread here once. Postgres allocates it, and this must not
// compute its own: an explicit id does not advance the sequence, so a
// hand-rolled MAX+1 leaves two allocators running in parallel that
// eventually land on the same number.
//
// The lock still earns its place. Two supervisors adding staff at the
// same instant could otherwise both find a PIN free and both take it,
// and a duplicate PIN attributes a bill to whichever row is read first.
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
return err
}
if pin > 0 {
taken, err := posPinTaken(tx, tenantID, locationID, pin, 0)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
// Checked under the same advisory lock as the PIN, and for the same
// reason: two supervisors provisioning at once would otherwise both see
// the number free and both write it, leaving a login that resolves to
// two people and therefore to nobody.
if phone != "" {
taken, err := posPhoneTaken(tx, tenantID, phone, 0)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another till account in this business already signs in with %s", phone)
}
}
// Uniqueness is checked against `authname` and `email` together because
// the insert below writes the same value to both, and
// `app_users_email_unique` is a real constraint — a clash there fails the
// transaction rather than returning a message anyone can act on.
taken := func(candidate string) (bool, error) {
var n int64
err := tx.Raw(`SELECT COUNT(1) FROM app_users
WHERE LOWER(TRIM(authname)) = ? OR LOWER(TRIM(email)) = ?`,
candidate, candidate).Scan(&n).Error
return n > 0, err
}
if nameWasGenerated {
// Walk to the first free one. Bounded so a bug here cannot spin:
// twenty till accounts of one role at a single outlet is already far
// past what a counter has, and the error names the fix.
found := false
for i := 0; i < 20; i++ {
clash, err := taken(authname)
if err != nil {
return err
}
if !clash {
found = true
break
}
authname = posDefaultAuthname(roleID, locationID, i+2)
}
if !found {
return fmt.Errorf("this outlet already has too many %s accounts; supply an email explicitly",
strings.ToLower(models.PosRoleName(roleID)))
}
} else {
clash, err := taken(authname)
if err != nil {
return err
}
if clash {
return fmt.Errorf("an account already uses %s", authname)
}
}
// `userid` is omitted so the identity column allocates it, and read back
// with RETURNING rather than guessed.
//
// The email columns go through NULLIF because `app_users_email_unique`
// is a real constraint: a second person created without an email would
// collide on the empty string, while NULLs do not collide in Postgres.
// A cashier who signs in by PIN alone has no email, and that is the
// common case.
var nextID int
if err := tx.Raw(`
INSERT INTO app_users
(firstname, lastname, authname, email, contactno, password,
pin, shiftid, roleid, configid, tenantid, locationid, status)
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
NULLIF(?, 0), NULLIF(?, 0), ?, ?, ?, ?, 'Active')
RETURNING userid`,
first, last, authname, authname, phone,
password, pin, req.Shiftid, roleID, configID, tenantID, locationID,
).Scan(&nextID).Error; err != nil {
return err
}
if nextID <= 0 {
return fmt.Errorf("the account was not created")
}
created = &models.PosUser{
Userid: nextID,
Fullname: name,
Firstname: first,
Lastname: last,
Authname: authname,
Contactno: phone,
Shiftid: req.Shiftid,
Roleid: roleID,
Role: models.PosRoleName(roleID),
Pin: posPinString(pin),
Haspassword: password != "",
Locationid: locationID,
Status: "Active",
// The one moment this is ever returned. Listing a till user reports
// only whether a password exists, so an admin who loses this has to
// reissue rather than look it up — which is the right shape even
// while the column itself is plaintext.
Password: password,
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// UpdatePosUser edits a till user at the caller's outlet.
//
// Scoped by tenant *and* location in the WHERE clause rather than checked
// first: a supervisor sending somebody else's user id updates no rows and is
// told so, instead of quietly editing another shop's staff.
func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
if req.Userid <= 0 {
return nil, fmt.Errorf("user_id is required")
}
sets := []string{}
args := []interface{}{}
if name := strings.TrimSpace(req.Fullname); name != "" {
first, last := splitName(name)
sets = append(sets, "firstname = ?", "lastname = ?")
args = append(args, first, last)
}
if role := strings.TrimSpace(req.Role); role != "" {
roleID := models.PosRoleFromName(role)
if roleID == 0 {
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
}
sets = append(sets, "roleid = ?")
args = append(args, roleID)
}
pin := int64(0)
if strings.TrimSpace(req.Pin) != "" {
p, err := validatePosPin(req.Pin)
if err != nil {
return nil, err
}
pin = p
sets = append(sets, "pin = ?")
args = append(args, pin)
}
// The username a supervisor opens a closed terminal with.
//
// Editable because a password on its own is unusable: sign-in matches on
// `authname` or `contactno`, so an account given a password and no username
// cannot be reached by either. This was missing, and the failure was silent
// — the update reported success, wrote the password, dropped the username,
// and the supervisor was refused at the counter with "not recognised".
if authname := strings.TrimSpace(req.Authname); authname != "" {
sets = append(sets, "authname = ?")
args = append(args, authname)
}
// Normalised on the way in, exactly as on create — a number edited to
// "+91 98765 43210" would otherwise stop matching the login that reduces
// what is typed to ten digits.
phone := ""
if strings.TrimSpace(req.Contactno) != "" {
p, err := normalisePosPhone(req.Contactno)
if err != nil {
return nil, err
}
phone = p
sets = append(sets, "contactno = ?")
args = append(args, phone)
}
// Zero means "not specified" and leaves the shift alone. Clearing one is
// therefore not expressible here, which is deliberate: every other field on
// this endpoint behaves the same way, and a sentinel that only one field
// honours is the kind of asymmetry that gets forgotten.
if req.Shiftid > 0 {
sets = append(sets, "shiftid = ?")
args = append(args, req.Shiftid)
}
if password := strings.TrimSpace(req.Password); password != "" {
sets = append(sets, "password = ?")
args = append(args, password)
}
if status := strings.TrimSpace(req.Status); status != "" {
sets = append(sets, "status = ?")
args = append(args, status)
}
if len(sets) == 0 {
return nil, fmt.Errorf("nothing to change")
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
return err
}
if pin > 0 {
taken, err := posPinTaken(tx, tenantID, locationID, pin, req.Userid)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
// The person being edited is excluded, so re-saving an unchanged number
// is not reported as a clash with themselves.
if phone != "" {
taken, err := posPhoneTaken(tx, tenantID, phone, req.Userid)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another till account in this business already signs in with %s", phone)
}
}
query := fmt.Sprintf(
`UPDATE app_users SET %s WHERE userid = ? AND tenantid = ? AND locationid = ?`,
strings.Join(sets, ", "))
args = append(args, req.Userid, tenantID, locationID)
result := tx.Exec(query, args...)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("no user %d at this outlet", req.Userid)
}
return nil
})
if err != nil {
return nil, err
}
users, err := r.ListPosUsers(tenantID, locationID, true)
if err != nil {
return nil, err
}
for i := range users {
if users[i].Userid == req.Userid {
return &users[i], nil
}
}
return nil, nil
}
// ListPosUsers returns the till users at an outlet.
func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
rows := make([]struct {
Userid int
Firstname string
Lastname string
Authname string
Contactno string
Roleid int
Pin int64
Haspassword bool
Status string
Shiftid int
Shiftname string
Shiftstart string
Shiftend string
}, 0)
// The shift is LEFT JOINed and matched on the outlet as well as the id.
//
// `app_users.shiftid` predates this table and points at `ridershifts` for
// riders, so the same number means different things depending on the row's
// role. Joining on tenant and location too means a rider shift id can never
// resolve to a staff shift that happens to share it — an unmatched id just
// comes back blank, which is the honest answer for an account created
// before shifts existed.
query := `
SELECT a.userid,
COALESCE(a.firstname,'') AS firstname, COALESCE(a.lastname,'') AS lastname,
COALESCE(a.authname,'') AS authname, COALESCE(a.contactno,'') AS contactno,
COALESCE(a.roleid,0) AS roleid, COALESCE(a.pin,0) AS pin,
(COALESCE(a.password,'') <> '') AS haspassword,
COALESCE(a.status,'') AS status,
COALESCE(s.staffshiftid,0) AS shiftid,
COALESCE(s.name,'') AS shiftname,
COALESCE(s.starttime,'') AS shiftstart,
COALESCE(s.endtime,'') AS shiftend
FROM app_users a
LEFT JOIN staffshifts s
ON s.staffshiftid = a.shiftid
AND s.tenantid = a.tenantid
AND s.locationid = a.locationid
WHERE a.tenantid = ? AND a.locationid = ?
AND COALESCE(a.roleid,0) IN (?, ?)`
params := []interface{}{tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier}
if !includeInactive {
// `a.status`, qualified. `staffshifts` carries a `status` column too, so
// the bare name made Postgres refuse the whole statement with
// `column reference "status" is ambiguous` (42702) — a 500 on every
// console call, since the console never asks for inactive rows.
query += ` AND LOWER(COALESCE(a.status,'active')) <> 'inactive'`
}
query += ` ORDER BY a.userid`
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
users := make([]models.PosUser, 0, len(rows))
for _, row := range rows {
users = append(users, models.PosUser{
Userid: row.Userid,
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
Firstname: row.Firstname,
Lastname: row.Lastname,
Authname: row.Authname,
Contactno: row.Contactno,
Roleid: row.Roleid,
Role: models.PosRoleName(row.Roleid),
Pin: posPinString(row.Pin),
Haspassword: row.Haspassword,
Locationid: locationID,
Status: row.Status,
Shiftid: row.Shiftid,
Shiftname: row.Shiftname,
Shiftstart: row.Shiftstart,
Shiftend: row.Shiftend,
})
}
return users, nil
}
// DeactivatePosUser retires somebody without deleting them.
//
// Bills carry the cashier's name and shifts settle against it, so a hard delete
// would orphan a day's takings.
func (r *posRepository) DeactivatePosUser(tenantID, locationID, userID int) error {
result := r.db.Exec(`
UPDATE app_users SET status = 'InActive'
WHERE userid = ? AND tenantid = ? AND locationid = ?
AND COALESCE(roleid,0) IN (?, ?)`,
userID, tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// Either no such person, or they belong to another shop, or they are a
// back-office account rather than till staff. One message for all three
// — distinguishing them tells a caller about rows they cannot see.
return fmt.Errorf("no till user %d at this outlet", userID)
}
return nil
}
// PosLoginByPin signs somebody in with a PIN alone, inside an outlet.
//
// A PIN is four digits, so this must never be reachable by an anonymous caller
// — ten thousand guesses is not a barrier. It is only called with a tenant and
// location taken from an *already valid* session token, which means a
// supervisor has opened the terminal with a real password first and the guesses
// are confined to one outlet's own staff.
func (r *posRepository) PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
// posLoginPin, not validatePosPin: the latter also refuses the PINs nobody
// should be *given*, and applying a creation rule on the way in would lock
// out every account issued before it existed. Live data has 1234 on eleven
// accounts and 1111 on nine.
value, err := posLoginPin(pin)
if err != nil {
return nil, errPosLoginRejected
}
var rows []posLoginRow
err = r.db.Raw(`
SELECT userid, COALESCE(password,'') AS password, COALESCE(pin,0) AS pin,
COALESCE(status,'') AS status,
COALESCE(roleid,0) AS roleid, COALESCE(configid,0) AS configid,
COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
COALESCE(email,'') AS email
FROM app_users
WHERE tenantid = ? AND locationid = ? AND pin = ?
AND LOWER(COALESCE(status,'active')) <> 'inactive'
ORDER BY userid`, tenantID, locationID, value).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, errPosLoginRejected
}
// Two people on one PIN would attribute a bill to whichever row was read
// first. Creation refuses a duplicate, but data predating this endpoint
// need not have, so it is refused here too rather than guessed.
if len(rows) > 1 {
return nil, fmt.Errorf("more than one person at this outlet uses that PIN; ask a supervisor to change one of them")
}
return r.sessionFor(rows[0], locationID)
}
// posPinTaken reports whether a PIN is already in use at an outlet.
//
// Scoped to the outlet rather than globally, because a PIN only ever
// distinguishes people standing at the same counter — making them unique across
// the platform would exhaust nine thousand combinations very quickly.
func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
var count int64
err := tx.Raw(`
SELECT COUNT(1) FROM app_users
WHERE tenantid = ? AND locationid = ? AND pin = ? AND userid <> ?
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
tenantID, locationID, pin, exceptUser).Scan(&count).Error
return count > 0, err
}
// normalisePosPhone reduces a mobile number to the ten digits stored on the row.
//
// The till signs in with this, so what is stored and what is typed have to
// agree exactly. A number arrives as "+91 98765 43210", "098765-43210" or
// "9876543210" depending on who typed it, and matching those as free text means
// a cashier who is certain of their own number cannot get in.
//
// Reduced to digits, then a leading 91 or 0 is dropped once. Anything that is
// not ten digits afterwards is refused rather than stored — a number that
// cannot be typed back identically is not a credential.
func normalisePosPhone(raw string) (string, error) {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, raw)
if digits == "" {
return "", nil
}
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
digits = digits[2:]
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
digits = digits[1:]
}
if len(digits) != 10 {
return "", fmt.Errorf("mobile number must be 10 digits; got %q", raw)
}
return digits, nil
}
// posPhoneTaken reports whether another till account already signs in with this
// number.
//
// Scoped to the tenant rather than the outlet, unlike the PIN check: a PIN is
// typed at one counter and only has to be unique there, but a phone number is a
// login and must resolve to exactly one person across the whole chain. A person
// working two shops of the same tenant is one account, not two.
//
// Only till roles are counted, matching what the login itself looks at — 34
// numbers are already shared among 104 back-office accounts, and a cashier must
// not be blocked by a tenant admin who happens to share their number.
func posPhoneTaken(tx *gorm.DB, tenantID int, phone string, exceptUser int) (bool, error) {
if phone == "" {
return false, nil
}
var count int64
err := tx.Raw(`
SELECT COUNT(1) FROM app_users
WHERE tenantid = ? AND TRIM(COALESCE(contactno,'')) = ? AND userid <> ?
AND COALESCE(roleid,0) IN (?, ?)
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
tenantID, phone, exceptUser, models.PosRoleSupervisor, models.PosRoleCashier,
).Scan(&count).Error
return count > 0, err
}
// posLoginPin reads a PIN somebody has just typed at a terminal.
//
// Format only — four digits the column can hold, and nothing about whether the
// PIN was a wise one to issue. That distinction is the whole reason this is
// separate from [validatePosPin]: a rule about what may be *created* must never
// run on the way *in*. Applied at sign-in, the guessable-PIN list below would
// permanently lock out the eleven live accounts holding 1234 and the nine
// holding 1111 — accounts this system itself issued before the rule existed.
func posLoginPin(raw string) (int64, error) {
pin := strings.TrimSpace(raw)
if len(pin) != 4 {
return 0, fmt.Errorf("a PIN is exactly 4 digits")
}
value, err := strconv.ParseInt(pin, 10, 64)
if err != nil {
return 0, fmt.Errorf("a PIN is digits only")
}
if value < PosPinMin || value > PosPinMax {
// Which is to say: it started with a zero. Said plainly, because "a PIN
// is 4 digits" would be baffling to somebody who just typed four.
return 0, fmt.Errorf("a PIN cannot start with 0")
}
return value, nil
}
// validatePosPin checks a PIN is one this schema can store faithfully, and one
// worth issuing.
func validatePosPin(raw string) (int64, error) {
pin := strings.TrimSpace(raw)
if pin == "" {
return 0, nil
}
value, err := posLoginPin(pin)
if err != nil {
return 0, err
}
// The first thing anyone tries, and live data already has 1234 on eleven
// accounts and 1111 on nine. Refused at creation only — see posLoginPin.
switch pin {
case "1234", "1111", "0000", "2345", "3456", "4321", "9999", "2222":
return 0, fmt.Errorf("that PIN is too easy to guess; choose another")
}
return value, nil
}
// posPinString renders a stored PIN.
//
// Anything the schema cannot represent as four digits comes back empty rather
// than short: a three-digit PIN on screen is one a cashier cannot type, and
// showing it would send them to a supervisor for a fault they cannot describe.
func posPinString(pin int64) string {
if pin < PosPinMin || pin > PosPinMax {
return ""
}
return strconv.FormatInt(pin, 10)
}
// splitName turns a typed name into the two columns this schema has.
func splitName(full string) (first, last string) {
parts := strings.Fields(strings.TrimSpace(full))
if len(parts) == 0 {
return "", ""
}
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}
// ValidateStaffUser applies the till's rules to a staff row from anywhere.
//
// Exported because the web console writes `app_users` too, through
// `tenants/createstaff`, and that path had no validation whatsoever — no PIN
// rules, no role check, no duplicate check. A cashier created there could be
// given "0451", which a bigint column stores as 451, and would then type four
// digits at the counter and be refused for ever with nothing to explain it.
//
// Two paths writing one table drift apart. This is the shared rule set, so a
// person created from a browser and a person created from a till are subject to
// the same constraints and behave the same way at the counter.
//
// Returns the parsed PIN, or an error a caller can show to whoever typed it.
func ValidateStaffUser(user *models.User) (int64, error) {
if strings.TrimSpace(user.Firstname+user.Lastname) == "" {
return 0, fmt.Errorf("a name is required")
}
// Only the roles this platform actually defines. `roleid` 0 is the one that
// matters: it is not a role, it is what a row carries when nobody set one,
// and live data has riders and shop accounts sharing it.
if user.Roleid <= 0 {
return 0, fmt.Errorf("a role is required")
}
pin := int64(user.Pin)
if pin != 0 {
parsed, err := validatePosPin(strconv.FormatInt(pin, 10))
if err != nil {
return 0, err
}
pin = parsed
}
if pin == 0 && strings.TrimSpace(user.Password) == "" {
return 0, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
}
return pin, nil
}
// StaffPinAvailable reports whether a PIN is free at an outlet.
//
// Exported for the same reason as [ValidateStaffUser]: the web console needs
// the check the till already makes. Two people sharing a PIN would attribute a
// bill to whichever row happened to be read first.
func (r *posRepository) StaffPinAvailable(tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
if pin == 0 {
return true, nil
}
taken, err := posPinTaken(r.db, tenantID, locationID, pin, exceptUser)
return !taken, err
}
// PosConfigidFor returns the configid an outlet's people already use.
//
// The console cannot sensibly be asked for this. It is a number nobody looks
// up, it varies per tenant — live data has tenant 1087 spread across 1, 6 and
// 15 — and getting it wrong creates an account that cannot sign into the portal
// its colleagues use and is invisible to half the platform's queries.
//
// So it is inferred from whichever value that tenant's existing accounts most
// commonly carry. Returns 0 for a tenant with no accounts at all, which is
// simply what a fresh tenant looks like.
func (r *posRepository) PosConfigidFor(tenantID int) int {
var configID int
r.db.Raw(`SELECT COALESCE(configid, 0) FROM app_users
WHERE tenantid = ? AND COALESCE(configid, 0) > 0
GROUP BY configid ORDER BY COUNT(*) DESC, configid LIMIT 1`,
tenantID).Scan(&configID)
return configID
}

View File

@@ -0,0 +1,246 @@
package repositories
import (
"testing"
"nearle/models"
)
// A PIN has to survive a round trip through a `bigint` column, and has to be
// hard enough to guess to be worth having. These cover both, because the schema
// makes the first one non-obvious.
func TestAPinMustSurviveTheColumnItIsStoredIn(t *testing.T) {
// `app_users.pin` is a bigint. "0451" stored there comes back as 451, so a
// cashier would type four digits and be refused for ever. Live data already
// holds one such account.
if _, err := validatePosPin("0451"); err == nil {
t.Fatal("a PIN starting with zero was accepted; it cannot round-trip through a bigint")
}
value, err := validatePosPin("4821")
if err != nil {
t.Fatalf("a good PIN was refused: %v", err)
}
if value != 4821 {
t.Fatalf("PIN parsed to %d, want 4821", value)
}
}
func TestAPinIsExactlyFourDigits(t *testing.T) {
for _, pin := range []string{"123", "12345", "abcd", "12a4", " 12 "} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted", pin)
}
}
}
// The first thing anyone tries. Live data has 1234 on eleven accounts and 1111
// on nine, which is exactly the outcome this prevents repeating.
func TestAnObviousPinIsRefused(t *testing.T) {
for _, pin := range []string{"1234", "1111", "2345", "4321", "9999", "2222"} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted despite being one of the first guessed", pin)
}
}
}
// An empty PIN is not an error — somebody may be given a password instead. The
// caller decides whether having neither is a problem.
func TestAnAbsentPinIsNotAnError(t *testing.T) {
value, err := validatePosPin("")
if err != nil {
t.Fatalf("an absent PIN was treated as invalid: %v", err)
}
if value != 0 {
t.Fatalf("an absent PIN parsed to %d, want 0", value)
}
}
// A stored PIN the schema cannot represent as four digits comes back empty
// rather than short, because a three-digit PIN on screen is one a cashier
// cannot type — and they would have no way to describe the fault.
func TestAnUnrepresentablePinIsNotShown(t *testing.T) {
if got := posPinString(451); got != "" {
t.Fatalf("a three-digit PIN rendered as %q, want empty", got)
}
if got := posPinString(0); got != "" {
t.Fatalf("an unset PIN rendered as %q, want empty", got)
}
if got := posPinString(4821); got != "4821" {
t.Fatalf("PIN rendered as %q, want 4821", got)
}
}
// A till account with no mobile number cannot reach the sign-in screen, so
// creating one is refused rather than deferred to the counter.
//
// Reaches the check with a nil database on purpose: it has to run before
// anything is written, and a test that needed a connection would not prove that.
func TestATillAccountCannotBeCreatedWithoutAMobileNumber(t *testing.T) {
repo := &posRepository{}
// "abc" is the case worth pinning. normalisePosPhone answers ("", nil) for a
// value holding no digits, so it arrives looking like a number rather than
// like an absence — and would have been written as a blank.
for _, contactno := range []string{"", " ", "abc"} {
_, err := repo.CreatePosUser(1087, 1135, 1, models.PosUserRequest{
Fullname: "Priya Raman",
Role: "cashier",
Pin: "4731",
Contactno: contactno,
})
if err == nil {
t.Errorf("contactno %q was accepted; an account created this way cannot sign in", contactno)
}
}
}
// The accounts that predate the number keep working: an edit that does not
// mention contactno leaves the stored one alone rather than clearing it, so the
// rule above cannot strand somebody mid-backfill.
func TestAnEditThatOmitsTheNumberLeavesItAlone(t *testing.T) {
if _, err := normalisePosPhone(""); err != nil {
t.Fatalf("an absent number was treated as malformed: %v", err)
}
}
func TestANameIsSplitAcrossTheTwoColumnsThisSchemaHas(t *testing.T) {
cases := []struct {
in string
first, last string
}{
{"Asha", "Asha", ""},
{"Asha Kumar", "Asha", "Kumar"},
{"Ragul Kannan Selvam", "Ragul", "Kannan Selvam"},
{" Divya R ", "Divya", "R"},
{"", "", ""},
}
for _, tc := range cases {
first, last := splitName(tc.in)
if first != tc.first || last != tc.last {
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)",
tc.in, first, last, tc.first, tc.last)
}
}
}
// Only a role that can actually be checked should grant anything. Zero is the
// one that matters: it is not a role, it is what an account carries when nobody
// set one, and live data has riders and shop accounts sharing it.
func TestOnlyRealRolesCanManageStaff(t *testing.T) {
if models.PosRoleCanManageStaff(0) {
t.Error("roleid 0 was allowed to manage staff; it is unset, not a role")
}
if models.PosRoleCanManageStaff(models.PosRoleCashier) {
t.Error("a cashier was allowed to manage staff, so could promote themselves")
}
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
t.Error("a supervisor was refused staff management, which is their whole purpose")
}
// A Nearle Daily role is not a POS role. This once granted staff management
// to 1 through 6, on the reasoning that a browser administrator loses
// nothing by standing at the counter — which handed till-supervisor powers
// to 68 live accounts, 59 of them platform Super admins, not one of them
// anybody's POS administrator. The back office provisions a supervisor; it
// does not become one.
for _, role := range []int{1, 2, 3, 4, 5, 6} {
if models.PosRoleCanManageStaff(role) {
t.Errorf("back-office role %d was granted till staff management", role)
}
}
}
// The till and the Nearle Daily application share one table and nothing else.
// Eligibility is provisioned, never inherited.
func TestOnlyPosRolesCanOpenATill(t *testing.T) {
for _, role := range []int{models.PosRoleSupervisor, models.PosRoleCashier} {
if !models.PosRoleEligible(role) {
t.Errorf("POS role %d was refused a till", role)
}
}
// Zero matters most: it is not a role but the absence of one, and 22 live
// accounts carry it, including a delivery rider.
for _, role := range []int{0, 1, 2, 3, 4, 5, 6, 9, 99, -1} {
if models.PosRoleEligible(role) {
t.Errorf("non-POS role %d was allowed to open a till", role)
}
}
}
func TestARoleIsReadFromItsNameNotItsNumber(t *testing.T) {
if got := models.PosRoleFromName("supervisor"); got != models.PosRoleSupervisor {
t.Errorf("supervisor = %d, want %d", got, models.PosRoleSupervisor)
}
if got := models.PosRoleFromName(" Cashier "); got != models.PosRoleCashier {
t.Errorf("cashier = %d, want %d", got, models.PosRoleCashier)
}
// Anything unrecognised is zero, and every caller treats zero as a refusal
// rather than as a default — an unknown role must never become a supervisor.
for _, name := range []string{"", "admin", "manager", "owner", "7"} {
if got := models.PosRoleFromName(name); got != 0 {
t.Errorf("PosRoleFromName(%q) = %d, want 0", name, got)
}
}
}
// The web console writes `app_users` too, through `tenants/createstaff`, and
// that path had no validation at all. These cover the shared rule set, so a
// person created from a browser is subject to the same constraints as one
// created at a till — two paths writing one table is how they drift.
func TestStaffFromTheWebConsoleObeysTheTillsRules(t *testing.T) {
cases := []struct {
name string
user models.User
ok bool
}{
{
name: "a usable cashier",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 7391},
ok: true,
},
{
name: "a password instead of a PIN is fine",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Password: "s3cret"},
ok: true,
},
{
name: "no name",
user: models.User{Roleid: models.PosRoleCashier, Pin: 7391},
},
{
name: "no role — 0 is unset, not a role",
user: models.User{Firstname: "Asha", Pin: 7391},
},
{
name: "no way at all to sign in",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier},
},
{
// 451 is what "0451" becomes in a bigint column. Accepting it here
// creates somebody who types four digits and is refused for ever.
name: "a PIN the column cannot hold",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 451},
},
{
name: "a PIN anyone would guess first",
user: models.User{Firstname: "Asha", Roleid: models.PosRoleCashier, Pin: 1234},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user := tc.user
_, err := ValidateStaffUser(&user)
if tc.ok && err != nil {
t.Fatalf("refused a valid staff row: %v", err)
}
if !tc.ok && err == nil {
t.Fatal("accepted a staff row the till could not use")
}
})
}
}

View File

@@ -0,0 +1,169 @@
package repositories
import (
"fmt"
"nearle/models"
"gorm.io/gorm"
)
// Releasing a product from the admin catalogue to the shops.
//
// Until this existed there was no such step. The import created a
// `productlocations` row and membership of that table *was* publication, so a
// product reached every store the moment it was imported — unpriced, because
// pricing had not happened yet. That is how the platform ended up with a
// catalogue of products a till could not ring up.
//
// Two rules, both enforced here rather than in the console, because a rule that
// only exists in a form is a rule that holds until somebody calls the API:
//
// 1. **A product cannot be published without a price.** An unpriced product
// reaching a shop is the exact failure this separation prevents — the POS
// catalogue sends it down as `is_active: false` and it cannot be sold.
// 2. **Publishing covers every outlet the tenant runs.** A price is a
// business-level decision, and a product live at one branch and absent from
// another is a support call nobody can explain.
// PublishProduct releases one product to every outlet of a tenant at the given
// price, creating the store link where it does not exist yet.
//
// Idempotent: publishing an already-published product re-prices it and leaves
// the original `publishedat` alone, so "when did this go live" survives a
// second click.
func (r *productRepository) PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error) {
if tenantID <= 0 || productID <= 0 {
return 0, fmt.Errorf("tenantid and productid are required")
}
if price <= 0 {
return 0, fmt.Errorf("set a price before publishing — a product with no price cannot be sold at a till")
}
// Zero is a legitimate rate — plenty of staples are zero-rated — so it
// cannot double as "not specified". Negative is refused rather than stored:
// three products already carry taxpercent -1, which yields negative tax
// under either convention.
if taxPercent < 0 {
return 0, fmt.Errorf("tax percentage cannot be negative")
}
var affected int
err := r.db.Transaction(func(tx *gorm.DB) error {
// The tenant's outlets are read here rather than accepted from the
// caller. A console that sent its own list could publish to a subset by
// omission and nobody would notice which shop was missing.
var locationIDs []int
if err := tx.Raw(`
SELECT locationid FROM tenantlocations
WHERE tenantid = ? AND LOWER(COALESCE(status,'active')) <> 'inactive'
ORDER BY locationid`, tenantID).Scan(&locationIDs).Error; err != nil {
return err
}
if len(locationIDs) == 0 {
return fmt.Errorf("this business has no active outlet to publish to")
}
// Verify the product belongs to this tenant before writing anything
// against it. Everything below is keyed on (tenantid, productid), but a
// caller naming another tenant's product would otherwise create rows for
// a product that is not theirs.
var owned int64
if err := tx.Raw(`SELECT COUNT(1) FROM products WHERE productid = ? AND tenantid = ?`,
productID, tenantID).Scan(&owned).Error; err != nil {
return err
}
if owned == 0 {
return fmt.Errorf("product %d does not belong to this business", productID)
}
for _, locationID := range locationIDs {
// COALESCE on publishedat keeps the first release date through a
// re-publish; NOW() only applies to a row that has never been live.
res := tx.Exec(`
UPDATE productlocations
SET price = ?, publishedat = COALESCE(publishedat, NOW()), updated = NOW()
WHERE tenantid = ? AND locationid = ? AND productid = ?`,
price, tenantID, locationID, productID)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
if err := tx.Exec(`
INSERT INTO productlocations
(tenantid, locationid, productid, price, status, publishedat, created, updated)
VALUES (?, ?, ?, ?, 'Active', NOW(), NOW(), NOW())`,
tenantID, locationID, productID, price).Error; err != nil {
return err
}
}
affected++
}
// The master price follows, so an outlet that has no row of its own
// still reads the right figure through the COALESCE fallback in
// GetProducts.
return tx.Exec(`UPDATE products SET retailprice = ?, taxpercent = ?, updated = NOW()
WHERE productid = ? AND tenantid = ?`,
price, taxPercent, productID, tenantID).Error
})
return affected, err
}
// UnpublishProduct withdraws a product from every shop.
//
// Clears `publishedat` and keeps the rows. Deleting them would lose the price,
// the stock ledger's link and any history, and a product pulled from sale for a
// week should come back the way it left.
func (r *productRepository) UnpublishProduct(tenantID, productID int) (int, error) {
if tenantID <= 0 || productID <= 0 {
return 0, fmt.Errorf("tenantid and productid are required")
}
res := r.db.Exec(`
UPDATE productlocations SET publishedat = NULL, updated = NOW()
WHERE tenantid = ? AND productid = ? AND publishedat IS NOT NULL`,
tenantID, productID)
return int(res.RowsAffected), res.Error
}
// PublishPricedLocations releases the rows an import has just created, at the
// one branch it imported into.
//
// The publish gate exists to stop an unpriced product reaching a till that
// would ring it up at zero. An import from a spreadsheet CARRIES the price — it
// is on the row this writes — so the gate's condition is already met, and
// leaving these rows unpublished asks somebody to click a button that can only
// answer "yes" seventeen times.
//
// It was not a harmless extra step. Nothing in the backend filters on
// `publishedat`, so the products were already on sale in the customer app while
// the merchant's own store catalogue — the one screen that does read it —
// showed nothing. The shopkeeper could not see what their shoppers could buy.
//
// Three things keep this honest:
//
// - `price > 0` is checked in SQL, not by the caller. A row that somehow
// arrived unpriced stays unpublished, which is the rule the gate is for.
// - COALESCE keeps the first release date, so re-importing does not rewrite
// the history of a product that has been on sale for months.
// - Only the named branch. PublishProduct releases to every outlet a tenant
// runs and that is right for a deliberate release; an import names one
// branch, and quietly stocking the others would put products in shops
// nobody chose.
func (r *productRepository) PublishPricedLocations(refs []models.ProductLocationRef) error {
if len(refs) == 0 {
return nil
}
for _, ref := range refs {
if err := r.db.Exec(`
UPDATE productlocations
SET publishedat = COALESCE(publishedat, NOW()), updated = NOW()
WHERE tenantid = ? AND locationid = ? AND productid = ?
AND COALESCE(price, 0) > 0`,
ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil {
return err
}
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,228 @@
package repositories
import (
"errors"
"strings"
"nearle/models"
"gorm.io/gorm"
)
/*
Variants, as a relationship between products.
The `productvariants` table always had `productid` (the parent) and
`variantproductid` (the product a shopper actually orders for that size). The
model did not map either, so no code could use them — which is why the ordering
screen fell back to `products.variants`, a bare group number that is 0 on every
product, and asked the backend for "everything in group 0". That matched the
tenant's entire ungrouped catalogue.
This file is the missing half: attach a size to a product, and read the sizes
back with the one call that fetches the product.
*/
// ErrVariantParentMissing and friends are returned to the controller so a bad
// request answers 400 with a reason rather than 500 with a stack trace.
var (
ErrVariantParentMissing = errors.New("a variant needs a parent product: send productid")
ErrVariantProductMissing = errors.New("a variant needs a product to order: send variantproductid")
ErrVariantSelfReference = errors.New("a product cannot be a variant of itself")
ErrVariantNotYours = errors.New("both products must belong to this tenant")
ErrVariantDuplicate = errors.New("that product is already a variant of this one")
)
// AddProductVariant hangs one product under another as a size.
//
// Every check here is about a shape that would break the ordering screen rather
// than merely store bad data:
//
// - No parent, or no variant product: the row could never be read back by
// either side of the relationship.
// - A product as its own variant: the size picker would offer the thing you
// already tapped, and picking it would loop.
// - A product from another tenant: one shop's basket could be filled from
// another shop's shelf.
// - The same pair twice: the picker would show the size twice, and there is
// no way for a shopper to tell the duplicates apart.
func (r *productRepository) AddProductVariant(v models.Productvariant) (models.Productvariant, error) {
if v.Productid <= 0 {
return v, ErrVariantParentMissing
}
if v.Variantproductid <= 0 {
return v, ErrVariantProductMissing
}
if v.Productid == v.Variantproductid {
return v, ErrVariantSelfReference
}
// Both ends checked in one query: two rows back means both exist and both
// are this tenant's. Anything less is a refusal, and the caller does not
// need to know which end was wrong to fix the request.
var owned int64
if err := r.db.Table("products").
Where("tenantid = ? AND productid IN (?, ?)", v.Tenantid, v.Productid, v.Variantproductid).
Count(&owned).Error; err != nil {
return v, err
}
if owned < 2 {
return v, ErrVariantNotYours
}
var clash int64
if err := r.db.Table("productvariants").
Where("tenantid = ? AND productid = ? AND variantproductid = ?",
v.Tenantid, v.Productid, v.Variantproductid).
Count(&clash).Error; err != nil {
return v, err
}
if clash > 0 {
return v, ErrVariantDuplicate
}
if v.Status == "" {
v.Status = "Active"
}
if err := r.db.Table("productvariants").Create(&v).Error; err != nil {
return v, err
}
return v, nil
}
// RemoveProductVariant detaches a size. Scoped by tenant so an id from one
// shop cannot delete another's row.
func (r *productRepository) RemoveProductVariant(tenantid, variantid int) error {
if tenantid <= 0 || variantid <= 0 {
return ErrVariantParentMissing
}
return r.db.Table("productvariants").
Where("tenantid = ? AND variantid = ?", tenantid, variantid).
Delete(nil).Error
}
// VariantsForProducts reads the sizes for a set of parents in ONE query.
//
// Batched deliberately: the alternative is a query per product inside the loop
// that builds the response, which is the classic N+1 — a 200-product listing
// would fire 200 extra round trips to add a field that is empty for almost
// every row.
//
// The variant's own product supplies the name, the live price and the stock, so
// the app can draw a size picker — including greying out a size that is out of
// stock — without a second call. Price follows the same rule as everywhere
// else: the store's own price when it has set one, the master price otherwise.
func (r *productRepository) VariantsForProducts(tenantid, locationid int, productids []int) (map[int][]models.Productvariant, error) {
out := map[int][]models.Productvariant{}
if tenantid <= 0 || len(productids) == 0 {
return out, nil
}
// `price` is emitted as the EFFECTIVE price, not the raw override.
//
// The column holds an override where 0 means "no override", so a client
// reading the obvious field name got 0 for every size while the real figure
// sat in `variantprice` beside it. Measured on R mart: a grouped product
// whose three sizes are priced 50, 250 and 500 was emitting price 0 on all
// three, so the app showed them as free.
//
// Selected after `v.*` so it overwrites the raw column on the way out — the
// same trick GetProductByVariant uses for `quantity`. The stored override is
// untouched; only what is emitted changes.
var rows []models.Productvariant
err := r.db.
Table("productvariants v").
Select(`
v.*,
p.productname AS variantproductname,
COALESCE(p.unitvalue, '') AS variantunitvalue,
COALESCE(p.productunit, '') AS variantproductunit,
COALESCE(NULLIF(v.price, 0), NULLIF((
SELECT pl.price FROM productlocations pl
WHERE pl.productid = v.variantproductid AND pl.tenantid = v.tenantid AND pl.locationid = ?
LIMIT 1
), 0), p.retailprice, 0) AS price,
-- The variant’s own override wins, then the outlet’s price, then the
-- master price. The override was being ignored entirely: a size
-- priced differently from its product was sold at the product’s
-- price. NULLIF because 0 in that column means "no override", not
-- "free".
COALESCE(NULLIF(v.price, 0), NULLIF((
SELECT pl.price FROM productlocations pl
WHERE pl.productid = v.variantproductid AND pl.tenantid = v.tenantid AND pl.locationid = ?
LIMIT 1
), 0), p.retailprice, 0) AS variantprice,
COALESCE((
SELECT SUM(CASE WHEN LOWER(ps.stocktype) = 'in' THEN ps.quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(ps.stocktype) = 'out' THEN ps.quantity ELSE 0 END)
FROM productstocks ps
WHERE ps.productid = v.variantproductid AND ps.tenantid = v.tenantid AND ps.locationid = ?
), 0) AS variantstock
`, locationid, locationid, locationid).
// INNER, and that is the intended filter: a variant whose product was
// deleted is not a size a shopper can be offered.
Joins("JOIN products p ON p.productid = v.variantproductid AND p.tenantid = v.tenantid").
Where("v.tenantid = ? AND v.productid IN ?", tenantid, productids).
Where("LOWER(COALESCE(v.status, 'active')) <> 'inactive'").
Order("v.variantid").
Scan(&rows).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return out, err
}
for _, row := range rows {
out[row.Productid] = append(out[row.Productid], row)
}
return out, nil
}
// variantLabelFor names the product's own size for the picker.
//
// The unit if it has one — "500g" is what a shopper is choosing between. A
// product with no unit recorded falls back to its name rather than to an empty
// label, because a blank option in a size picker is unpickable.
func variantLabelFor(p models.Products) string {
unit := strings.TrimSpace(strings.TrimSpace(p.Unitvalue) + " " + strings.TrimSpace(p.Productunit))
if unit != "" {
return unit
}
return p.Productname
}
// VariantChildIDs is every product that is a size OF something else.
//
// These are the rows the customer app must not list on their own. A shop that
// stocks Aachi Baby Fryums in 100g, 500g and 1kg has three product rows, and a
// shopper should meet one product with three sizes — not three products that
// happen to share a name and differ by a suffix they have to read carefully.
//
// One query for the whole tenant rather than per product: the alternative is a
// lookup inside the loop that builds the response, which is the classic N+1 on
// a listing that can run to hundreds of rows.
//
// A product that is a size of something is NOT deleted or hidden from the
// merchant — the console still lists all three, because each has its own price,
// its own barcode and its own shelf to restock.
func (r *productRepository) VariantChildIDs(tenantid int) (map[int]bool, error) {
out := map[int]bool{}
if tenantid <= 0 {
return out, nil
}
var ids []int
err := r.db.Table("productvariants").
Where("tenantid = ?", tenantid).
Where("LOWER(COALESCE(status, 'active')) <> 'inactive'").
// A row that is its own parent would remove the group's only visible
// product. It should not exist — AddProductVariant refuses it — but a
// listing is the wrong place to discover that it does.
Where("variantproductid <> productid").
Pluck("variantproductid", &ids).Error
if err != nil {
return out, err
}
for _, id := range ids {
out[id] = true
}
return out, nil
}

View File

@@ -0,0 +1,67 @@
package repositories
import (
"errors"
"testing"
"nearle/models"
)
/*
Variants are a relationship between two products, and every test here is about
a shape that would break the ordering screen rather than merely store bad data.
The bug this replaces: `productvariants` had `productid` and `variantproductid`
all along, neither was mapped, so a variant could never be attached to a
product. The app fell back to `products.variants` — a group number that is 0 on
every product — and asked for "everything in group 0", which matched the
tenant's whole ungrouped catalogue. Six unrelated products came back as each
other's sizes and the order could not be placed.
*/
func TestAVariantWithoutAParentIsRefused(t *testing.T) {
r := &productRepository{}
_, err := r.AddProductVariant(models.Productvariant{Tenantid: 1, Variantproductid: 7})
if !errors.Is(err, ErrVariantParentMissing) {
t.Fatalf("expected a missing-parent refusal, got %v", err)
}
}
func TestAVariantThatOrdersNothingIsRefused(t *testing.T) {
// Without `variantproductid` there is no product to put in the basket, so
// the size would be pickable and unbuyable.
r := &productRepository{}
_, err := r.AddProductVariant(models.Productvariant{Tenantid: 1, Productid: 7})
if !errors.Is(err, ErrVariantProductMissing) {
t.Fatalf("expected a missing-product refusal, got %v", err)
}
}
func TestAProductCannotBeItsOwnVariant(t *testing.T) {
// The picker would offer the thing already tapped, and choosing it loops.
r := &productRepository{}
_, err := r.AddProductVariant(models.Productvariant{Tenantid: 1, Productid: 7, Variantproductid: 7})
if !errors.Is(err, ErrVariantSelfReference) {
t.Fatalf("expected a self-reference refusal, got %v", err)
}
}
func TestNoProductsMeansNoQueryAndNoVariants(t *testing.T) {
// Guards the N+1: the batch loader must not run a query for an empty page.
// `r.db` is nil here, so reaching the database at all would panic.
r := &productRepository{}
got, err := r.VariantsForProducts(1, 1, nil)
if err != nil {
t.Fatalf("VariantsForProducts: %v", err)
}
if len(got) != 0 {
t.Errorf("expected no variants, got %d", len(got))
}
}
func TestAnUnknownTenantAsksTheDatabaseNothing(t *testing.T) {
r := &productRepository{}
if _, err := r.VariantsForProducts(0, 1, []int{7, 8}); err != nil {
t.Fatalf("VariantsForProducts: %v", err)
}
}

View File

@@ -0,0 +1,729 @@
package repositories
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"nearle/db"
"nearle/models"
"nearle/utils"
"sort"
"strings"
"sync"
"time"
"gorm.io/gorm"
)
/*
Scan-to-order reads from three places and this file is the only one that
knows which is which:
- the catalogue database (pgvector, one table per brand) — to turn a label
into a catalogue product;
- nearledb — who the customer is, which outlets they scanned into, and what
those outlets have on the shelf right now;
- Redis — a cache for the expensive and stable half (the label's vector and
its catalogue hits). Live stock is never cached.
Two connections are held rather than one because the catalogue must never be
reachable through the nearledb handle: the comment on db.CatalogueDB is
explicit about that and every catalogue reader in this package honours it.
*/
// CatalogueKey is how a tenant's product row points back at the catalogue.
// Imageid is the stable one; brand+catalogueid is kept for rows imported
// before imageid existed (see models.Products.Imageid).
type CatalogueKey struct {
Brand string
Catalogueid int64
Imageid string
}
// CatalogueHit is one catalogue row the search considered.
type CatalogueHit struct {
Brand string
ID int64
ProductName string
Title string
Category string
Size string
VariantKey string
ImageID string
ImageURL string
// Cosine distance from pgvector (0 = identical); -1 for a text-only hit.
Distance float64
}
// StoreOptionRow is one sellable product at one outlet, with its live stock.
type StoreOptionRow struct {
Tenantid int
Locationid int
Productid int
Productname string
Productbrand string
Catalogueid int64
Imageid string
Productimage string
Productunit string
Unitvalue string
Price float64
Stock int
// For a size row: the product it hangs under and the label given to it.
Parentid int
Variantname string
}
type ScanRepository interface {
// nearledb
CustomerExists(ctx context.Context, customerid int) (bool, error)
CustomerHome(ctx context.Context, customerid int) (lat, lng float64, ok bool, err error)
RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error)
// StoreOptions finds, at the given outlets, every published product tied
// to one of the catalogue keys (or, for hand-made products, one of the
// names) — and every size hanging under those products.
StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error)
// ProductAt is one product at one outlet with its live stock, or nil.
ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error)
// catalogue
VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error)
TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error)
VectorSearchAvailable() bool
// CatalogueRef is one product named by its catalogue key, with its other
// pack sizes after it. Nothing is recognised or scored.
CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error)
// cache
CachedVector(ctx context.Context, model, label string) ([]float32, bool)
CacheVector(ctx context.Context, model, label string, v []float32)
CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool)
CacheHits(ctx context.Context, method, label string, hits []CatalogueHit)
}
type scanRepository struct {
db *gorm.DB
catalogue *gorm.DB
// Catalogue tables and their columns, discovered once and refreshed on a
// timer — the catalogue pipeline adds brands without telling anyone.
tablesMu sync.Mutex
tables map[string]map[string]bool // table -> column set
tablesAt time.Time
embeddingDim int
// Process-local cache in front of Redis, bounded, so a hot label costs
// nothing even when Redis is not configured.
memMu sync.Mutex
memVecs map[string][]float32
memHits map[string][]CatalogueHit
}
const (
scanTablesTTL = 10 * time.Minute
scanVectorTTL = 7 * 24 * time.Hour // a label's vector never changes for a given model
scanHitsTTL = 30 * time.Minute // the catalogue is rebuilt by scrape; not for long
scanMemCacheMax = 2000
)
func NewScanRepository(nearle, catalogue *gorm.DB) ScanRepository {
return &scanRepository{
db: nearle,
catalogue: catalogue,
memVecs: make(map[string][]float32),
memHits: make(map[string][]CatalogueHit),
}
}
// ── nearledb ────────────────────────────────────────────────────────────────
func (r *scanRepository) CustomerExists(ctx context.Context, customerid int) (bool, error) {
var n int64
err := r.db.WithContext(ctx).Raw(
`SELECT COUNT(1) FROM customers WHERE customerid = ?`, customerid).Scan(&n).Error
return n > 0, err
}
// CustomerHome is the saved primary address, falling back to the customers
// row itself. Either may be blank or unparsable — a customer created from a
// phone number alone has neither — and that is reported as ok=false rather
// than as (0, 0), which is a real place in the Gulf of Guinea.
func (r *scanRepository) CustomerHome(ctx context.Context, customerid int) (float64, float64, bool, error) {
var row struct {
Lat string
Lng string
}
err := r.db.WithContext(ctx).Raw(`
SELECT COALESCE(NULLIF(l.latitude, ''), c.latitude, '') AS lat,
COALESCE(NULLIF(l.longitude, ''), c.longitude, '') AS lng
FROM customers c
LEFT JOIN customerlocations l ON l.customerid = c.customerid AND l.primaryaddress = 1
WHERE c.customerid = ?
LIMIT 1`, customerid).Scan(&row).Error
if err != nil {
return 0, 0, false, err
}
lat, lng, ok := utils.ParseLatLng(row.Lat, row.Lng)
return lat, lng, ok, nil
}
// RegisteredStores is every active outlet of every tenant the customer has
// scanned into. A tenantcustomers row with locationid 0 means "the tenant",
// i.e. all of its outlets; a non-zero one pins a single outlet.
func (r *scanRepository) RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error) {
var rows []struct {
Tenantid int
Tenantname string
Locationid int
Locationname string
Address string
Latitude string
Longitude string
Deliveryradius int
Deliverymins int
Opentime string
Closetime string
}
err := r.db.WithContext(ctx).Raw(`
SELECT DISTINCT
tl.tenantid, COALESCE(t.tenantname, '') AS tenantname,
tl.locationid, COALESCE(tl.locationname, '') AS locationname,
COALESCE(tl.address, '') AS address,
COALESCE(tl.latitude, '') AS latitude, COALESCE(tl.longitude, '') AS longitude,
COALESCE(tl.deliveryradius, 0) AS deliveryradius, COALESCE(tl.deliverymins, 0) AS deliverymins,
COALESCE(tl.opentime, '') AS opentime, COALESCE(tl.closetime, '') AS closetime
FROM tenantcustomers tc
INNER JOIN tenantlocations tl
ON tl.tenantid = tc.tenantid
AND (COALESCE(tc.locationid, 0) = 0 OR tc.locationid = tl.locationid)
LEFT JOIN tenants t ON t.tenantid = tl.tenantid
WHERE tc.customerid = ?
AND LOWER(COALESCE(tl.status, 'active')) <> 'inactive'
ORDER BY tl.tenantid, tl.locationid`, customerid).Scan(&rows).Error
if err != nil {
return nil, err
}
now := time.Now()
stores := make([]models.ScanStore, 0, len(rows))
for _, row := range rows {
lat, lng, _ := utils.ParseLatLng(row.Latitude, row.Longitude)
stores = append(stores, models.ScanStore{
Tenantid: row.Tenantid,
Tenantname: row.Tenantname,
Locationid: row.Locationid,
Locationname: row.Locationname,
Address: row.Address,
Latitude: lat,
Longitude: lng,
DistanceKm: -1,
Deliveryradius: row.Deliveryradius,
Deliverymins: row.Deliverymins,
Open: utils.OpenNow(row.Opentime, row.Closetime, now),
})
}
return stores, nil
}
// storeOptionSelect is the projection every outlet read shares, so the
// price and stock rules cannot differ between the lookup and the confirm.
//
// Price: the outlet's own price when it set one, else the tenant's retail
// price — the same rule GetProducts applies. Stock: the live IN−OUT balance
// of the ledger at that outlet, the same expression the app displays, so a
// product can never be offered here and show 0 on the next screen.
const storeOptionSelect = `
SELECT a.tenantid, b.locationid, a.productid,
COALESCE(a.productname, '') AS productname,
LOWER(COALESCE(a.productbrand, '')) AS productbrand,
COALESCE(a.catalogueid, 0) AS catalogueid,
COALESCE(a.imageid, '') AS imageid,
COALESCE(a.productimage, '') AS productimage,
COALESCE(a.productunit, '') AS productunit,
COALESCE(a.unitvalue, '') AS unitvalue,
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
COALESCE((
SELECT SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity
WHEN LOWER(c.stocktype) = 'out' THEN -c.quantity
ELSE 0 END)
FROM productstocks c
WHERE c.productid = a.productid AND c.locationid = b.locationid AND c.tenantid = a.tenantid
), 0) AS stock,
COALESCE(v.productid, 0) AS parentid,
COALESCE(v.variantname, '') AS variantname
FROM products a
INNER JOIN productlocations b ON b.productid = a.productid AND b.tenantid = a.tenantid
LEFT JOIN productvariants v ON v.variantproductid = a.productid AND v.tenantid = a.tenantid
AND LOWER(COALESCE(v.status, 'active')) <> 'inactive'`
func (r *scanRepository) StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error) {
if len(locationids) == 0 || (len(keys) == 0 && len(names) == 0) {
return nil, nil
}
// The products that ARE the catalogue match, at these outlets.
var matchConds []string
var args []interface{}
args = append(args, locationids)
for _, k := range keys {
if k.Imageid != "" {
matchConds = append(matchConds, "a.imageid = ?")
args = append(args, k.Imageid)
}
if k.Brand != "" && k.Catalogueid > 0 {
matchConds = append(matchConds, "(LOWER(a.productbrand) = ? AND a.catalogueid = ?)")
args = append(args, strings.ToLower(k.Brand), k.Catalogueid)
}
}
for _, n := range names {
if n = strings.ToLower(strings.TrimSpace(n)); n != "" {
matchConds = append(matchConds, "LOWER(a.productname) = ?")
args = append(args, n)
}
}
if len(matchConds) == 0 {
return nil, nil
}
// Two reads rather than one recursive query: the second is keyed on the
// first's product ids, and a variant of a variant is not a thing here.
query := storeOptionSelect + `
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
AND b.locationid IN (?)
AND (` + strings.Join(matchConds, " OR ") + `)`
var parents []StoreOptionRow
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&parents).Error; err != nil {
return nil, err
}
if len(parents) == 0 {
return nil, nil
}
parentIDs := make([]int, 0, len(parents))
for _, p := range parents {
parentIDs = append(parentIDs, p.Productid)
}
// The sizes hanging under those products, at the same outlets. Only the
// rows whose parent is one of ours — the LEFT JOIN in the select can
// attach any parent, so it is pinned here.
var sizes []StoreOptionRow
err := r.db.WithContext(ctx).Raw(storeOptionSelect+`
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
AND b.locationid IN (?)
AND v.productid IN (?)`, locationids, parentIDs).Scan(&sizes).Error
if err != nil {
return nil, err
}
return append(parents, sizes...), nil
}
func (r *scanRepository) ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error) {
var rows []StoreOptionRow
err := r.db.WithContext(ctx).Raw(storeOptionSelect+`
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
AND a.tenantid = ? AND b.locationid = ? AND a.productid = ?
LIMIT 1`, tenantid, locationid, productid).Scan(&rows).Error
if err != nil || len(rows) == 0 {
return nil, err
}
return &rows[0], nil
}
// ── catalogue ───────────────────────────────────────────────────────────────
// brandTables is every `brand_*` table and its columns, cached briefly.
func (r *scanRepository) brandTables(ctx context.Context) (map[string]map[string]bool, error) {
if r.catalogue == nil {
return nil, ErrCatalogueDBUnavailable
}
r.tablesMu.Lock()
defer r.tablesMu.Unlock()
if r.tables != nil && time.Since(r.tablesAt) < scanTablesTTL {
return r.tables, nil
}
var rows []struct {
TableName string
ColumnName string
}
err := r.catalogue.WithContext(ctx).Raw(`
SELECT c.table_name, c.column_name
FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name LIKE 'brand\_%'`).Scan(&rows).Error
if err != nil {
return nil, err
}
tables := make(map[string]map[string]bool)
for _, row := range rows {
if tables[row.TableName] == nil {
tables[row.TableName] = make(map[string]bool)
}
tables[row.TableName][row.ColumnName] = true
}
for name, cols := range tables {
if !cols["id"] || !cols["product_name"] {
delete(tables, name)
}
}
// The vector width, read from the first embedding column found. pgvector
// stores it as the type modifier, so a mismatch with the model can be
// named in the error instead of surfacing as a bare "different vector
// dimensions" from the driver.
if r.embeddingDim == 0 {
for name, cols := range tables {
if !cols["embedding"] {
continue
}
var dim int
r.catalogue.WithContext(ctx).Raw(`
SELECT a.atttypmod FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relname = ? AND a.attname = 'embedding'`, name).Scan(&dim)
if dim > 0 {
r.embeddingDim = dim
}
break
}
}
r.tables, r.tablesAt = tables, time.Now()
return tables, nil
}
// VectorSearchAvailable is whether any catalogue table carries a vector.
func (r *scanRepository) VectorSearchAvailable() bool {
tables, err := r.brandTables(context.Background())
if err != nil {
return false
}
for _, cols := range tables {
if cols["embedding"] {
return true
}
}
return false
}
// hitColumns is the projection each search returns, with NULL stand-ins for
// columns a particular brand table lacks — the same tolerance
// catalogueRepository applies, for the same reason: a newer table missing
// one enrichment column is still a perfectly good catalogue of products.
func hitColumns(brand string, cols map[string]bool) string {
opt := func(name string) string {
if cols[name] {
return "COALESCE(" + name + ", '') AS " + name
}
return "'' AS " + name
}
return fmt.Sprintf(`'%s' AS brand, id, COALESCE(product_name, '') AS product_name, %s, %s, %s, %s, %s, %s`,
brand, opt("title"), opt("category"), opt("size"), opt("variant_key"), opt("image_id"), opt("image_url"))
}
// VectorSearch ranks every brand table by cosine distance to the label's
// vector and merges the top of each.
//
// One branch per table, each with its own ORDER BY and LIMIT inside
// parentheses, so Postgres can use the per-table vector index instead of
// scanning the union. The literal is bound as a parameter and cast — never
// concatenated — and table names come from information_schema, never from
// the request.
func (r *scanRepository) VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error) {
tables, err := r.brandTables(ctx)
if err != nil {
return nil, err
}
if r.embeddingDim > 0 && len(vector) != r.embeddingDim {
return nil, fmt.Errorf("embedding is %d wide but the catalogue's embedding column is %d: EMBEDDING_MODEL/EMBEDDING_DIMENSIONS do not match the model that indexed the catalogue", len(vector), r.embeddingDim)
}
literal := utils.VectorLiteral(vector)
var branches []string
var args []interface{}
for _, table := range sortedKeys(tables) {
cols := tables[table]
if !cols["embedding"] {
continue
}
brand := strings.TrimPrefix(table, "brand_")
branches = append(branches, fmt.Sprintf(
`(SELECT %s, (embedding <=> ?::vector) AS distance FROM %s WHERE embedding IS NOT NULL ORDER BY embedding <=> ?::vector LIMIT %d)`,
hitColumns(brand, cols), table, limit))
args = append(args, literal, literal)
}
if len(branches) == 0 {
return nil, errors.New("no catalogue table has an embedding column")
}
query := strings.Join(branches, " UNION ALL ") + fmt.Sprintf(" ORDER BY distance LIMIT %d", limit)
var hits []CatalogueHit
if err := r.catalogue.WithContext(ctx).Raw(query, args...).Scan(&hits).Error; err != nil {
return nil, err
}
return hits, nil
}
// minTokenHits is how many of the label's words a row must carry to be worth
// looking at. Every word was once required, which meant a single word the
// catalogue does not use — "Parle G biscuit pack", "Milk Bikis pack" — kept
// the right product out of the result entirely, leaving the vector search to
// answer alone and confidently wrong. Most of them is enough; scoring sorts
// out the rest.
func minTokenHits(n int) int {
if n <= 2 {
return n
}
return (n*2 + 2) / 3 // two thirds, rounded up; never below 2 for n >= 3
}
// TextSearch is the fallback when there is no embedder, and the tie-breaker
// beside it when there is: rows whose name or title contains the label, or
// carry most of its words.
func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error) {
tables, err := r.brandTables(ctx)
if err != nil {
return nil, err
}
label = strings.ToLower(strings.TrimSpace(label))
tokens := utils.SearchTokens(label)
if label == "" || len(tokens) == 0 {
return nil, nil
}
var branches []string
var args []interface{}
for _, table := range sortedKeys(tables) {
cols := tables[table]
brand := strings.TrimPrefix(table, "brand_")
hay := "LOWER(COALESCE(product_name, ''))"
if cols["title"] {
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, ''))"
}
if cols["search_query"] {
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, '') || ' ' || COALESCE(search_query, ''))"
}
// How well a row matches, as a number: the whole label as a substring
// outweighs any number of loose words, then one point per word found.
hits := make([]string, 0, len(tokens)+1)
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 100 ELSE 0 END)")
for range tokens {
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 1 ELSE 0 END)")
}
rank := strings.Join(hits, " + ")
// The expression appears twice in the SQL — once to filter, once to
// order — so its arguments are bound twice, in that order.
bind := func() {
args = append(args, "%"+label+"%")
for _, tok := range tokens {
args = append(args, "%"+tok+"%")
}
}
bind()
bind()
// Ordering matters as much as the threshold: a looser WHERE lets more
// rows qualify, and an unordered LIMIT would then be free to return
// the wrong ones. Best match per brand first, id to keep it stable.
branches = append(branches, fmt.Sprintf(
`(SELECT %s, -1::float8 AS distance FROM %s WHERE (%s) >= %d ORDER BY (%s) DESC, id LIMIT %d)`,
hitColumns(brand, cols), table, rank, minTokenHits(len(tokens)), rank, limit))
}
if len(branches) == 0 {
return nil, nil
}
var hits []CatalogueHit
if err := r.catalogue.WithContext(ctx).Raw(strings.Join(branches, " UNION ALL "), args...).Scan(&hits).Error; err != nil {
return nil, err
}
return hits, nil
}
// tableFor resolves a brand the caller named to a real catalogue table.
//
// The lookup is against the tables discovered from information_schema, never
// a string built from the request: table names cannot be parameterised in
// SQL, so the discovered map is what keeps this from being an injection
// point. Both the table suffix ("britannia") and a display name ("24 Mantra"
// → brand_24_mantra) resolve.
func (r *scanRepository) tableFor(ctx context.Context, brand string) (string, map[string]bool, error) {
tables, err := r.brandTables(ctx)
if err != nil {
return "", nil, err
}
for _, candidate := range []string{
"brand_" + strings.ToLower(strings.TrimSpace(brand)),
"brand_" + normaliseBrandKey(brand),
} {
if cols, ok := tables[candidate]; ok {
return candidate, cols, nil
}
}
return "", nil, ErrUnknownBrand
}
// CatalogueRef reads one product by (brand, id) and appends its other pack
// sizes — same variant_key where the catalogue assigned one, same name
// otherwise, matching how the search groups a family.
//
// Distance is 0 on every row: nothing here was ranked, the caller said which
// product they meant.
func (r *scanRepository) CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error) {
table, cols, err := r.tableFor(ctx, brand)
if err != nil {
return nil, err
}
suffix := strings.TrimPrefix(table, "brand_")
columns := hitColumns(suffix, cols)
var self []CatalogueHit
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
`SELECT %s, 0::float8 AS distance FROM %s WHERE id = ?`, columns, table), id).Scan(&self).Error
if err != nil {
return nil, err
}
if len(self) == 0 {
return nil, nil
}
var siblings []CatalogueHit
if cols["variant_key"] && strings.TrimSpace(self[0].VariantKey) != "" {
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
`SELECT %s, 0::float8 AS distance FROM %s WHERE variant_key = ? AND id <> ? ORDER BY id`,
columns, table), self[0].VariantKey, id).Scan(&siblings).Error
} else {
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
`SELECT %s, 0::float8 AS distance FROM %s WHERE LOWER(product_name) = LOWER(?) AND id <> ? ORDER BY id`,
columns, table), self[0].ProductName, id).Scan(&siblings).Error
}
if err != nil {
// The product itself was found; losing its other sizes is the smaller
// failure and the caller asked for this one.
log.Printf("scan: could not read pack sizes of %s#%d: %v", brand, id, err)
return self, nil
}
return append(self, siblings...), nil
}
func sortedKeys(m map[string]map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// ── cache ───────────────────────────────────────────────────────────────────
//
// Two tiers. Redis is shared across replicas and survives a restart; the
// in-process map is there so the request after a cache hit costs no network
// round trip at all, and so a deployment without Redis still gets the
// benefit within one process. Neither tier ever holds stock.
func scanCacheKey(kind, scope, label string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(label))))
return "scan:" + kind + ":v1:" + scope + ":" + hex.EncodeToString(sum[:16])
}
func (r *scanRepository) CachedVector(ctx context.Context, model, label string) ([]float32, bool) {
key := scanCacheKey("emb", model, label)
r.memMu.Lock()
v, ok := r.memVecs[key]
r.memMu.Unlock()
if ok {
return v, true
}
if db.Rdb == nil {
return nil, false
}
raw, err := db.Rdb.Get(ctx, key).Bytes()
if err != nil {
return nil, false
}
if json.Unmarshal(raw, &v) != nil || len(v) == 0 {
return nil, false
}
r.remember(key, v, nil)
return v, true
}
func (r *scanRepository) CacheVector(ctx context.Context, model, label string, v []float32) {
key := scanCacheKey("emb", model, label)
r.remember(key, v, nil)
if db.Rdb == nil {
return
}
if raw, err := json.Marshal(v); err == nil {
if err := db.Rdb.Set(ctx, key, raw, scanVectorTTL).Err(); err != nil {
log.Printf("scan: could not cache vector: %v", err)
}
}
}
func (r *scanRepository) CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool) {
key := scanCacheKey("hits", method, label)
r.memMu.Lock()
h, ok := r.memHits[key]
r.memMu.Unlock()
if ok {
return h, true
}
if db.Rdb == nil {
return nil, false
}
raw, err := db.Rdb.Get(ctx, key).Bytes()
if err != nil {
return nil, false
}
if json.Unmarshal(raw, &h) != nil {
return nil, false
}
r.remember(key, nil, h)
return h, true
}
func (r *scanRepository) CacheHits(ctx context.Context, method, label string, hits []CatalogueHit) {
key := scanCacheKey("hits", method, label)
r.remember(key, nil, hits)
if db.Rdb == nil {
return
}
if raw, err := json.Marshal(hits); err == nil {
if err := db.Rdb.Set(ctx, key, raw, scanHitsTTL).Err(); err != nil {
log.Printf("scan: could not cache hits: %v", err)
}
}
}
// remember writes one entry into the process-local tier. Eviction is the
// simplest thing that bounds memory: when full, drop everything. Labels are
// short-lived popularity, not a working set worth an LRU.
func (r *scanRepository) remember(key string, v []float32, h []CatalogueHit) {
r.memMu.Lock()
defer r.memMu.Unlock()
if len(r.memVecs)+len(r.memHits) >= scanMemCacheMax {
r.memVecs = make(map[string][]float32)
r.memHits = make(map[string][]CatalogueHit)
}
if v != nil {
r.memVecs[key] = v
}
if h != nil {
r.memHits[key] = h
}
}

210
repositories/stockLedger.go Normal file
View File

@@ -0,0 +1,210 @@
package repositories
import (
"fmt"
"math"
"sort"
"time"
"nearle/models"
"gorm.io/gorm"
)
// Shared stock machinery.
//
// Extracted from createOrderTx so that an order placed in the app and a bill
// rung up at a counter deduct stock through exactly the same code. Two
// implementations of the rule that stops overselling would drift, and the first
// anyone would know about it is a shelf that is empty in the database and full
// in the shop, or the reverse.
//
// None of these commit or roll back — the caller owns the transaction boundary,
// because what should happen to the rest of the work on failure is the caller's
// business, not the ledger's.
// stockLine is the minimum the ledger needs to know about one sold line.
//
// Deliberately not models.OrderDetail: the POS ingest writes its own tables and
// has no OrderDetail to hand, and coupling the ledger to one caller's row type
// is what forced the duplication this file removes.
type stockLine struct {
Productid int
Locationid int
Productname string
// Units sold. Fractional because a counter sells 1.5 kg of onions; the
// ledger itself is integer-only, and roundStockQty explains the gap.
Quantity float64
}
// roundStockQty turns a sold quantity into a ledger quantity.
//
// productstocks.quantity is an integer column, so fractional sales cannot be
// represented exactly. Rounding *up* is the conservative direction: 1.5 kg
// deducts 2, so the recorded stock is never higher than what is physically on
// the shelf. Truncating instead would under-deduct on every fractional sale and
// let the shop oversell a little more each time.
//
// This is a workaround, not a fix. A shop that sells much by weight needs the
// column to be numeric.
func roundStockQty(quantity float64) int {
if quantity <= 0 {
return 1
}
return int(math.Ceil(quantity - 1e-9))
}
// lockStockRows takes a row lock on every (tenant, location, product) the sale
// touches, before anything reads availability.
//
// Without it two concurrent sales of the same product can both read "in stock"
// before either commits its deduction, oversell the item and drive the balance
// negative. Locking productlocations — the row the stock computation is already
// keyed against — serialises conflicting sales instead.
//
// Locks are taken in a fixed (productid, locationid) order so two sales sharing
// products always contend in the same sequence. Without that ordering they
// deadlock against each other rather than merely blocking.
func lockStockRows(tx *gorm.DB, tenantID int, lines []stockLine) error {
type lockTarget struct {
productid int
locationid int
}
seen := make(map[lockTarget]bool, len(lines))
locks := make([]lockTarget, 0, len(lines))
for _, line := range lines {
lt := lockTarget{productid: line.Productid, locationid: line.Locationid}
if !seen[lt] {
seen[lt] = true
locks = append(locks, lt)
}
}
sort.Slice(locks, func(a, b int) bool {
if locks[a].productid != locks[b].productid {
return locks[a].productid < locks[b].productid
}
return locks[a].locationid < locks[b].locationid
})
for _, lt := range locks {
var locked int
const q = `SELECT productlocationid FROM productlocations
WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
if err := tx.Raw(q, tenantID, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
return fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
}
}
return nil
}
// availableStock is the ledger balance for one product at one location.
func availableStock(tx *gorm.DB, tenantID, locationID, productID int) (int, error) {
var available int
const q = `
SELECT COALESCE(
SUM(CASE WHEN LOWER(stocktype) = 'in' THEN quantity ELSE 0 END) -
SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END),
0
)
FROM productstocks
WHERE productid = ? AND tenantid = ? AND locationid = ?`
if err := tx.Raw(q, productID, tenantID, locationID).Scan(&available).Error; err != nil {
return 0, fmt.Errorf("failed to verify stock for product %d: %w", productID, err)
}
return available, nil
}
// assertStockAvailable refuses the whole sale if any line cannot be met.
//
// Checked for every line before any is written, so a sale never lands
// half-deducted. Call it only with the locks from lockStockRows already held —
// otherwise the balance it reads can change before the deduction is written.
func assertStockAvailable(tx *gorm.DB, tenantID int, lines []stockLine, qtyOf func(stockLine) int) error {
for _, line := range lines {
available, err := availableStock(tx, tenantID, line.Locationid, line.Productid)
if err != nil {
return err
}
requested := qtyOf(line)
if available < requested {
name := line.Productname
if name == "" {
name = fmt.Sprintf("ID %d", line.Productid)
}
return fmt.Errorf(
"insufficient stock for product '%s': requested %d, available %d",
name, requested, available,
)
}
}
return nil
}
// recordStockOut writes the ledger entry for one sold line and re-derives the
// location's availability flag from the balance it just produced.
func recordStockOut(tx *gorm.DB, tenantID int, line stockLine, quantity int) error {
stock := models.Productstock{
Tenantid: tenantID,
Stockdate: time.Now(),
Locationid: line.Locationid,
Productid: line.Productid,
Quantity: quantity,
Stocktype: "out",
Status: "Active",
}
if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
return err
}
syncProductLocationStatus(tx, tenantID, line.Locationid, line.Productid)
return nil
}
// legacyOrderQty is how createOrderTx has always turned an order quantity into
// a ledger quantity: truncate, then floor at 1.
//
// Preserved exactly rather than corrected, because changing it would silently
// alter stock deduction for every app order in production. It under-deducts a
// fractional line — 1.5 becomes 1 — which is why the POS path uses
// roundStockQty instead. Worth reconciling once someone owns the decision.
func legacyOrderQty(quantity float64) int {
q := int(quantity)
if q <= 0 {
q = 1
}
return q
}
// assertOutletNamed refuses an order that does not say which shop it is for.
//
// Stock is held per outlet — availableStock filters `locationid = ?` — so a
// line with no outlet matches no ledger row and reads as zero available. Every
// such order was then refused as "insufficient stock ... available 0", naming
// the shelf as the problem when the shelf was full and the request was what was
// incomplete. A merchant reading that goes and checks their stock, finds it,
// and has nowhere else to look.
//
// Per line rather than per order: a line may carry its own outlet, and only
// falls back to the header's when it does not.
func assertOutletNamed(lines []stockLine) error {
for _, line := range lines {
if line.Locationid != 0 {
continue
}
name := line.Productname
if name == "" {
name = fmt.Sprintf("ID %d", line.Productid)
}
return fmt.Errorf(
"this order names no outlet, so there is no shelf to sell '%s' from: "+
"send locationid on the order, or on each item", name,
)
}
return nil
}

View File

@@ -80,6 +80,25 @@ func (r *stockRequestRepository) GetStockRequestByID(requestID int) (*models.Sto
return &req, err
}
// UpdateStockRequest sets one request’s status, and refuses an id that is not
// there.
//
// An UPDATE that matches no row is not an SQL error, so this used to report
// success for a request that does not exist. Approving happened to catch it —
// the service reads the row first to move the stock — but rejecting went
// straight to the UPDATE and said it had worked.
//
// Harmless for one id typed by hand; not harmless in a batch, where the answer
// is a count. Measured on production 2026-09-02: rejecting [31,32,33,9999999]
// answered "4 updated" when only three requests existed. A merchant clearing a
// stale queue would be told every row was dealt with.
func (r *stockRequestRepository) UpdateStockRequest(requestID int, status string) error {
return r.db.Model(&models.StockRequest{}).Where("requestid = ?", requestID).Update("status", status).Error
result := r.db.Model(&models.StockRequest{}).Where("requestid = ?", requestID).Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}

View File

@@ -22,8 +22,11 @@ type TenantRepository interface {
UpdateLocation(input models.Tenantlocations) error
CreateLocation(data models.Tenantlocations) error
DeleteLocation(locationid int, tenantid int) error
UpdateTenantProfile(tenantID int, fields map[string]any) error
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
GetStaffs(tid int) ([]models.StaffInfo, error)
CreateStaff(user models.User) error
AssignStaffToBranch(tenantID, userID, locationID int) error
UpdateStaff(user models.User) error
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error)
UpdateTenantLocation(data models.Tenantlocations) error
@@ -31,6 +34,7 @@ type TenantRepository interface {
CreateTenantUser(data models.Tenants) (bool, error)
GetUserByNo(cno string) models.UserInfo
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
AssignPartner(tenantID, partnerID int) error
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
}
@@ -81,7 +85,22 @@ func (r *tenantRepository) GetAllTenants(pageno, pagesize, aid int, status, tena
var data []models.Tenantinfo
base := `SELECT * FROM tenants a WHERE 1 = 1`
// `branchcount` is selected here because there is nowhere else to get it.
//
// This returns one row per TENANT — there is no join to tenantlocations at
// all — but the console's store list read it as one row per
// tenant-location pair and counted the duplicates, so every merchant on the
// platform showed exactly one branch, and the "Branches" and "Avg branches"
// tiles above the list were the tenant count wearing another name. The
// tenant's own detail page, which reads gettenantlocations, disagreed with
// the list it was opened from.
//
// A correlated subquery rather than a LEFT JOIN + GROUP BY: the row shape
// stays exactly as it was, so nothing else that reads this endpoint has to
// change, and every filter below still applies to `a` alone.
base := `SELECT a.*,
(SELECT COUNT(*) FROM tenantlocations tl WHERE tl.tenantid = a.tenantid) AS branchcount
FROM tenants a WHERE 1 = 1`
var (
conds []string
@@ -312,6 +331,18 @@ func (r *tenantRepository) CreateLocation(data models.Tenantlocations) error {
return nil
}
// GetStaffs lists a merchant's people, INCLUDING the ones not yet given a
// branch.
//
// The join was INNER, which excluded exactly the state this list exists to
// show. A person hired before their outlet opens — or moved off a branch, or
// created and not yet placed — has `locationid` 0, matches no `tenantlocations`
// row, and vanished from the only screen that could assign them one. They could
// sign in (and were met with "No store assigned"), they simply could not be
// seen by the person able to fix it.
//
// LEFT, and unassigned first: they are the ones needing an action, and a list
// sorted by branch buries them under everybody already settled.
func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
var data []models.StaffInfo
@@ -320,10 +351,19 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
a.email,a.contactno,a.address,a.suburb,a.city,
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
a.roleid,a.partnerid,a.tenantid,a.locationid,
b.locationname
b.locationname,
COALESCE(c.rolename,'') AS rolename,
-- Whether the account still works. Absent from this SELECT
-- until now, so Users & access had nothing to read and showed
-- every person on the platform as "Unknown" — an admin could not
-- tell a working login from one that had been switched off.
COALESCE(a.status,'') AS status
FROM app_users a
INNER JOIN tenantlocations b ON a.locationid = b.locationid
WHERE a.tenantid = ?`
LEFT JOIN tenantlocations b ON a.locationid = b.locationid
LEFT JOIN app_roles c ON c.roleid = a.roleid
WHERE a.tenantid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)
ORDER BY a.locationid NULLS FIRST, a.userid DESC`
if err := r.db.Raw(q1, tid).Scan(&data).Error; err != nil {
return nil, err
@@ -332,7 +372,34 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
return data, nil
}
// CreateStaff adds a person to a shop from the web console.
//
// Now subject to the same rules the till applies — see ValidateStaffUser. This
// wrote whatever it was handed, so a cashier could be created with a PIN the
// schema cannot store, a PIN somebody else already has, or no way to sign in at
// all. The failure surfaced at the counter rather than on the screen that
// caused it.
//
// `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY`
// column and Postgres allocates it. Computing one here would leave the sequence
// unadvanced and two allocators racing each other.
func (r *tenantRepository) CreateStaff(user models.User) error {
pin, err := ValidateStaffUser(&user)
if err != nil {
return err
}
user.Pin = int(pin)
if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 {
taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
if err := r.db.Table("app_users").Create(&user).Error; err != nil {
return err
}
@@ -360,6 +427,21 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
data.Status = "Active"
}
// An outlet nobody can sign in to is a dead end, and a silent one — it
// appears in every list and every branch picker, and the first person to
// notice is whoever is standing in the shop.
//
// So a branch must arrive with an operator, one way or the other: an
// existing person named in Operatorid, or an email to spawn a login from.
// Neither used to be checked, and a create with a blank email produced an
// account whose authname was the empty string — a row that can never
// authenticate.
if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" {
tx.Rollback()
return models.Tenantlocations{}, errors.New(
"a branch needs somebody to run it: name an existing user in operatorid, or give an email to create a login from")
}
// Step 1: Insert into tenantlocations. GORM writes the DB-assigned
// locationid back onto data, which callers need to build the store's
// QR code (payload is just {tenantid, locationid}) right after onboarding.
@@ -368,7 +450,42 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
return models.Tenantlocations{}, err
}
// Step 2: Insert into app_users
// Step 2a: bind an existing person, when one was named.
//
// Scoped to this tenant in the WHERE clause rather than checked first: a
// userid belonging to another merchant then matches no row, and the branch
// is refused rather than handed to a stranger. Doing it as one guarded
// UPDATE also means the check and the write cannot drift apart under a
// concurrent reassignment.
if data.Operatorid > 0 {
res := tx.Table("app_users").
Where("userid = ? AND tenantid = ? AND COALESCE(roleid, 0) NOT IN (7, 8)",
data.Operatorid, data.Tenantid).
Updates(map[string]any{"locationid": data.Locationid})
if res.Error != nil {
tx.Rollback()
return models.Tenantlocations{}, res.Error
}
if res.RowsAffected == 0 {
// Either the person does not exist, belongs to another merchant, or
// is a till account. All three are the same answer to the caller,
// and none of them should leave a branch standing.
tx.Rollback()
return models.Tenantlocations{}, fmt.Errorf(
"user %d cannot run this branch — they belong to another business, do not exist, or are a till account",
data.Operatorid)
}
if err := tx.Commit().Error; err != nil {
return models.Tenantlocations{}, err
}
return data, nil
}
// Step 2b: no person named — spawn a login, as before.
//
// Kept so every existing caller behaves exactly as it did. The account it
// makes is named after the shop and sits on the shop's email, which is why
// naming a real person above is the better path where the caller has one.
user.Authname = data.Email
user.Firstname = data.Locationname
user.Email = data.Email
@@ -528,6 +645,51 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
var custloc models.Customerlocations
var tcust models.Tenantcustomers
// A tenant with configid 0 is unreachable, and it takes its customer row
// with it.
//
// Step 3 below already forces `user.Configid = 1`, with a comment
// explaining that AppLogin only ever queries configid 1 and a zero makes
// the account permanently unfindable. The same zero was left to flow into
// `tenants` itself and into the `customers` row copied from it at step 4,
// where nothing corrected it — so a caller that omits configid (the console
// sends it; the mobile route and anything else need not) created a business
// and a customer that no scoped read can see.
//
// Defaulted rather than rejected: 1 is the only value any caller has ever
// meant here, and refusing the create would break callers that work today.
if data.Configid == 0 {
data.Configid = 1
}
// Give the primary outlet the scaffolding the tenant already has.
//
// The outlet itself is created by GORM, as the `Tenantlocations`
// association on the struct below — the console nests a full object in the
// request and step 1 saves it with the tenant. What it does NOT do is fill
// anything the caller left out, and two of those columns matter:
//
// applocationid — `orderRepository.go` calls it "authoritative" and has
// no fallback anywhere for a 0.
// moduleid — same file: "tenantlocations carries 0 for
// moduleid/partnerid at outlets whose live orders
// nonetheless use non-zero values", worked around there
// by copying scaffolding off the most recent real order.
// A shop commissioned a minute ago has no such order.
//
// Neither column has a database default, and no onboarding form asks for
// them — they describe the platform, not the shop. The tenant's own values
// are the right answer and are already right here.
//
// Filled before the insert rather than corrected after it, so there is one
// write and no window where the row exists with a zero in it.
if data.Tenantlocations.Applocationid == 0 {
data.Tenantlocations.Applocationid = data.Applocationid
}
if data.Tenantlocations.Moduleid == 0 {
data.Tenantlocations.Moduleid = data.Moduleid
}
tx := r.db.Begin()
// Step 1: Insert into tenants
@@ -555,7 +717,24 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
user.Deviceid = data.Deviceid
user.Tenantid = data.Tenantid
user.Locationid = data.Tenantlocations.Locationid
user.Roleid = 1
// A merchant's own administrator is an ADMIN (3), not a Super admin (1).
//
// This wrote 1, and `app_roles` calls 1 "Super admin" — so every shop on the
// platform was provisioned with an account that reads as a platform
// operator on its own Users & access screen. It never had platform access:
// that is `app_users.issuperadmin`, a separate column the console checks
// first, and no store account has it set. But a label saying "Super admin"
// on a merchant's staff list is a thing somebody will eventually act on.
//
// 3 also matches the old console's ladder, which this one is meant to
// follow: Super Admin = platform, Admin = the merchant group, Manager (4) =
// a single store. `rmartuser` and `Kmartuser` — the store users there — are
// both roleid 4.
//
// Existing tenants keep roleid 1 and keep working: `resolveRole` still
// treats 1 and 3 alike, deliberately, because changing that would lock out
// every merchant provisioned before today.
user.Roleid = 3
// The onboarding form never sends a tenant configid, so copier.Copy left
// this at zero — AppLogin's GetUserByAuthname always queries configid=1
// for the web login, so a zero here makes the account permanently
@@ -582,8 +761,8 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
cust.State = data.State
cust.Postcode = data.Postcode
cust.Applocationid = data.Applocationid
cust.Latitude = data.Latitude
cust.Longitude = data.Longitude
cust.Latitude = models.FlexibleString(data.Latitude)
cust.Longitude = models.FlexibleString(data.Longitude)
cust.Primaryaddress = 1
cid := r.CheckCustomer(data.Primarycontact)
@@ -664,6 +843,24 @@ func (r *tenantRepository) GetUserByNo(cno string) models.UserInfo {
return user
}
// GetTenantByID reads one business.
//
// Both master joins are LEFT, and that is the whole fix. They were INNER —
// `app_category` on `a.categoryid` and `app_location` on `a.applocationid` — so
// a tenant whose category or city master row is missing did not come back
// "without a category name", it did not come back AT ALL. The endpoint answered
// 200 with an all-zero record: tenantid 0, every string empty.
//
// Four of two hundred tenants have `categoryid = 0`, which matches no
// `app_category` row, and a newly onboarded shop is the likeliest to be one of
// them. The damage was silent and total: the shop-profile screen seeded an
// empty form over a business that had an address, and the setup walkthrough
// read every field as blank forever — so its first step could never complete
// however many times somebody saved it.
//
// The same INNER-JOIN-as-a-filter mistake has been fixed twice before in this
// file's neighbours, in GetStaffs and GetUserById. A master row is context; its
// absence must never delete the record it decorates.
func (r *tenantRepository) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) {
var data models.Tenantinfo
@@ -679,8 +876,8 @@ func (r *tenantRepository) GetTenantByID(tid int, locationid int, userid int) (m
SELECT a.*,b.categoryname,c.locationname AS applocation, d.allocationid AS allocationmode,e.typename AS allocationtype,e.mapid AS allocationid,f.locationid,
f.locationname, f.contactno as locationcontact
FROM tenants a
INNER JOIN app_category b ON a.categoryid = b.categoryid
INNER JOIN app_location c ON a.applocationid = c.applocationid
LEFT JOIN app_category b ON a.categoryid = b.categoryid
LEFT JOIN app_location c ON a.applocationid = c.applocationid
LEFT JOIN partnerinfo d ON a.partnerid = d.partnerid
LEFT JOIN app_types e ON d.allocationid = e.apptypeid
LEFT JOIN tenantlocations f ON a.tenantid = f.tenantid
@@ -724,3 +921,144 @@ func (r *tenantRepository) GetTenantByKeyword(keyword string) ([]models.TenantSe
return data, nil
}
// AssignStaffToBranch moves one of a merchant's people to a branch, or takes
// them off one.
//
// `locationid` of 0 unassigns — a real state, not a missing value. Somebody
// leaves a shop before the next one opens, and the alternative to holding them
// unassigned is deleting the account and losing who did what.
//
// Both the person and the branch are checked against the tenant IN THE QUERY
// rather than beforehand. A userid from another merchant then matches no row
// and the call fails, instead of one business quietly moving another's staff —
// and the check cannot drift from the write under a concurrent edit.
//
// Till accounts (roleids 7 and 8) are excluded for the same reason GetStaffs
// hides them: they are POS people with no back-office screen, and their branch
// is managed by the till console, not here.
func (r *tenantRepository) AssignStaffToBranch(tenantID, userID, locationID int) error {
if locationID > 0 {
var owned int64
if err := r.db.Raw(
`SELECT COUNT(1) FROM tenantlocations WHERE locationid = ? AND tenantid = ?`,
locationID, tenantID).Scan(&owned).Error; err != nil {
return err
}
if owned == 0 {
return fmt.Errorf("branch %d does not belong to this business", locationID)
}
}
res := r.db.Table("app_users").
Where("userid = ? AND tenantid = ? AND COALESCE(roleid, 0) NOT IN (7, 8)",
userID, tenantID).
Updates(map[string]any{"locationid": locationID})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf(
"user %d is not one of this business's people", userID)
}
return nil
}
// UpdateTenantProfile writes a merchant's own business record.
//
// The FIRST write path this table has ever had. Every field on `tenants` was
// set once at onboarding by a Nearle Admin and could never be changed again, by
// anybody — which is why, across 200 merchants, 18 had a shop photograph and
// none had a licence number.
//
// `fields` has already been reduced to the merchant-editable columns by
// services.TenantProfileUpdate. This deliberately does not take a struct: GORM
// would then decide what to write from which values happen to be non-zero, and
// the set of columns a merchant may touch would be implied by a form rather
// than stated anywhere.
func (r *tenantRepository) UpdateTenantProfile(tenantID int, fields map[string]any) error {
if tenantID <= 0 {
return errors.New("tenantid is required")
}
if len(fields) == 0 {
return errors.New("nothing to update")
}
res := r.db.Table("tenants").Where("tenantid = ?", tenantID).Updates(fields)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("no business with tenantid %d", tenantID)
}
return nil
}
// UpdateOwnProfile writes the fields a person owns about themselves.
//
// Scoped by userid AND tenantid together, in the WHERE clause. `UpdateStaff`
// checks only the userid, so a request naming somebody else's account is
// carried out — which is survivable while the only caller is an admin screen,
// and is not once a person can edit their own profile.
//
// `fields` has already been reduced by services.OwnProfileUpdate to identity
// columns. Role, branch, tenant, status, password and PIN are not in it: this
// table keeps who-you-are next to what-you-may-do, and only the first half
// belongs to the person.
func (r *tenantRepository) UpdateOwnProfile(userID, tenantID int, fields map[string]any) error {
if userID <= 0 || tenantID <= 0 {
return errors.New("userid and tenantid are both required")
}
if len(fields) == 0 {
return errors.New("nothing to update")
}
res := r.db.Table("app_users").
Where("userid = ? AND tenantid = ?", userID, tenantID).
Updates(fields)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("no account %d in this business", userID)
}
return nil
}
// AssignPartner sets which delivery partner supplies a merchant's riders.
//
// A route of its own rather than a field on `updatetenant`, and that is the
// whole point. `partnerid` is deliberately absent from `editableTenantFields`:
// a merchant who could set it would move themselves under another partner's
// billing and pick up their riders. This is the platform's decision, so it gets
// the platform's endpoint.
//
// `partnerid` 0 is meaningful and allowed — it takes the partner away, which a
// merchant switching to their own riders needs. So the value is written rather
// than skipped when zero, unlike everywhere else in this file.
func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error {
if tenantID <= 0 {
return errors.New("tenantid is required")
}
// A partner that does not exist would silently orphan every rider lookup
// the merchant makes afterwards — `getriders?partnerid=` would answer 200
// with nothing, which reads as "no riders on duty".
if partnerID > 0 {
var found int64
if err := r.db.Table("partnerinfo").Where("partnerid = ?", partnerID).Count(&found).Error; err != nil {
return err
}
if found == 0 {
return fmt.Errorf("no delivery partner with partnerid %d", partnerID)
}
}
res := r.db.Table("tenants").Where("tenantid = ?", tenantID).
Updates(map[string]any{"partnerid": partnerID, "updated": gorm.Expr("NOW()")})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return fmt.Errorf("no business with tenantid %d", tenantID)
}
return nil
}

View File

@@ -1,6 +1,8 @@
package repositories
import (
"database/sql"
"errors"
"fmt"
"strings"
@@ -14,15 +16,12 @@ type UserRepository interface {
GetUserByID(uid int) (models.UserInfo, error)
Login(user models.User) (models.UserInfo, error)
FindUserID(authname, contactno string, configid int) (int, error)
GetTenantUserByID(userid int) (models.TenantUserInfo, error)
UpdateStaff(user models.User) error
GetUserByAuthname(authname string, configid int) (int, string, string)
GetUserByContactNo(contactno string, configid int) (int, string, string)
UpdateFCMToken(userid int, token string) error
GetTenantUserById(userid int) models.TenantUserInfo
CreateUser(user models.User) (int, error)
GetUserById(uid int) (models.UserInfo, error)
GetUserLogin(field, value string, configid int) (int, string, string, int)
GetUserLogin(field, value string, configid int) (int, string, string, int, error)
UpdateUserFcmToken(uid int, token string) error
GetLocationStatus(locationid int) string
DeleteUser(userid int) error
@@ -55,6 +54,18 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
WHERE 1=1`)
// Till accounts are not Nearle Daily users and must not be listed as though
// they were. The two products share this table and nothing else: a cashier
// has no app login, no rider shift and no back-office screen, so a row
// returned here is one every action on the page would fail against.
//
// Asking for 7 or 8 by name still works, so the POS console can read its own
// people through the same endpoint — this hides them from the general list,
// it does not make them unreachable.
if roleID != models.PosRoleSupervisor && roleID != models.PosRoleCashier {
queryBuilder.WriteString(" AND COALESCE(a.roleid, 0) NOT IN (7, 8)")
}
if roleID != 0 {
queryBuilder.WriteString(" AND a.roleid = ?")
params = append(params, roleID)
@@ -78,8 +89,6 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
queryBuilder.WriteString(" ORDER BY a.userid DESC LIMIT ? OFFSET ?")
params = append(params, pagesize, offset)
print(queryBuilder.String())
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
return nil, err
}
@@ -119,14 +128,16 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
var q string
if user.Authname != "" {
q = `SELECT a.userid FROM app_users a
WHERE a.authname = ? AND a.configid = ?`
q = `SELECT a.userid FROM app_users a
WHERE a.authname = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(q, user.Authname, user.Configid).Scan(&uid).Error; err != nil {
return models.UserInfo{}, err
}
} else {
q = `SELECT a.userid FROM app_users a
WHERE a.contactno = ? AND a.configid = ?`
q = `SELECT a.userid FROM app_users a
WHERE a.contactno = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(q, user.Contactno, user.Configid).Scan(&uid).Error; err != nil {
return models.UserInfo{}, err
}
@@ -153,18 +164,21 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
return userInfo, nil
}
func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) {
var uid int
var query string
if authname != "" {
query = `SELECT a.userid FROM app_users a WHERE a.authname = ? AND a.configid = ?`
query = `SELECT a.userid FROM app_users a
WHERE a.authname = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(query, authname, configid).Scan(&uid).Error; err != nil {
return 0, err
}
} else {
query = `SELECT a.userid FROM app_users a WHERE a.contactno = ? AND a.configid = ?`
query = `SELECT a.userid FROM app_users a
WHERE a.contactno = ? AND a.configid = ?
AND COALESCE(a.roleid, 0) NOT IN (7, 8)`
if err := r.db.Raw(query, contactno, configid).Scan(&uid).Error; err != nil {
return 0, err
}
@@ -172,44 +186,24 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
return uid, nil
}
func (r *userRepository) GetTenantUserByID(userid int) (models.TenantUserInfo, error) {
var info models.TenantUserInfo
query := `SELECT a.userid, a.authname, a.contactno, a.tenantid, t.tenantname
FROM app_users a
LEFT JOIN tenants t ON a.tenantid = t.tenantid
WHERE a.userid = ?`
if err := r.db.Raw(query, userid).Scan(&info).Error; err != nil {
return info, err
}
return info, nil
}
func (r *userRepository) UpdateStaff(user models.User) error {
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
}
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
var uid int
var password, status string
query := `SELECT userid, password, status FROM app_users WHERE authname = ? AND configid = ?`
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
return uid, password, status
}
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
var uid int
var password, status string
query := `SELECT userid, password, status FROM app_users WHERE contactno = ? AND configid = ?`
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
return uid, password, status
}
func (r *userRepository) UpdateFCMToken(userid int, token string) error {
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
return r.db.Exec(query, token, userid).Error
}
// The one tenant-user read. There used to be two.
//
// A `GetTenantUserByID` sat beside this — one capital letter apart, twenty-eight
// columns short, selecting only userid, authname, contactno, tenantid and
// tenantname. `TenantLogin` called that one, so the mobile login it served
// answered with a record whose name, branch, region and coordinates were blank,
// and the route was quietly pointed at a different handler to work around it.
// Deleted rather than documented: two functions this similar, where picking the
// wrong one fails silently, is a trap and not an API.
func (r *userRepository) GetTenantUserById(userid int) models.TenantUserInfo {
var info models.TenantUserInfo
@@ -222,24 +216,69 @@ func (r *userRepository) GetTenantUserById(userid int) models.TenantUserInfo {
a.firstname,a.lastname,concat(a.firstname,' ',a.lastname) as fullname,
a.userfcmtoken,a.pin,a.deviceid,a.devicetype,a.tenantid,a.locationid,a.applocationid,
a.issuperadmin,
-- The PERSON's own address, kept distinct from the shop's below.
--
-- Omitted when this query only served the web logins, and it only
-- mattered once it began serving /mob/users/tenant/login: that route
-- used to answer from UserInfo, where these five were populated, so
-- leaving them out turned five live fields into empty strings. The
-- tenant side is aliased (tenantaddress, tenantcity, ...) precisely
-- so both a person and their shop can be returned together.
a.address,a.suburb,a.city,a.state,a.postcode,
b.partnerid,b.moduleid,b.categoryid as categoryid,b.subcategoryid as subcategoryid,
b.applocationid,b.tenantname,b.address as tenantaddress,b.state as tenantstate,b.city as tenantcity,
b.postcode as tenantpostcode,b.latitude as tenantlat,b.longitude as tenantlong,c.locationname AS applocation,
c.latitude as applatitude,c.longitude as applongitude,c.radius as appradius, d.categoryname, e.locationname
c.latitude as applatitude,c.longitude as applongitude,c.radius as appradius, d.categoryname, e.locationname,
a.shiftid, concat(f.starttime, ' - ', f.endtime) as shiftname, a.status
from app_users a
LEFT JOIN tenants b ON a.tenantid=b.tenantid
LEFT JOIN app_location c on c.applocationid=b.applocationid
LEFT JOIN app_category d ON b.categoryid=d.categoryid
LEFT JOIN tenantlocations e ON a.locationid=e.locationid
LEFT JOIN ridershifts f ON a.shiftid = f.shiftid
WHERE a.userid = ?
`
r.db.Raw(query, userid).Scan(&info)
print(query)
// Lower-cased, as the other three reads of this table already do.
//
// `app_users.status` is stored inconsistently — "Active" here, "active"
// elsewhere — and every other path through this repository normalises it on
// the way out. This one did not, which only surfaced when it began serving
// /mob/users/tenant/login: that route previously answered "active" and now
// answered "Active", so any client comparing the string exactly would have
// read a live shop as disabled.
info.Status = strings.ToLower(info.Status)
return info
}
func (r *userRepository) CreateUser(user models.User) (int, error) {
// Inherit the delivery region from the tenant when the caller did not name
// one.
//
// `app_users.applocationid` has no column default, and no console form
// collects it — it is a platform region, not something a merchant picks
// per person. So every back-office account created through this path landed
// with 0, which is not a region: `orderRepository.go` calls the equivalent
// column on tenantlocations "authoritative" and has no fallback for a zero,
// and 43 of 75 live branches are already in that state.
//
// A lookup rather than a default value, because the right answer is
// whichever region the business trades in. Failure is not fatal: the
// account is still worth creating, and a 0 here is exactly what would have
// been written anyway.
if user.Applocationid == 0 && user.Tenantid > 0 {
var inherited int
if err := r.db.Raw(
`SELECT COALESCE(applocationid, 0) FROM tenants WHERE tenantid = ?`,
user.Tenantid,
).Scan(&inherited).Error; err == nil && inherited > 0 {
user.Applocationid = inherited
}
}
tx := r.db.Begin()
if err := tx.Table("app_users").Create(&user).Error; err != nil {
@@ -254,6 +293,15 @@ func (r *userRepository) CreateUser(user models.User) (int, error) {
return user.Userid, nil
}
// GetUserById reads one person back.
//
// The app_location join is LEFT, not INNER, and that is the whole fix. A user is
// not required to belong to an app location, and an INNER JOIN did not "filter"
// those users — it made them unreadable. CreateUser looks the new row up through
// here to return it, so creating a store user with no applocationid answered 201
// with userid 0 and every field blank. The user existed and every listing showed
// it; only the response meant to confirm the creation came back empty, which
// reads as a failure that silently succeeded.
func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
var user models.UserInfo
@@ -262,7 +310,7 @@ func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
a.userfcmtoken,a.pin,a.deviceid,a.devicetype,a.tenantid,a.shiftid,
a.applocationid,b.locationname as applocation,b.latitude as applatitude,b.longitude as applongitude, b.radius as appradius , concat(c.starttime, ' - ', c.endtime) as shiftname, a.status
FROM app_users a
INNER JOIN app_location b on a.applocationid=b.applocationid
LEFT JOIN app_location b on a.applocationid=b.applocationid
LEFT JOIN ridershifts c ON a.shiftid = c.shiftid
WHERE a.userid= ?`
@@ -275,18 +323,57 @@ func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
return user, nil
}
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int) {
var uid, roleid int
var password, status string
// GetUserLogin is the one sign-in lookup, for the app and the console alike.
//
// `field` is the column matched — "authname" or "contactno", nothing else is
// accepted — and it is interpolated, so the whitelist is what keeps this from
// being an injection point.
//
// A till account is not a Nearle Daily user. The two products share this table
// and nothing else, so the lookup itself excludes roles 7 and 8: a cashier is
// not "refused", they are simply not found. Doing it in the query rather than
// after it is deliberate — a check bolted on afterwards has to be repeated at
// every call site and is one edit away from being forgotten at one of them.
//
// Three outcomes, and the caller must tell them apart:
//
// - found: uid > 0, err == nil
// - not found: uid == 0, err == nil
// - failed: err != nil — the database could not answer at all
//
// The third used to be invisible. `Row().Scan`'s error was discarded, so a
// database that was down, a connection pool that was exhausted or a
// misconfigured `configid` all came back as uid 0 — which the service then
// reported as "Invalid Email". On 2026-07-20 the deployment lost its
// ConfigMaps/Secrets and every user on the platform was told their email was
// wrong, and nothing in the logs said otherwise.
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int, error) {
switch field {
case "authname", "contactno":
default:
return 0, "", "", 0, fmt.Errorf("login: %q is not a sign-in field", field)
}
var uid int
var password, status sql.NullString
var roleid sql.NullInt64
query := fmt.Sprintf(`
SELECT userid, password, status, roleid
FROM app_users
WHERE %s = ? AND configid = ?`, field)
WHERE %s = ? AND configid = ?
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
return uid, password, status, roleid
err := r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", "", 0, nil
}
if err != nil {
return 0, "", "", 0, err
}
// Nullable columns scanned through sql.Null* so that a NULL password or
// role — both exist on real rows — does not itself read as a failed query.
return uid, password.String, status.String, int(roleid.Int64), nil
}
func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error {
@@ -304,5 +391,3 @@ func (r *userRepository) GetLocationStatus(locationid int) string {
func (r *userRepository) DeleteUser(userid int) error {
return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error
}

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