Compare commits
101 Commits
c2f7d2481c
...
feat/env-l
| Author | SHA1 | Date | |
|---|---|---|---|
| 24339a8b51 | |||
| 01bc89ab77 | |||
| 692c10e553 | |||
| 2f1501883b | |||
| c06b029cb2 | |||
| 28af3e05f2 | |||
| 42ea007fe7 | |||
| 76bff883ec | |||
| aaea1bfc00 | |||
| 369e9fc7b4 | |||
| 72907dae74 | |||
| 1633617dc4 | |||
| 4474479735 | |||
| be47435547 | |||
| 1b2cc77063 | |||
| 8cc567c89b | |||
| e7577fe0cf | |||
| 62a441d798 | |||
| 3853b84c9f | |||
| d9cad2bbe4 | |||
| 7d9e628fd0 | |||
| e87f09c4f2 | |||
| c699a400c3 | |||
| f1b3a5eba4 | |||
| 593b11f1b8 | |||
| e51ad615ed | |||
| fb5d7da81f | |||
| 18a0f9b34d | |||
| 0d17f46fcf | |||
| 7ed821e8b0 | |||
| da0e9d987e | |||
| 2401158190 | |||
| e45da9f7df | |||
| 09efc5403f | |||
| 68871cb043 | |||
| 9f9b05ed1c | |||
| c0d39e550d | |||
| 1e3fe88e87 | |||
| 6b27448ff9 | |||
| ab74e70ba5 | |||
| 485f31239d | |||
| ae6162a632 | |||
| d8cfe2e87b | |||
| bc10b589cd | |||
| d55f101834 | |||
| 4bce5ac854 | |||
| ffc66462fc | |||
| 76554d26e5 | |||
| c5109bf216 | |||
| fb4fcaee66 | |||
| 7c5be9b5cf | |||
| 40500f936a | |||
| 6c1ad472f2 | |||
| c31f3aca27 | |||
| aef105979a | |||
| 46da26c452 | |||
| edb9c2803f | |||
| dc9c049bb9 | |||
|
|
ca846f9cd6 | ||
| d566ca5591 | |||
| ff72af9a8a | |||
| fba42259ea | |||
| c179e4d642 | |||
| d35caf34d3 | |||
| 3531c656d4 | |||
|
|
5c04d40f0e | ||
|
|
f7895d3ccf | ||
|
|
9e9401215d | ||
|
|
c0a7fbc1b1 | ||
|
|
f5e16b54cc | ||
|
|
cd2459dbb6 | ||
|
|
6a62dbb9f3 | ||
|
|
f343f4e86e | ||
|
|
4b27b84b1f | ||
|
|
c696ec3e79 | ||
|
|
c4dfcd5387 | ||
|
|
12165d5e58 | ||
|
|
5864204d32 | ||
|
|
d0c3cb751e | ||
|
|
11595ad415 | ||
|
|
ec672a3087 | ||
| bddd8fa265 | |||
|
|
8aa4d86eb6 | ||
|
|
b0caacd90a | ||
|
|
b9f389fcdf | ||
|
|
1e3386fac8 | ||
|
|
fc81df14e4 | ||
|
|
8709556704 | ||
|
|
ef647d3395 | ||
|
|
64a219e7da | ||
|
|
27fbbf0422 | ||
|
|
e3459a0f1c | ||
| 5d2e1fca9b | |||
| 481be667db | |||
| c290e1729a | |||
| 583cd89063 | |||
| a11c4843ca | |||
| c94ddd34c7 | |||
| 3b60a90009 | |||
| a913077da0 | |||
| 139ce5cda2 |
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
# Nothing in here reaches the image.
|
||||
#
|
||||
# `.env*` most of all: the Dockerfile does `COPY . .`, and the production
|
||||
# credentials in `.env.production` were being baked into every image built
|
||||
# from this folder. The running container gets its environment from the
|
||||
# platform (Dokploy / Kubernetes), never from a file.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.git
|
||||
.claude
|
||||
.DS_Store
|
||||
docs
|
||||
scratch
|
||||
init
|
||||
nearle
|
||||
server
|
||||
docker-compose.local.yml
|
||||
81
.env
81
.env
@@ -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
122
.env.example
Normal 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
68
.env.local
Normal 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
80
.env.production
Normal 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
6
.gitignore
vendored
@@ -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.
|
||||
|
||||
|
||||
@@ -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
142
README.md
Normal 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.
|
||||
360
config/config.go
360
config/config.go
@@ -1,49 +1,361 @@
|
||||
// Package config is the one place the process reads its environment.
|
||||
//
|
||||
// Two jobs, in order:
|
||||
//
|
||||
// 1. Pick the right `.env` file for the environment we are in and load it.
|
||||
// 2. Read every setting into a typed Config and refuse to start if anything
|
||||
// required is missing — all of it, in one message, before a single
|
||||
// connection is attempted.
|
||||
//
|
||||
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
|
||||
// `.env.production` described an `APP_ENV` switch that did not exist, so the
|
||||
// only way to run against production was to overwrite `.env` by hand, and the
|
||||
// only way to find out a variable was missing was a `log.Fatalf` from inside
|
||||
// `db.Connect()` — one variable per restart.
|
||||
//
|
||||
// # Which file loads
|
||||
//
|
||||
// `APP_ENV` names the environment and defaults to "local":
|
||||
//
|
||||
// go run . → .env.local, then .env
|
||||
// APP_ENV=production go run . → .env.production, then .env
|
||||
//
|
||||
// `.env` is a shared base loaded after the environment file. godotenv never
|
||||
// overwrites a variable that is already set, so the order of precedence is:
|
||||
//
|
||||
// real environment > .env.<APP_ENV> > .env
|
||||
//
|
||||
// Neither file has to exist. On the deployed host every value comes from the
|
||||
// platform's environment settings (Dokploy today, ConfigMaps/Secrets under
|
||||
// Kubernetes) and there is no file at all — which is exactly why the
|
||||
// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the
|
||||
// image carries no `.env.*`, so it cannot fall back to localhost values that
|
||||
// happen to be lying around in the build context.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Environment names. Anything else is accepted (a staging file works the same
|
||||
// way) but only these two change behaviour.
|
||||
const (
|
||||
EnvLocal = "local"
|
||||
EnvProduction = "production"
|
||||
)
|
||||
|
||||
// Config is everything the process reads from its environment.
|
||||
//
|
||||
// A few settings are still read directly with os.Getenv at the point of use,
|
||||
// because they are consulted per request or per connection rather than once
|
||||
// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED
|
||||
// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_*
|
||||
// and POS_* settings in package messaging. They are listed and validated here
|
||||
// so that a misconfiguration is still caught at startup.
|
||||
type Config struct {
|
||||
Env string
|
||||
Port string
|
||||
DBName string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBPort string
|
||||
DBHost string
|
||||
UserContextKey string
|
||||
// AppEnv is the value of APP_ENV: "local" or "production".
|
||||
AppEnv string
|
||||
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
|
||||
Port string
|
||||
|
||||
DB DBConfig
|
||||
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
|
||||
Redis RedisConfig
|
||||
S3 S3Config
|
||||
MQTT MQTTConfig
|
||||
Embedding EmbeddingConfig
|
||||
|
||||
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
|
||||
// unset, matching utils/postoken.go.
|
||||
POSTokenSecret string
|
||||
JWTSecret string
|
||||
UserContextKey string
|
||||
GeocoderAPIKey string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
// DBConfig is one Postgres connection.
|
||||
type DBConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Name string
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Enabled reports whether a host was configured at all. Only meaningful for
|
||||
// the optional catalogue connection; the main database is required.
|
||||
func (d DBConfig) Enabled() bool { return d.Host != "" }
|
||||
|
||||
// RedisConfig is the shared Redis used for POS terminal presence. Optional:
|
||||
// Host empty means presence is disabled and bills still commit.
|
||||
type RedisConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
func (r RedisConfig) Enabled() bool { return r.Host != "" }
|
||||
|
||||
// S3Config is the DigitalOcean Spaces bucket holding catalogue product images.
|
||||
type S3Config struct {
|
||||
Enabled bool // USE_S3=true
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Region string
|
||||
}
|
||||
|
||||
// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty
|
||||
// means the MQTT ingest and the console live stream stay quiet.
|
||||
type MQTTConfig struct {
|
||||
URL string
|
||||
User string
|
||||
Password string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
func (m MQTTConfig) Enabled() bool { return m.URL != "" }
|
||||
|
||||
// EmbeddingConfig is the text-embedding model behind the scan-to-product
|
||||
// search (services/scanService.go). It MUST be the model that filled the
|
||||
// catalogue's `embedding` column — vectors from two different models are not
|
||||
// comparable, and pgvector will happily rank garbage. Optional: with no
|
||||
// provider the search falls back to plain text matching.
|
||||
type EmbeddingConfig struct {
|
||||
Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini"
|
||||
Model string
|
||||
APIKey string
|
||||
BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1
|
||||
Dimensions int // 0 = the model's default
|
||||
}
|
||||
|
||||
func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" }
|
||||
|
||||
// IsProduction is true under APP_ENV=production.
|
||||
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
|
||||
|
||||
// Load picks and loads the environment files, reads every setting and
|
||||
// validates them. The returned error lists every problem at once.
|
||||
func Load() (*Config, error) {
|
||||
loadEnvFiles()
|
||||
|
||||
cfg := &Config{
|
||||
Env: getEnv("ENV", "production"),
|
||||
Port: getEnv("APP_PORT", "1009"),
|
||||
AppEnv: env("APP_ENV", EnvLocal),
|
||||
Port: env("APP_PORT", "1122"),
|
||||
|
||||
// ✅ STANDARDIZED DB ENV KEYS
|
||||
DBName: getEnv("DB_NAME", ""),
|
||||
DBUser: getEnv("DB_USER", ""),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DB: DBConfig{
|
||||
Host: env("DB_HOST", ""),
|
||||
Port: env("DB_PORT", "5433"),
|
||||
Name: env("DB_NAME", ""),
|
||||
User: env("DB_USER", ""),
|
||||
Password: env("DB_PASSWORD", ""),
|
||||
},
|
||||
Catalogue: DBConfig{
|
||||
Host: env("CATALOGUE_DB_HOST", ""),
|
||||
Port: env("CATALOGUE_DB_PORT", "5432"),
|
||||
Name: env("CATALOGUE_DB_NAME", ""),
|
||||
User: env("CATALOGUE_DB_USER", ""),
|
||||
Password: env("CATALOGUE_DB_PASSWORD", ""),
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Host: env("REDIS_HOST", ""),
|
||||
Port: env("REDIS_PORT", "6379"),
|
||||
User: env("REDIS_USER", "default"),
|
||||
Password: env("REDIS_PASSWORD", ""),
|
||||
},
|
||||
S3: S3Config{
|
||||
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
|
||||
Endpoint: env("S3_ENDPOINT", ""),
|
||||
Bucket: env("S3_BUCKET", ""),
|
||||
AccessKey: env("S3_ACCESS_KEY", ""),
|
||||
SecretKey: env("S3_SECRET_KEY", ""),
|
||||
Region: env("S3_REGION", ""),
|
||||
},
|
||||
MQTT: MQTTConfig{
|
||||
URL: env("MQTT_URL", ""),
|
||||
// MQTT_USERNAME is accepted because livehub.go read that name for
|
||||
// a while, so an existing deployment may still set it.
|
||||
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
|
||||
Password: env("MQTT_PASSWORD", ""),
|
||||
ClientID: env("MQTT_CLIENT_ID", ""),
|
||||
},
|
||||
|
||||
UserContextKey: getEnv("USER_CONTEXT_KEY", "nearle"),
|
||||
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
|
||||
Embedding: EmbeddingConfig{
|
||||
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
|
||||
Model: env("EMBEDDING_MODEL", ""),
|
||||
APIKey: env("EMBEDDING_API_KEY", ""),
|
||||
BaseURL: env("EMBEDDING_BASE_URL", ""),
|
||||
},
|
||||
|
||||
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
|
||||
JWTSecret: env("JWT_SECRET_KEY", ""),
|
||||
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
|
||||
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
|
||||
}
|
||||
|
||||
// ✅ Correct validation
|
||||
if cfg.DBPassword == "" {
|
||||
log.Println("Warning: DB_PASSWORD is not set")
|
||||
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
|
||||
cfg.Redis.DB = db
|
||||
} else {
|
||||
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
|
||||
}
|
||||
|
||||
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
|
||||
n, err := strconv.Atoi(dims)
|
||||
if err != nil || n < 0 {
|
||||
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
|
||||
}
|
||||
cfg.Embedding.Dimensions = n
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// MustLoad is Load for main(): every problem is printed and the process exits.
|
||||
func MustLoad() *Config {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
|
||||
}
|
||||
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
|
||||
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
// validate collects every problem rather than stopping at the first, so one
|
||||
// restart is enough to learn everything that is wrong.
|
||||
func (c *Config) validate() error {
|
||||
var problems []string
|
||||
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
|
||||
|
||||
if c.DB.Host == "" {
|
||||
missing("DB_HOST")
|
||||
}
|
||||
if c.DB.User == "" {
|
||||
missing("DB_USER")
|
||||
}
|
||||
if c.DB.Password == "" {
|
||||
missing("DB_PASSWORD")
|
||||
}
|
||||
if c.DB.Name == "" {
|
||||
missing("DB_NAME")
|
||||
}
|
||||
|
||||
// Optional subsystems are either fully configured or absent. Half a
|
||||
// configuration used to be skipped with a warning, which reads as "fine"
|
||||
// in a log and turns into "why are there no images" a week later.
|
||||
if c.Catalogue.Enabled() {
|
||||
if c.Catalogue.User == "" {
|
||||
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
if c.Catalogue.Password == "" {
|
||||
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
if c.Catalogue.Name == "" {
|
||||
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
}
|
||||
if c.S3.Enabled {
|
||||
if c.S3.Endpoint == "" {
|
||||
missing("S3_ENDPOINT (USE_S3=true)")
|
||||
}
|
||||
if c.S3.Bucket == "" {
|
||||
missing("S3_BUCKET (USE_S3=true)")
|
||||
}
|
||||
if c.S3.AccessKey == "" {
|
||||
missing("S3_ACCESS_KEY (USE_S3=true)")
|
||||
}
|
||||
if c.S3.SecretKey == "" {
|
||||
missing("S3_SECRET_KEY (USE_S3=true)")
|
||||
}
|
||||
if c.S3.Region == "" {
|
||||
missing("S3_REGION (USE_S3=true)")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Embedding.Enabled() {
|
||||
switch c.Embedding.Provider {
|
||||
case "openai", "gemini":
|
||||
default:
|
||||
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
|
||||
}
|
||||
if c.Embedding.Model == "" {
|
||||
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
|
||||
}
|
||||
if c.Embedding.APIKey == "" {
|
||||
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
|
||||
}
|
||||
}
|
||||
|
||||
if c.IsProduction() {
|
||||
// utils/postoken.go refuses to sign with a short secret at request
|
||||
// time; catching it here means the first till login is not the first
|
||||
// anyone hears of it.
|
||||
secret := c.POSTokenSecret
|
||||
if secret == "" {
|
||||
secret = c.JWTSecret
|
||||
}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
|
||||
} else if len(strings.TrimSpace(secret)) < 16 {
|
||||
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
|
||||
}
|
||||
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
|
||||
// Not fatal: a dump restored on another machine on the LAN is a valid
|
||||
// local setup. But `.env.local` pointing at the live host is the
|
||||
// mistake every comment in that file warns about, so say it out loud.
|
||||
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
|
||||
c.AppEnv, c.DB.Host)
|
||||
}
|
||||
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New(strings.Join(problems, "\n"))
|
||||
}
|
||||
|
||||
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
|
||||
//
|
||||
// APP_ENV is read from the real environment before any file, so a file cannot
|
||||
// change which environment it is loaded for.
|
||||
func loadEnvFiles() {
|
||||
appEnv := env("APP_ENV", EnvLocal)
|
||||
|
||||
for _, name := range []string{".env." + appEnv, ".env"} {
|
||||
if _, err := os.Stat(name); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := godotenv.Load(name); err != nil {
|
||||
log.Printf("config: could not read %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("config: loaded %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isLocalHost(host string) bool {
|
||||
switch strings.ToLower(host) {
|
||||
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(host, "127.")
|
||||
}
|
||||
|
||||
251
config/config_test.go
Normal file
251
config/config_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every key Load reads, so a test starts from nothing rather than from
|
||||
// whatever the developer's shell happens to export.
|
||||
var allKeys = []string{
|
||||
"APP_ENV", "APP_PORT",
|
||||
"DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD",
|
||||
"CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_NAME", "CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD",
|
||||
"REDIS_HOST", "REDIS_PORT", "REDIS_USER", "REDIS_PASSWORD", "REDIS_DB",
|
||||
"USE_S3", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",
|
||||
"MQTT_URL", "MQTT_USER", "MQTT_USERNAME", "MQTT_PASSWORD", "MQTT_CLIENT_ID",
|
||||
"POS_TOKEN_SECRET", "JWT_SECRET_KEY", "USER_CONTEXT_KEY", "GEOCODER_API_KEY",
|
||||
"EMBEDDING_PROVIDER", "EMBEDDING_MODEL", "EMBEDDING_API_KEY", "EMBEDDING_BASE_URL", "EMBEDDING_DIMENSIONS",
|
||||
}
|
||||
|
||||
// cleanEnv clears every setting and moves into an empty directory so no
|
||||
// `.env` file is picked up by accident. t.Setenv registers the restore; the
|
||||
// Unsetenv after it matters because godotenv treats a variable that is present
|
||||
// but empty as set and will not fill it from a file.
|
||||
func cleanEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
unsetAll(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func unsetAll(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range allKeys {
|
||||
t.Setenv(k, "")
|
||||
os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
func setMainDB(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DB_HOST", "localhost")
|
||||
t.Setenv("DB_USER", "nearle")
|
||||
t.Setenv("DB_PASSWORD", "localdev")
|
||||
t.Setenv("DB_NAME", "nearledb")
|
||||
}
|
||||
|
||||
func write(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportsEveryMissingRequiredKeyAtOnce(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error with no database configured")
|
||||
}
|
||||
for _, key := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error should name %s, got:\n%s", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AppEnv != EnvLocal {
|
||||
t.Errorf("AppEnv = %q, want local", cfg.AppEnv)
|
||||
}
|
||||
if cfg.IsProduction() {
|
||||
t.Error("IsProduction should be false by default")
|
||||
}
|
||||
if cfg.Port != "1122" {
|
||||
t.Errorf("Port = %q, want 1122", cfg.Port)
|
||||
}
|
||||
if cfg.DB.Port != "5433" {
|
||||
t.Errorf("DB.Port = %q, want 5433 (the port production and the local compose share)", cfg.DB.Port)
|
||||
}
|
||||
if cfg.Catalogue.Enabled() || cfg.Redis.Enabled() || cfg.S3.Enabled || cfg.MQTT.Enabled() {
|
||||
t.Error("optional subsystems should be off when unset")
|
||||
}
|
||||
if cfg.Redis.User != "default" || cfg.Redis.Port != "6379" || cfg.Redis.DB != 0 {
|
||||
t.Errorf("redis defaults wrong: %+v", cfg.Redis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppEnvSelectsTheEnvFile(t *testing.T) {
|
||||
dir := cleanEnv(t)
|
||||
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=local\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1122\n")
|
||||
write(t, dir, ".env.production", "DB_HOST=db.internal\nDB_USER=prod\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1009\nPOS_TOKEN_SECRET=0123456789abcdef\n")
|
||||
// The shared base: only fills in what the environment file left unset.
|
||||
write(t, dir, ".env", "DB_USER=base\nUSER_CONTEXT_KEY=from-base\n")
|
||||
|
||||
t.Run("default is local", func(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DB.User != "local" || cfg.Port != "1122" {
|
||||
t.Errorf("expected .env.local values, got user=%s port=%s", cfg.DB.User, cfg.Port)
|
||||
}
|
||||
if cfg.UserContextKey != "from-base" {
|
||||
t.Errorf(".env should fill in what .env.local left unset, got %q", cfg.UserContextKey)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("APP_ENV=production", func(t *testing.T) {
|
||||
// Clears what godotenv loaded in the sibling above; restored on return.
|
||||
unsetAll(t)
|
||||
t.Setenv("APP_ENV", EnvProduction)
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.IsProduction() || cfg.DB.User != "prod" || cfg.Port != "1009" {
|
||||
t.Errorf("expected .env.production values, got env=%s user=%s port=%s", cfg.AppEnv, cfg.DB.User, cfg.Port)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRealEnvironmentBeatsTheFile(t *testing.T) {
|
||||
dir := cleanEnv(t)
|
||||
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=file\nDB_PASSWORD=x\nDB_NAME=nearledb\n")
|
||||
t.Setenv("DB_USER", "shell")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DB.User != "shell" {
|
||||
t.Errorf("a variable already in the environment must not be overwritten by the file, got %q", cfg.DB.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRequiresASigningSecret(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("APP_ENV", EnvProduction)
|
||||
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "POS_TOKEN_SECRET") {
|
||||
t.Fatalf("production without a secret should fail naming POS_TOKEN_SECRET, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "short")
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "16 characters") {
|
||||
t.Fatalf("a short secret should be refused, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "")
|
||||
t.Setenv("JWT_SECRET_KEY", "a-long-enough-fallback-secret")
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("JWT_SECRET_KEY should be accepted as the fallback, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHalfConfiguredSubsystemsAreRefused(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
|
||||
t.Setenv("CATALOGUE_DB_HOST", "localhost")
|
||||
t.Setenv("USE_S3", "true")
|
||||
t.Setenv("S3_BUCKET", "nearle")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
for _, want := range []string{"CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME", "S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should name %s, got:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "S3_BUCKET") {
|
||||
t.Error("S3_BUCKET was set and must not be reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTUsernameFallback(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("MQTT_URL", "tcp://broker:1883")
|
||||
t.Setenv("MQTT_USERNAME", "legacy")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MQTT.User != "legacy" {
|
||||
t.Errorf("MQTT_USERNAME should still be honoured, got %q", cfg.MQTT.User)
|
||||
}
|
||||
|
||||
t.Setenv("MQTT_USER", "current")
|
||||
cfg, err = Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MQTT.User != "current" {
|
||||
t.Errorf("MQTT_USER should win over MQTT_USERNAME, got %q", cfg.MQTT.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisDBMustBeNumeric(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("REDIS_DB", "zero")
|
||||
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "REDIS_DB") {
|
||||
t.Fatalf("expected REDIS_DB error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingProviderNeedsModelAndKey(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("EMBEDDING_PROVIDER", "openai")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "EMBEDDING_MODEL") || !strings.Contains(err.Error(), "EMBEDDING_API_KEY") {
|
||||
t.Fatalf("a provider without model and key should be refused naming both, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMBEDDING_PROVIDER", "cohere")
|
||||
t.Setenv("EMBEDDING_MODEL", "x")
|
||||
t.Setenv("EMBEDDING_API_KEY", "y")
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "EMBEDDING_PROVIDER") {
|
||||
t.Fatalf("an unknown provider should be refused, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMBEDDING_PROVIDER", "Gemini")
|
||||
t.Setenv("EMBEDDING_DIMENSIONS", "768")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Embedding.Provider != "gemini" || cfg.Embedding.Dimensions != 768 || !cfg.Embedding.Enabled() {
|
||||
t.Fatalf("unexpected embedding config: %+v", cfg.Embedding)
|
||||
}
|
||||
}
|
||||
26
controllers/appRequest.go
Normal file
26
controllers/appRequest.go
Normal 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/")
|
||||
}
|
||||
50
controllers/appRequest_test.go
Normal file
50
controllers/appRequest_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
159
controllers/catalogueUploadController.go
Normal file
159
controllers/catalogueUploadController.go
Normal 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})
|
||||
}
|
||||
165
controllers/liveController.go
Normal file
165
controllers/liveController.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
984
controllers/posController.go
Normal file
984
controllers/posController.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
96
controllers/productVariantLinkController.go
Normal file
96
controllers/productVariantLinkController.go
Normal 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",
|
||||
})
|
||||
}
|
||||
41
controllers/riderOwnership_test.go
Normal file
41
controllers/riderOwnership_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
101
controllers/scanController.go
Normal file
101
controllers/scanController.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"net/http"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// ScanController is the scan-to-order surface for the customer app:
|
||||
//
|
||||
// POST /v1/mob/scan/lookup a label → the product, and which of my stores has it
|
||||
// POST /v1/mob/scan/confirm I picked a store and a size → still there? else where?
|
||||
// GET /v1/mob/scan/stores my stores, nearest first
|
||||
//
|
||||
// Business outcomes ("out of stock", "not registered with that store") are
|
||||
// 200s with a reason in the body: the app renders them, it does not retry
|
||||
// them. HTTP errors are reserved for a request that cannot be served at all.
|
||||
type ScanController struct {
|
||||
scanService services.ScanService
|
||||
}
|
||||
|
||||
func NewScanController(scanService services.ScanService) *ScanController {
|
||||
return &ScanController{scanService: scanService}
|
||||
}
|
||||
|
||||
func (ctl *ScanController) Lookup(c *fiber.Ctx) error {
|
||||
var req models.ScanLookupRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return scanBadRequest(c, "Invalid request body")
|
||||
}
|
||||
resp, err := ctl.scanService.Lookup(c.Context(), req)
|
||||
if err != nil {
|
||||
return scanError(c, err, "Could not look up that product")
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"message": resp.Message,
|
||||
"details": resp,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctl *ScanController) Confirm(c *fiber.Ctx) error {
|
||||
var req models.ScanConfirmRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return scanBadRequest(c, "Invalid request body")
|
||||
}
|
||||
resp, err := ctl.scanService.Confirm(c.Context(), req)
|
||||
if err != nil {
|
||||
return scanError(c, err, "Could not check that store")
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"message": resp.Message,
|
||||
"details": resp,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctl *ScanController) Stores(c *fiber.Ctx) error {
|
||||
customerid, _ := c.QueryInt("customerid"), 0
|
||||
stores, err := ctl.scanService.Stores(c.Context(), customerid,
|
||||
models.FlexibleString(c.Query("latitude")), models.FlexibleString(c.Query("longitude")))
|
||||
if err != nil {
|
||||
return scanError(c, err, "Could not list your stores")
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"message": "Success",
|
||||
"details": stores,
|
||||
})
|
||||
}
|
||||
|
||||
func scanBadRequest(c *fiber.Ctx, msg string) error {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"status": false,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
func scanError(c *fiber.Ctx, err error, fallback string) error {
|
||||
code, msg := http.StatusInternalServerError, fallback
|
||||
switch {
|
||||
case errors.Is(err, services.ErrScanBadRequest):
|
||||
code, msg = http.StatusBadRequest, err.Error()
|
||||
case errors.Is(err, services.ErrScanCustomerNotFound):
|
||||
code, msg = http.StatusNotFound, "Customer not found"
|
||||
case errors.Is(err, services.ErrScanCatalogueDown):
|
||||
code, msg = http.StatusServiceUnavailable, "Product search is temporarily unavailable"
|
||||
}
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"code": code,
|
||||
"status": false,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
68
controllers/stockRequestBatch.go
Normal file
68
controllers/stockRequestBatch.go
Normal 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)
|
||||
}
|
||||
102
controllers/stockRequestBatch_test.go
Normal file
102
controllers/stockRequestBatch_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func CreateStockRequestsTable() {
|
||||
dsn := "host=66.116.207.225 user=admin password=Package@123# dbname=nearledb port=5433 sslmode=disable TimeZone=Asia/Kolkata"
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect database: %v", err)
|
||||
}
|
||||
|
||||
query := `CREATE TABLE IF NOT EXISTS stockrequests (
|
||||
requestid SERIAL PRIMARY KEY,
|
||||
tenantid INT NOT NULL,
|
||||
locationid INT NOT NULL,
|
||||
productid INT NOT NULL,
|
||||
qty INT NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'Pending',
|
||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
if err := db.Exec(query).Error; err != nil {
|
||||
log.Fatalf("failed to create table: %v", err)
|
||||
}
|
||||
fmt.Println("Table stockrequests created successfully")
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/config"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -23,14 +23,18 @@ var (
|
||||
// DATABASE CONNECTION
|
||||
// --------------------
|
||||
|
||||
func Connect() {
|
||||
// Connect opens the main database and then the optional catalogue database
|
||||
// and image store. Everything it needs has already been validated by
|
||||
// config.Load, so a missing variable can no longer surface here as a
|
||||
// log.Fatal halfway through boot.
|
||||
func Connect(cfg *config.Config) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
|
||||
mustEnv("DB_HOST"),
|
||||
mustEnv("DB_USER"),
|
||||
mustEnv("DB_PASSWORD"),
|
||||
mustEnv("DB_NAME"),
|
||||
getEnv("DB_PORT", "5433"),
|
||||
cfg.DB.Host,
|
||||
cfg.DB.User,
|
||||
cfg.DB.Password,
|
||||
cfg.DB.Name,
|
||||
cfg.DB.Port,
|
||||
)
|
||||
|
||||
var err error
|
||||
@@ -42,17 +46,16 @@ func Connect() {
|
||||
setupDB(DB)
|
||||
fmt.Println("✅ Database connected")
|
||||
|
||||
connectCatalogueDB()
|
||||
connectImageStore()
|
||||
connectCatalogueDB(cfg.Catalogue)
|
||||
connectImageStore(cfg.S3)
|
||||
}
|
||||
|
||||
// connectCatalogueDB opens the read-only connection to the catalogue
|
||||
// (pgvector) database. If its env vars are not set, catalogue endpoints
|
||||
// are simply unavailable — this must never block startup of the main app.
|
||||
func connectCatalogueDB() {
|
||||
host := getEnv("CATALOGUE_DB_HOST", "")
|
||||
if host == "" {
|
||||
fmt.Println("⚠️ Catalogue DB env vars not set, skipping catalogue DB connection")
|
||||
func connectCatalogueDB(c config.DBConfig) {
|
||||
if !c.Enabled() {
|
||||
fmt.Println("⚠️ CATALOGUE_DB_HOST not set, skipping catalogue DB connection")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,9 +65,9 @@ func connectCatalogueDB() {
|
||||
// quoting/comment syntax.
|
||||
dsnURL := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(mustEnv("CATALOGUE_DB_USER"), mustEnv("CATALOGUE_DB_PASSWORD")),
|
||||
Host: fmt.Sprintf("%s:%s", host, getEnv("CATALOGUE_DB_PORT", "5432")),
|
||||
Path: "/" + mustEnv("CATALOGUE_DB_NAME"),
|
||||
User: url.UserPassword(c.User, c.Password),
|
||||
Host: fmt.Sprintf("%s:%s", c.Host, c.Port),
|
||||
Path: "/" + c.Name,
|
||||
}
|
||||
q := dsnURL.Query()
|
||||
q.Set("sslmode", "disable")
|
||||
@@ -108,22 +111,3 @@ func CloseDB() {
|
||||
}
|
||||
fmt.Println("Connection closed Successfully")
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// ENV HELPERS
|
||||
// --------------------
|
||||
|
||||
func mustEnv(key string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
log.Fatalf("Missing required env variable: %s", key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/config"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -33,23 +34,20 @@ var ImageStore *imageStore
|
||||
|
||||
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
|
||||
// used to resolve catalogue product images. Like the catalogue DB, this must
|
||||
// never block or fail app startup — if S3 env vars are absent, image URLs
|
||||
// are simply omitted from catalogue responses.
|
||||
func connectImageStore() {
|
||||
if getEnv("USE_S3", "") != "true" {
|
||||
fmt.Println("⚠️ S3 not enabled, skipping image store")
|
||||
// never block or fail app startup — with USE_S3 unset, image URLs are simply
|
||||
// omitted from catalogue responses. (USE_S3=true with a key missing is caught
|
||||
// by config.Load before we get here.)
|
||||
func connectImageStore(c config.S3Config) {
|
||||
if !c.Enabled {
|
||||
fmt.Println("⚠️ USE_S3 not set, skipping image store")
|
||||
return
|
||||
}
|
||||
|
||||
endpoint := getEnv("S3_ENDPOINT", "")
|
||||
bucket := getEnv("S3_BUCKET", "")
|
||||
accessKey := getEnv("S3_ACCESS_KEY", "")
|
||||
secretKey := getEnv("S3_SECRET_KEY", "")
|
||||
region := getEnv("S3_REGION", "")
|
||||
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
|
||||
fmt.Println("⚠️ S3 env vars incomplete, skipping image store")
|
||||
return
|
||||
}
|
||||
endpoint := c.Endpoint
|
||||
bucket := c.Bucket
|
||||
accessKey := c.AccessKey
|
||||
secretKey := c.SecretKey
|
||||
region := c.Region
|
||||
|
||||
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com).
|
||||
// The SDK's virtual-hosted-style client re-prepends the bucket to whatever
|
||||
|
||||
78
db/redis.go
Normal file
78
db/redis.go
Normal 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
114
docker-compose.local.yml
Normal 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
103
docs/ENVIRONMENT.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Environment & configuration
|
||||
|
||||
Everything the API reads from its environment goes through `config/config.go`.
|
||||
It loads the right `.env` file, reads every setting into one `Config`, and
|
||||
refuses to start — listing *everything* that is wrong in one message — before
|
||||
a single connection is attempted.
|
||||
|
||||
## Which file loads
|
||||
|
||||
`APP_ENV` names the environment. It defaults to `local`.
|
||||
|
||||
| Command | Files loaded, in order |
|
||||
|----------------------------------|---------------------------------|
|
||||
| `go run .` | `.env.local`, then `.env` |
|
||||
| `APP_ENV=production go run .` | `.env.production`, then `.env` |
|
||||
| `APP_ENV=staging go run .` | `.env.staging`, then `.env` |
|
||||
|
||||
Precedence, highest first:
|
||||
|
||||
```
|
||||
real environment > .env.<APP_ENV> > .env
|
||||
```
|
||||
|
||||
A variable that is already set is never overwritten by a file, and no file has
|
||||
to exist. `APP_ENV` itself is read from the real environment before any file
|
||||
is opened — a file cannot decide which file gets loaded.
|
||||
|
||||
| File | Role |
|
||||
|-------------------|------------------------------------------------------------|
|
||||
| `.env.example` | The complete list of settings, with comments. Start here. |
|
||||
| `.env.local` | The docker-compose stack. Every host is `localhost`. |
|
||||
| `.env.production` | The live hosts. Loaded only when asked for. |
|
||||
| `.env` | Shared base: fallbacks for whatever the file above left out. Keep it local. |
|
||||
|
||||
## Running locally
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.local.yml up -d # postgres :5433, pgvector :5434, redis :6379
|
||||
go run . # APP_ENV unset → .env.local
|
||||
```
|
||||
|
||||
An empty database is not enough — `main.go` runs migrations that assume the
|
||||
live schema. See `init/README.md` for loading a schema dump first.
|
||||
|
||||
Startup prints what it loaded and where it is pointed:
|
||||
|
||||
```
|
||||
config: loaded .env.local
|
||||
config: loaded .env
|
||||
config: APP_ENV=local, listening on :1122, database nearle@localhost:5433/nearledb
|
||||
```
|
||||
|
||||
If `DB_HOST` is not a local address under `APP_ENV=local`, it says so:
|
||||
|
||||
```
|
||||
⚠️ APP_ENV=local but DB_HOST=66.116.x.x is not a local address — every write goes to that database for real
|
||||
```
|
||||
|
||||
That is a warning, not a stop. There is no "local mode" that protects
|
||||
production: `go run .` against the live host creates real tenants and real
|
||||
logins, and runs schema migrations on boot.
|
||||
|
||||
## Running in production
|
||||
|
||||
The container gets **no `.env` file at all** — `.dockerignore` keeps every
|
||||
`.env*` out of the image — and the `Dockerfile` sets `APP_ENV=production`.
|
||||
Every value comes from the platform's environment settings (Dokploy today;
|
||||
ConfigMaps/Secrets under Kubernetes).
|
||||
|
||||
Under `APP_ENV=production` startup additionally insists on:
|
||||
|
||||
- `POS_TOKEN_SECRET` (or `JWT_SECRET_KEY` as a fallback), at least 16 characters.
|
||||
|
||||
A variable added to `.env.production` and not to the platform is a variable
|
||||
that is unset in production. Missing required ones stop the boot with the
|
||||
full list; missing optional ones (`MQTT_URL`, `REDIS_HOST`, `USE_S3`,
|
||||
`CATALOGUE_DB_HOST`) silently disable that subsystem — check the startup log
|
||||
lines when something is "not working".
|
||||
|
||||
## What is required
|
||||
|
||||
| Always | Only when enabled |
|
||||
|----------------------------------------------|---------------------------------------------------------|
|
||||
| `DB_HOST` `DB_USER` `DB_PASSWORD` `DB_NAME` | `CATALOGUE_DB_HOST` set → `CATALOGUE_DB_USER/PASSWORD/NAME` |
|
||||
| (production) `POS_TOKEN_SECRET` | `USE_S3=true` → `S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY/REGION` |
|
||||
|
||||
A half-configured subsystem is an error, not a warning: a warning reads as
|
||||
"fine" in a log and turns into "why are there no images" a week later.
|
||||
|
||||
## The committed credentials
|
||||
|
||||
The three `.env` files, including `.env.production`, are currently tracked in
|
||||
git (commit `be47435`), and an earlier `.env` was committed before
|
||||
2026-08-03. Every credential in them has to be treated as public:
|
||||
|
||||
1. Rotate the database, catalogue, Spaces, MQTT and Redis credentials and the
|
||||
POS signing secret, and update them in the platform.
|
||||
2. Take the files back out of the index and restore the ignore rules:
|
||||
```sh
|
||||
git rm --cached .env .env.local .env.production
|
||||
printf '.env\n.env.*\n!.env.example\n' >> .gitignore
|
||||
```
|
||||
The files stay on disk; they just stop being committed.
|
||||
778
docs/PORTFOLIO.md
Normal file
778
docs/PORTFOLIO.md
Normal file
@@ -0,0 +1,778 @@
|
||||
# Nearle "Fiesta" backend — what was built
|
||||
|
||||
A Go monolith that serves a multi-tenant retail platform: neighbourhood shops
|
||||
(tenants) with one or more outlets, selling through a consumer app, a rider
|
||||
delivery fleet, and — the newest and largest piece of work — **physical
|
||||
point-of-sale terminals sitting on shop counters**.
|
||||
|
||||
There are really five distinct systems in here. They are ordered below by how
|
||||
much original engineering they represent.
|
||||
|
||||
---
|
||||
|
||||
## 1. The POS terminal integration (the centrepiece)
|
||||
|
||||
### What it is
|
||||
|
||||
Retail tills in shops run a Flutter app with its own local SQLite database. They
|
||||
keep selling with **no network at all** — a village shop's connection drops for
|
||||
hours. When connectivity returns, each till uploads the bills it rang while
|
||||
offline, pulls down an updated product catalogue, and reports its own health.
|
||||
|
||||
This backend is the other end of that conversation. It has to solve the classic
|
||||
offline-first sync problem under a hard constraint: *a sale that has already been
|
||||
paid for in cash must never be lost, and must never be counted twice.*
|
||||
|
||||
The problem it solves for the business: shops that were doing counter sales
|
||||
entirely off-platform now have those sales in the same database as their app
|
||||
orders, deducting from the same stock, appearing in the same revenue reports.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Till (Flutter + SQLite) — not in this repo │
|
||||
│ sale committed locally first, sync_status = 0 │
|
||||
└───────────────┬──────────────────────────┬──────────────┘
|
||||
│ │
|
||||
(transport A) MQTT (transport B) HTTPS
|
||||
nearle/pos/{loc}/{term}/order POST /live/api/v1/pos/orders
|
||||
nearle/pos/{loc}/{term}/customer POST .../customers
|
||||
nearle/pos/{loc}/{term}/health POST .../health
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌────────────────────────┐
|
||||
│ messaging/posmqtt.go │ │ controllers/ │
|
||||
│ • leader election │ │ posController.go │
|
||||
│ • bounded worker │ │ • PosAuth middleware │
|
||||
│ pools (ingest, │ │ verifies token + │
|
||||
│ health) │ │ outlet ownership │
|
||||
└──────────┬───────────┘ └───────────┬────────────┘
|
||||
└────────────┬──────────────┘
|
||||
▼
|
||||
services.PosService ← one code path for both
|
||||
│
|
||||
┌─────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
posRepository posSalesRepository posPresence
|
||||
(ingest, catalogue) (read-back) (Redis)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────┐ ┌──────────────┐
|
||||
│ POSTGRES (nearledb) │ │ REDIS │
|
||||
│ pos_orders │ │ pos:terminal:│
|
||||
│ pos_order_items │ │ {id} HASH │
|
||||
│ productstocks ← shared │ │ TTL 90s │
|
||||
│ customers, app_users │ │ pos:location:│
|
||||
└──────────────────────────────┘ │ {id} SET │
|
||||
└──────────────┘
|
||||
│
|
||||
▼
|
||||
ack → nearle/pos/{loc}/{term}/ack (or HTTP 200 body)
|
||||
│
|
||||
▼
|
||||
Till marks bill synced, keeps its copy 7 more days
|
||||
```
|
||||
|
||||
The storage split is deliberate and explained in the code:
|
||||
|
||||
- **Postgres `pos_orders` / `pos_order_items`** — the permanent record of counter
|
||||
bills, kept *separate* from the app's `orders` table. A bill carries a cashier,
|
||||
a terminal id, a rounding adjustment, promo campaigns, loyalty movement and a
|
||||
payment split across several tenders; `orders` has nowhere to put any of that.
|
||||
The stated cost of the split is that every revenue query has to union both —
|
||||
which was done, in `orderRepository.posRevenue` and `posSalesTotals`.
|
||||
- **Postgres `productstocks`** — stock is deliberately *not* split. A counter sale
|
||||
writes the same "out" ledger rows an app order does, through the same helper, so
|
||||
the catalogue pushed down to a till reflects the till's own trading.
|
||||
- **Redis** — terminal presence only, with a 90-second TTL. Shared with a separate
|
||||
Express backend (the rider app reads the board), namespaced `pos:*` so it cannot
|
||||
collide with that service's `delivery:*` / `city:*` keys.
|
||||
|
||||
### How the problem was solved — the approach
|
||||
|
||||
Six interlocking decisions.
|
||||
|
||||
**(a) The acknowledgement protocol is the whole design.** Delivery is
|
||||
at-least-once. The rule, stated in `models.PosAck`: *a till marks a record synced
|
||||
if and only if its id appears in `accepted`.* Silence is not acceptance — an empty
|
||||
ack, a dropped connection, or a 200 with no body all leave the record pending, and
|
||||
it gets re-sent. `rejected` is a separate, deliberate verdict meaning "stop
|
||||
retrying this one, fetch a human" — used for a malformed bill, never for "the
|
||||
database is having a bad minute." That distinction is enforced right up in the
|
||||
HTTP layer: `posIngestError` decides 4xx (permanent — the till halts and shows a
|
||||
person) versus 5xx (unknown — the till keeps everything and backs off).
|
||||
|
||||
**(b) Idempotency: a duplicate is a success, not a failure.** Each bill carries a
|
||||
UUID minted at the till, stored as `terminalorderid` with a unique index. On
|
||||
arrival, `importPosOrder` opens a transaction, takes
|
||||
`pg_advisory_xact_lock(hashtext('possale:' || id))`, then checks whether the bill
|
||||
is already held. If it is, the transaction rolls back and the bill is
|
||||
**accepted** — stock untouched. Calling a re-delivered bill a failure would strand
|
||||
a day's takings on the till forever. The advisory lock turns what would otherwise
|
||||
be a unique-constraint violation into an orderly "already held," and closes the
|
||||
race where two redeliveries arrive simultaneously.
|
||||
|
||||
**(c) Ordering inside the transaction — locks, then availability, then writes.**
|
||||
`stockLedger.go` holds the shared machinery. `lockStockRows` takes
|
||||
`SELECT … FOR UPDATE` on every `(tenant, location, product)` the sale touches,
|
||||
**sorted by (productid, locationid)** so two concurrent sales sharing products
|
||||
always contend in the same sequence and block rather than deadlock. Only then does
|
||||
`assertStockAvailable` read balances, and only then are rows written. This is the
|
||||
same path an app order and a spreadsheet import take — the code was extracted
|
||||
specifically so there aren't three implementations of the anti-overselling rule
|
||||
drifting apart.
|
||||
|
||||
**(d) Line-item reconciliation.** A subtle one. The till has already apportioned
|
||||
bill-level discounts across its lines to get tax right, but it sends each line at
|
||||
its *pre-apportionment* value. Left alone, summing line items gives the subtotal
|
||||
while the header carries the total, and two reports disagree. So the code computes
|
||||
`amountFactor = (total − roundoff) / Σ line_total` and
|
||||
`taxFactor = header_tax / Σ line_tax`, and scales each line onto what was actually
|
||||
collected. The till stays authoritative for the bill as a whole; this only decides
|
||||
attribution *within* it.
|
||||
|
||||
**(e) The catalogue downlink is a delta protocol with a safety invariant.**
|
||||
`GET /pos/catalogue` answers either a full snapshot or a change set. The terminal
|
||||
treats `is_delta: false` as a snapshot and **withdraws every product the response
|
||||
does not mention** — so mislabelling a filtered result empties the shop's shelf.
|
||||
The code therefore derives both the filter and the flag from one value:
|
||||
`cutoff := posRevisionCutoff(...)`; zero cutoff ⇒ no filter ⇒ `is_delta: false`,
|
||||
non-zero ⇒ filtered ⇒ `is_delta: true`. There is no path that filters without
|
||||
setting the flag. Supporting details:
|
||||
|
||||
- The revision is an opaque token `loc{id}-{YYYYMMDDTHHMMSSZ}` the till stores and
|
||||
hands back. If it is unreadable, malformed, or belongs to a *different outlet*,
|
||||
the cutoff is zero and you get a full snapshot — failing toward "send
|
||||
everything" is the only safe direction.
|
||||
- **The revision only advances on the final page.** Mid-pagination it echoes back
|
||||
whatever the till already had. A till that dies half way through a paginated
|
||||
pull must not end up holding a revision claiming it saw pages it never received
|
||||
— those products would be excluded from every future delta, silently, forever.
|
||||
- The revision stamp is taken **one second in the past**, so a product written
|
||||
during the same second the query ran cannot land on the wrong side of the next
|
||||
cutoff. Costs one redundant row; cannot lose one.
|
||||
- "Changed" is one predicate covering three things: the product row, its
|
||||
per-location row (price/availability), or its stock ledger. Stock is in there
|
||||
because a shop's count drifts on every sale rung at another counter.
|
||||
- Acknowledged limitation, in a comment: a product *deleted* from
|
||||
`productlocations` leaves no tombstone, so a delta cannot know to withdraw it.
|
||||
Only a full pull collects those — hence "pull without a revision every morning."
|
||||
|
||||
**(f) Presence is a TTL, not a table.** A heartbeat is a fact with an expiry date.
|
||||
In Postgres it would be ~288k writes/day across a hundred tills plus a reaper job
|
||||
to mark them dead. A Redis hash with a 90s TTL (three missed 30s heartbeats — "two
|
||||
would make a GPRS hiccup look like a dead till; five would take 2½ minutes to
|
||||
notice a real one") ages out for free. The location→terminals SET has *no* TTL:
|
||||
it is an index of what exists, not a claim anything is alive. A till whose hash
|
||||
expired comes back as a stub marked `offline` with a reason, rather than being
|
||||
omitted — because the missing till is exactly what someone is looking for. The
|
||||
write also `HDEL`s fields the till stopped reporting, so a hash never lingers at a
|
||||
stale battery reading.
|
||||
|
||||
#### Tricky cases the code explicitly handles
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Bill with no id | Rejected — nothing to dedupe on, and it would double on every retry |
|
||||
| Fractional quantity (1.5 kg onions) vs integer stock column | `roundStockQty` rounds **up** — conservative, never records more stock than is physically there. Flagged in-code as a workaround, not a fix |
|
||||
| Timestamps without a timezone offset (older terminal builds) | Accepted, with a comment stating the instant will be wrong by the offset and is unrecoverable — but the *business date* is right, which is what daily figures use |
|
||||
| `jsonb` columns left at Go's zero value | Forced through `posJSON`, which emits `"null"` — an empty string reaches Postgres as invalid JSON and takes the whole bill down |
|
||||
| Terminal id present on the batch but not the bill | `posTerminalFor` falls back, trimming first so `" "` is not mistaken for a real code |
|
||||
| Broker Last Will (`{"status":"offline"}`) | Arrives on the same handler and is recorded verbatim — exactly right for a till that lost power |
|
||||
| Customer registrations replayed | Insert-if-absent, **never update** — a profile corrected at head office must not be reverted by a till replaying months-old data |
|
||||
| Loyalty points on the uplink | Deliberately absent from the wire format. Points are derived from the bill stream (idempotent, sees every counter); accepting a till's local balance would make "last till to sync wins" |
|
||||
|
||||
#### Trade-offs, and what they buy
|
||||
|
||||
- Bills separate from `orders` → full fidelity, at the cost of every report needing
|
||||
a union.
|
||||
- Payment mode denormalised to "largest tender" for grouping, with the full split
|
||||
kept verbatim in `paymentsjson` → fast reports, no lost reconciliation data.
|
||||
- `businessdate` denormalised as a `YYYY-MM-DD` string → a day's takings is one
|
||||
indexed equality match instead of a range scan with timezone arithmetic.
|
||||
- Barcode generation: `products.productsku` cannot be trusted for the till's
|
||||
*unique* barcode index (in live data, thousands of products share a single SKU
|
||||
value), so a SKU is only used if it looks like a real EAN/UPC — 8–14 digits —
|
||||
and otherwise the product id stands in. Scanning physical barcodes will not work
|
||||
until real ones are populated; the comment says so plainly and notes it starts
|
||||
working with no code change.
|
||||
|
||||
---
|
||||
|
||||
## 2. Transport, concurrency and delivery guarantees
|
||||
|
||||
### What it is
|
||||
|
||||
The same ingest, over two transports (MQTT and HTTP), running under multiple
|
||||
replicas, with backpressure that reaches all the way back to the till.
|
||||
|
||||
### How it's built
|
||||
|
||||
`messaging/posmqtt.go` (broker client, topic routing, ack publishing) plus
|
||||
`messaging/posworkers.go` (a bounded worker pool). Both hand off to the same
|
||||
`PosService` the HTTP controller uses — the facade exposes `f.PosService()`
|
||||
specifically so a bill cannot behave differently depending on how it arrived.
|
||||
|
||||
### The approach
|
||||
|
||||
**Leader election with no coordination service.** MQTT has no queue groups — every
|
||||
subscriber gets every message, so three replicas would each commit the same bill
|
||||
and publish three acks. The ingest is idempotent so nothing double-counts, but it
|
||||
is 3× the database work. The fix: a StatefulSet gives pods stable ordinal names,
|
||||
so **ordinal 0 is the elected consumer** — no lease, no lock, no extra dependency.
|
||||
Overridable via `POS_MQTT_CONSUMER=always|never`; a non-ordinal hostname (bare
|
||||
container, local dev) is elected, because "a single instance that refused to
|
||||
consume would be a far more confusing failure."
|
||||
|
||||
**Backpressure by construction.** paho delivers on one goroutine, so naively every
|
||||
bill commits serially — a bill is a full transaction (advisory lock, dedup, row
|
||||
locks, availability, four inserts, commit) at ~10–30 ms, giving 30–100 bills/sec,
|
||||
and a shop-wide backlog after an outage takes minutes. paho *can* call handlers
|
||||
concurrently, but it spawns without limit — a storm would open a transaction per
|
||||
message, exhaust the connection pool, and stall everything at once.
|
||||
|
||||
So: a fixed pool behind a bounded queue, and `submit` **blocks** when the queue is
|
||||
full. That is the point — paho stops acking, the broker's in-flight window fills,
|
||||
it stops sending, and the till holds its bills and retries. `SetOrderMatters(true)`
|
||||
is kept on precisely because single-goroutine delivery is what makes that chain
|
||||
work; concurrent delivery would let paho keep reading no matter how far behind the
|
||||
workers were.
|
||||
|
||||
**Separate pools for bills and heartbeats.** A heartbeat is one Redis write; a bill
|
||||
is a transaction. Sharing a queue would delay presence behind a bill backlog, and
|
||||
every till would appear to go dark at the exact moment the system was busiest.
|
||||
|
||||
**The pool's shutdown race is handled explicitly.** A plain `select` over a
|
||||
done-channel and the job channel is not enough — once both are ready Go picks at
|
||||
random, and picking the send panics on a closed channel. So there is an `RWMutex`
|
||||
held for *reading across the whole of `submit`*, and `stop` takes the write lock
|
||||
before closing. The comment works through why this cannot deadlock: workers only
|
||||
exit once the channel is closed, which happens under the write lock the in-flight
|
||||
send is holding off. Post-close submissions run **inline** rather than being
|
||||
dropped — discarding a bill that already reached you is worse than doing it slowly.
|
||||
|
||||
**Identity comes from the topic, never the body.** `topicIdentity` parses store and
|
||||
terminal out of `nearle/pos/{store}/{terminal}/{kind}`. A till that could name its
|
||||
own store in a payload could post sales into another shop's books. There is a test
|
||||
named exactly that: `TestABodyCannotOverrideTheTopicIdentity`.
|
||||
|
||||
**Shutdown order is load-bearing.** `Close()` drains the worker pools *before*
|
||||
disconnecting, so a bill mid-commit still gets its ack out. Disconnecting first
|
||||
would strand it — committed here, unacknowledged there, re-sent on the till's next
|
||||
attempt.
|
||||
|
||||
Payloads are copied in `wrapHandler` because paho reuses its buffer once the
|
||||
handler returns, and the work now happens after that.
|
||||
|
||||
The test suite here is genuinely good: bounded concurrency, blocking-not-dropping,
|
||||
drain-on-stop, idempotent stop, payload copying, ordinal election, and "a failed
|
||||
presence write does not stop the till."
|
||||
|
||||
---
|
||||
|
||||
## 3. Terminal authentication and shop-managed staff
|
||||
|
||||
### What it is
|
||||
|
||||
Before this work, the POS surface was completely open: a till held a store id
|
||||
typed into a Settings screen and a password compiled into the app, so
|
||||
`store_id=1185` in a URL was enough to read another tenant's catalogue or post
|
||||
bills into their books. One leaked build opened every tenant on the platform. This
|
||||
subsystem replaces that with a real session, and adds shop-run staff management on
|
||||
top.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
POST /pos/login (the only unguarded route — it's where tokens come from)
|
||||
│ authname|contactno + password [+ optional configid, location_id]
|
||||
▼
|
||||
posAuthRepository.PosLogin
|
||||
│ reads the SAME app_users rows the web console authenticates against
|
||||
│ resolves the outlet FROM the user's record — never from the wire
|
||||
▼
|
||||
posService.mint → utils.MintPosToken
|
||||
│ base64url(payload) "." base64url(HMAC-SHA256)
|
||||
│ claims: uid, tid, lid, rid, cid, trm, iat, exp TTL 30 days
|
||||
▼
|
||||
PosSession { token, expires_at, role, can_manage_staff,
|
||||
tenant + GSTIN + address (for the printed invoice),
|
||||
locations[] (picker for multi-outlet owners),
|
||||
staff[] (so the till can trade immediately) }
|
||||
│
|
||||
▼
|
||||
every other /pos/* route → middleware.PosAuth
|
||||
1. verify signature 2. is the named outlet owned by the token's tenant?
|
||||
```
|
||||
|
||||
### The approach
|
||||
|
||||
**A signed, self-describing token rather than a session table.** Reasoning given in
|
||||
`utils/postoken.go`: a till is not a browser. It signs in when the shop opens and
|
||||
bills for a whole day on a connection that comes and goes, so the credential must
|
||||
survive reboot, network loss, and an hour in a drawer. A server-side session table
|
||||
fails that (a till that cannot reach you must still be able to prove who it is when
|
||||
it returns), and so does a short expiry.
|
||||
|
||||
**Deliberately not JWT.** One issuer, one audience, one algorithm — the header JWT
|
||||
spends bytes negotiating is a constant. And `alg` is the source of JWT's
|
||||
worst-known footgun (`alg: none`); a format with no algorithm field cannot have
|
||||
that bug. The MAC is taken over the *encoded* payload so verification never
|
||||
re-serialises anything.
|
||||
|
||||
**Verification order is deliberate:** signature first, *then* expiry. Reading `exp`
|
||||
out of an unverified payload would mean taking the attacker's word for when their
|
||||
own token runs out. Comparison is `hmac.Equal` (constant time). A token that
|
||||
verifies but names no outlet is refused, so it cannot be mistaken for one that
|
||||
authorises everything.
|
||||
|
||||
**The middleware's second check is the one that matters.** A valid token is not a
|
||||
licence to name *any* outlet — it is a licence to name *your* outlets.
|
||||
`requestedLocation` reads all three spellings the routes use (`store_id`,
|
||||
`locationid`, `location_id`) rather than breaking terminals in the field by
|
||||
normalising, and — crucially — **searches the JSON body, not just the query
|
||||
string**, because the two routes that *write* carry `store_id` in the batch and
|
||||
never in the URL. It handles the id being sent quoted or bare, since accepting only
|
||||
one shape would silently skip the check, and "a skipped check reads exactly like a
|
||||
passed one."
|
||||
|
||||
**A migration escape hatch, honestly labelled.** `POS_AUTH_REQUIRED` defaults to
|
||||
**off**, because terminals are already in shops billing real customers against
|
||||
unauthenticated endpoints and flipping enforcement at deploy would stop every one
|
||||
of them mid-trade. While off, a token that *is* sent is still fully verified and a
|
||||
wrong-tenant request is still refused — the flag only governs requests carrying
|
||||
none.
|
||||
|
||||
**Two-tier sign-in: password opens the terminal, PIN switches the operator.**
|
||||
|
||||
- The session token is the security boundary. A four-digit PIN is not:
|
||||
`POST /pos/login/pin` sits *behind* the guard, so guesses are confined to one
|
||||
already-opened outlet's own staff.
|
||||
- PIN login mints a **fresh** token rather than reusing the presented one, so a
|
||||
cashier taking over from a supervisor drops the supervisor's permissions instead
|
||||
of inheriting them.
|
||||
- PINs travel in the clear over TLS, and the model file argues the case rather than
|
||||
hiding it: four digits is brute-forceable in microseconds whatever it is wrapped
|
||||
in, so hashing here buys the appearance of strength; meanwhile the terminal salts
|
||||
every PIN with its own random salt, so a hash computed server-side could never be
|
||||
verified there without inventing and maintaining a shared scheme across two
|
||||
codebases. The honest framing: **a PIN is shift attribution, not a security
|
||||
boundary.**
|
||||
|
||||
**Schema archaeology, handled rather than wished away:**
|
||||
|
||||
- `authname` is not unique in `app_users` — live data has the same address twice
|
||||
under one config. Rather than `LIMIT 1` (which would let a stranger's account
|
||||
shadow the one a person meant, and on a POS means billing into the wrong tenant),
|
||||
multiple matches are **refused** with an actionable message.
|
||||
- Inactive accounts are excluded from the *match*, not matched-then-refused, so a
|
||||
deactivated leaver cannot make a live login ambiguous.
|
||||
- `configid` (which tenant portal an account belongs to) is asked for by the web
|
||||
console because the browser knows it — but a person at a counter has never seen
|
||||
the number. So it is honoured when sent, inferred when not, and an ambiguous
|
||||
inference is reported rather than guessed. `PosConfigidFor` infers it from
|
||||
whichever value the tenant's existing accounts most commonly carry.
|
||||
- Staff come from **two** sources unioned: the purpose-built `tenantstaffs` table
|
||||
(a dozen rows on the entire platform) and `app_users.locationid` (where staff
|
||||
actually ended up). Reading either alone returns the wrong answer.
|
||||
- Duplicate PINs are dropped from the response, because live data has one PIN
|
||||
shared across many accounts — a shared PIN would attribute a bill to whichever
|
||||
row was read first.
|
||||
- PINs are bounded 1000–9999 with **no leading zero**, because `app_users.pin` is a
|
||||
`bigint`: "0451" stores as 451, and the cashier types four digits and is refused
|
||||
forever. That costs 1000 of 10000 combinations and buys a PIN that means the same
|
||||
thing in both directions. Obvious PINs (1234, 1111, …) are rejected.
|
||||
- `userid` is left to Postgres's identity column, with a comment explaining the
|
||||
earlier mistake: `information_schema.column_default` is empty for identity
|
||||
columns, which reads like "no default," and a hand-rolled MAX+1 leaves two
|
||||
allocators racing.
|
||||
- Emails go through `NULLIF(?, '')` because a unique constraint means a second
|
||||
PIN-only cashier would collide on the empty string, whereas NULLs do not collide
|
||||
in Postgres.
|
||||
|
||||
**The inversion, applied to people.** `PosUserRequest` has no tenant and no
|
||||
location field. A supervisor creating staff can only ever create them at their own
|
||||
outlet, and *no field in the struct can say otherwise* — the same inversion that
|
||||
stopped a till naming its own shop. Updates are scoped by tenant *and* location in
|
||||
the `WHERE` clause rather than checked first, so a wrong user id updates zero rows
|
||||
and is reported, instead of quietly editing another shop's staff. Deactivation is a
|
||||
status change, never a delete, because bills carry the cashier's name. You cannot
|
||||
deactivate the account you are signed in as, or the last supervisor could lock the
|
||||
whole shop out with one tap.
|
||||
|
||||
The same service calls are exposed to the web console under `/web/tenants/*` and
|
||||
`/mob/tenants/*` — deliberately the same code, not a parallel implementation,
|
||||
"because two code paths writing one table is exactly how that stops being true."
|
||||
The route file itself flags that this half is weaker: the console *asserts* its
|
||||
outlet where a terminal *proves* it, and says these should move behind a session
|
||||
guard as soon as the console can hold one.
|
||||
|
||||
---
|
||||
|
||||
## 4. The shared order + stock engine
|
||||
|
||||
### What it is
|
||||
|
||||
One transactional path that every sale in the platform goes through, regardless of
|
||||
channel: an app order, a spreadsheet import of historical counter sales, or a POS
|
||||
bill.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
CreateOrder (app) UploadOfflineSales (spreadsheet) importPosOrder (till)
|
||||
│ │ │
|
||||
│ per-bill tx + advisory lock per-bill tx + advisory lock
|
||||
│ + remarks-based dedup + terminalorderid dedup
|
||||
▼ ▼ │
|
||||
┌──────────────────────── createOrderTx ──────────────────────┐ │
|
||||
│ 0. lockStockRows (FOR UPDATE, sorted, deduped) │ │
|
||||
│ 1. assertStockAvailable (ledger balance, all lines first) │◄───────┤ (uses the same
|
||||
│ 1b. priceOrderLines (fill unpriced lines from catalogue) │ │ stockLedger.go
|
||||
│ 2. nextSequenceNo (UPDATE…RETURNING inside the tx) │ │ helpers directly)
|
||||
│ 3. insert header, insert lines, recordStockOut per line │ │
|
||||
│ → syncProductLocationStatus re-derives availability │ │
|
||||
└─────────────────────────────────────────────────────────────┘ │
|
||||
│ contract: on failure it has already rolled back; │
|
||||
│ on success tx is left OPEN so the caller can │
|
||||
│ include its own dedup guard in the same tx │
|
||||
▼ ▼
|
||||
COMMIT pos_orders + productstocks
|
||||
```
|
||||
|
||||
### The approach
|
||||
|
||||
**Stock is derived, never stored.** Availability is `SUM(in) − SUM(out)` over
|
||||
`productstocks`, computed under the row locks. `productlocations.status` is a
|
||||
*derived cache* re-synced from that balance after every movement, in the same
|
||||
transaction, by the same rule on both the sale side and the receiving side. A
|
||||
commit message captures the earlier bug this replaced: receiving stock used to
|
||||
overwrite `products.productstatus` — a per-product **lifecycle** column — with an
|
||||
**availability** value, destroying the lifecycle state of well over a hundred
|
||||
products. Availability is a per-outlet fact and a single column on `products`
|
||||
cannot express it, since the same product can be stocked at one outlet and empty at
|
||||
another.
|
||||
|
||||
**Order-number allocation.** `nextSequenceNo` does read-and-increment in a single
|
||||
`UPDATE … RETURNING` inside the caller's transaction. The doc comment is a small
|
||||
forensic report on the previous implementation: two separate calls on `r.db` (not
|
||||
the transaction), so concurrent orders read the same value; a `NULL` counter made
|
||||
`COALESCE(MAX(x)+1, 1)` evaluate `NULL+1 = NULL` and fall through to a hardcoded
|
||||
`"<tenantid>-1"`, so a whole cohort of live orders share one id; tenants with
|
||||
multiple sequence rows hit a `GROUP BY` where the read kept the first row and the
|
||||
write updated all of them. The fix pins to `MIN(sequenceid)`, seeds a NULL from the
|
||||
tenant's existing order count (guaranteed ≥ any id already issued, so recovery never
|
||||
reissues), and creates the row on first use.
|
||||
|
||||
**Two rounding conventions, kept apart on purpose.** `legacyOrderQty` (truncate,
|
||||
floor at 1) is preserved *exactly* for app orders — changing it would silently
|
||||
alter stock deduction for every order in production. `roundStockQty` (ceil) is used
|
||||
by the POS path. Both are named, tested, and flagged in a comment as something to
|
||||
reconcile once someone owns the decision. That is the right call: the divergence is
|
||||
documented rather than papered over.
|
||||
|
||||
**Deduplication without a natural key.** The spreadsheet importer dedupes on
|
||||
`orders.remarks = "OFFLINE:<billno>"` under an advisory lock. When the sheet has no
|
||||
bill number, an **FNV-1a hash of the bill's own contents** (date, mobile, payment
|
||||
mode, and each line's product/qty/price) stands in — so re-uploading the same file
|
||||
is a no-op rather than a double stock deduction. The trade-off is stated: two
|
||||
genuinely separate identical baskets on the same day with no bill numbers will
|
||||
collide, and the result *names* the collision rather than hiding it.
|
||||
|
||||
**Referential scaffolding.** The order-listing query INNER JOINs five tables, so an
|
||||
imported order with a zero `applocationid` or `customerid` would be written
|
||||
successfully and then be **invisible in every screen**.
|
||||
`resolveOfflineLocationContext` is the single place where "this location belongs to
|
||||
this tenant" is established (so editing a locationid in a spreadsheet reaches
|
||||
nothing), and it fills the scaffolding from `tenantlocations` plus the most recent
|
||||
real order at that outlet — because `tenantlocations` carries 0 for
|
||||
`moduleid`/`partnerid` at outlets whose live orders use non-zero values. It refuses
|
||||
outright rather than writing an order that will never be visible.
|
||||
|
||||
**Batch semantics.** Each bill is its own transaction, so one bad row cannot undo
|
||||
the rest, and the response says exactly which landed, which were duplicates, and
|
||||
which failed with why. Branch context and catalogue are memoised per outlet so a
|
||||
workbook covering six branches does not re-run both queries per bill.
|
||||
|
||||
Also here: `priceOrderLines` fills lines the client sent unpriced from the
|
||||
merchant's own catalogue, using arithmetic that *deliberately matches* the offline
|
||||
importer exactly — gross, minus discount, tax extracted from the landing amount
|
||||
because shelf prices are MRP (tax inside). One convention for both channels, so the
|
||||
same basket rings up the same either way. This fixed a real bug where
|
||||
catalogue-imported products had no per-store price, so real delivered orders
|
||||
recorded zero revenue.
|
||||
|
||||
---
|
||||
|
||||
## 5. Brand catalogue bridge and the rest of the platform
|
||||
|
||||
### What it is
|
||||
|
||||
A **second, isolated Postgres database** holds a curated master catalogue of
|
||||
packaged goods, organised one table per brand, with rich metadata (title,
|
||||
description, nutrients, highlights, FSSAI licence, size, variant key, providers).
|
||||
Shop owners browse it and "import" products into their own store catalogue rather
|
||||
than typing them in. Product photography lives in S3-compatible object storage.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
CatalogueDB (separate conn, may be nil) Object storage (S3-compatible)
|
||||
brand_<name> tables daily/brands/{brand}/{image_id}/*
|
||||
│ │
|
||||
│ catalogueRepository │ db/imagestore.go
|
||||
│ (table name from a fixed allowlist) │ full LIST → in-memory map,
|
||||
│ │ swapped atomically, 30-min refresh
|
||||
└──────────────┬───────────────────────────────┘
|
||||
▼
|
||||
productService.ImportCatalogueProduct
|
||||
│ snapshot into tenant `products` (keyed brand+catalogueid)
|
||||
│ + link via productlocations (price, stock, status)
|
||||
▼
|
||||
nearledb: products / productlocations / productstocks
|
||||
▼
|
||||
POS catalogue pull · customer app · order lines
|
||||
```
|
||||
|
||||
### The approach
|
||||
|
||||
- **Isolation is enforced structurally.** `NewCatalogueRepository` takes the
|
||||
catalogue connection and *never* `db.DB`. If the catalogue env vars are absent,
|
||||
the connection is simply nil and catalogue endpoints return a normal error — it
|
||||
must never block startup of the main app. Same rule for Redis and the image
|
||||
store: optional dependencies degrade, they do not kill the process. The one
|
||||
dependency that *is* fatal on misconfiguration is the MQTT broker, and the
|
||||
comment says why: "coming up healthy while every till quietly queues is the worse
|
||||
failure."
|
||||
- **Table names cannot be parameterised in SQL**, so brand → table goes through a
|
||||
fixed allowlist map. That is the correct pattern.
|
||||
- The catalogue DSN is built as a URL and percent-encoded via `net/url` rather than
|
||||
a `keyword=value` DSN, because that password contains characters the keyword
|
||||
format would misparse as quoting/comment syntax.
|
||||
- GORM's raw scan silently drops slice-kind destination fields, so `text[]` columns
|
||||
are cast to text in SQL and parsed in Go.
|
||||
- Cross-brand browsing merges and sorts in Go, with an explicit note that this is
|
||||
fine at a few hundred rows and should become a `UNION ALL` if it grows.
|
||||
- The image store caches a full object listing so no GET ever calls out to S3,
|
||||
rebuilt from scratch every 30 minutes and swapped under a write lock. A nice
|
||||
deployment detail: the endpoint is bucket-qualified, and the SDK's
|
||||
virtual-hosted-style client re-prepends the bucket — so the client is pointed at
|
||||
the bare region host or requests get addressed to `bucket.bucket.…`.
|
||||
- Import is idempotent on `(tenant, brand, catalogueid)`: re-import updates pricing
|
||||
rather than creating a duplicate product.
|
||||
|
||||
**The surrounding platform** — roughly two thirds of the file count — is a
|
||||
conventional layered Fiber/GORM app: orders, deliveries (rider dispatch, status
|
||||
lifecycle with mirrored timestamps, rider/report summaries), products and stock,
|
||||
tenants and outlets, customers, partners, users, and FCM push. Most of it is CRUD
|
||||
and reporting SQL. The parts worth noting are the *repairs*, which are documented
|
||||
in-place with the evidence that motivated them:
|
||||
|
||||
- `UpdateDelivery` used to write the parent order with `WHERE orderheaderid = ?`
|
||||
from a client-supplied field. Clients often omitted it, making it `= 0`, matching
|
||||
nothing — and GORM reports no error for an update affecting zero rows, so the API
|
||||
answered success while the order silently kept its old status. Hundreds of
|
||||
deliveries were marked delivered against orders still reading pending. Fixed by
|
||||
deriving the link from `deliveryid` (the one field every caller must send) and
|
||||
failing loudly when the row is not there.
|
||||
- The rider push-notification route had been commented out since the initial commit
|
||||
— the handler, the model, the Firebase service account and the Dockerfile `COPY`
|
||||
were all in place; only the route registration was missing. So every rider push
|
||||
the admin console ever sent returned 404, and riders were assigned deliveries and
|
||||
never told.
|
||||
- New store outlets and their logins were being forced to `InActive`, which blocked
|
||||
the spawned manager login before it could ever reach the password-setup screen.
|
||||
- Tenant onboarding created admin users with `configid = 0`, which the web login
|
||||
(which queries `configid = 1`) could never find — permanently unfindable accounts.
|
||||
- Order line items were being silently dropped.
|
||||
|
||||
---
|
||||
|
||||
## The stack
|
||||
|
||||
**Language / runtime**
|
||||
|
||||
- Go 1.24
|
||||
|
||||
**Web / API**
|
||||
|
||||
- Fiber v2 (`gofiber/fiber/v2`), CORS middleware, custom `PosAuth` middleware
|
||||
|
||||
**Data**
|
||||
|
||||
- PostgreSQL (primary, `nearledb`) via GORM 1.25 + `pgx/v5` driver — heavily raw
|
||||
SQL, GORM mostly as a connection/scan layer
|
||||
- A second PostgreSQL instance for the brand catalogue (described in code as
|
||||
pgvector)
|
||||
- Redis 7-family via `go-redis/v9` — TTL-based presence, shared with a separate
|
||||
Node/Express service
|
||||
- Postgres features used directly: advisory locks (`pg_advisory_xact_lock`),
|
||||
`SELECT … FOR UPDATE`, `UPDATE … RETURNING`, `jsonb`, identity columns
|
||||
|
||||
**Messaging**
|
||||
|
||||
- Eclipse Mosquitto over MQTT, `eclipse/paho.mqtt.golang` v1.5 — QoS 1, persistent
|
||||
sessions, retained messages, Last Will
|
||||
|
||||
**Crypto / auth**
|
||||
|
||||
- `crypto/hmac` + SHA-256, custom compact token format (not JWT)
|
||||
|
||||
**Cloud / integrations**
|
||||
|
||||
- AWS SDK Go v2 S3 client pointed at an S3-compatible object store
|
||||
- Firebase Cloud Messaging via `golang.org/x/oauth2` JWT service-account flow
|
||||
(`firebase.google.com/go` present)
|
||||
|
||||
**Config / ops**
|
||||
|
||||
- `godotenv`, `spf13/viper`, `time/tzdata` (Asia/Kolkata baked in)
|
||||
- Multi-stage Dockerfile → static binary on Alpine
|
||||
- Kubernetes StatefulSet *(inferred — from the `HOSTNAME` ordinal election logic
|
||||
and the `-0` convention, not from manifests in this repo)*
|
||||
|
||||
**Testing**
|
||||
|
||||
- Go stdlib `testing`, table-driven, with hand-rolled fakes for the MQTT client and
|
||||
the POS service — no mocking framework
|
||||
|
||||
**Consumers of this API** *(not in this repo; described in docs and comments)*: a
|
||||
Flutter POS terminal app with local SQLite, a React/TypeScript merchant console, a
|
||||
consumer mobile app, a rider app, and a separate Node/Express backend sharing the
|
||||
Redis instance.
|
||||
|
||||
---
|
||||
|
||||
## What's genuinely hard here
|
||||
|
||||
**1. The ack protocol and idempotency, together.** Anyone can write "insert if not
|
||||
exists." What is hard is the discipline that follows from at-least-once delivery
|
||||
when the payload is *money that has already changed hands*: a duplicate must be
|
||||
reported as success, silence must never be interpreted as acceptance, "reject" must
|
||||
be reserved for permanent faults, transport errors must produce *no* ack at all,
|
||||
and the whole thing has to hold under a shutdown. The code gets all five right and
|
||||
the reasoning is written down at each decision point. The
|
||||
advisory-lock-then-check pattern — turning a constraint violation into an orderly
|
||||
"already held" — is the specific move to point to.
|
||||
|
||||
**2. The delta/snapshot invariant in the catalogue pull.** The failure mode —
|
||||
`is_delta: false` on a filtered response empties a real shop's shelf — is the kind
|
||||
of bug that only shows up in a store, at a counter, with a queue. Deriving the
|
||||
filter and the flag from a *single* value so no code path can set them
|
||||
inconsistently is the right structural answer, not a defensive check. The
|
||||
pagination detail (never advance the revision mid-pull) and the one-second cutoff
|
||||
overlap are both real distributed-systems reasoning: each is a choice about which
|
||||
direction to fail in, and each picks "redundant work" over "silent permanent data
|
||||
loss."
|
||||
|
||||
**3. Backpressure that reaches the physical device.** The chain is: bounded queue
|
||||
blocks → paho's single delivery goroutine stalls → broker's in-flight window fills
|
||||
→ broker stops sending → till holds its bills. Every link is a deliberate
|
||||
configuration choice (`SetOrderMatters(true)` exists solely to preserve link two).
|
||||
The alternative designs are both worse in ways that are only obvious once you have
|
||||
reasoned it through: unbounded goroutines exhaust the connection pool and stall
|
||||
everything at once; dropping work loses a sale you had already accepted. Plus the
|
||||
pool's close race — recognising that `select` over done-and-jobs picks randomly and
|
||||
can panic on a closed channel, and solving it with a read-lock held *across the
|
||||
send* — is a genuinely subtle piece of Go concurrency.
|
||||
|
||||
**4. Retrofitting authorisation onto a live, unauthenticated fleet.** The
|
||||
intellectual move is small and correct: **invert the direction of the store id.**
|
||||
It was an input (typed into Settings, believed on the wire); it becomes an output
|
||||
(derived from the authenticated user's record, sealed under a signature).
|
||||
Everything else follows — `PosUserRequest` having no tenant field, the middleware's
|
||||
tenant-owns-outlet cross-check, the read-the-body-not-just-the-query detail that
|
||||
closes the hole on the exact routes that write. The rollout strategy (ship the
|
||||
endpoint, let the fleet adopt, then flip `POS_AUTH_REQUIRED`) is how you do this
|
||||
without stopping a hundred shops trading, and the code is honest that the flag is a
|
||||
temporary state and not a design.
|
||||
|
||||
**5. Sharing one transactional path across three channels without forking it.**
|
||||
`createOrderTx`'s contract — *on failure it has already rolled back; on success the
|
||||
transaction is left open so the caller can put its own dedup guard inside the same
|
||||
transaction* — is unusual and slightly dangerous, but it is what allows an app
|
||||
order, a spreadsheet import and a POS bill to share row-locking, availability
|
||||
checks, ledger writes and sequence allocation. The alternative (three
|
||||
implementations of the anti-overselling rule) is exactly the drift that produces
|
||||
"empty in the database, full on the shelf."
|
||||
|
||||
**6. Making a hostile schema work without a migration.** This is unglamorous and it
|
||||
is a lot of the actual difficulty. Non-unique `authname`; a `bigint` PIN column
|
||||
that eats leading zeros; a `roleid` of 0 that is not a role; `app_roles` with six
|
||||
rows for four roles and most accounts carrying an id that is not in it; a
|
||||
`registrationno` column that is really the GSTIN; `configid` varying per tenant
|
||||
with no way to look it up; a SKU column where thousands of products share one
|
||||
value; tax rates in live data that include 3, 7, 15 and −1 when Indian GST only has
|
||||
0/5/12/18/28. Each of these gets a handler *and a written justification measured
|
||||
against the actual data*, rather than a schema change nobody has the appetite to
|
||||
run. The negative-GST floor is a good example of why this matters: a negative rate
|
||||
would put negative tax on a bill and a negative figure in a slab on a **filed tax
|
||||
return**.
|
||||
|
||||
**7. The commenting itself.** Worth calling out explicitly. Nearly every non-obvious
|
||||
decision carries a comment that states the alternative considered, the failure it
|
||||
prevents, and often the count of live rows that motivated it. Several read as small
|
||||
post-mortems (the sequence-number one, the delivery-status one). That is a real
|
||||
engineering artefact, not decoration — it is what makes the codebase maintainable
|
||||
by someone who was not there.
|
||||
|
||||
---
|
||||
|
||||
## Flags before publishing any of this
|
||||
|
||||
### Security issues found while reading
|
||||
|
||||
1. **A Firebase service-account JSON key is committed to the repository** and
|
||||
`COPY`'d into the Docker image by the Dockerfile. That is a live private key in
|
||||
version control. Rotate it and move it to a secret/volume mount.
|
||||
2. **`.env` was tracked until 2026-08-03.** The `.gitignore` says so itself and
|
||||
notes the database credentials are still in history and the password should be
|
||||
rotated. That does not appear to have happened.
|
||||
3. **SQL injection in `tenantRepository.CheckTenantByNo`** — the contact number is
|
||||
string-concatenated into raw SQL. Everything else in the codebase
|
||||
parameterises; this one does not.
|
||||
4. **Passwords are stored and compared in plaintext platform-wide.** There is an
|
||||
explicit `TODO` acknowledging it, correctly noting a POS token minted off a
|
||||
plaintext password is only as good as that column. The comparison is at least
|
||||
constant-time.
|
||||
5. **The main web/mobile API has no authentication at all.**
|
||||
`SECURITY_HANDOFF.md` documents this: identity comes from client-supplied query
|
||||
params, so requesting another tenant's id returns their data. Eight IDOR
|
||||
endpoints were patched with controller-level scoping guards, but the doc is
|
||||
clear that the root fix has not started.
|
||||
6. **`POS_AUTH_REQUIRED` defaults to false**, so the POS surface is open unless
|
||||
explicitly enabled — intentional and documented, but worth confirming whether it
|
||||
has been flipped in production.
|
||||
7. **The `/web/tenants/*` and `/mob/tenants/*` staff routes mint till credentials
|
||||
on unauthenticated requests.** The route file flags this itself.
|
||||
8. **CORS is `AllowOrigins: "*"` with `AllowCredentials: true`** — that combination
|
||||
is rejected by browsers and is a smell either way.
|
||||
|
||||
### Commercially sensitive — genericise before this goes public
|
||||
|
||||
- **Named brand partners** in the catalogue allowlist (six FMCG brands, some of
|
||||
them major). That is a partnership roster.
|
||||
- **The production hostname** and the deployed API base path, which appear in the
|
||||
proof scripts under `scratch/`.
|
||||
- **Live customer/tenant identifiers** — the scratch scripts and doc examples
|
||||
contain real tenant ids, location ids, store names and at least one real email
|
||||
address. All of them have been kept out of this document.
|
||||
- **Data-quality statistics about the live estate** (row counts, how many products
|
||||
share a SKU, how many accounts use a given PIN, duplicate-order-id counts,
|
||||
desynced-delivery counts). These make the writeup much more convincing, but they
|
||||
are an unflattering audit of a client's production data. Keep the reasoning and
|
||||
drop or round the numbers — "thousands of products shared a single SKU value"
|
||||
carries the point without publishing the audit. Exact figures have been rounded
|
||||
or removed here already.
|
||||
- **The terminal sync contract itself** (topic structure, ack semantics, revision
|
||||
format). It is the interface between two of their products; the *techniques* are
|
||||
portfolio-safe, the exact wire protocol less so.
|
||||
|
||||
### Inferred rather than read directly
|
||||
|
||||
Deployment as a Kubernetes StatefulSet (from the ordinal election logic, not from
|
||||
manifests); the existence and behaviour of the Flutter till app, the React console,
|
||||
the consumer/rider apps and the Express backend (from docs and comments — none are
|
||||
in this repo); and that the catalogue database uses pgvector (asserted in comments,
|
||||
but nothing in this repo issues a vector query).
|
||||
458
docs/POS_API.md
Normal file
458
docs/POS_API.md
Normal 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
649
docs/POS_LOGIN.md
Normal 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.
|
||||
221
docs/POS_PHONE_LOGIN_HANDOVER.md
Normal file
221
docs/POS_PHONE_LOGIN_HANDOVER.md
Normal 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"
|
||||
```
|
||||
321
docs/POS_PHONE_PIN_LOGIN_HANDOVER.md
Normal file
321
docs/POS_PHONE_PIN_LOGIN_HANDOVER.md
Normal 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
362
docs/POS_TERMINAL_INGEST.md
Normal 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
441
docs/SCAN_TO_ORDER.md
Normal 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
95
docs/SECURITY_HANDOFF.md
Normal 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.
|
||||
@@ -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
62
go.mod
@@ -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
440
go.sum
@@ -1,59 +1,21 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
||||
cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
|
||||
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
|
||||
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.13.0 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk=
|
||||
cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8=
|
||||
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
|
||||
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
|
||||
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
|
||||
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
@@ -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
10
init/.gitignore
vendored
Normal 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
79
init/README.md
Normal 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.
|
||||
109
init/cataloguedb/02-seed.sql
Normal file
109
init/cataloguedb/02-seed.sql
Normal 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
232
init/nearledb/02-seed.sql
Normal 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
377
main.go
@@ -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
356
messaging/livehub.go
Normal 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
340
messaging/posmqtt.go
Normal 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
474
messaging/posmqtt_test.go
Normal 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
174
messaging/posworkers.go
Normal 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() {}
|
||||
247
messaging/posworkers_test.go
Normal file
247
messaging/posworkers_test.go
Normal 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
219
middleware/posauth.go
Normal 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
151
middleware/posauth_test.go
Normal 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
111
models/catalogueupload.go
Normal 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"
|
||||
}
|
||||
@@ -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
116
models/flexibleInt.go
Normal 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
|
||||
}
|
||||
88
models/flexibleInt_test.go
Normal file
88
models/flexibleInt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
163
models/order.go
163
models/order.go
@@ -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"`
|
||||
|
||||
@@ -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
519
models/pos.go
Normal 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
58
models/poshealth.go
Normal 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
209
models/posorder.go
Normal 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
53
models/posshift.go
Normal 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"
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
71
models/productWire_test.go
Normal file
71
models/productWire_test.go
Normal 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
172
models/scan.go
Normal 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"`
|
||||
}
|
||||
105
models/tenant.go
105
models/tenant.go
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
242
repositories/catalogueColumns_test.go
Normal file
242
repositories/catalogueColumns_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
repositories/catalogueNotify.go
Normal file
85
repositories/catalogueNotify.go
Normal 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
177
repositories/catalogueUploadRepository.go
Normal file
177
repositories/catalogueUploadRepository.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
62
repositories/orderOutlet_test.go
Normal file
62
repositories/orderOutlet_test.go
Normal 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
41
repositories/partnerOnboard_test.go
Normal file
41
repositories/partnerOnboard_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
554
repositories/posAuthRepository.go
Normal file
554
repositories/posAuthRepository.go
Normal 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
|
||||
}
|
||||
197
repositories/posLogin_test.go
Normal file
197
repositories/posLogin_test.go
Normal 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
195
repositories/posPresence.go
Normal 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
|
||||
}
|
||||
869
repositories/posRepository.go
Normal file
869
repositories/posRepository.go
Normal 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"
|
||||
}
|
||||
}
|
||||
253
repositories/posRepository_test.go
Normal file
253
repositories/posRepository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
223
repositories/posSalesRepository.go
Normal file
223
repositories/posSalesRepository.go
Normal 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
|
||||
}
|
||||
170
repositories/posShiftRepository.go
Normal file
170
repositories/posShiftRepository.go
Normal 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
|
||||
}
|
||||
812
repositories/posUserRepository.go
Normal file
812
repositories/posUserRepository.go
Normal 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
|
||||
}
|
||||
246
repositories/posUserRepository_test.go
Normal file
246
repositories/posUserRepository_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
169
repositories/productPublishRepository.go
Normal file
169
repositories/productPublishRepository.go
Normal 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
228
repositories/productVariantLink.go
Normal file
228
repositories/productVariantLink.go
Normal 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
|
||||
}
|
||||
67
repositories/productVariantLink_test.go
Normal file
67
repositories/productVariantLink_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
729
repositories/scanRepository.go
Normal file
729
repositories/scanRepository.go
Normal 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
210
repositories/stockLedger.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user