Compare commits
70 Commits
cd2459dbb6
...
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 |
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
|
||||
68
.env
Normal file
68
.env
Normal file
@@ -0,0 +1,68 @@
|
||||
# Fiesta configuration — the shared base file.
|
||||
#
|
||||
# Loaded AFTER `.env.<APP_ENV>` (see config/config.go), and godotenv never
|
||||
# overwrites a value that is already set, so anything here is a fallback for
|
||||
# what the environment file and the real environment leave unset. Keep it
|
||||
# local: with APP_ENV unset this is loaded straight after `.env.local`, and a
|
||||
# production value here would silently reach a local run.
|
||||
#
|
||||
# go run . → .env.local, then this file
|
||||
# APP_ENV=production go run . → .env.production, then this file
|
||||
#
|
||||
# The full list of settings, with what each one does, is in `.env.example`.
|
||||
|
||||
# ── Where it listens ────────────────────────────────────────────────────────
|
||||
# 1122 locally; production serves on 1009. Change it to run a second copy
|
||||
# beside something else; the console then points at the same number.
|
||||
APP_PORT=1122
|
||||
|
||||
ENV=development
|
||||
|
||||
# ── The main database (nearledb) ────────────────────────────────────────────
|
||||
#
|
||||
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
|
||||
#
|
||||
# There is no "local mode" that protects you: `go run .` against the live host
|
||||
# creates real tenants, real logins and real stock movements, and main.go runs
|
||||
# schema migrations on boot. If the point of running locally is to try a change
|
||||
# before it is deployed, a local Postgres with a dump restored into it is the
|
||||
# only version that actually does that.
|
||||
# These match docker-compose.local.yml, so `docker compose -f
|
||||
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5433
|
||||
DB_NAME=nearledb
|
||||
DB_USER=nearle
|
||||
DB_PASSWORD=localdev
|
||||
|
||||
|
||||
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
||||
#
|
||||
# A separate connection on purpose, so catalogue work never touches nearledb.
|
||||
# Leave blank to start without it: catalogue endpoints then fail at query time
|
||||
# rather than at boot, which is fine for testing anything else.
|
||||
# 5434, not 5432: a developer machine usually has something on 5432 already,
|
||||
# and a silent connection to the wrong database is worse than a refused one.
|
||||
CATALOGUE_DB_HOST=localhost
|
||||
CATALOGUE_DB_PORT=5434
|
||||
CATALOGUE_DB_NAME=cataloguedb
|
||||
CATALOGUE_DB_USER=nearle
|
||||
CATALOGUE_DB_PASSWORD=localdev
|
||||
|
||||
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
|
||||
#
|
||||
# Optional. Losing the health board is an inconvenience; losing a sale is not,
|
||||
# so the API runs without it.
|
||||
# Set to enable POS terminal presence. The local compose publishes redis on
|
||||
# 6379, so localhost is all it needs; leave blank to run without it.
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=
|
||||
REDIS_DB=0
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
|
||||
# sign in against the local stack; production sets its own in the platform.
|
||||
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||
JWT_SECRET_KEY=
|
||||
USER_CONTEXT_KEY=
|
||||
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -53,6 +53,4 @@ Thumbs.db
|
||||
# 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.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -350,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -181,6 +181,12 @@ func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
||||
|
||||
// TerminalHealth returns one till's live state, for a support call that starts
|
||||
// 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 == "" {
|
||||
@@ -200,6 +206,11 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
||||
// Not an error. The till has simply not reported inside its TTL, which
|
||||
// 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,
|
||||
@@ -211,9 +222,54 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.posTerminalInScope(c, fields); err != nil {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -387,6 +443,11 @@ func posIngestError(c *fiber.Ctx, op string, err error) error {
|
||||
// The one POS route that is deliberately left unauthenticated — it is where a
|
||||
// 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 {
|
||||
@@ -396,10 +457,23 @@ func (ctl *PosController) Login(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" {
|
||||
// Which account, and which of the two credentials was offered. Both are
|
||||
// checked here so an empty field is answered as the malformed request it is,
|
||||
// rather than spending a database round trip to say the same thing.
|
||||
identity := strings.TrimSpace(req.Contactno)
|
||||
if identity == "" {
|
||||
identity = strings.TrimSpace(req.Authname)
|
||||
}
|
||||
if identity == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "an email or mobile number is required",
|
||||
"message": "a mobile number is required",
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(req.Pin) == "" && strings.TrimSpace(req.Password) == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "a PIN is required",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,7 +489,7 @@ func (ctl *PosController) Login(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("pos login (%s): %v", req.Authname, err)
|
||||
log.Printf("pos login (%s): %v", identity, err)
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
||||
})
|
||||
@@ -459,9 +533,14 @@ func (ctl *PosController) Session(c *fiber.Ctx) error {
|
||||
// Staff lists who may ring a bill at this terminal's outlet.
|
||||
//
|
||||
// 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,
|
||||
// and the answer carries PINs — so the outlet comes from the token, and a
|
||||
// request without one is refused whatever POS_AUTH_REQUIRED says.
|
||||
// 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 {
|
||||
@@ -637,7 +716,7 @@ func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
|
||||
if !ok {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||
"code": http.StatusUnauthorized, "status": false,
|
||||
"message": "sign the terminal in with an email and password before using PIN sign-in",
|
||||
"message": "sign the terminal in with a mobile number and PIN before switching operator",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -816,14 +895,90 @@ func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
|
||||
"details": []fiber.Map{
|
||||
{
|
||||
"role_id": models.PosRoleSupervisor, "role": "supervisor",
|
||||
"label": models.PosRoleName(models.PosRoleSupervisor),
|
||||
"description": "Runs the terminal and creates counter staff. Also signs into the app.",
|
||||
"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.",
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,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,
|
||||
@@ -404,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 {
|
||||
|
||||
@@ -524,6 +593,33 @@ func (ctl *ProductController) CreateProductLocation(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProductVariant puts a product into a variant group, or takes it out.
|
||||
//
|
||||
// variantid 0 means ungrouped and is allowed — it is a real thing to want. The
|
||||
// read path is what refuses to treat 0 as a group to search for.
|
||||
func (ctl *ProductController) UpdateProductVariant(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Productid int `json:"productid"`
|
||||
Variantid int `json:"variantid"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
if body.Productid <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "productid is required",
|
||||
})
|
||||
}
|
||||
if err := ctl.productService.UpdateProductVariant(body.Productid, body.Variantid); err != nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "message": "Success"})
|
||||
}
|
||||
|
||||
func (ctl *ProductController) CreateProductVariant(c *fiber.Ctx) error {
|
||||
var input models.Productvariant
|
||||
|
||||
@@ -572,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,
|
||||
})
|
||||
}
|
||||
@@ -654,6 +794,93 @@ func (ctl *ProductController) GetTenantCategories(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Recategorise re-files a tenant's products in bulk.
|
||||
//
|
||||
// PUT and not POST: it changes rows that already exist and creates none. The
|
||||
// response carries how many rows actually moved, which is the only way a caller
|
||||
// can tell "all done" from "every id I sent was wrong".
|
||||
func (ctl *ProductController) Recategorise(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Updates []models.ProductCategoryUpdate `json:"updates"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "invalid body",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
if body.Tenantid == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
moved, err := ctl.productService.RecategoriseProducts(body.Tenantid, body.Updates)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"status": true,
|
||||
"details": fiber.Map{"moved": moved},
|
||||
})
|
||||
}
|
||||
|
||||
// ResolveCategories exchanges category names for this tenant's category ids.
|
||||
//
|
||||
// POST, not GET, because it CREATES the categories it cannot find — a sheet
|
||||
// naming an aisle this shop has never stocked should not fail, it should open
|
||||
// the aisle. The response maps the name a caller sent to the id it must store.
|
||||
//
|
||||
// Keyed on the lowercased, trimmed name, so a caller can look up whatever
|
||||
// casing its own sheet used without having to guess how it was filed.
|
||||
func (ctl *ProductController) ResolveCategories(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "invalid body",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
if body.Tenantid == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
resolved, err := ctl.productService.EnsureTenantCategories(body.Tenantid, body.Names)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"status": true,
|
||||
"details": resolved,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
||||
var input struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
@@ -691,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")
|
||||
@@ -580,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
|
||||
|
||||
19
db/redis.go
19
db/redis.go
@@ -3,9 +3,7 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"nearle/config"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -31,23 +29,18 @@ var RedisCtx = context.Background()
|
||||
// Redis is optional here: without it the POS health board goes dark, but bills
|
||||
// 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() {
|
||||
host := strings.TrimSpace(os.Getenv("REDIS_HOST"))
|
||||
if host == "" {
|
||||
func InitRedis(c config.RedisConfig) {
|
||||
if !c.Enabled() {
|
||||
log.Println("redis: REDIS_HOST not set, POS presence disabled")
|
||||
return
|
||||
}
|
||||
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0"))
|
||||
if err != nil {
|
||||
dbIndex = 0
|
||||
}
|
||||
host, port, dbIndex := c.Host, c.Port, c.DB
|
||||
|
||||
Rdb = redis.NewClient(&redis.Options{
|
||||
Addr: host + ":" + port,
|
||||
Username: getEnv("REDIS_USER", "default"),
|
||||
Password: os.Getenv("REDIS_PASSWORD"),
|
||||
Username: c.User,
|
||||
Password: c.Password,
|
||||
DB: dbIndex,
|
||||
|
||||
// Short on purpose. A degraded Redis must fail fast rather than tie up
|
||||
|
||||
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).
|
||||
@@ -29,10 +29,10 @@ told.**
|
||||
```bash
|
||||
BASE=https://fiesta.nearle.app/live/api/v1/pos
|
||||
|
||||
# 1. Sign in
|
||||
# 1. Sign in — a mobile number and a 4-digit PIN
|
||||
curl -s -X POST $BASE/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}'
|
||||
-d '{"contactno":"9876543210","pin":"4821","terminal_id":"T5EDD"}'
|
||||
|
||||
# 2. Use the token on everything else
|
||||
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
|
||||
@@ -44,9 +44,9 @@ curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
These steps are in order, and the order matters.
|
||||
|
||||
**1. Sign in.** `POST /login` with the operator's own credentials — the same
|
||||
`app_users` account they use for the web console. There is no separate POS
|
||||
password.
|
||||
**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.
|
||||
@@ -74,8 +74,8 @@ The only unauthenticated route. It is where a token comes from.
|
||||
|
||||
```json
|
||||
{
|
||||
"authname": "rsselvapuram@gmail.com",
|
||||
"password": "…",
|
||||
"contactno": "9876543210",
|
||||
"pin": "4821",
|
||||
"terminal_id": "T5EDD",
|
||||
"device_id": "a5f3…",
|
||||
"location_id": 1135,
|
||||
@@ -85,15 +85,15 @@ The only unauthenticated route. It is where a token comes from.
|
||||
|
||||
| Field | Required | Notes |
|
||||
|---|---|---|
|
||||
| `authname` | yes* | Email. **Or** send `contactno` instead. |
|
||||
| `contactno` | yes* | Mobile number, as an alternative to `authname`. |
|
||||
| `password` | yes | |
|
||||
| `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). |
|
||||
|
||||
\* one of `authname` or `contactno`.
|
||||
`pin` wins if a password is sent as well; `authname` wins over `contactno`.
|
||||
|
||||
### Response — `200`
|
||||
|
||||
@@ -128,7 +128,7 @@ The only unauthenticated route. It is where a token comes from.
|
||||
|
||||
"staff": [
|
||||
{ "user_id": 1148, "full_name": "Ragul Kannan",
|
||||
"role": "Super admin", "pin": "1111", "status": "Active" }
|
||||
"role": "Super admin", "status": "Active" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,10 @@ sign-in.
|
||||
**`locations`** — every outlet this account may open a till at. Length 1 is the
|
||||
normal case.
|
||||
|
||||
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty.**
|
||||
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty**, and it
|
||||
**no longer carries `pin`**: a PIN is now half of the sign-in, so a list of them
|
||||
is a list of working credentials for the outlet. Switch operator through
|
||||
`POST /login/pin` instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -191,9 +194,13 @@ Requires the token. Returns `401` when there isn't one.
|
||||
Who may ring a bill at this terminal's outlet. For pulling down somebody hired
|
||||
mid-shift without signing the terminal out.
|
||||
|
||||
**Takes no parameters.** The answer carries PINs, so 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.
|
||||
**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
|
||||
{
|
||||
@@ -228,10 +235,84 @@ four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most
|
||||
accounts carry an id that is not in the table at all — any mapping written on
|
||||
the terminal would be wrong.
|
||||
|
||||
Back-office roles 1–6 also count as supervisors: somebody who already
|
||||
administers the shop from a browser is not made less privileged by standing at
|
||||
the counter. **`role_id` 0 is not a role** — it is what an account carries when
|
||||
nobody set one, and it grants nothing.
|
||||
### 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."*
|
||||
|
||||
---
|
||||
|
||||
@@ -239,11 +320,12 @@ nobody set one, and it grants nothing.
|
||||
|
||||
For a cashier taking over a counter a supervisor has already opened.
|
||||
|
||||
**Requires an existing valid token.** That is the security model, not an
|
||||
oversight: four digits is ten thousand guesses, which is no barrier at all to an
|
||||
anonymous caller. Tying it to a session means a supervisor has opened the
|
||||
terminal with a real password first, and the guesses are confined to that one
|
||||
outlet's staff.
|
||||
**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 \
|
||||
@@ -286,11 +368,16 @@ own store id.
|
||||
|---|---|
|
||||
| `full_name` | required; split across `firstname`/`lastname` |
|
||||
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
|
||||
| `pin` | 4 digits. See the rules below |
|
||||
| `password` + `authname` | optional; for someone who also signs the terminal in |
|
||||
| `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 |
|
||||
|
||||
**At least one of `pin` or `password` is required.** Creating a person who can
|
||||
sign in by neither would look like it worked right up until somebody tried.
|
||||
**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**
|
||||
|
||||
@@ -417,22 +504,35 @@ GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach o
|
||||
|
||||
| Code | Meaning | What the till should do |
|
||||
|---|---|---|
|
||||
| `400` | Body unreadable, or neither `authname` nor `contactno` sent | Fix the request |
|
||||
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **Wrong email and wrong password give the same message** — deliberately, so the endpoint isn't a directory of who banks here |
|
||||
| `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: `authname` is not unique in this schema.
|
||||
Live data has the same address twice. We refuse rather than pick one, because
|
||||
picking wrong means billing into another tenant's books.
|
||||
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
|
||||
|
||||
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` |
|
||||
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.
|
||||
@@ -4,22 +4,26 @@ 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
|
||||
PosController *controllers.PosController
|
||||
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.
|
||||
@@ -30,7 +34,8 @@ type Facade struct {
|
||||
// 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)
|
||||
@@ -94,19 +99,42 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||
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,
|
||||
PosController: posController,
|
||||
posService: posService,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
go.mod
23
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
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
|
||||
@@ -42,8 +43,8 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||
github.com/aws/smithy-go v1.27.3 // 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/fsnotify/fsnotify v1.7.0 // 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
|
||||
@@ -54,7 +55,6 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/gorilla/schema v1.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -62,28 +62,17 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-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/rogpeppe/go-internal v1.11.0 // 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/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr 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.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
@@ -94,15 +83,11 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
389
go.sum
389
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=
|
||||
@@ -100,13 +62,8 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/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=
|
||||
@@ -118,16 +75,7 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.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/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=
|
||||
@@ -135,67 +83,34 @@ github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmh
|
||||
github.com/gofiber/utils v0.0.10 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U=
|
||||
github.com/gofiber/utils v0.0.10/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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -203,21 +118,12 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -234,24 +140,11 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/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.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/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -261,12 +154,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-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=
|
||||
@@ -276,37 +163,16 @@ github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQ
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
||||
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
|
||||
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
|
||||
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
|
||||
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
|
||||
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.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=
|
||||
@@ -315,302 +181,74 @@ github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE
|
||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||
github.com/valyala/tcplisten 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=
|
||||
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.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-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.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.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.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.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.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=
|
||||
@@ -618,21 +256,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/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=
|
||||
@@ -643,20 +270,12 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -665,12 +284,4 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/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;
|
||||
346
main.go
346
main.go
@@ -3,11 +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"
|
||||
@@ -17,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()
|
||||
|
||||
@@ -37,13 +39,13 @@ 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()
|
||||
db.InitRedis(cfg.Redis)
|
||||
|
||||
// Ensure schema is updated
|
||||
db.DB.AutoMigrate(&models.StockRequest{})
|
||||
@@ -55,7 +57,312 @@ func main() {
|
||||
log.Fatal("POS schema migration failed:", err)
|
||||
}
|
||||
|
||||
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
||||
// 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)
|
||||
|
||||
@@ -65,14 +372,33 @@ func main() {
|
||||
//
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -99,6 +99,18 @@ 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) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
142
models/pos.go
142
models/pos.go
@@ -226,24 +226,41 @@ type PosCatalogueResponse struct {
|
||||
|
||||
// PosLoginRequest is what a till sends to sign in.
|
||||
//
|
||||
// Authname or Contactno, matching the web console's own login — a shop should
|
||||
// not need a second set of credentials just because the screen is a till.
|
||||
// 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 {
|
||||
Authname string `json:"authname"`
|
||||
Contactno string `json:"contactno"`
|
||||
Password string `json:"password"`
|
||||
Configid int `json:"configid"`
|
||||
Locationid int `json:"location_id"`
|
||||
// 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 PIN, for signing on at a terminal a supervisor has already opened. Only
|
||||
// honoured by the PIN route, which requires an existing session — four
|
||||
// digits is no barrier to an anonymous caller.
|
||||
// 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"`
|
||||
@@ -319,24 +336,25 @@ type PosSession struct {
|
||||
// what gets stamped on a bill as `cashiername` and settled against at the end
|
||||
// of a shift.
|
||||
//
|
||||
// The PIN travels in the clear, over TLS, and that is a considered choice
|
||||
// rather than an oversight. A four-digit PIN is brute-forceable in microseconds
|
||||
// whatever it is wrapped in, so hashing it here would buy the appearance of
|
||||
// strength and not the substance. What it would cost is real: the terminal
|
||||
// salts every PIN with its own random salt before storing it, so a hash
|
||||
// computed here could never be verified there without inventing a shared
|
||||
// scheme and keeping two codebases agreeing about it for ever.
|
||||
// The 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.
|
||||
//
|
||||
// The honest framing is that a PIN is *shift attribution*, not a security
|
||||
// boundary. The boundary is the session token — which is what stops a till
|
||||
// reaching another tenant's books at all. The PIN decides which of the people
|
||||
// already inside a shop gets credited with a sale, and the terminal still
|
||||
// stores it hashed at rest.
|
||||
// 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:"pin,omitempty"`
|
||||
Pin string `json:"-"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
@@ -393,21 +411,37 @@ func PosRoleFromName(name string) int {
|
||||
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, plus the back office's own admin and manager roles — somebody
|
||||
// who can already administer the shop from a browser is not made less
|
||||
// privileged by standing at the counter.
|
||||
// Supervisors, and nobody else.
|
||||
//
|
||||
// A cashier is never included, and neither is roleid 0. Zero is not a role: it
|
||||
// is what an account carries when nobody set one, and live data has riders and
|
||||
// shop accounts sharing it.
|
||||
// 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 {
|
||||
switch roleID {
|
||||
case PosRoleSupervisor, 1, 2, 3, 4, 5, 6:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return roleID == PosRoleSupervisor
|
||||
}
|
||||
|
||||
// PosUser is a person who signs in at a till.
|
||||
@@ -422,6 +456,18 @@ type PosUser struct {
|
||||
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"`
|
||||
}
|
||||
@@ -433,14 +479,28 @@ type PosUser struct {
|
||||
// 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"`
|
||||
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"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// 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.
|
||||
|
||||
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,47 +53,153 @@ 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"`
|
||||
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
|
||||
@@ -84,13 +207,13 @@ type Products struct {
|
||||
// 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,omitempty"`
|
||||
Diffprice float64 `json:"diffprice,omitempty"`
|
||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||
Othercost float64 `json:"othercost,omitempty"`
|
||||
Approve int `json:"approve"`
|
||||
Productstatus string `json:"productstatus" `
|
||||
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
|
||||
@@ -119,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"`
|
||||
@@ -137,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 {
|
||||
@@ -291,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
|
||||
@@ -324,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
|
||||
|
||||
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"`
|
||||
}
|
||||
101
models/tenant.go
101
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,7 @@ 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.
|
||||
@@ -147,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
|
||||
}
|
||||
@@ -61,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
|
||||
@@ -77,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
|
||||
@@ -130,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
|
||||
}
|
||||
@@ -145,6 +197,12 @@ 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
|
||||
@@ -158,6 +216,52 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
|
||||
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
|
||||
@@ -210,7 +314,7 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
|
||||
if strings.TrimSpace(supplied) != "" {
|
||||
return supplied
|
||||
}
|
||||
return time.Now().Format("2006-01-02 15:04:05")
|
||||
return stampNow()
|
||||
}
|
||||
|
||||
switch data.Orderstatus {
|
||||
@@ -224,8 +328,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
|
||||
|
||||
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
|
||||
@@ -247,8 +351,8 @@ func (r *deliveriesRepository) UpdateDelivery(data models.UpdateDeliveryStatus)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -433,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`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,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)
|
||||
@@ -903,10 +1010,15 @@ func (r *deliveriesRepository) GetDeliveryQueues(uid int, fdate, tdate string) (
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// 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)=CURDATE()
|
||||
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
|
||||
@@ -924,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,
|
||||
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 = ?
|
||||
`)
|
||||
|
||||
@@ -948,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)
|
||||
}
|
||||
}
|
||||
@@ -42,24 +42,74 @@ func NewOrderRepository(db *gorm.DB) OrderRepository {
|
||||
return &orderRepository{db: db}
|
||||
}
|
||||
|
||||
// base backs the tenant and location order lists.
|
||||
//
|
||||
// It now selects the MONEY columns, and their absence is why every order in
|
||||
// the console Sales list read as worth nothing. The values were always stored
|
||||
// correctly - an order for 3 x 45 carries landingamount 135 on its line and
|
||||
// orderamount 135 on its header, and the orderdetails query below has always
|
||||
// selected it. This query simply never asked, so the columns scanned as zero
|
||||
// and the screen reported that faithfully.
|
||||
//
|
||||
// It made a real revenue figure indistinguishable from an order that genuinely
|
||||
// booked nothing, which is why the historic orders on several tenants all look
|
||||
// worthless.
|
||||
// ── Why every join below is a LEFT JOIN ──────────────────────────────────────
|
||||
//
|
||||
// These queries list orders. An order that exists is an order that must be
|
||||
// listable, so a lookup that cannot be resolved may blank a column — it must
|
||||
// never remove the row.
|
||||
//
|
||||
// They were INNER JOINs against customers, tenants, tenantlocations,
|
||||
// app_location and app_locationconfig, which made each one a silent filter on a
|
||||
// column the caller never asked to filter by. An order written with
|
||||
// applocationid 0 or customerid 0 was created successfully, deducted stock, and
|
||||
// then appeared in no screen that lists orders — not the shopper’s own history,
|
||||
// not the shop’s. Measured 2026-09-02: two identical orders one field apart,
|
||||
// both in the database, one of them visible.
|
||||
//
|
||||
// Some of these joins contributed no columns at all to the query they sat in,
|
||||
// so they were pure filters by accident.
|
||||
//
|
||||
// The workaround is still in the tree and shows what this cost: the offline
|
||||
// sales importer builds "header scaffolding" and invents a walk-in customer
|
||||
// solely so its orders would not vanish (offlineLocationContext,
|
||||
// resolveOfflineCustomer). Populating those fields stays good practice, but it
|
||||
// is no longer what keeps an order visible.
|
||||
//
|
||||
// Deliberate filtering is unaffected. A query with a WHERE on a joined table —
|
||||
// GetUserOrders filters e.status and e.userid — still excludes rows the join
|
||||
// could not resolve, because WHERE runs after the join. GetDistinctLocations
|
||||
// keeps its INNER JOIN on purpose: it asks which app locations have orders, so
|
||||
// one that cannot be resolved is genuinely not an answer.
|
||||
const (
|
||||
base = `SELECT DISTINCT a.orderheaderid, a.applocationid, h.locationname AS applocation, a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
|
||||
a.orderid, a.orderstatus, a.orderdate, a.ordernotes, a.itemcount, a.deliverytime AS deliverydate,
|
||||
a.pending, a.processing, a.ready, a.delivered AS completed, a.cancelled,
|
||||
a.deliverycharge, a.kms,
|
||||
a.orderamount, a.ordervalue, a.taxamount, a.ordercharges,
|
||||
a.customerid, a.pickuplocationid, a.pickupaddress, a.pickuplat, a.pickuplong,
|
||||
a.pickupcustomer, a.pickupcontactno, a.pickuplocation as pickupsuburb, a.pickupcity,
|
||||
a.deliveryid AS deliverycustomerid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
|
||||
-- deliveryid is selected under its own name as well as under the
|
||||
-- deliverycustomerid alias it has always carried.
|
||||
--
|
||||
-- The alias alone meant no caller could ask an order whether a rider
|
||||
-- had been assigned to it: the deliveryid field on the response was the
|
||||
-- struct's zero value on every row ever returned, because nothing was
|
||||
-- ever selected into it. The alias stays rather than being corrected --
|
||||
-- the customer app has read it for as long as it has existed, and both
|
||||
-- names now carry the same value.
|
||||
a.deliveryid AS deliverycustomerid, a.deliveryid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
|
||||
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery, b.customertoken,
|
||||
c.tenantname, c.tenanttoken, c.primarycontact AS tenantcontactno, c.postcode AS tenantpostcode, c.suburb AS tenantsuburb, c.city AS tenantcity,
|
||||
d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity
|
||||
FROM orders a
|
||||
INNER JOIN customers b ON a.customerid = b.customerid
|
||||
INNER JOIN tenants c ON a.tenantid = c.tenantid
|
||||
INNER JOIN tenantlocations d ON a.locationid = d.locationid
|
||||
LEFT JOIN customers b ON a.customerid = b.customerid
|
||||
LEFT JOIN tenants c ON a.tenantid = c.tenantid
|
||||
LEFT JOIN tenantlocations d ON a.locationid = d.locationid
|
||||
|
||||
INNER JOIN app_location h ON a.applocationid = h.applocationid
|
||||
INNER JOIN app_locationconfig i ON a.applocationid = i.applocationid`
|
||||
LEFT JOIN app_location h ON a.applocationid = h.applocationid
|
||||
LEFT JOIN app_locationconfig i ON a.applocationid = i.applocationid`
|
||||
|
||||
orderdetails = `SELECT DISTINCT a.orderheaderid, a.applocationid,
|
||||
a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
|
||||
@@ -74,10 +124,10 @@ const (
|
||||
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode, c.suburb AS locationsuburb, c.city AS locationcity,
|
||||
d.locationname AS applocation
|
||||
FROM orders a
|
||||
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
||||
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid`
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid`
|
||||
)
|
||||
|
||||
func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) {
|
||||
@@ -89,15 +139,33 @@ func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.
|
||||
baseQuery := base + ` WHERE a.tenantid = ?`
|
||||
params = append(params, input.Tenantid)
|
||||
|
||||
// Status filters only when one was asked for.
|
||||
//
|
||||
// The else branch ran unconditionally, so a request naming no status became
|
||||
// `orderstatus = ''` — which matches nothing. That is precisely the
|
||||
// console’s default view: All branches, no status chosen. It listed no
|
||||
// orders at all, however many the shop had taken.
|
||||
//
|
||||
// Compared case-insensitively because the ladder is stored capitalised
|
||||
// ("Pending") while every literal here is lower case. Postgres agrees:
|
||||
// `SELECT 'Pending' IN ('pending','processing','ready')` is false, so
|
||||
// "ongoing" matched nothing either.
|
||||
query = baseQuery
|
||||
if input.Status == "ongoing" {
|
||||
query = baseQuery + ` AND a.orderstatus IN ('pending','processing','ready')`
|
||||
} else {
|
||||
query = baseQuery + ` AND a.orderstatus = ?`
|
||||
query += ` AND LOWER(a.orderstatus) IN ('pending','processing','ready')`
|
||||
} else if input.Status != "" {
|
||||
query += ` AND LOWER(a.orderstatus) = LOWER(?)`
|
||||
params = append(params, input.Status)
|
||||
}
|
||||
|
||||
query += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
||||
params = append(params, input.Fromdate, input.ToDate)
|
||||
// A date range narrows the list; it is not a precondition for having one.
|
||||
// Applied unconditionally it cast the empty string to a date and answered
|
||||
// 500, so the console’s order list failed outright until somebody picked a
|
||||
// range.
|
||||
if input.Fromdate != "" && input.ToDate != "" {
|
||||
query += ` AND a.deliverytime::date BETWEEN ? AND ?`
|
||||
params = append(params, input.Fromdate, input.ToDate)
|
||||
}
|
||||
|
||||
if input.Keyword != "" {
|
||||
query += ` AND (
|
||||
@@ -147,7 +215,7 @@ func (r *orderRepository) GetPartnerOrders(stat, fdate, tdate string, pid, pagen
|
||||
params = append(params, fdate, tdate)
|
||||
}
|
||||
if stat != "" {
|
||||
query += ` AND a.orderstatus = ?`
|
||||
query += ` AND LOWER(a.orderstatus) = LOWER(?)`
|
||||
params = append(params, stat)
|
||||
}
|
||||
if keyword != "" {
|
||||
@@ -167,10 +235,10 @@ func (r *orderRepository) GetPartnerOrders(stat, fdate, tdate string, pid, pagen
|
||||
countQuery := `
|
||||
SELECT COUNT(DISTINCT a.orderheaderid)
|
||||
FROM orders a
|
||||
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
||||
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
||||
LEFT JOIN app_users g ON f.userid = g.userid
|
||||
WHERE a.partnerid = ?`
|
||||
@@ -261,10 +329,10 @@ func (r *orderRepository) GetCustomerOrders(stat, fdate, tdate string, cid, mid,
|
||||
countQuery := `
|
||||
SELECT COUNT(DISTINCT a.orderheaderid)
|
||||
FROM orders a
|
||||
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
||||
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
WHERE a.customerid = ?`
|
||||
countParams := []interface{}{cid}
|
||||
|
||||
@@ -363,10 +431,10 @@ func (r *orderRepository) GetAdminOrders(stat, fdate, tdate string, aid, pageno,
|
||||
countQuery := `
|
||||
SELECT COUNT(DISTINCT a.orderheaderid)
|
||||
FROM orders a
|
||||
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
||||
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
||||
LEFT JOIN app_users g ON f.userid = g.userid
|
||||
WHERE 1=1
|
||||
@@ -458,10 +526,10 @@ func (r *orderRepository) GetUserOrders(stat, fdate, tdate string, uid, pageno,
|
||||
countQuery := `
|
||||
SELECT COUNT(DISTINCT a.orderheaderid)
|
||||
FROM orders a
|
||||
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
||||
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
||||
LEFT JOIN app_users g ON f.userid = g.userid
|
||||
WHERE e.status = 'Active' AND e.userid = ?
|
||||
@@ -551,10 +619,10 @@ func (r *orderRepository) GetAllOrders(stat, fdate, tdate string, pageno, pagesi
|
||||
countQuery := `
|
||||
SELECT COUNT(DISTINCT a.orderheaderid)
|
||||
FROM orders a
|
||||
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
||||
INNER JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN deliveries f ON a.orderheaderid = f.orderheaderid
|
||||
LEFT JOIN app_users g ON f.userid = g.userid
|
||||
WHERE 1=1
|
||||
@@ -984,7 +1052,7 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
|
||||
|
||||
totalsQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS total_revenue,
|
||||
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0)), 0) AS total_revenue,
|
||||
COUNT(orderheaderid) AS total_orders
|
||||
FROM orders
|
||||
WHERE %s`, whereClause)
|
||||
@@ -1016,7 +1084,7 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
|
||||
chartQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
CAST(orderdate AS DATE) AS date,
|
||||
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0) + COALESCE(deliveryamt, 0)), 0) AS revenue,
|
||||
COALESCE(SUM(COALESCE(ordervalue, 0) + COALESCE(orderamount, 0)), 0) AS revenue,
|
||||
COUNT(orderheaderid) AS orders
|
||||
FROM orders
|
||||
WHERE %s
|
||||
@@ -1050,7 +1118,7 @@ func (r *orderRepository) GetSalesSummary(tid, lid int, fdate, tdate string) (*m
|
||||
cleanTopLocQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(l.locationname, 'Unknown') AS locationname,
|
||||
COALESCE(SUM(COALESCE(o.ordervalue, 0) + COALESCE(o.orderamount, 0) + COALESCE(o.deliveryamt, 0)), 0) AS revenue
|
||||
COALESCE(SUM(COALESCE(o.ordervalue, 0) + COALESCE(o.orderamount, 0)), 0) AS revenue
|
||||
FROM orders o
|
||||
LEFT JOIN tenantlocations l ON o.locationid = l.locationid
|
||||
WHERE %s
|
||||
@@ -1354,6 +1422,13 @@ func (r *orderRepository) CreateOrder(data models.Orders) (models.Orders, error)
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
// An app order depletes the same shelf the counter sells from, so the tills
|
||||
// at that outlet need to know as much as they do after a counter sale. This
|
||||
// half was the one that would have been forgotten: it is easy to think of
|
||||
// the catalogue as a POS concern, and a till overselling something the app
|
||||
// just took is exactly the failure the push exists to prevent.
|
||||
notifyCatalogueChanged(created.Locationid)
|
||||
|
||||
return r.reloadOrder(created.Orderheaderid)
|
||||
}
|
||||
|
||||
@@ -1515,6 +1590,23 @@ func (r *orderRepository) createOrderTx(tx *gorm.DB, data models.Orders) (models
|
||||
})
|
||||
}
|
||||
|
||||
// An order has to name the outlet it is placed at, and one that did not was
|
||||
// rejected as though the shop were empty.
|
||||
//
|
||||
// Stock is held per outlet: availableStock filters `locationid = ?`, so with
|
||||
// 0 it matches no ledger row and returns 0 for every product. Each line then
|
||||
// failed the check below with "insufficient stock ... available 0" — for a
|
||||
// product sitting on the shelf with 25 of them. The message named the wrong
|
||||
// thing entirely and sent people hunting for stock that was already there,
|
||||
// which is the most expensive kind of error: confidently wrong.
|
||||
//
|
||||
// Checked per line, because a line may carry its own outlet and otherwise
|
||||
// falls back to the header's.
|
||||
if err := assertOutletNamed(lines); err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, err
|
||||
}
|
||||
|
||||
if err := lockStockRows(tx, data.Tenantid, lines); err != nil {
|
||||
tx.Rollback()
|
||||
return models.Orders{}, err
|
||||
@@ -2277,16 +2369,16 @@ func (r *orderRepository) GetCustomerOrdersv3(customerID, tenantID, moduleID, fr
|
||||
a.pickupcity, a.deliveryid AS deliverycustomerid, a.deliveryaddress, a.deliverylat,
|
||||
a.deliverylong, a.deliverytype, a.deliverycustomer, a.deliverycontactno,
|
||||
a.deliverylocation AS deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery,
|
||||
a.taxamount,
|
||||
a.orderamount, a.taxamount,
|
||||
b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno,
|
||||
b.postcode AS tenantpostcode, b.suburb AS tenantsuburb, b.city AS tenantcity, b.registrationno,
|
||||
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode,
|
||||
c.suburb AS locationsuburb, c.city AS locationcity,
|
||||
d.locationname AS applocation
|
||||
FROM orders a
|
||||
INNER JOIN tenants b ON a.tenantid = b.tenantid
|
||||
INNER JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
INNER JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
WHERE 1=1
|
||||
`
|
||||
|
||||
@@ -2365,10 +2457,15 @@ func (r *orderRepository) attachOrderDetails(orders []models.CustomerOrder) ([]m
|
||||
for i := range orders {
|
||||
orders[i].OrderDetails = detailMap[orders[i].Orderheaderid]
|
||||
|
||||
if len(orders[i].OrderDetails) > 0 {
|
||||
orders[i].Orderamount = orders[i].OrderDetails[0].Orderamount
|
||||
orders[i].Totaltaxamount = orders[i].OrderDetails[0].Totaltaxamount
|
||||
}
|
||||
// The header already carries the order total, and it is the only place
|
||||
// that has one. This used to overwrite it with the first LINE’s
|
||||
// Orderamount and Totaltaxamount — columns that do not exist on the
|
||||
// orderdetails table, so both scanned as zero and clobbered a correct
|
||||
// value. Every order in a shopper’s history read ₹0 as a result, however
|
||||
// much they had actually spent.
|
||||
//
|
||||
// Taking the first line’s figure could not have been right even if the
|
||||
// columns existed: one line is not the order.
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
@@ -2390,7 +2487,7 @@ func (r *orderRepository) GetTenantLocationOrders(input models.DeliveryQuery) ([
|
||||
|
||||
// Status filter
|
||||
if input.Status == "ongoing" {
|
||||
query += ` AND a.orderstatus IN ('pending','processing','ready')`
|
||||
query += ` AND LOWER(a.orderstatus) IN ('pending','processing','ready')`
|
||||
} else if input.Status != "" {
|
||||
query += ` AND a.orderstatus = ?`
|
||||
params = append(params, input.Status)
|
||||
@@ -2409,8 +2506,12 @@ func (r *orderRepository) GetTenantLocationOrders(input models.DeliveryQuery) ([
|
||||
}
|
||||
|
||||
// Date filter
|
||||
query += ` AND DATE(a.deliverytime) BETWEEN ? AND ?`
|
||||
params = append(params, input.Fromdate, input.ToDate)
|
||||
// Narrows the list rather than gating it — same reasoning as
|
||||
// GetTenantOrders above, where empty dates cast to a date and answered 500.
|
||||
if input.Fromdate != "" && input.ToDate != "" {
|
||||
query += ` AND DATE(a.deliverytime) BETWEEN ? AND ?`
|
||||
params = append(params, input.Fromdate, input.ToDate)
|
||||
}
|
||||
|
||||
// Keyword filter
|
||||
if input.Keyword != "" {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
type posLoginRow struct {
|
||||
Userid int
|
||||
Password string
|
||||
Pin int64
|
||||
Status string
|
||||
Roleid int
|
||||
Configid int
|
||||
@@ -33,21 +34,94 @@ type posLoginRow struct {
|
||||
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 := "authname", strings.TrimSpace(req.Authname)
|
||||
if value == "" {
|
||||
field, value = "contactno", strings.TrimSpace(req.Contactno)
|
||||
}
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("an email or mobile number is required")
|
||||
field, value, err := posLoginIdentity(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.posLoginCandidates(field, value, req.Configid)
|
||||
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
|
||||
}
|
||||
@@ -55,6 +129,19 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -72,24 +159,43 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
|
||||
// that a deactivated duplicate cannot make a live login ambiguous.
|
||||
row := rows[0]
|
||||
|
||||
// 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.
|
||||
//
|
||||
// TODO: the password column is plaintext across the whole platform. Hashing
|
||||
// it is a migration touching every login path, not something this endpoint
|
||||
// can fix alone — but a POS token minted off a plaintext password is only
|
||||
// ever as good as that column.
|
||||
if strings.TrimSpace(row.Password) == "" {
|
||||
return nil, fmt.Errorf("this account has no password set; set one in the web console first")
|
||||
// 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 !constantTimeEqual(row.Password, req.Password) {
|
||||
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
|
||||
@@ -101,6 +207,18 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
|
||||
// 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")
|
||||
}
|
||||
@@ -193,11 +311,12 @@ func posRoleLabel(roleID int) string {
|
||||
// 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) ([]posLoginRow, error) {
|
||||
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(status, '') AS status,
|
||||
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,
|
||||
@@ -211,6 +330,25 @@ func (r *posRepository) posLoginCandidates(field, value string, configID int) ([
|
||||
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
|
||||
@@ -324,6 +462,17 @@ func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, erro
|
||||
// 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.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ type PosRepository interface {
|
||||
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
|
||||
@@ -381,6 +386,11 @@ func (r *posRepository) importPosOrder(
|
||||
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 ""
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -32,6 +34,51 @@ const (
|
||||
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)
|
||||
@@ -50,18 +97,64 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
||||
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))
|
||||
|
||||
// One or the other, at least. A person with neither cannot sign in, and
|
||||
// creating them would look like it worked right up until somebody tried.
|
||||
if pin == 0 && password == "" {
|
||||
return nil, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
|
||||
// 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 != "" && authname == "" {
|
||||
return nil, fmt.Errorf("a password needs an email to go with it")
|
||||
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 {
|
||||
@@ -91,13 +184,58 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
||||
}
|
||||
}
|
||||
|
||||
if authname != "" {
|
||||
var clash int64
|
||||
if err := tx.Raw(`SELECT COUNT(1) FROM app_users WHERE LOWER(TRIM(authname)) = ?`,
|
||||
authname).Scan(&clash).Error; err != nil {
|
||||
// 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 clash > 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -114,12 +252,12 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO app_users
|
||||
(firstname, lastname, authname, email, contactno, password,
|
||||
pin, roleid, configid, tenantid, locationid, status)
|
||||
pin, shiftid, roleid, configid, tenantid, locationid, status)
|
||||
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
|
||||
NULLIF(?, 0), ?, ?, ?, ?, 'Active')
|
||||
NULLIF(?, 0), NULLIF(?, 0), ?, ?, ?, ?, 'Active')
|
||||
RETURNING userid`,
|
||||
first, last, authname, authname, strings.TrimSpace(req.Contactno),
|
||||
password, pin, roleID, configID, tenantID, locationID,
|
||||
first, last, authname, authname, phone,
|
||||
password, pin, req.Shiftid, roleID, configID, tenantID, locationID,
|
||||
).Scan(&nextID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,13 +271,20 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
||||
Firstname: first,
|
||||
Lastname: last,
|
||||
Authname: authname,
|
||||
Contactno: strings.TrimSpace(req.Contactno),
|
||||
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
|
||||
})
|
||||
@@ -189,6 +334,41 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs
|
||||
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)
|
||||
@@ -218,6 +398,18 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs
|
||||
}
|
||||
}
|
||||
|
||||
// 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, ", "))
|
||||
@@ -260,24 +452,48 @@ func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive b
|
||||
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 userid,
|
||||
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
|
||||
COALESCE(authname,'') AS authname, COALESCE(contactno,'') AS contactno,
|
||||
COALESCE(roleid,0) AS roleid, COALESCE(pin,0) AS pin,
|
||||
(COALESCE(password,'') <> '') AS haspassword,
|
||||
COALESCE(status,'') AS status
|
||||
FROM app_users
|
||||
WHERE tenantid = ? AND locationid = ?
|
||||
AND COALESCE(roleid,0) IN (?, ?)`
|
||||
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 {
|
||||
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||
// `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 userid`
|
||||
query += ` ORDER BY a.userid`
|
||||
|
||||
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -298,6 +514,10 @@ func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive b
|
||||
Haspassword: row.Haspassword,
|
||||
Locationid: locationID,
|
||||
Status: row.Status,
|
||||
Shiftid: row.Shiftid,
|
||||
Shiftname: row.Shiftname,
|
||||
Shiftstart: row.Shiftstart,
|
||||
Shiftend: row.Shiftend,
|
||||
})
|
||||
}
|
||||
return users, nil
|
||||
@@ -334,14 +554,19 @@ func (r *posRepository) DeactivatePosUser(tenantID, locationID, userID int) erro
|
||||
// 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) {
|
||||
value, err := validatePosPin(pin)
|
||||
// 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(status,'') AS status,
|
||||
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,
|
||||
@@ -382,12 +607,74 @@ func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser in
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// validatePosPin checks a PIN is one this schema can store faithfully.
|
||||
func validatePosPin(raw string) (int64, error) {
|
||||
pin := strings.TrimSpace(raw)
|
||||
if pin == "" {
|
||||
return 0, nil
|
||||
// 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")
|
||||
@@ -401,9 +688,24 @@ func validatePosPin(raw string) (int64, error) {
|
||||
// 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.
|
||||
// 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")
|
||||
|
||||
@@ -72,6 +72,39 @@ func TestAnUnrepresentablePinIsNotShown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -106,11 +139,32 @@ func TestOnlyRealRolesCanManageStaff(t *testing.T) {
|
||||
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
|
||||
t.Error("a supervisor was refused staff management, which is their whole purpose")
|
||||
}
|
||||
// Somebody who already administers the shop from a browser is not made less
|
||||
// privileged by standing at the counter.
|
||||
// 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 refused staff management", role)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -17,13 +17,15 @@ type ProductRepository interface {
|
||||
GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error)
|
||||
GetProductCount(tenantID, categoryID, subcategoryID int, approve string) ([]models.Productcount, error)
|
||||
GetProductCategory() ([]models.ProductCategory, error)
|
||||
EnsureTenantCategories(tenantID int, names []string) (map[string]int, error)
|
||||
RecategoriseProducts(tenantID int, updates []models.ProductCategoryUpdate) (int64, error)
|
||||
GetProductVariants(tenantID, subcategoryID int) ([]models.Productvariant, error)
|
||||
GetCatalougeProducts(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Products, error)
|
||||
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
|
||||
CreateProductStock(stocks []models.Productstock) error
|
||||
UpdateProductStatus(productIDs []int, status string) error
|
||||
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
||||
CreateProduct(product models.Products) error
|
||||
EnsureProductLocation(refs []models.ProductLocationRef) error
|
||||
UpdateProduct(product models.Products) error
|
||||
DeleteProduct(productID int) error
|
||||
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
||||
@@ -31,7 +33,7 @@ type ProductRepository interface {
|
||||
GetLocationProductSummary(tenantID, locationID int) ([]models.ProductSummary, error)
|
||||
GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error)
|
||||
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
||||
GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error)
|
||||
GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error)
|
||||
GetSubcategories(categoryID int) ([]models.Subcategory, error)
|
||||
GetProducts(params models.ProductFilter) ([]models.Products, error)
|
||||
GetTenantInfo(tenantID, applocationID int) (map[string]interface{}, error)
|
||||
@@ -39,11 +41,26 @@ type ProductRepository interface {
|
||||
CreateProductLocation(input []models.Productlocations) error
|
||||
CreateProductVariant(input models.Productvariant) error
|
||||
DeleteProductLocation(tenantid, locationid, productid int) error
|
||||
|
||||
// Releasing a product from the admin catalogue to every outlet, and pulling
|
||||
// it back. See productPublishRepository.go.
|
||||
PublishProduct(tenantID, productID int, price, taxPercent float64) (int, error)
|
||||
PublishPricedLocations(refs []models.ProductLocationRef) error
|
||||
UnpublishProduct(tenantID, productID int) (int, error)
|
||||
FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error)
|
||||
FindTenantProductByImageID(tenantid int, imageid string) (*models.Products, error)
|
||||
SetCatalogueLink(productid int, imageid string, catalogueid int) error
|
||||
ListCatalogueLinkedProducts(tenantid int) ([]models.Products, error)
|
||||
CreateProductReturningID(product models.Products) (int, error)
|
||||
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
||||
GetTenantCategories(tenantid int) ([]models.TenantCategory, error)
|
||||
UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error
|
||||
UpdateProductCategory(productid, categoryid, subcategoryid int) error
|
||||
UpdateProductVariant(productid, variantid int) error
|
||||
AddProductVariant(v models.Productvariant) (models.Productvariant, error)
|
||||
RemoveProductVariant(tenantid, variantid int) error
|
||||
VariantsForProducts(tenantid, locationid int, productids []int) (map[int][]models.Productvariant, error)
|
||||
VariantChildIDs(tenantid int) (map[int]bool, error)
|
||||
}
|
||||
|
||||
type productRepository struct {
|
||||
@@ -255,7 +272,7 @@ func (r *productRepository) GetProductStocks(tenantID, locationID string) ([]mod
|
||||
MAX(a.stocktype) AS stocktype, MAX(a.maxquantity) AS maxquantity,
|
||||
MAX(a.minquantity) AS minquantity, MAX(a.status) AS status,
|
||||
b.applocationid, b.categoryid, b.subcategoryid, b.catalogueid, b.addonid, b.discountid, b.pricingid,
|
||||
b.productname, b.productimage, b.productdesc, b.productsku, b.brandid, b.productbrand, b.productunit,
|
||||
b.productname, b.productimage, COALESCE(b.productimages::text, '') AS productimages, b.productdesc, b.productsku, b.brandid, b.productbrand, b.productunit,
|
||||
b.unitvalue, b.toppicks, b.productcost, b.taxamount, b.taxpercent, b.producttax, b.productstock,
|
||||
b.productcombo, b.variants, b.retailprice, b.diffprice, b.diffpercent, b.othercost, b.approve,
|
||||
b.productstatus, b.created, b.updated, c.subcatname AS subcategoryname,
|
||||
@@ -294,6 +311,49 @@ func (r *productRepository) CreateProductStock(stocks []models.Productstock) err
|
||||
return r.db.Table("productstocks").Create(&stocks).Error
|
||||
}
|
||||
|
||||
// EnsureProductLocation puts a product on an outlet's shelf if it is not there
|
||||
// already, and leaves it completely alone if it is.
|
||||
//
|
||||
// Receiving stock used to write the ledger and nothing else. The ledger is not
|
||||
// what the customer app reads: GetProducts joins productlocations and then
|
||||
// filters `pl.locationid = ?`, so a product with no row for that outlet is
|
||||
// dropped by the join no matter how much stock arrived. A shop could request a
|
||||
// product, have it approved, receive it, watch the stock rise in the console —
|
||||
// and the product was still not for sale, with nothing anywhere saying why.
|
||||
//
|
||||
// SyncProductLocationStatus below cannot cover this: it is an UPDATE, so with
|
||||
// no row to update it succeeds having changed nothing.
|
||||
//
|
||||
// INSERT ... SELECT ... WHERE NOT EXISTS rather than an upsert, deliberately.
|
||||
// An upsert here would carry a price, and the only price this code could supply
|
||||
// is the master retailprice — which would overwrite an outlet's own, carefully
|
||||
// different, price every time a delivery arrived. Existing rows must not be
|
||||
// touched; this only ever creates the missing one.
|
||||
//
|
||||
// The seeded status is "outofstock" because it is true at the instant of the
|
||||
// insert. The caller runs SyncProductLocationStatus immediately after, which
|
||||
// derives the real value from the ledger, so a genuine receipt corrects it in
|
||||
// the same call.
|
||||
func (r *productRepository) EnsureProductLocation(refs []models.ProductLocationRef) error {
|
||||
for _, ref := range refs {
|
||||
if err := r.db.Exec(`
|
||||
INSERT INTO productlocations (tenantid, locationid, productid, price, status)
|
||||
SELECT ?, ?, ?, COALESCE(p.retailprice, 0), 'outofstock'
|
||||
FROM products p
|
||||
WHERE p.productid = ? AND p.tenantid = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM productlocations pl
|
||||
WHERE pl.tenantid = ? AND pl.locationid = ? AND pl.productid = ?
|
||||
)`,
|
||||
ref.Tenantid, ref.Locationid, ref.Productid,
|
||||
ref.Productid, ref.Tenantid,
|
||||
ref.Tenantid, ref.Locationid, ref.Productid).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncProductLocationStatus recomputes productlocations.status for each ref
|
||||
// from the productstocks ledger: "available" when the live SUM(in)-SUM(out)
|
||||
// balance is positive, "outofstock" when it is not.
|
||||
@@ -332,19 +392,32 @@ func (r *productRepository) UpdateProductStatus(productIDs []int, status string)
|
||||
Update("productstatus", status).Error
|
||||
}
|
||||
|
||||
func (r *productRepository) CreateProduct(product models.Products) error {
|
||||
tx := r.db.Begin()
|
||||
|
||||
if err := tx.Create(&product).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
// normaliseProductJSON makes a product safe to INSERT.
|
||||
//
|
||||
// `products.productimages` is jsonb and `models.Products.Productimages` is a
|
||||
// plain string, so a caller that never set it hands GORM the zero value — and
|
||||
// GORM puts that empty string in the INSERT rather than omitting the column.
|
||||
// Postgres answers "invalid input syntax for type json (SQLSTATE 22P02)" and
|
||||
// the whole row is rejected, over a field nobody asked for.
|
||||
//
|
||||
// That was not a corner case: the console's sheet importer sends no
|
||||
// productimages at all, so EVERY product it created failed with a 500, and
|
||||
// ImportCatalogueProduct leaves the field empty for any catalogue product that
|
||||
// has no photos. An empty ARRAY is the honest value — there are no extra
|
||||
// images — and it is what `catalogueUploadService` already does for its own
|
||||
// jsonb column, for the same reason.
|
||||
//
|
||||
// Applied at the one create path, which is the last point before the SQL, and
|
||||
// the constraint being satisfied is the database's.
|
||||
func normaliseProductJSON(product *models.Products) {
|
||||
if strings.TrimSpace(product.Productimages) == "" {
|
||||
product.Productimages = "[]"
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
// An OBJECT, not an array: this one holds named catalogue fields, and `{}`
|
||||
// is what a reader parsing it expects to find when there are none.
|
||||
if strings.TrimSpace(product.Cataloguefacts) == "" {
|
||||
product.Cataloguefacts = "{}"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *productRepository) UpdateProduct(product models.Products) error {
|
||||
@@ -453,7 +526,27 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
||||
}
|
||||
offset := (pageno - 1) * pagesize
|
||||
|
||||
params := []interface{}{tenantID, locationID}
|
||||
// locationID 0 means EVERY branch this tenant runs.
|
||||
//
|
||||
// There was no such read, and the console papered over it by listing the
|
||||
// FIRST branch under "All branches" — so a merchant with five outlets saw
|
||||
// RS Puram's four products and not NSN's five, with nothing on screen
|
||||
// saying so. Picking one branch showed MORE than picking all of them, which
|
||||
// is the one shape a filter must never have.
|
||||
//
|
||||
// Aggregated per product rather than one row per branch: this is the admin
|
||||
// catalogue answering "what do we sell", and the same product repeated five
|
||||
// times is a different question nobody asked. Stock sums across branches —
|
||||
// each productstocks row belongs to exactly one branch, so the join cannot
|
||||
// double-count it — while price, publication and status collapse to the
|
||||
// best answer any branch has, which is what a tenant-wide view of a
|
||||
// per-branch column means.
|
||||
allBranches := locationID <= 0
|
||||
|
||||
params := []interface{}{tenantID}
|
||||
if !allBranches {
|
||||
params = append(params, locationID)
|
||||
}
|
||||
|
||||
// b.price is the per-store selling price. It has to be selected explicitly:
|
||||
// a.* only covers products (whose price column is retailprice, the master
|
||||
@@ -470,8 +563,26 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
||||
// retailprice when the store hasn't set its own. Returning a bare b.price
|
||||
// reported 0 for any product priced only at tenant level, which the store
|
||||
// catalogue then rendered as "—".
|
||||
query := `SELECT a.*, b.productlocationid, b.status,
|
||||
COALESCE(NULLIF(b.price, 0), a.retailprice, 0) AS price,
|
||||
// b.publishedat decides whether a store user may see this row at all, so it
|
||||
// has to be selected explicitly for the same reason b.price does: `a.*`
|
||||
// covers products, and publication is a per-outlet fact on
|
||||
// productlocations. Without it the column came back null for every row —
|
||||
// including ones that had just been published — and the store catalogue,
|
||||
// which filters on it, showed nothing.
|
||||
// Per-branch columns are aggregated when every branch is asked for, and
|
||||
// selected plainly when one is. Postgres needs one or the other — a column
|
||||
// that is neither grouped nor aggregated is a hard error, not a warning —
|
||||
// so the two shapes are written out rather than patched together.
|
||||
perBranch := `b.productlocationid, b.status, b.publishedat,
|
||||
COALESCE(NULLIF(b.price, 0), a.retailprice, 0) AS price,`
|
||||
if allBranches {
|
||||
perBranch = `MIN(b.productlocationid) AS productlocationid,
|
||||
MAX(b.status) AS status,
|
||||
MAX(b.publishedat) AS publishedat,
|
||||
COALESCE(NULLIF(MAX(b.price), 0), a.retailprice, 0) AS price,`
|
||||
}
|
||||
|
||||
query := `SELECT a.*, ` + perBranch + `
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END), 0) AS total_in,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'OUT' THEN c.quantity ELSE 0 END), 0) AS total_out,
|
||||
COALESCE(SUM(CASE WHEN UPPER(c.stocktype) = 'IN' THEN c.quantity ELSE 0 END) -
|
||||
@@ -481,7 +592,10 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
||||
FROM products a
|
||||
INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
|
||||
LEFT JOIN productstocks c ON a.productid = c.productid AND b.locationid = c.locationid AND a.tenantid = c.tenantid
|
||||
WHERE a.approve=1 AND a.tenantid = ? AND b.locationid = ?`
|
||||
WHERE a.approve=1 AND a.tenantid = ?`
|
||||
if !allBranches {
|
||||
query += ` AND b.locationid = ?`
|
||||
}
|
||||
|
||||
if subcategoryID != 0 {
|
||||
query += " AND a.subcategoryid = ?"
|
||||
@@ -493,10 +607,18 @@ func (r *productRepository) GetLocationProducts(tenantID, locationID, subcategor
|
||||
params = append(params, "%"+strings.ToLower(keyword)+"%")
|
||||
}
|
||||
|
||||
query += ` GROUP BY a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid,
|
||||
// Grouped by the product alone when every branch is asked for, so one
|
||||
// product is one row however many outlets stock it. Including b.locationid
|
||||
// there would return the same product five times over — which is what a
|
||||
// merchant with five branches would have seen instead of a catalogue.
|
||||
groupBy := ` GROUP BY a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid,
|
||||
a.productunit, a.productcost, a.taxpercent, a.taxamount, a.retailprice,
|
||||
b.tenantid, b.locationid, b.productlocationid, b.status, b.price
|
||||
ORDER BY a.productid DESC LIMIT ? OFFSET ?`
|
||||
b.tenantid, b.locationid, b.productlocationid, b.status, b.price, b.publishedat`
|
||||
if allBranches {
|
||||
groupBy = ` GROUP BY a.productid, a.productname, a.productimage, a.categoryid, a.subcategoryid,
|
||||
a.productunit, a.productcost, a.taxpercent, a.taxamount, a.retailprice`
|
||||
}
|
||||
query += groupBy + ` ORDER BY a.productid DESC LIMIT ? OFFSET ?`
|
||||
|
||||
params = append(params, pagesize, offset)
|
||||
|
||||
@@ -692,6 +814,21 @@ func (r *productRepository) FetchFilteredProducts(
|
||||
// dropped from the balance.
|
||||
// locationID 0 means "not scoped to an outlet": stock is then the tenant's
|
||||
// total across outlets, which is what an unscoped listing should show.
|
||||
//
|
||||
// `price` is computed here for the same reason it is computed in GetProducts
|
||||
// and GetProductByVariant, and its absence was the same bug all three times.
|
||||
// `a.*` carries `products.price`, which nothing ever writes — the selling
|
||||
// price lives in `productlocations.price`, set per outlet when the admin
|
||||
// prices a product, with `products.retailprice` as the tenant-wide fallback.
|
||||
// Without this the endpoint returned price 0 for every row while
|
||||
// retailprice held the real figure, so an app reading `price` showed nothing
|
||||
// at all. Measured on tenant 1135: price 0 / retailprice 50 on this
|
||||
// endpoint, against price 50 on the other three.
|
||||
//
|
||||
// A correlated subquery rather than a read off the joined `b`, because that
|
||||
// derived table takes MAX(locationid) to collapse duplicates and would hand
|
||||
// back an arbitrary outlet's price whenever the caller did not scope to one.
|
||||
// Ordering by locationid at least makes the unscoped answer deterministic.
|
||||
query := r.db.
|
||||
Table("products a").
|
||||
Select(`
|
||||
@@ -700,9 +837,16 @@ func (r *productRepository) FetchFilteredProducts(
|
||||
b.locationid,
|
||||
c.categoryname,
|
||||
d.subcatname AS subcategoryname,
|
||||
COALESCE(NULLIF((
|
||||
SELECT pl2.price FROM productlocations pl2
|
||||
WHERE pl2.productid = a.productid AND pl2.tenantid = a.tenantid
|
||||
AND (? = 0 OR pl2.locationid = ?)
|
||||
ORDER BY pl2.locationid
|
||||
LIMIT 1
|
||||
), 0), a.retailprice, 0) AS price,
|
||||
COALESCE(ps.quantity, 0) AS productstock,
|
||||
COALESCE(ps.quantity, 0) AS quantity
|
||||
`).
|
||||
`, locationID, locationID).
|
||||
Joins(`
|
||||
LEFT JOIN (
|
||||
SELECT productid, tenantid,
|
||||
@@ -786,10 +930,37 @@ func (r *productRepository) FetchFilteredProducts(
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid int) ([]models.Products, error) {
|
||||
// GetProductByVariant returns what the app can offer for one tapped product.
|
||||
//
|
||||
// Two shapes, decided by the product rather than by the caller: a product that
|
||||
// belongs to a variant group comes back with ALL its siblings, so the shopper
|
||||
// picks a size; a product in no group comes back alone, ready to order. That is
|
||||
// what the ordering screen needs, and until now only the first half existed.
|
||||
//
|
||||
// The query keyed solely on `p.variants = ?`, so an ungrouped product — which
|
||||
// means `variants = 0`, and that is EVERY product for most tenants — could not
|
||||
// be fetched at all. Asking for one returned an empty list and the app had
|
||||
// nothing to place an order against. `productid` is the way in: given one, this
|
||||
// reads that product's own group and answers accordingly.
|
||||
//
|
||||
// An explicit `variantid` still wins, so existing callers are unaffected.
|
||||
func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error) {
|
||||
|
||||
var data []models.Products
|
||||
|
||||
// Which group to return, resolved from the product when not named outright.
|
||||
group := variantid
|
||||
if group <= 0 && productid > 0 {
|
||||
var found struct{ Variants int }
|
||||
if err := r.db.Table("products").
|
||||
Select("variants").
|
||||
Where("productid = ? AND tenantid = ?", productid, tenantid).
|
||||
Scan(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group = found.Variants
|
||||
}
|
||||
|
||||
// productstock/quantity are correlated subqueries (not a JOIN+GROUP BY) so
|
||||
// they can coexist with `p.*` without having to enumerate every products
|
||||
// column. quantity is duplicated on purpose: it's placed after `p.*` so it
|
||||
@@ -799,7 +970,7 @@ func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid
|
||||
// productlocations join simply match nothing, so
|
||||
// Productstock/Quantity/Locationstatus come back zero-valued — same
|
||||
// response shape as before this field existed, not an error.
|
||||
err := r.db.
|
||||
q := r.db.
|
||||
Table("products p").
|
||||
Select(`
|
||||
p.*,
|
||||
@@ -808,6 +979,11 @@ func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid
|
||||
COALESCE(pd.discountvalue, 0) AS discountvalue,
|
||||
pd.discountid,
|
||||
pl.status AS locationstatus,
|
||||
COALESCE(NULLIF((
|
||||
SELECT pl2.price FROM productlocations pl2
|
||||
WHERE pl2.productid = p.productid AND pl2.tenantid = p.tenantid AND pl2.locationid = ?
|
||||
LIMIT 1
|
||||
), 0), p.retailprice, 0) AS price,
|
||||
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)
|
||||
@@ -820,12 +996,44 @@ func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid
|
||||
FROM productstocks ps
|
||||
WHERE ps.productid = p.productid AND ps.tenantid = p.tenantid AND ps.locationid = ?
|
||||
), 0) AS quantity
|
||||
`, locationid, locationid).
|
||||
`, locationid, locationid, locationid).
|
||||
Joins("LEFT JOIN productcategories c ON p.categoryid = c.categoryid").
|
||||
Joins("LEFT JOIN productsubcategories d ON p.subcategoryid = d.subcatid").
|
||||
Joins("LEFT JOIN productdiscounts pd ON pd.productid = p.productid").
|
||||
Joins("LEFT JOIN productlocations pl ON pl.productid = p.productid AND pl.tenantid = p.tenantid AND pl.locationid = ?", locationid).
|
||||
Where("p.tenantid = ? AND p.variants = ?", tenantid, variantid).
|
||||
Joins("LEFT JOIN productlocations pl ON pl.productid = p.productid AND pl.tenantid = p.tenantid AND pl.locationid = ?", locationid)
|
||||
|
||||
// A group returns the whole family; no group returns the one product.
|
||||
// Neither given keeps the original behaviour exactly, so nothing that
|
||||
// called this before sees a different answer.
|
||||
switch {
|
||||
case group > 0:
|
||||
q = q.Where("p.tenantid = ? AND p.variants = ?", tenantid, group)
|
||||
case productid > 0:
|
||||
q = q.Where("p.tenantid = ? AND p.productid = ?", tenantid, productid)
|
||||
default:
|
||||
// Neither a group nor a product was named, so there is nothing to
|
||||
// return — and returning nothing is the point.
|
||||
//
|
||||
// This branch used to run `WHERE p.variants = 0`, which is not "no
|
||||
// match": EVERY ungrouped product carries variants 0, so asking for
|
||||
// variant 0 handed back the tenant's entire ungrouped catalogue as if
|
||||
// those products were variants of one another. Measured on tenant 1135:
|
||||
// six unrelated products — a chocolate bar, a chewing gum and an apple —
|
||||
// returned as each other's variants.
|
||||
//
|
||||
// That is what stops an order being placed. The app sends the tapped
|
||||
// product's `variants` value, which is 0 for almost every product, and
|
||||
// receives six things it must choose between. There is no correct choice
|
||||
// to make, so the screen cannot proceed.
|
||||
//
|
||||
// An empty result is the honest answer to a question that named nothing.
|
||||
// The controller rejects this case outright with a message naming
|
||||
// `productid`; this is the second line of defence, so no future caller
|
||||
// can reach the match-everything behaviour by another route.
|
||||
return data, nil
|
||||
}
|
||||
|
||||
err := q.
|
||||
Order("p.productid DESC").
|
||||
Scan(&data).Error
|
||||
|
||||
@@ -833,6 +1041,63 @@ func (r *productRepository) GetProductByVariant(tenantid, variantid, locationid
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The sizes, attached to the products they belong to.
|
||||
//
|
||||
// This is what makes one call enough to draw the ordering screen: the app
|
||||
// asks for the product it was tapped on and gets back the product plus every
|
||||
// size hanging under it. A product with none comes back with an empty list,
|
||||
// which is the answer that lets an order proceed.
|
||||
//
|
||||
// A failure here does not fail the request. The product and its price are
|
||||
// the answer; the sizes are an enrichment, and returning the product without
|
||||
// them beats returning nothing at all.
|
||||
ids := make([]int, 0, len(data))
|
||||
for i := range data {
|
||||
ids = append(ids, data[i].Productid)
|
||||
}
|
||||
if byParent, vErr := r.VariantsForProducts(tenantid, locationid, ids); vErr == nil {
|
||||
for i := range data {
|
||||
children := byParent[data[i].Productid]
|
||||
if len(children) == 0 {
|
||||
// An explicit empty list, never a JSON null: a client that does
|
||||
// `variantoptions.length` must not have to null-check first, and
|
||||
// an empty list is the complete answer “order this one directly”.
|
||||
data[i].Variantoptions = []models.Productvariant{}
|
||||
continue
|
||||
}
|
||||
|
||||
// The product being looked at is one of its own sizes.
|
||||
//
|
||||
// Without this the picker on “Aachi Baby Fryums 100g” offers 500g and
|
||||
// 1kg and not the 100g the shopper is already reading — so the size
|
||||
// they came for is the one option they cannot choose, and there is no
|
||||
// way back to it once they move off. Every screen in the group now
|
||||
// shows the same complete list.
|
||||
//
|
||||
// Built from the product row already in hand rather than by another
|
||||
// query: it carries the effective price and the live balance this
|
||||
// query just computed, so the entry cannot disagree with the row it
|
||||
// came from.
|
||||
self := models.Productvariant{
|
||||
Tenantid: data[i].Tenantid,
|
||||
Productid: data[i].Productid,
|
||||
Variantproductid: data[i].Productid,
|
||||
Variantname: variantLabelFor(data[i]),
|
||||
Status: "Active",
|
||||
// The parent is one of its own sizes, so its entry has to carry
|
||||
// money in the same field the others do. Without this the first
|
||||
// option in every picker read 0 while the rest were priced.
|
||||
Price: data[i].Price,
|
||||
Variantproductname: data[i].Productname,
|
||||
Variantprice: data[i].Price,
|
||||
Variantstock: data[i].Productstock,
|
||||
Variantunitvalue: data[i].Unitvalue,
|
||||
Variantproductunit: data[i].Productunit,
|
||||
}
|
||||
data[i].Variantoptions = append([]models.Productvariant{self}, children...)
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -1063,16 +1328,82 @@ func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
// CreateProductReturningID inserts a new product snapshot and returns its
|
||||
// generated productid. Kept separate from CreateProduct so existing callers
|
||||
// of CreateProduct are unaffected.
|
||||
// CreateProductReturningID inserts a product and returns its generated
|
||||
// productid.
|
||||
//
|
||||
// This is now the only way to create one. There used to be a second method,
|
||||
// `CreateProduct`, that did the same INSERT and threw the id away — it took
|
||||
// the struct by value, so GORM wrote the generated id onto a copy that went
|
||||
// out of scope, and `POST /products/create` answered `productid: 0` for every
|
||||
// product it had just created. The console worked around it by creating, then
|
||||
// re-reading the whole tenant catalogue, then matching back by SKU.
|
||||
//
|
||||
// The two were kept apart so that "existing callers are unaffected", but the
|
||||
// only caller of the id-less one was the endpoint that needed the id most.
|
||||
// One create path also means the jsonb guard above has one place to live.
|
||||
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
||||
normaliseProductJSON(&product)
|
||||
|
||||
if err := r.db.Create(&product).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return product.Productid, nil
|
||||
}
|
||||
|
||||
// FindTenantProductByImageID looks up a tenant's snapshot by the catalogue's
|
||||
// own stable key.
|
||||
//
|
||||
// Preferred over FindTenantProductByCatalogueRef wherever an image_id is
|
||||
// available, because that one keys on a number the catalogue renumbers. After a
|
||||
// re-scrape the id no longer names the product it was stored for, so the lookup
|
||||
// misses, the import believes it is seeing the product for the first time, and
|
||||
// the shop gets a second copy of something it already stocks.
|
||||
//
|
||||
// No brand in the key: image_id already carries it (`cheetos_chips_2d6bf74f`,
|
||||
// `pepsico_kurkure_masala_munch_90g`) and is unique across the whole catalogue,
|
||||
// which is exactly what the bare catalogueid is not.
|
||||
func (r *productRepository) FindTenantProductByImageID(tenantid int, imageid string) (*models.Products, error) {
|
||||
if strings.TrimSpace(imageid) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var product models.Products
|
||||
result := r.db.Table("products").
|
||||
Where("tenantid = ? AND imageid = ?", tenantid, imageid).
|
||||
First(&product)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
// SetCatalogueLink repairs one product's pointer back into the catalogue.
|
||||
//
|
||||
// Both halves move together and both can be cleared, which is the point.
|
||||
// `imageid` is what a product should be linked by from now on; a `catalogueid`
|
||||
// of 0 marks a link that could not be repaired at all — the row it named is
|
||||
// gone and the re-scrape left nothing matching behind it. Clearing it is not
|
||||
// data loss: the pointer already pointed at nothing, and leaving it in place
|
||||
// makes a browse screen claim the product is imported from a row that does not
|
||||
// exist, and makes re-importing fail outright.
|
||||
func (r *productRepository) SetCatalogueLink(productid int, imageid string, catalogueid int) error {
|
||||
return r.db.Table("products").
|
||||
Where("productid = ?", productid).
|
||||
Updates(map[string]any{"imageid": imageid, "catalogueid": catalogueid}).Error
|
||||
}
|
||||
|
||||
// ListCatalogueLinkedProducts returns every product of a tenant that claims to
|
||||
// have come from the catalogue, so each claim can be checked.
|
||||
func (r *productRepository) ListCatalogueLinkedProducts(tenantid int) ([]models.Products, error) {
|
||||
products := make([]models.Products, 0)
|
||||
err := r.db.Table("products").
|
||||
Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid).
|
||||
Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
|
||||
// GetImportedCatalogueRefs returns the (brand, catalogueid) pairs this
|
||||
// tenant has already imported, so a catalogue browse screen can mark items
|
||||
// as already-imported without diffing full product lists client-side. Brand
|
||||
@@ -1082,7 +1413,7 @@ func (r *productRepository) CreateProductReturningID(product models.Products) (i
|
||||
func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) {
|
||||
refs := make([]models.ImportedCatalogueRef, 0)
|
||||
query := r.db.Table("products").
|
||||
Select("productbrand AS brand, catalogueid").
|
||||
Select("productbrand AS brand, catalogueid, COALESCE(imageid, '') AS imageid").
|
||||
Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid)
|
||||
if brand != "" {
|
||||
query = query.Where("productbrand = ?", brand)
|
||||
@@ -1098,21 +1429,293 @@ func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string)
|
||||
// row despite being in real use). This is deliberately not the global
|
||||
// productcategories list: that list can omit categoryids tenants actually
|
||||
// have products in, which would make the import category picker unusable.
|
||||
// RecategoriseProducts moves products into different categories, in bulk.
|
||||
//
|
||||
// Written for the backfill that re-files everything imported before categories
|
||||
// existed: those rows all carry whichever single category the tenant had, while
|
||||
// the catalogue has known all along what each product actually is.
|
||||
//
|
||||
// ── Two things this deliberately does not do ────────────────────────────────
|
||||
//
|
||||
// It does not touch any column but categoryid. UpdateProduct next door runs
|
||||
// GORM's Updates over a whole struct, which is why it can only be trusted with
|
||||
// a hand-built partial — a bulk caller sending full rows would write back every
|
||||
// field it happened to have read, including a price it never meant to change.
|
||||
// One column, named explicitly, cannot do that.
|
||||
//
|
||||
// It is scoped by tenantid in the WHERE clause, not just trusted from the
|
||||
// caller. A productid is global, so a mistyped id in a list would otherwise
|
||||
// re-file another merchant's product into a category that does not exist for
|
||||
// them — invisible to both shops and to the app.
|
||||
func (r *productRepository) RecategoriseProducts(tenantID int, updates []models.ProductCategoryUpdate) (int64, error) {
|
||||
if tenantID == 0 || len(updates) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, 0, len(updates))
|
||||
args := make([]any, 0, len(updates)*3)
|
||||
for _, u := range updates {
|
||||
if u.Productid == 0 || u.Categoryid == 0 {
|
||||
// categoryid 0 is the value the customer app rejects outright.
|
||||
// Refusing it here means a bad row is skipped rather than hiding a
|
||||
// product from every shopper.
|
||||
continue
|
||||
}
|
||||
placeholders = append(placeholders, "(?::bigint, ?::bigint, ?::bigint)")
|
||||
args = append(args, u.Productid, u.Categoryid, u.Subcategoryid)
|
||||
}
|
||||
if len(placeholders) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// subcategoryid is written only when the caller sent one.
|
||||
//
|
||||
// It is the field the customer app groups by, so a 0 arriving from a caller
|
||||
// that does not know about aisles would silently move a product back into
|
||||
// the app's "Uncategorized" bucket. Zero therefore means "leave it alone",
|
||||
// the same rule UpdateProductCategory already follows.
|
||||
args = append(args, tenantID)
|
||||
tx := r.db.Exec(`
|
||||
UPDATE products p
|
||||
SET categoryid = v.categoryid,
|
||||
subcategoryid = CASE WHEN v.subcategoryid > 0
|
||||
THEN v.subcategoryid
|
||||
ELSE p.subcategoryid END
|
||||
FROM (VALUES `+strings.Join(placeholders, ",")+`) AS v(productid, categoryid, subcategoryid)
|
||||
WHERE p.productid = v.productid
|
||||
AND p.tenantid = ?
|
||||
`, args...)
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
// EnsureTenantCategories maps category NAMES to this tenant's category ids,
|
||||
// creating any that do not exist yet, and is safe to call repeatedly.
|
||||
//
|
||||
// The console resolves a category name from a product's title using a
|
||||
// deterministic ladder, but the customer app browses by categoryid and rejects
|
||||
// 0 outright — so a name is useless until it has an id. This is the bridge.
|
||||
//
|
||||
// ── Why the id is computed rather than defaulted ────────────────────────────
|
||||
//
|
||||
// productcategories.categoryid is a plain bigint: no identity, no default, no
|
||||
// sequence. Every insert has to supply one, so two shops uploading a sheet at
|
||||
// the same moment would both read the same MAX and write the same id.
|
||||
//
|
||||
// Both halves of the guard matter:
|
||||
//
|
||||
// - pg_advisory_xact_lock serialises callers on one key for the length of the
|
||||
// transaction, so the read-then-write cannot interleave. It is released
|
||||
// when the transaction ends, including on rollback.
|
||||
// - ON CONFLICT DO NOTHING makes the insert idempotent against the unique
|
||||
// name-per-tenant a caller may add later, and harmless if two requests
|
||||
// somehow race anyway.
|
||||
//
|
||||
// Names are matched case-insensitively on trimmed text: "Dairy" and "dairy "
|
||||
// are one category, because a merchant's sheet will spell it both ways and two
|
||||
// ids for one aisle splits their shop in the app.
|
||||
func (r *productRepository) EnsureTenantCategories(tenantID int, names []string) (map[string]int, error) {
|
||||
out := make(map[string]int)
|
||||
if tenantID == 0 || len(names) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Deduplicate on the same key the lookup uses, so one insert per aisle.
|
||||
wanted := make([]string, 0, len(names))
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
wanted = append(wanted, trimmed)
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// A parameterised VALUES list rather than a Postgres array literal. Two
|
||||
// reasons: no array driver is vendored here, and "Pulses, Grains & Spices"
|
||||
// contains a comma — any delimiter-joined string would split it into three
|
||||
// categories and file a bag of dal under "Grains".
|
||||
placeholders := make([]string, 0, len(wanted))
|
||||
args := make([]any, 0, len(wanted))
|
||||
for _, name := range wanted {
|
||||
placeholders = append(placeholders, "(?)")
|
||||
args = append(args, name)
|
||||
}
|
||||
values := strings.Join(placeholders, ",")
|
||||
|
||||
tx := r.db.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// One key for every caller of this function. Held until commit or rollback.
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", categoryLockKey).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Insert whatever is missing, numbering from the current maximum. The whole
|
||||
// batch is one statement, so the MAX is read once and cannot drift mid-way.
|
||||
insertArgs := append(append([]any{}, args...), tenantID, tenantID)
|
||||
if err := tx.Exec(`
|
||||
WITH incoming(categoryname) AS (VALUES `+values+`),
|
||||
numbered AS (
|
||||
SELECT trim(categoryname) AS categoryname,
|
||||
row_number() OVER (ORDER BY trim(categoryname)) AS n
|
||||
FROM incoming
|
||||
),
|
||||
missing AS (
|
||||
SELECT n.categoryname, row_number() OVER (ORDER BY n.n) AS seq
|
||||
FROM numbered n
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM productcategories pc
|
||||
WHERE pc.tenantid = ?
|
||||
AND lower(trim(pc.categoryname)) = lower(n.categoryname)
|
||||
)
|
||||
)
|
||||
INSERT INTO productcategories
|
||||
(categoryid, tenantid, moduleid, categoryname, sortorder, status, created)
|
||||
SELECT
|
||||
COALESCE((SELECT MAX(categoryid) FROM productcategories), 0) + m.seq,
|
||||
?, 2, m.categoryname, m.seq, 'Active', now()
|
||||
FROM missing m
|
||||
ON CONFLICT DO NOTHING
|
||||
`, insertArgs...).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []models.TenantCategory
|
||||
selectArgs := append([]any{tenantID}, args...)
|
||||
if err := tx.Raw(`
|
||||
SELECT categoryid, categoryname
|
||||
FROM productcategories
|
||||
WHERE tenantid = ?
|
||||
AND lower(trim(categoryname)) IN (
|
||||
SELECT lower(trim(categoryname)) FROM (VALUES `+values+`) AS w(categoryname)
|
||||
)
|
||||
`, selectArgs...).Scan(&rows).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
out[strings.ToLower(strings.TrimSpace(row.Categoryname))] = row.Categoryid
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The advisory-lock key for category creation. An arbitrary constant — it only
|
||||
// has to be the same number in every caller and different from other locks.
|
||||
const categoryLockKey = 8710431
|
||||
|
||||
func (r *productRepository) GetTenantCategories(tenantid int) ([]models.TenantCategory, error) {
|
||||
categories := make([]models.TenantCategory, 0)
|
||||
// Two sources, unioned.
|
||||
//
|
||||
// The first is the categories this tenant's products actually sit in, which
|
||||
// is what this always returned and is still the important half: it is the
|
||||
// only thing that describes a shop stocked before productcategories had rows
|
||||
// for it, and its 'Category N' fallback names those orphans rather than
|
||||
// dropping them.
|
||||
//
|
||||
// The second is the tenant's own rows in productcategories. Without it a
|
||||
// category created by an import is invisible until a product lands in it —
|
||||
// so the import picker offering "where shall I file this?" could not offer
|
||||
// the aisle it had just opened.
|
||||
err := r.db.Raw(`
|
||||
SELECT DISTINCT p.categoryid,
|
||||
COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname
|
||||
FROM products p
|
||||
LEFT JOIN productcategories pc ON pc.categoryid = p.categoryid
|
||||
WHERE p.tenantid = ? AND p.categoryid != 0
|
||||
SELECT categoryid, categoryname FROM (
|
||||
SELECT DISTINCT p.categoryid,
|
||||
COALESCE(NULLIF(pc.categoryname, ''), 'Category ' || p.categoryid) AS categoryname
|
||||
FROM products p
|
||||
LEFT JOIN productcategories pc ON pc.categoryid = p.categoryid
|
||||
WHERE p.tenantid = ? AND p.categoryid != 0
|
||||
|
||||
UNION
|
||||
|
||||
SELECT c.categoryid, c.categoryname
|
||||
FROM productcategories c
|
||||
WHERE c.tenantid = ? AND c.status = 'Active' AND COALESCE(c.categoryname, '') != ''
|
||||
) AS merged
|
||||
ORDER BY categoryname
|
||||
`, tenantid).Scan(&categories).Error
|
||||
`, tenantid, tenantid).Scan(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// UpdateProductPricing updates only the pricing fields on a product
|
||||
// snapshot, used when a catalogue product is re-imported with new pricing.
|
||||
// UpdateProductCategory files a product under a category after the fact.
|
||||
//
|
||||
// It exists because nothing else could. `UpdateProduct` writes only
|
||||
// productlocations.status despite its name, and re-importing a product the
|
||||
// tenant already has took the `existing != nil` branch, which corrected the
|
||||
// pricing and left the category as it was. So a product imported with
|
||||
// categoryid 0 was permanently invisible to the customer app — the endpoint it
|
||||
// browses rejects categoryid 0 outright — with no API able to repair it. Seven
|
||||
// products across three tenants were in that state, six outlets showing an
|
||||
// empty shop to shoppers while the console listed their stock.
|
||||
//
|
||||
// A zero is never written. Callers pass whatever the import request carried,
|
||||
// and a request that omits the category must not erase one that is already
|
||||
// correct — the guard belongs here rather than in each caller.
|
||||
func (r *productRepository) UpdateProductCategory(productid, categoryid, subcategoryid int) error {
|
||||
if categoryid <= 0 {
|
||||
return nil
|
||||
}
|
||||
updates := map[string]interface{}{"categoryid": categoryid}
|
||||
if subcategoryid > 0 {
|
||||
updates["subcategoryid"] = subcategoryid
|
||||
}
|
||||
return r.db.Table("products").Where("productid = ?", productid).Updates(updates).Error
|
||||
}
|
||||
|
||||
// UpdateProductVariant puts an existing product into a variant group, or takes
|
||||
// it out of one.
|
||||
//
|
||||
// It exists because nothing else could. `products.variants` is the grouping the
|
||||
// ordering screen reads — it is what makes three pack sizes one choice rather
|
||||
// than three unrelated products — and until now it could only ever be set at
|
||||
// CREATE time, by a caller that already knew the group id:
|
||||
//
|
||||
// products/create writes whatever the body carries, variants included
|
||||
// importcatalogueproduct never sets it, so every imported product is 0
|
||||
// UpdateProduct writes productlocations.status only, despite the name
|
||||
//
|
||||
// Since importing from the catalogue is how products actually arrive, every
|
||||
// product this console creates is ungrouped and there was no call that could
|
||||
// change that. Groups could be created (createproductvariant) and never used.
|
||||
//
|
||||
// Zero is allowed here, unlike UpdateProductCategory: ungrouping a product is a
|
||||
// real thing to want, and 0 is what ungrouped means. The guard that matters for
|
||||
// this column lives in the read path, which refuses to treat 0 as a group.
|
||||
func (r *productRepository) UpdateProductVariant(productid, variantid int) error {
|
||||
if productid <= 0 {
|
||||
return fmt.Errorf("productid is required")
|
||||
}
|
||||
if variantid < 0 {
|
||||
variantid = 0
|
||||
}
|
||||
return r.db.Table("products").
|
||||
Where("productid = ?", productid).
|
||||
Update("variants", variantid).Error
|
||||
}
|
||||
|
||||
func (r *productRepository) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error {
|
||||
return r.db.Table("products").
|
||||
Where("productid = ?", productid).
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -180,3 +180,31 @@ func legacyOrderQty(quantity float64) int {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -321,11 +352,18 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
||||
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
||||
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
||||
b.locationname,
|
||||
COALESCE(c.rolename,'') AS rolename
|
||||
COALESCE(c.rolename,'') AS rolename,
|
||||
-- Whether the account still works. Absent from this SELECT
|
||||
-- until now, so Users & access had nothing to read and showed
|
||||
-- every person on the platform as "Unknown" — an admin could not
|
||||
-- tell a working login from one that had been switched off.
|
||||
COALESCE(a.status,'') AS status
|
||||
FROM app_users a
|
||||
INNER JOIN tenantlocations b ON a.locationid = b.locationid
|
||||
LEFT JOIN tenantlocations b ON a.locationid = b.locationid
|
||||
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
||||
WHERE a.tenantid = ?`
|
||||
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
|
||||
@@ -389,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.
|
||||
@@ -397,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
|
||||
@@ -557,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
|
||||
@@ -584,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
|
||||
@@ -611,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)
|
||||
@@ -693,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
|
||||
|
||||
@@ -708,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
|
||||
@@ -753,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
|
||||
}
|
||||
@@ -120,13 +129,15 @@ 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 = ?`
|
||||
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 = ?`
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ func RegisterCatalogueRoutes(api fiber.Router, f *facade.Facade) {
|
||||
catalogue.Get("/getcategories", f.CatalogueController.GetCategories)
|
||||
catalogue.Get("/getproducts", f.CatalogueController.GetProducts)
|
||||
catalogue.Get("/getproduct", f.CatalogueController.GetProductBySKU)
|
||||
catalogue.Get("/getproductbyimageid", f.CatalogueController.GetProductByImageID)
|
||||
|
||||
catalogue = api.Group("/v1/mob/catalogue")
|
||||
|
||||
@@ -21,4 +22,5 @@ func RegisterCatalogueRoutes(api fiber.Router, f *facade.Facade) {
|
||||
catalogue.Get("/getcategories", f.CatalogueController.GetCategories)
|
||||
catalogue.Get("/getproducts", f.CatalogueController.GetProducts)
|
||||
catalogue.Get("/getproduct", f.CatalogueController.GetProductBySKU)
|
||||
catalogue.Get("/getproductbyimageid", f.CatalogueController.GetProductByImageID)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,31 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) {
|
||||
partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs)
|
||||
partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary)
|
||||
|
||||
// Hiring a rider, and the directory that shows them before they clock on.
|
||||
//
|
||||
// Until these existed the only way to create a rider was POST /users/create,
|
||||
// which writes app_users and stops — it answered 201 and produced somebody
|
||||
// every rider query ignored, because getriders joins ridersettings and
|
||||
// app_userpools too.
|
||||
//
|
||||
// Web group only. A rider is onboarded from the merchant's console, not from
|
||||
// a phone, so the mobile group below stays read-only as it was.
|
||||
// Onboarding the COMPANY that supplies riders, as opposed to the riders
|
||||
// themselves. `getpartners` has always existed and nothing could create
|
||||
// one, which is why the five partners live today were inserted by hand —
|
||||
// two of them are still named "Test".
|
||||
//
|
||||
// Web group only, and deliberately not in the mob group below: a partner is
|
||||
// onboarded by the platform from a console, never from a phone and never by
|
||||
// a merchant. A merchant's side of this is being assigned one.
|
||||
partner.Post("/createpartner", f.PartnerController.CreatePartner)
|
||||
partner.Put("/updatepartner", f.PartnerController.UpdatePartner)
|
||||
partner.Get("/getpartnerlocations", f.PartnerController.GetPartnerLocations)
|
||||
|
||||
partner.Post("/createrider", f.PartnerController.CreateRider)
|
||||
partner.Put("/updaterider", f.PartnerController.UpdateRider)
|
||||
partner.Get("/getriderroster", f.PartnerController.GetRiderRoster)
|
||||
|
||||
partner = api.Group("/v1/mob/partners")
|
||||
|
||||
partner.Get("/getpartners", f.PartnerController.GetPartners)
|
||||
|
||||
@@ -75,6 +75,49 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
||||
pos.Get("/health/location", f.PosController.LocationHealth)
|
||||
|
||||
registerPosStaffConsoleRoutes(api, f)
|
||||
registerPosReadConsoleRoutes(api, f)
|
||||
registerLiveRoutes(api, f)
|
||||
}
|
||||
|
||||
// The same counter-sales reads, for callers that are not a terminal.
|
||||
//
|
||||
// `PosAuth` pins a request to the outlet inside a terminal's token. The web
|
||||
// console has no such token and cannot obtain one — `/pos/login` refuses an
|
||||
// account that is not a till account, which is the separation working as
|
||||
// intended. So the moment `POS_AUTH_REQUIRED=true` is set, every POS screen in
|
||||
// the back office goes dark: the two were mutually exclusive.
|
||||
//
|
||||
// Rather than weaken the terminal guard or hand the console a terminal
|
||||
// identity, the reads are offered again outside the group. A browser and a till
|
||||
// are different callers and belong on different doors.
|
||||
//
|
||||
// Only the five the console actually reads, and only reads. Specifically NOT
|
||||
// `/health/terminal`: it takes a terminal code and no outlet, so it resolves
|
||||
// the shop from the heartbeat and checks that against the caller's token. Off
|
||||
// this group there is no token to check, and mirroring it would undo that.
|
||||
// Nothing in the console calls it — `/health/location` answers the same
|
||||
// question with an outlet to scope by.
|
||||
//
|
||||
// These inherit the `/web` surface's authentication, which is none. That is not
|
||||
// a new hole opened here — `getposusers` on the neighbouring group already
|
||||
// answers unauthenticated and returns PINs — but it is the reason this whole
|
||||
// surface wants a session guard, which is tracked separately.
|
||||
func registerPosReadConsoleRoutes(api fiber.Router, f *facade.Facade) {
|
||||
for _, group := range []string{"/v1/web/pos", "/v1/mob/pos"} {
|
||||
g := api.Group(group)
|
||||
|
||||
g.Get("/sales", f.PosController.GetSales)
|
||||
g.Get("/sales/detail", f.PosController.GetSaleDetail)
|
||||
g.Get("/sales/summary", f.PosController.GetSalesSummary)
|
||||
|
||||
// The fleet board. Scoped by the outlet named in the query, exactly as
|
||||
// on the terminal group — the handler reads nothing from a session.
|
||||
g.Get("/health/location", f.PosController.LocationHealth)
|
||||
|
||||
// What a till at this shop can sell. Read-only and already scoped by
|
||||
// store id; the console shows it to explain why a product will not ring.
|
||||
g.Get("/catalogue", f.PosController.Catalogue)
|
||||
}
|
||||
}
|
||||
|
||||
// Till staff, managed from the web console rather than from a counter.
|
||||
@@ -105,5 +148,28 @@ func registerPosStaffConsoleRoutes(api fiber.Router, f *facade.Facade) {
|
||||
g.Post("/createposuser", f.PosController.WebCreatePosUser)
|
||||
g.Put("/updateposuser", f.PosController.WebUpdatePosUser)
|
||||
g.Delete("/deleteposuser", f.PosController.WebDeletePosUser)
|
||||
|
||||
// Working windows a till account can be assigned to. Alongside the
|
||||
// staff routes rather than under /pos, for the same reason: the caller
|
||||
// is the back office setting a shop up, and it holds no terminal
|
||||
// session to be checked against.
|
||||
g.Get("/getstaffshifts", f.PosController.WebListStaffShifts)
|
||||
g.Post("/createstaffshift", f.PosController.WebCreateStaffShift)
|
||||
g.Put("/updatestaffshift", f.PosController.WebUpdateStaffShift)
|
||||
}
|
||||
}
|
||||
|
||||
// The console's live event stream.
|
||||
//
|
||||
// Separate from the POS groups above because the caller is the back office,
|
||||
// not a terminal, and because it is the one route here that holds a
|
||||
// connection open — worth being obvious about when reading the route table.
|
||||
//
|
||||
// `/events` is Server-Sent Events and never returns until the client goes
|
||||
// away or the age limit fires. `/events/health` is an ordinary JSON read that
|
||||
// says how many consoles are attached to THIS replica.
|
||||
func registerLiveRoutes(api fiber.Router, f *facade.Facade) {
|
||||
g := api.Group("/v1/web/live")
|
||||
g.Get("/events", f.LiveController.Stream)
|
||||
g.Get("/events/health", f.LiveController.Health)
|
||||
}
|
||||
|
||||
@@ -29,9 +29,27 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
||||
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
||||
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
||||
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
||||
|
||||
// Repairing catalogue links. A dry run unless `apply=true` — see the handler,
|
||||
// and `services/catalogueRelink.go` for why a dangling link can only be
|
||||
// adopted or cleared, never repaired by name.
|
||||
products.Get("/relinkcatalogue", f.ProductController.RelinkCatalogue)
|
||||
|
||||
products.Get("/gettenantcategories", f.ProductController.GetTenantCategories)
|
||||
products.Post("/resolvecategories", f.ProductController.ResolveCategories)
|
||||
products.Put("/recategorise", f.ProductController.Recategorise)
|
||||
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
|
||||
|
||||
// Releasing a product to the shops. Importing no longer does this: a
|
||||
// product lands in the admin catalogue unpublished and unpriced, and only
|
||||
// this step — which refuses without a price — makes it sellable.
|
||||
products.Post("/publishproduct", f.ProductController.PublishProduct)
|
||||
products.Post("/unpublishproduct", f.ProductController.UnpublishProduct)
|
||||
products.Post("/createproductvariant", f.ProductController.CreateProductVariant)
|
||||
products.Put("/updateproductvariant", f.ProductController.UpdateProductVariant)
|
||||
// Sizes as a relationship: attach one product under another, and detach it.
|
||||
products.Post("/addproductvariant", f.ProductController.AddProductVariant)
|
||||
products.Delete("/removeproductvariant", f.ProductController.RemoveProductVariant)
|
||||
|
||||
products.Post("/createstockrequest", f.StockRequestController.CreateStockRequest)
|
||||
products.Get("/getstockrequests", f.StockRequestController.GetStockRequests)
|
||||
|
||||
@@ -20,4 +20,6 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
||||
RegisterCustomerRoutes(api, f)
|
||||
RegisterCatalogueRoutes(api, f)
|
||||
RegisterPosRoutes(api, f)
|
||||
RegisterUploadRoutes(api, f)
|
||||
RegisterScanRoutes(api, f)
|
||||
}
|
||||
|
||||
17
routes/scanroutes.go
Normal file
17
routes/scanroutes.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"nearle/facade"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Scan-to-order, customer app only. See controllers/scanController.go for
|
||||
// the three calls and services/scanService.go for the pipeline behind them.
|
||||
func RegisterScanRoutes(api fiber.Router, f *facade.Facade) {
|
||||
scan := api.Group("/v1/mob/scan")
|
||||
|
||||
scan.Post("/lookup", f.ScanController.Lookup)
|
||||
scan.Post("/confirm", f.ScanController.Confirm)
|
||||
scan.Get("/stores", f.ScanController.Stores)
|
||||
}
|
||||
@@ -22,6 +22,44 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
|
||||
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
|
||||
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
|
||||
|
||||
// One business, by id.
|
||||
//
|
||||
// Also /mob-only until now, so the console's only way to read its own
|
||||
// merchant was `getalltenants` — 262 rows, paginated, and a shop on page
|
||||
// two was simply not found. A profile screen cannot be built on that.
|
||||
tenant.Get("/gettenantinfo", f.TenantController.GetTenantInfo)
|
||||
|
||||
// Which delivery partner supplies this merchant's riders.
|
||||
//
|
||||
// Its own route rather than a field on updatetenant: `partnerid` is kept
|
||||
// out of the merchant-editable allowlist on purpose, because a merchant
|
||||
// who could set it would move themselves under another partner.
|
||||
tenant.Put("/assignpartner", f.TenantController.AssignPartner)
|
||||
|
||||
// A merchant editing their own business record. The first write path the
|
||||
// `tenants` table has ever had — see UpdateTenantProfile for what may be
|
||||
// set, and what deliberately may not.
|
||||
tenant.Put("/updatetenant", f.TenantController.UpdateTenantProfile)
|
||||
|
||||
// Somebody editing their own name, mobile or email. Scoped to their own
|
||||
// account AND their own business — `users/update` checks neither, which is
|
||||
// why the store user's account page could only ever be read-only.
|
||||
tenant.Put("/updateownprofile", f.TenantController.UpdateOwnProfile)
|
||||
|
||||
// A merchant's people, and where each of them works.
|
||||
//
|
||||
// These existed only under /mob, which is why the console has never had a
|
||||
// screen for them: back-office staff were reachable exclusively through the
|
||||
// customer app's door. The /mob registrations stay — something may be
|
||||
// calling them — but this is where they belong.
|
||||
tenant.Get("/getstaffs", f.TenantController.GetStaffs)
|
||||
tenant.Post("/createstaff", f.TenantController.CreateStaff)
|
||||
|
||||
// Placing a person at a branch, or taking them off one. Separate from
|
||||
// createstaff because hiring and posting are different decisions, and the
|
||||
// second happens repeatedly over an account's life.
|
||||
tenant.Put("/assignstaff", f.TenantController.AssignStaff)
|
||||
|
||||
tenant = api.Group("/v1/mob/tenants")
|
||||
|
||||
tenant.Get("/gettenantslot", f.TenantController.GetTenantSlot)
|
||||
@@ -34,6 +72,11 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
|
||||
tenant.Post("/createlocation", f.TenantController.CreateLocation)
|
||||
tenant.Get("/getstaffs", f.TenantController.GetStaffs)
|
||||
tenant.Post("/createstaff", f.TenantController.CreateStaff)
|
||||
|
||||
// Placing a person at a branch, or taking them off one. Separate from
|
||||
// createstaff because hiring and posting are different decisions, and the
|
||||
// second happens again and again over an account's life.
|
||||
tenant.Put("/assignstaff", f.TenantController.AssignStaff)
|
||||
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
|
||||
tenant.Get("/gettenantinfo", f.TenantController.GetTenantInfo)
|
||||
|
||||
|
||||
37
routes/uploadroutes.go
Normal file
37
routes/uploadroutes.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"nearle/facade"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Receipts for spreadsheets sent to the catalogue ingest service.
|
||||
//
|
||||
// Web only, and that is not an oversight. These are a back-office audit trail —
|
||||
// who uploaded what, for which shop, and whether it reached the shelf. The
|
||||
// customer app has no use for any of it, and mounting them under /mob would
|
||||
// publish one merchant's stocking activity to an endpoint built for shoppers.
|
||||
func RegisterUploadRoutes(api fiber.Router, f *facade.Facade) {
|
||||
|
||||
uploads := api.Group("/v1/web/uploads")
|
||||
|
||||
// Written the instant a drop is accepted. The batch id is the only
|
||||
// credential for reading the result back, and the ingest service deletes an
|
||||
// unreviewed drop after seven days — so this is the call that keeps an
|
||||
// upload findable at all.
|
||||
uploads.Post("/record", f.CatalogueUploadController.RecordUpload)
|
||||
uploads.Get("/list", f.CatalogueUploadController.GetUploads)
|
||||
|
||||
// Progress, cached from whichever browser is polling the ingest service.
|
||||
uploads.Put("/update", f.CatalogueUploadController.UpdateUpload)
|
||||
|
||||
// The other half: priced, shelved and stocked at a branch. Kept apart from
|
||||
// /update because the ingest service confirms the shared global catalogue
|
||||
// and knows nothing about a shop's shelf.
|
||||
// The rescue path: hand the spreadsheet over again for a receipt whose
|
||||
// prices and opening stock were never stored, so it can still be shelved.
|
||||
uploads.Put("/sheet", f.CatalogueUploadController.AttachSheet)
|
||||
|
||||
uploads.Put("/shelved", f.CatalogueUploadController.MarkShelved)
|
||||
}
|
||||
@@ -18,7 +18,12 @@ func RegisterUserRoutes(api fiber.Router, f *facade.Facade) {
|
||||
users.Delete("/delete", f.UserController.DeleteUser)
|
||||
|
||||
users = api.Group("/v1/mob/users")
|
||||
users.Post("/tenant/login", f.UserController.Login)
|
||||
// `TenantLogin`, not `Login`. The route is named for the tenant and the app
|
||||
// needs the shop with it — its name, address, category, branch and the
|
||||
// superadmin flag — none of which `UserInfo` carries. `TenantUserInfo` now
|
||||
// also carries the shiftid, shiftname and status that `Login` returned, so
|
||||
// this response is a superset of the old one and no client loses a field.
|
||||
users.Post("/tenant/login", f.UserController.TenantLogin)
|
||||
users.Get("/getusers", f.UserController.GetUserInfo)
|
||||
users.Post("/create", f.UserController.CreateUser)
|
||||
users.Put("/update", f.UserController.UpdateStaff)
|
||||
|
||||
90
scratch/cataloguedims/main.go
Normal file
90
scratch/cataloguedims/main.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Reports how the catalogue's `embedding` columns are shaped — width, how
|
||||
// many rows are filled, and a sample norm — so the embedding model Fiesta
|
||||
// calls can be matched to the one that indexed the catalogue. Metadata and
|
||||
// counts only, on a read-only transaction; it never writes.
|
||||
//
|
||||
// go run ./scratch/cataloguedims # reads CATALOGUE_DB_* from .env.production
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
sample := flag.String("sample", "", "print one row's texts and stored vector from this table, to check which model produced it")
|
||||
flag.Parse()
|
||||
_ = godotenv.Load(".env.production")
|
||||
dsn := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(os.Getenv("CATALOGUE_DB_USER"), os.Getenv("CATALOGUE_DB_PASSWORD")),
|
||||
Host: os.Getenv("CATALOGUE_DB_HOST") + ":" + os.Getenv("CATALOGUE_DB_PORT"),
|
||||
Path: "/" + os.Getenv("CATALOGUE_DB_NAME"),
|
||||
}
|
||||
q := dsn.Query()
|
||||
q.Set("sslmode", "disable")
|
||||
q.Set("default_transaction_read_only", "on")
|
||||
dsn.RawQuery = q.Encode()
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn.String()), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if *sample != "" {
|
||||
var row struct {
|
||||
ProductName string
|
||||
Title string
|
||||
SearchQuery string
|
||||
Embedding string
|
||||
}
|
||||
db.Raw(fmt.Sprintf(`SELECT product_name, COALESCE(title, '') AS title, COALESCE(search_query, '') AS search_query,
|
||||
embedding::text AS embedding FROM %s WHERE embedding IS NOT NULL ORDER BY id LIMIT 1`, *sample)).Scan(&row)
|
||||
fmt.Printf("product_name: %s\ntitle: %s\nsearch_query: %s\nembedding: %s\n", row.ProductName, row.Title, row.SearchQuery, row.Embedding)
|
||||
return
|
||||
}
|
||||
|
||||
var cols []struct {
|
||||
Relname string
|
||||
Attname string
|
||||
Typname string
|
||||
Atttypmod int
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT c.relname, a.attname, t.typname, a.atttypmod
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
JOIN pg_type t ON t.oid = a.atttypid
|
||||
WHERE t.typname = 'vector' AND a.attnum > 0 AND c.relname LIKE 'brand\_%'
|
||||
ORDER BY c.relname, a.attname`).Scan(&cols).Error; err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
fmt.Println("no brand_* table has a vector column")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-24s %-16s %-8s %5s %5s %5s\n", "table", "column", "type", "dims", "rows", "filled")
|
||||
for _, c := range cols {
|
||||
var total, filled int64
|
||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s`, c.Relname)).Scan(&total)
|
||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s WHERE %s IS NOT NULL`, c.Relname, c.Attname)).Scan(&filled)
|
||||
fmt.Printf("%-24s %-16s %-8s %5d %5d %5d\n", c.Relname, c.Attname, c.Typname, c.Atttypmod, total, filled)
|
||||
}
|
||||
|
||||
// nomic/bge emit unit vectors; a norm far from 1 means another pipeline.
|
||||
for _, c := range cols {
|
||||
var norm float64
|
||||
db.Raw(fmt.Sprintf(`SELECT vector_norm(embedding) FROM %s WHERE embedding IS NOT NULL LIMIT 1`, c.Relname)).Scan(&norm)
|
||||
if norm > 0 {
|
||||
fmt.Printf("sample vector norm (%s): %.4f\n", c.Relname, norm)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
402
scratch/cataloguefactsbackfill/main.go
Normal file
402
scratch/cataloguefactsbackfill/main.go
Normal file
@@ -0,0 +1,402 @@
|
||||
// Backfills products.cataloguefacts for products imported before the column existed.
|
||||
//
|
||||
// The catalogue import copied eight of the catalogue's eighteen fields onto a
|
||||
// tenant's product and left the other ten behind — the FSSAI licence, nutrients,
|
||||
// highlights, providers, the typical price range, the variant key. The console
|
||||
// covered for it by asking the catalogue again on every drawer open, and that
|
||||
// stops working the moment a re-scrape retires the source row: a tenant's
|
||||
// product is a SNAPSHOT and outlives it, so a licence number came off a product
|
||||
// the shop was still selling with no way back.
|
||||
//
|
||||
// The import keeps them now. Every product imported BEFORE that does not have
|
||||
// them, and no amount of new code fixes a row that was written last month — so
|
||||
// this reads each one's catalogue entry while it is still there and stores it.
|
||||
//
|
||||
// go run ./scratch/cataloguefactsbackfill # dry run — shows every change
|
||||
// go run ./scratch/cataloguefactsbackfill apply # writes, then prints the undo
|
||||
//
|
||||
// ── What it will and will not touch ─────────────────────────────────────────
|
||||
//
|
||||
// Only products with an `imageid` and a NULL `cataloguefacts`. That is the
|
||||
// whole safety story:
|
||||
//
|
||||
// - NULL means nothing was ever written. A product whose facts are already
|
||||
// stored — including one stored as `{}` because the catalogue genuinely had
|
||||
// nothing to say — is never overwritten, so re-running this is a no-op
|
||||
// rather than a second opinion.
|
||||
// - No `imageid` means it never came from the catalogue. Sheet-imported
|
||||
// products have no entry to read and are left alone.
|
||||
// - A catalogue row that has already been retired cannot be recovered by
|
||||
// anything, here or later. Those are counted and named rather than written
|
||||
// as empty, because `{}` would claim the catalogue said nothing when the
|
||||
// truth is that nobody asked in time.
|
||||
//
|
||||
// Brand tables are discovered rather than assumed, and their columns are
|
||||
// checked one by one before being selected: the catalogue is another team's
|
||||
// scrape, brands appear between runs, and a table missing `nutrients` is a
|
||||
// perfectly good catalogue of products. Demanding the full column set is the
|
||||
// exact mistake that once made 16 of 35 live brands invisible to this side.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
// The columns worth keeping, in the order the drawer reads them. Scalars and
|
||||
// arrays are separated because an array comes back as a Postgres text[] literal
|
||||
// and has to be parsed before it can be re-encoded as JSON.
|
||||
var scalarFacts = []string{
|
||||
"title", "category", "variant_key", "sku_source",
|
||||
"price_range", "fssai_license", "search_query",
|
||||
}
|
||||
|
||||
var arrayFacts = []string{"providers", "highlights", "nutrients"}
|
||||
|
||||
type product struct {
|
||||
Productid int
|
||||
Productbrand string
|
||||
Imageid string
|
||||
Productname string
|
||||
Tenantid int
|
||||
}
|
||||
|
||||
func main() {
|
||||
apply := len(os.Args) > 1 && os.Args[1] == "apply"
|
||||
|
||||
_ = godotenv.Load()
|
||||
|
||||
main, err := open("DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME")
|
||||
if err != nil {
|
||||
log.Fatal("nearledb: ", err)
|
||||
}
|
||||
cat, err := open("CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_USER",
|
||||
"CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME")
|
||||
if err != nil {
|
||||
log.Fatal("cataloguedb: ", err)
|
||||
}
|
||||
|
||||
// The column has to exist before there is anything to fill. Checked rather
|
||||
// than assumed so this says so plainly instead of failing inside a query.
|
||||
var hasColumn int
|
||||
main.Raw(`SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_name = 'products' AND column_name = 'cataloguefacts'`).Scan(&hasColumn)
|
||||
if hasColumn == 0 {
|
||||
log.Fatal("products.cataloguefacts does not exist — start the API once to run the migration, then re-run this")
|
||||
}
|
||||
|
||||
var candidates []product
|
||||
main.Raw(`SELECT productid, tenantid, COALESCE(productbrand,'') AS productbrand,
|
||||
COALESCE(imageid,'') AS imageid, COALESCE(productname,'') AS productname
|
||||
FROM products
|
||||
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL
|
||||
ORDER BY productbrand, productid`).Scan(&candidates)
|
||||
|
||||
var (
|
||||
total int
|
||||
alreadyDone int
|
||||
noImageid int
|
||||
)
|
||||
main.Raw(`SELECT COUNT(*) FROM products`).Scan(&total)
|
||||
main.Raw(`SELECT COUNT(*) FROM products WHERE cataloguefacts IS NOT NULL`).Scan(&alreadyDone)
|
||||
main.Raw(`SELECT COUNT(*) FROM products WHERE COALESCE(imageid,'') = ''`).Scan(&noImageid)
|
||||
|
||||
fmt.Printf("products on the platform : %d\n", total)
|
||||
fmt.Printf(" never came from the catalogue : %d (no imageid — left alone)\n", noImageid)
|
||||
fmt.Printf(" facts already stored : %d (never overwritten)\n", alreadyDone)
|
||||
fmt.Printf(" to backfill : %d\n\n", len(candidates))
|
||||
|
||||
if len(candidates) == 0 {
|
||||
fmt.Println("nothing to do.")
|
||||
return
|
||||
}
|
||||
|
||||
// One column check per brand table, not per product: the shape is a
|
||||
// property of the table and a per-row check would be thousands of
|
||||
// information_schema reads to learn the same thing.
|
||||
columnsByTable := map[string][]string{}
|
||||
missingTable := map[string]bool{}
|
||||
|
||||
type update struct {
|
||||
product product
|
||||
facts string
|
||||
}
|
||||
var (
|
||||
updates []update
|
||||
retired []product
|
||||
unknown []product
|
||||
emptyOnly []product
|
||||
)
|
||||
|
||||
for _, p := range candidates {
|
||||
table := brandTable(p.Productbrand)
|
||||
if table == "" {
|
||||
unknown = append(unknown, p)
|
||||
continue
|
||||
}
|
||||
if missingTable[table] {
|
||||
retired = append(retired, p)
|
||||
continue
|
||||
}
|
||||
|
||||
cols, known := columnsByTable[table]
|
||||
if !known {
|
||||
cols = factColumnsOf(cat, table)
|
||||
if cols == nil {
|
||||
missingTable[table] = true
|
||||
retired = append(retired, p)
|
||||
continue
|
||||
}
|
||||
columnsByTable[table] = cols
|
||||
}
|
||||
|
||||
facts, found := factsFor(cat, table, cols, p.Imageid)
|
||||
if !found {
|
||||
retired = append(retired, p)
|
||||
continue
|
||||
}
|
||||
if len(facts) == 0 {
|
||||
// The row is there and had nothing in these columns. Worth writing
|
||||
// `{}` — it is the true answer and it stops the console asking the
|
||||
// catalogue again on every open.
|
||||
emptyOnly = append(emptyOnly, p)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(facts)
|
||||
if err != nil {
|
||||
log.Printf("could not encode facts for product %d: %v", p.Productid, err)
|
||||
continue
|
||||
}
|
||||
updates = append(updates, update{product: p, facts: string(encoded)})
|
||||
}
|
||||
|
||||
fmt.Printf("%-9s %-14s %-22s %-34s %s\n", "product", "brand", "imageid", "name", "facts recovered")
|
||||
for _, u := range updates {
|
||||
var keys []string
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal([]byte(u.facts), &got)
|
||||
for k := range got {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
summary := strings.Join(keys, ",")
|
||||
if summary == "" {
|
||||
summary = "(catalogue row has none)"
|
||||
}
|
||||
fmt.Printf("%-9d %-14s %-22s %-34s %s\n",
|
||||
u.product.Productid, trim(u.product.Productbrand, 14), trim(u.product.Imageid, 22),
|
||||
trim(u.product.Productname, 34), summary)
|
||||
}
|
||||
|
||||
if len(retired) > 0 {
|
||||
fmt.Printf("\n!! %d product(s) cannot be recovered — their catalogue row is gone:\n", len(retired))
|
||||
for _, p := range retired {
|
||||
fmt.Printf(" %-9d %-14s %-22s %s\n", p.Productid, trim(p.Productbrand, 14),
|
||||
trim(p.Imageid, 22), trim(p.Productname, 40))
|
||||
}
|
||||
fmt.Println(" These are left NULL. The console falls back to the live lookup for them,")
|
||||
fmt.Println(" which will also find nothing — the detail was lost before this ran.")
|
||||
}
|
||||
|
||||
if len(unknown) > 0 {
|
||||
fmt.Printf("\n!! %d product(s) carry a brand with no table in the catalogue:\n", len(unknown))
|
||||
for _, p := range unknown {
|
||||
fmt.Printf(" %-9d %-14s %s\n", p.Productid, trim(p.Productbrand, 14), trim(p.Productname, 40))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nwill write %d product(s)", len(updates))
|
||||
if len(emptyOnly) > 0 {
|
||||
fmt.Printf(", %d of them as `{}` because the catalogue row carries none of these fields", len(emptyOnly))
|
||||
}
|
||||
fmt.Printf("; leaving %d NULL\n", len(retired)+len(unknown))
|
||||
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
if !apply {
|
||||
fmt.Println("\ndry run — nothing written. re-run with `apply` to write.")
|
||||
return
|
||||
}
|
||||
|
||||
// One row at a time, each guarded by `cataloguefacts IS NULL` again.
|
||||
// Between the read above and this write another import could have stored
|
||||
// the real thing, and this must never be the one that overwrites it.
|
||||
written := 0
|
||||
ids := make([]int, 0, len(updates))
|
||||
for _, u := range updates {
|
||||
res := main.Exec(`UPDATE products SET cataloguefacts = ?::jsonb
|
||||
WHERE productid = ? AND cataloguefacts IS NULL`,
|
||||
u.facts, u.product.Productid)
|
||||
if res.Error != nil {
|
||||
log.Printf("product %d: %v", u.product.Productid, res.Error)
|
||||
continue
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
written++
|
||||
ids = append(ids, u.product.Productid)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nwrote %d product(s)\n", written)
|
||||
|
||||
var stillNull int
|
||||
main.Raw(`SELECT COUNT(*) FROM products
|
||||
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL`).Scan(&stillNull)
|
||||
fmt.Printf("catalogue-linked products still without facts: %d\n", stillNull)
|
||||
|
||||
if len(ids) > 0 {
|
||||
fmt.Printf("\nundo:\n UPDATE products SET cataloguefacts = NULL WHERE productid IN (%s);\n",
|
||||
joinInts(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func open(hostKey, portKey, userKey, passKey, nameKey string) (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv(hostKey), os.Getenv(portKey), os.Getenv(userKey),
|
||||
os.Getenv(passKey), os.Getenv(nameKey))
|
||||
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
}
|
||||
|
||||
// brandTable mirrors the repository's rule: a brand IS a `brand_<name>` table.
|
||||
//
|
||||
// Lowercased and stripped of anything that is not a letter, digit or
|
||||
// underscore. The table name cannot be parameterized in SQL, so this is the
|
||||
// one place it is built and it refuses to build anything else.
|
||||
func brandTable(brand string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r + 32
|
||||
}
|
||||
return -1
|
||||
}, strings.TrimSpace(brand))
|
||||
|
||||
if cleaned == "" {
|
||||
return ""
|
||||
}
|
||||
return "brand_" + cleaned
|
||||
}
|
||||
|
||||
// factColumnsOf returns which of the fact columns this brand table actually
|
||||
// has, or nil when the table is not there at all.
|
||||
func factColumnsOf(db *gorm.DB, table string) []string {
|
||||
var have []string
|
||||
db.Raw(`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ?`, table).Scan(&have)
|
||||
if len(have) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
present := map[string]bool{}
|
||||
for _, c := range have {
|
||||
present[c] = true
|
||||
}
|
||||
// image_id is how a product is found at all. Without it the table cannot
|
||||
// answer the question, whatever else it holds.
|
||||
if !present["image_id"] {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keep []string
|
||||
for _, c := range append(append([]string{}, scalarFacts...), arrayFacts...) {
|
||||
if present[c] {
|
||||
keep = append(keep, c)
|
||||
}
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
// factsFor reads one catalogue row and returns only what it actually stated.
|
||||
//
|
||||
// An empty field is omitted rather than stored as "" or [], so a reader can
|
||||
// tell "the catalogue did not say" from "the catalogue said none" — the drawer
|
||||
// prints a row per fact and an empty string would print an empty row.
|
||||
func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[string]any, bool) {
|
||||
if len(cols) == 0 {
|
||||
return map[string]any{}, true
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
if isArrayFact(c) {
|
||||
selects = append(selects, c+"::text AS "+c)
|
||||
continue
|
||||
}
|
||||
selects = append(selects, c)
|
||||
}
|
||||
|
||||
row := map[string]any{}
|
||||
res := db.Raw(`SELECT `+strings.Join(selects, ", ")+` FROM `+table+
|
||||
` WHERE image_id = ? LIMIT 1`, imageID).Scan(&row)
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
facts := map[string]any{}
|
||||
for _, c := range cols {
|
||||
raw, ok := row[c]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if isArrayFact(c) {
|
||||
values := models.ParsePGArray(text)
|
||||
kept := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
facts[c] = kept
|
||||
}
|
||||
continue
|
||||
}
|
||||
facts[c] = text
|
||||
}
|
||||
return facts, true
|
||||
}
|
||||
|
||||
func isArrayFact(name string) bool {
|
||||
for _, c := range arrayFacts {
|
||||
if c == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trim(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 1 {
|
||||
return s[:n]
|
||||
}
|
||||
return s[:n-1] + "…"
|
||||
}
|
||||
|
||||
func joinInts(ids []int) string {
|
||||
parts := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
parts[i] = fmt.Sprint(id)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user