Compare commits
42 Commits
be47435547
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ed32a2620e | |||
| a30763d323 | |||
| 03ae7d310b | |||
| f67cbad79a | |||
| fc2caffcd1 | |||
| 97f277f424 | |||
| ea90b95cdc | |||
| d0804ae84f | |||
| c49f5372a5 | |||
| fb859ecda1 | |||
| f9fb405974 | |||
| b46902f51b | |||
| b18080d429 | |||
| 090e9c0c2f | |||
| 7fdcc92528 | |||
| d562691f42 | |||
| 276e12beb9 | |||
| db84a9a752 | |||
| 00317a00d8 | |||
| 299871b820 | |||
| cf3e4ea159 | |||
| bb14445e21 | |||
| 294fb8ab93 | |||
| 9698de32d5 | |||
| 697b77f8c1 | |||
| 8e1549764b | |||
| bb5f40926f | |||
| c516c224e5 | |||
| 771d6a51cf | |||
| 24339a8b51 | |||
| 01bc89ab77 | |||
| 692c10e553 | |||
| 2f1501883b | |||
| c06b029cb2 | |||
| 28af3e05f2 | |||
| 42ea007fe7 | |||
| 76bff883ec | |||
| aaea1bfc00 | |||
| 369e9fc7b4 | |||
| 72907dae74 | |||
| 1633617dc4 | |||
| 4474479735 |
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Nothing in here reaches the image.
|
||||||
|
#
|
||||||
|
# `.env*` most of all: the Dockerfile does `COPY . .`, and the production
|
||||||
|
# credentials in `.env.production` were being baked into every image built
|
||||||
|
# from this folder. The running container gets its environment from the
|
||||||
|
# platform (Dokploy / Kubernetes), never from a file.
|
||||||
|
#
|
||||||
|
# An exception for `.env.production` was added on 2026-09-25 so the container
|
||||||
|
# could read its own configuration, and the deploy came back 502 on every
|
||||||
|
# endpoint. Reverted. The committed file is a stale snapshot; letting it fill
|
||||||
|
# whatever the platform leaves unset is not a safe default.
|
||||||
|
.env*
|
||||||
|
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.DS_Store
|
||||||
|
docs
|
||||||
|
scratch
|
||||||
|
init
|
||||||
|
nearle
|
||||||
|
server
|
||||||
|
docker-compose.local.yml
|
||||||
49
.env
49
.env
@@ -1,16 +1,18 @@
|
|||||||
# Fiesta configuration.
|
# Fiesta configuration — the shared base file.
|
||||||
#
|
#
|
||||||
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one
|
# Loaded AFTER `.env.<APP_ENV>` (see config/config.go), and godotenv never
|
||||||
# exception is this file) — and they are gitignored for a reason: this
|
# overwrites a value that is already set, so anything here is a fallback for
|
||||||
# repository's history already contains a committed `.env` from before
|
# what the environment file and the real environment leave unset. Keep it
|
||||||
# 2026-08-03, so those database credentials are in the history and should be
|
# local: with APP_ENV unset this is loaded straight after `.env.local`, and a
|
||||||
# rotated. Do not add another.
|
# production value here would silently reach a local run.
|
||||||
#
|
#
|
||||||
# `godotenv.Load()` in main.go reads `.env` from the working directory, so
|
# go run . → .env.local, then this file
|
||||||
# `go run .` from this folder picks it up with no flags.
|
# APP_ENV=production go run . → .env.production, then this file
|
||||||
|
#
|
||||||
|
# The full list of settings, with what each one does, is in `.env.example`.
|
||||||
|
|
||||||
# ── Where it listens ────────────────────────────────────────────────────────
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
# 1122 is what production serves on. Change it locally to run a second copy
|
# 1122 locally; production serves on 1009. Change it to run a second copy
|
||||||
# beside something else; the console then points at the same number.
|
# beside something else; the console then points at the same number.
|
||||||
APP_PORT=1122
|
APP_PORT=1122
|
||||||
|
|
||||||
@@ -59,5 +61,34 @@ REDIS_USER=
|
|||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
|
||||||
|
# sign in against the local stack; production sets its own in the platform.
|
||||||
|
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||||
JWT_SECRET_KEY=
|
JWT_SECRET_KEY=
|
||||||
USER_CONTEXT_KEY=
|
USER_CONTEXT_KEY=
|
||||||
|
|
||||||
|
# ── Email ───────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The first-password invitation. See docs/MAIL_SETUP.md.
|
||||||
|
#
|
||||||
|
# MAIL_HOST IS DELIBERATELY BLANK HERE. This file is tracked and shared, and a
|
||||||
|
# host set here would mean any local run could email a real merchant a real
|
||||||
|
# password link. Blank is the documented off state: the server boots, onboarding
|
||||||
|
# works, and every create answers `invited: false` with the reason.
|
||||||
|
#
|
||||||
|
# Turn it on by putting the Google Workspace host and App Password in
|
||||||
|
# `.env.secrets`, which is read first and is the only one of these git ignores.
|
||||||
|
MAIL_HOST=
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
# On nearledaily.com because the link points at app.nearledaily.com — a password
|
||||||
|
# mail whose sender and destination are different domains reads as phishing.
|
||||||
|
MAIL_FROM=care@nearledaily.com
|
||||||
|
MAIL_FROM_NAME=Nearle
|
||||||
|
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||||
|
|
||||||
|
# ── Nutrition ───────────────────────────────────────────────────────────────
|
||||||
|
# The catalogue-intelligence host behind the health score card. The customer
|
||||||
|
# app product screen reads its nutrition panel from here. Unset means no panel.
|
||||||
|
NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||||
|
|||||||
202
.env.example
202
.env.example
@@ -1,30 +1,41 @@
|
|||||||
# Fiesta configuration.
|
# Fiesta configuration — the complete list of settings, with local values.
|
||||||
#
|
#
|
||||||
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one
|
# How the files are picked (config/config.go):
|
||||||
# exception is this file) — and they are gitignored for a reason: this
|
|
||||||
# repository's history already contains a committed `.env` from before
|
|
||||||
# 2026-08-03, so those database credentials are in the history and should be
|
|
||||||
# rotated. Do not add another.
|
|
||||||
#
|
#
|
||||||
# `godotenv.Load()` in main.go reads `.env` from the working directory, so
|
# APP_ENV unset / local → .env.local then .env
|
||||||
# `go run .` from this folder picks it up with no flags.
|
# APP_ENV=production → .env.production then .env
|
||||||
|
#
|
||||||
|
# A real environment variable always wins over a file, and no file has to
|
||||||
|
# exist: on the deployed host the values come from the platform's environment
|
||||||
|
# settings (Dokploy / Kubernetes), not from a file. Anything added here must be
|
||||||
|
# added there too — a variable in this file and not in the platform is a
|
||||||
|
# variable that is unset in production.
|
||||||
|
#
|
||||||
|
# Startup checks every required setting and prints everything that is missing
|
||||||
|
# in one go, before any connection is attempted.
|
||||||
|
#
|
||||||
|
# The credentials in the committed .env.production have to be treated as
|
||||||
|
# public: rotate them, and keep new values out of git.
|
||||||
|
|
||||||
|
# ── Environment ─────────────────────────────────────────────────────────────
|
||||||
|
# local | production. Production requires POS_TOKEN_SECRET and never falls
|
||||||
|
# back to localhost defaults. Set in the real environment, not in a file: a
|
||||||
|
# file cannot decide which file gets loaded.
|
||||||
|
#APP_ENV=local
|
||||||
|
|
||||||
# ── Where it listens ────────────────────────────────────────────────────────
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
# 1122 is what production serves on. Change it locally to run a second copy
|
# 1122 locally; production serves on 1009 (matching the Dockerfile's EXPOSE).
|
||||||
# beside something else; the console then points at the same number.
|
|
||||||
APP_PORT=1122
|
APP_PORT=1122
|
||||||
|
|
||||||
ENV=development
|
|
||||||
|
|
||||||
# ── The main database (nearledb) ────────────────────────────────────────────
|
# ── The main database (nearledb) ────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
|
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
|
||||||
#
|
#
|
||||||
# There is no "local mode" that protects you: `go run .` against the live host
|
# There is no "local mode" that protects you: `go run .` against the live host
|
||||||
# creates real tenants, real logins and real stock movements, and main.go runs
|
# creates real tenants, real logins and real stock movements, and main.go runs
|
||||||
# schema migrations on boot. If the point of running locally is to try a change
|
# schema migrations on boot. Startup warns when APP_ENV=local and DB_HOST is
|
||||||
# before it is deployed, a local Postgres with a dump restored into it is the
|
# not a local address, but it does not stop you.
|
||||||
# only version that actually does that.
|
#
|
||||||
# These match docker-compose.local.yml, so `docker compose -f
|
# These match docker-compose.local.yml, so `docker compose -f
|
||||||
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
|
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
@@ -36,10 +47,9 @@ DB_PASSWORD=localdev
|
|||||||
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
||||||
#
|
#
|
||||||
# A separate connection on purpose, so catalogue work never touches nearledb.
|
# A separate connection on purpose, so catalogue work never touches nearledb.
|
||||||
# Leave blank to start without it: catalogue endpoints then fail at query time
|
# Leave CATALOGUE_DB_HOST blank to start without it: catalogue endpoints then
|
||||||
# rather than at boot, which is fine for testing anything else.
|
# fail at query time rather than at boot. With a host set, the other four are
|
||||||
# 5434, not 5432: a developer machine usually has something on 5432 already,
|
# required. 5434, not 5432: a developer machine usually has something on 5432.
|
||||||
# and a silent connection to the wrong database is worse than a refused one.
|
|
||||||
CATALOGUE_DB_HOST=localhost
|
CATALOGUE_DB_HOST=localhost
|
||||||
CATALOGUE_DB_PORT=5434
|
CATALOGUE_DB_PORT=5434
|
||||||
CATALOGUE_DB_NAME=cataloguedb
|
CATALOGUE_DB_NAME=cataloguedb
|
||||||
@@ -48,12 +58,158 @@ CATALOGUE_DB_PASSWORD=localdev
|
|||||||
|
|
||||||
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
|
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
|
||||||
#
|
#
|
||||||
# Optional. Losing the health board is an inconvenience; losing a sale is not,
|
# Optional: leave REDIS_HOST blank to run without it. Losing the health board
|
||||||
# so the API runs without it.
|
# is an inconvenience; losing a sale is not, so the API runs without it.
|
||||||
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_USER=
|
REDIS_USER=default
|
||||||
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# ── DigitalOcean Spaces (S3-compatible) — catalogue product images ──────────
|
||||||
|
#
|
||||||
|
# Optional locally. With USE_S3=true every S3_* value below is required.
|
||||||
|
USE_S3=false
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_REGION=
|
||||||
|
|
||||||
|
# ── POS terminals — the MQTT broker the in-store tills publish to ───────────
|
||||||
|
#
|
||||||
|
# Optional locally: with MQTT_URL blank the ingest and the console live stream
|
||||||
|
# stay quiet and the HTTP endpoints still work. In production a blank MQTT_URL
|
||||||
|
# means every till queues its bills silently — on startup you should see
|
||||||
|
# three lines reading "pos: subscribed to nearle/pos/+/+/...".
|
||||||
|
MQTT_URL=
|
||||||
|
MQTT_USER=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
# Unique per replica: a second connection with the same id evicts the first.
|
||||||
|
# Defaults to HOSTNAME (the pod name) when unset.
|
||||||
|
MQTT_CLIENT_ID=
|
||||||
|
# always | never — otherwise a StatefulSet pod ending in "-0" is elected and
|
||||||
|
# anything else consumes. See messaging/posmqtt.go.
|
||||||
|
#POS_MQTT_CONSUMER=
|
||||||
|
|
||||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
# Signs POS terminal sessions; at least 16 characters. Falls back to
|
||||||
|
# JWT_SECRET_KEY when unset. Required in production.
|
||||||
|
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||||
JWT_SECRET_KEY=
|
JWT_SECRET_KEY=
|
||||||
USER_CONTEXT_KEY=
|
USER_CONTEXT_KEY=nearle
|
||||||
|
# true to make the POS routes require a terminal session.
|
||||||
|
#POS_AUTH_REQUIRED=false
|
||||||
|
|
||||||
|
# ── Scan-to-order — the embedding model behind product recognition ─────────
|
||||||
|
#
|
||||||
|
# MUST be the model that filled the catalogue's `embedding` column: vectors
|
||||||
|
# from two models are not comparable and pgvector will rank garbage without
|
||||||
|
# complaint. The first search reads the column's width and refuses a mismatch
|
||||||
|
# with an error that names both numbers.
|
||||||
|
# Optional: with no provider the search matches on words alone (works, ranks
|
||||||
|
# worse). openai = any OpenAI-compatible /embeddings endpoint (set
|
||||||
|
# EMBEDDING_BASE_URL for Azure, Ollama, vLLM...); gemini = Google AI Studio.
|
||||||
|
EMBEDDING_PROVIDER=
|
||||||
|
EMBEDDING_MODEL=
|
||||||
|
EMBEDDING_API_KEY=
|
||||||
|
EMBEDDING_BASE_URL=
|
||||||
|
# 0 = the model's default width.
|
||||||
|
EMBEDDING_DIMENSIONS=0
|
||||||
|
|
||||||
|
# ── Geocoding ───────────────────────────────────────────────────────────────
|
||||||
|
# Google Geocoding when set; OpenStreetMap's Nominatim otherwise.
|
||||||
|
GEOCODER_API_KEY=
|
||||||
|
# ── Nutrition ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The catalogue-intelligence service — the same host the console reads its
|
||||||
|
# health score card from. The customer app product screen gets its nutrition
|
||||||
|
# panel from here, through `getproductbyvariant`.
|
||||||
|
#
|
||||||
|
# Read server-side rather than by the app: the brand-spelling resolution below
|
||||||
|
# would otherwise have to be reimplemented in the app, and a wrong spelling
|
||||||
|
# returns a well-formed record with every figure null — indistinguishable from
|
||||||
|
# a product nobody has scored.
|
||||||
|
#
|
||||||
|
# Unset means product screens carry no nutrition panel and nothing else changes.
|
||||||
|
NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||||
|
|
||||||
|
# ── Email ───────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Sending the first-password invitation a newly onboarded merchant receives.
|
||||||
|
# Without MAIL_HOST the server still boots and still onboards tenants — the
|
||||||
|
# create response comes back `invited: false` with the reason — but nobody is
|
||||||
|
# emailed, and the only way into a new account is a Nearle staff member using
|
||||||
|
# Resend invite.
|
||||||
|
#
|
||||||
|
# SMTP, because every provider speaks it. Any transactional service is the same
|
||||||
|
# five variables: its host, 587, the API key as MAIL_PASSWORD, and whatever
|
||||||
|
# username it documents.
|
||||||
|
#
|
||||||
|
# WE USE GOOGLE WORKSPACE SMTP, authenticating as care@nearledaily.com with a
|
||||||
|
# 16-character App Password — never the account's login password, because an App
|
||||||
|
# Password can be revoked on its own. See docs/MAIL_SETUP.md for the setup and
|
||||||
|
# for the DNS records, which are what actually decide whether the invitation
|
||||||
|
# reaches an inbox rather than a spam folder.
|
||||||
|
#
|
||||||
|
# Self-hosting (Postal) was the earlier plan and is the better answer at volume.
|
||||||
|
# At a few dozen invitations a month the work is not the software, it is IP
|
||||||
|
# reputation, rDNS and blocklists — so this buys the reputation instead.
|
||||||
|
#
|
||||||
|
# Google Workspace: smtp.gmail.com 587 an App Password
|
||||||
|
# via an SMTP relay: smtp-relay.gmail.com 587 if an admin sets one up
|
||||||
|
# Amazon SES: email-smtp.<region>.amazonaws.com 587
|
||||||
|
# SendGrid: smtp.sendgrid.net 587 username literally "apikey"
|
||||||
|
# Resend: smtp.resend.com 587 username literally "resend"
|
||||||
|
#
|
||||||
|
# Credentials belong in .env.secrets (git-ignored, read first), or in the
|
||||||
|
# deployment platform's own environment — NOT in this file and not in .env.
|
||||||
|
MAIL_HOST=
|
||||||
|
MAIL_PORT=587
|
||||||
|
# Optional. Leave both empty for a relay that authenticates by network rather
|
||||||
|
# than by credentials.
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
# Who the invitation appears to come from. Separate from MAIL_USERNAME because
|
||||||
|
# most providers authenticate as one identity and send as another, and using
|
||||||
|
# the login as the From address is how mail lands in spam.
|
||||||
|
#
|
||||||
|
# ON NEARLEDAILY.COM, DELIBERATELY. The link in the mail points at
|
||||||
|
# app.nearledaily.com, and a password link arriving from a DIFFERENT domain than
|
||||||
|
# the one it sends you to is the exact shape of a phishing mail — to a filter
|
||||||
|
# and to the merchant reading it. Sender and link stay on one domain.
|
||||||
|
#
|
||||||
|
# `care@` rather than `no-reply@`, also deliberately: somebody who replies "I
|
||||||
|
# never got this" is the single most useful reply this system can receive, and
|
||||||
|
# it should reach a person.
|
||||||
|
MAIL_FROM=care@nearledaily.com
|
||||||
|
MAIL_FROM_NAME=Nearle
|
||||||
|
# Where the invitation link points — the MERCHANT console, always. A merchant
|
||||||
|
# sets their password there and nowhere else, so this is never the platform
|
||||||
|
# console's address.
|
||||||
|
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nearle Buddy ────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ONE variable. The provider, endpoint and model are defaults in config.go
|
||||||
|
# (openai / api.groq.com / openai/gpt-oss-120b) because each has one right
|
||||||
|
# answer for this product — and three variables that must be typed correctly
|
||||||
|
# into a hosting platform are three ways for the assistant to sit silently off,
|
||||||
|
# which is how it spent its first week.
|
||||||
|
#
|
||||||
|
# The key is the only one that differs per deployment and the only one that
|
||||||
|
# cannot live in this repository. Locally it goes in `.env.secrets`, which git
|
||||||
|
# ignores; in production it is set on the platform.
|
||||||
|
ASSISTANT_API_KEY=
|
||||||
|
|
||||||
|
# Overrides, none of them needed for the shipped setup.
|
||||||
|
# Ollama on a laptop: ASSISTANT_BASE_URL=http://localhost:11434/v1 and
|
||||||
|
# ASSISTANT_MODEL=llama3 — a local endpoint needs no key.
|
||||||
|
ASSISTANT_PROVIDER=
|
||||||
|
ASSISTANT_BASE_URL=
|
||||||
|
ASSISTANT_MODEL=
|
||||||
|
# Per-tier overrides. ASSISTANT_MODEL alone sets all three.
|
||||||
|
ASSISTANT_MODEL_FAST=
|
||||||
|
ASSISTANT_MODEL_BALANCED=
|
||||||
|
ASSISTANT_MODEL_DEEP=
|
||||||
|
|||||||
31
.env.local
31
.env.local
@@ -7,13 +7,10 @@
|
|||||||
# Keep every host here pointing at localhost. The whole point of the split is
|
# Keep every host here pointing at localhost. The whole point of the split is
|
||||||
# that running the server locally cannot reach live data by accident.
|
# that running the server locally cannot reach live data by accident.
|
||||||
#
|
#
|
||||||
# `.env`, `.env.local` and `.env.production` are all gitignored (`.env.example`
|
# `config.Load()` reads `.env.$APP_ENV` and then `.env` from the working
|
||||||
# is the one exception) — and for a reason: this repository's history already
|
# directory, so `go run .` from this folder picks this file up with no flags.
|
||||||
# contains a committed `.env` from before 2026-08-03, so those credentials are
|
# A real environment variable always wins over either file. The full list of
|
||||||
# in the history and should be rotated. Do not add another.
|
# settings is in `.env.example`.
|
||||||
#
|
|
||||||
# `loadEnv()` in main.go reads `.env.$APP_ENV` from the working directory, so
|
|
||||||
# `go run .` from this folder picks this file up with no flags.
|
|
||||||
|
|
||||||
# ── Where it listens ────────────────────────────────────────────────────────
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
# Production serves on 1009 (see .env.production). Change this locally to run
|
# Production serves on 1009 (see .env.production). Change this locally to run
|
||||||
@@ -64,5 +61,25 @@ REDIS_USER=
|
|||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
|
||||||
|
# sign in against the local stack; production sets its own in the platform.
|
||||||
|
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||||
JWT_SECRET_KEY=
|
JWT_SECRET_KEY=
|
||||||
USER_CONTEXT_KEY=
|
USER_CONTEXT_KEY=
|
||||||
|
|
||||||
|
# ── Nearle Buddy ────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The model behind the assistant. Any OpenAI-compatible endpoint: Groq here,
|
||||||
|
# Ollama at http://localhost:11434/v1 with no key, or api.openai.com/v1.
|
||||||
|
#
|
||||||
|
# ASSISTANT_API_KEY is DELIBERATELY ABSENT. This file is tracked by git, so a
|
||||||
|
# key written here is a key pushed to the remote. Supply it from the real
|
||||||
|
# environment, which wins over both env files:
|
||||||
|
#
|
||||||
|
# ASSISTANT_API_KEY=gsk_... go run .
|
||||||
|
#
|
||||||
|
# On the deployed host there is no env file at all — every value comes from the
|
||||||
|
# platform's environment settings, which is where the key belongs.
|
||||||
|
ASSISTANT_PROVIDER=openai
|
||||||
|
ASSISTANT_BASE_URL=https://api.groq.com/openai/v1
|
||||||
|
ASSISTANT_MODEL=openai/gpt-oss-120b
|
||||||
|
|||||||
@@ -11,13 +11,15 @@
|
|||||||
# run. If the point is to try a change before it ships, use `.env.local` with a
|
# run. If the point is to try a change before it ships, use `.env.local` with a
|
||||||
# dump restored into the local Postgres — that is the only version that does.
|
# dump restored into the local Postgres — that is the only version that does.
|
||||||
#
|
#
|
||||||
# Gitignored by `.env.*`. Never commit it, and never paste it into a chat, an
|
# On the deployed host these values come from the platform's environment
|
||||||
# issue or a PR description: the credentials below have to be rotated if it
|
# settings (Dokploy / Kubernetes), never from this file: the image is built
|
||||||
# leaves this machine.
|
# without any `.env.*` (see .dockerignore). Keep the two in step — a variable
|
||||||
|
# added here and not there is a variable that is unset in production, and
|
||||||
|
# startup will refuse to boot on a missing required one.
|
||||||
#
|
#
|
||||||
# On the deployed host these values come from Dokploy's environment settings
|
# This file is currently committed to git, which means every credential in it
|
||||||
# rather than from this file. Keep the two in step — a variable added here and
|
# has to be treated as public: rotate them, and keep the new values out of
|
||||||
# not there is a variable that is unset in production.
|
# the repository.
|
||||||
|
|
||||||
# ── Where it listens ────────────────────────────────────────────────────────
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
APP_PORT=1009
|
APP_PORT=1009
|
||||||
@@ -76,3 +78,7 @@ REDIS_DB=0
|
|||||||
|
|
||||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl
|
POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl
|
||||||
|
|
||||||
|
# ── Nearle Buddy ────────────────────────────────────────────────────────────
|
||||||
|
# One variable. Provider, endpoint and model are constants in config.go.
|
||||||
|
ASSISTANT_API_KEY=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
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -54,3 +54,9 @@ Thumbs.db
|
|||||||
# that getting worse, but the existing history still has them and the password
|
# that getting worse, but the existing history still has them and the password
|
||||||
# should be rotated.
|
# should be rotated.
|
||||||
|
|
||||||
|
|
||||||
|
# Secrets, for local runs only. Never committed — the rule below is what makes
|
||||||
|
# that true, and it is why this file exists separately from .env.local, which
|
||||||
|
# IS tracked and therefore cannot hold a key.
|
||||||
|
|
||||||
|
.env.secrets
|
||||||
|
|||||||
65
Dockerfile
65
Dockerfile
@@ -4,7 +4,21 @@ FROM golang:1.24 AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o server
|
# Which commit this image is. Reported by GET /live/api/v1/health, so "I pushed
|
||||||
|
# it" and "it is running" stop being the same sentence — a redeploy can reuse a
|
||||||
|
# cached image, and there was no way to tell from outside.
|
||||||
|
#
|
||||||
|
# Passed by the platform as a build argument:
|
||||||
|
# docker build --build-arg BUILD_VERSION=$(git rev-parse --short HEAD) .
|
||||||
|
# In Dokploy this goes under the application's Build settings. Left unset it
|
||||||
|
# reads "unknown", which is itself worth seeing — it means nothing stamped it.
|
||||||
|
#
|
||||||
|
# `.git` is not in the build context (see .dockerignore), so the build cannot
|
||||||
|
# work this out for itself.
|
||||||
|
ARG BUILD_VERSION=unknown
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
|
-ldflags "-X nearle/controllers.Version=${BUILD_VERSION}" -o server
|
||||||
|
|
||||||
# ---------- Runtime Stage ----------
|
# ---------- Runtime Stage ----------
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
@@ -14,6 +28,55 @@ WORKDIR /app
|
|||||||
COPY --from=builder /app/server /app
|
COPY --from=builder /app/server /app
|
||||||
COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json .
|
COPY nearle-gear-firebase-adminsdk-l9oha-23ca3b3609.json .
|
||||||
|
|
||||||
|
# Nearle Buddy's credential, as ONE container variable.
|
||||||
|
#
|
||||||
|
# Not an env file. `COPY .env.production .` was tried on 2026-09-25 and took the
|
||||||
|
# backend down with 502 on every endpoint: that file declares twenty-three
|
||||||
|
# variables, and godotenv fills any the platform leaves unset, so a stale
|
||||||
|
# committed DB or Redis value replaced a live one and the process died at boot.
|
||||||
|
# Twenty-three variables shipped to deliver one.
|
||||||
|
#
|
||||||
|
# A single ENV cannot do that — it sets this name and no other. A value set on
|
||||||
|
# the platform still wins, because `docker run -e` overrides a Dockerfile ENV,
|
||||||
|
# so this is a default rather than an override.
|
||||||
|
#
|
||||||
|
# Provider, endpoint and model are constants in config.go, so this is the only
|
||||||
|
# thing the assistant needs to come up.
|
||||||
|
ENV ASSISTANT_API_KEY=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
|
||||||
|
|
||||||
|
# Where the nutrition panel and health score come from.
|
||||||
|
#
|
||||||
|
# A PUBLIC URL, not a secret — it is the catalogue-intelligence service the
|
||||||
|
# console already reads its health score card from, and the same value is in
|
||||||
|
# .env.example. So it is a build-time default rather than a platform setting,
|
||||||
|
# for the same reason ASSISTANT_API_KEY is: one variable, set in one place,
|
||||||
|
# that cannot be missed on a deploy.
|
||||||
|
#
|
||||||
|
# It has been missed twice. Unset, `getproductbyvariant` simply omits
|
||||||
|
# `nutrition` and `healthscore`, which is indistinguishable from a product the
|
||||||
|
# service has not scored — so the feature ships switched off and looks broken
|
||||||
|
# rather than absent. The startup log now names which state it is in.
|
||||||
|
#
|
||||||
|
# A value set on the platform still wins: `docker run -e` overrides a Dockerfile
|
||||||
|
# ENV, so this is a default and not a lock-in.
|
||||||
|
ENV NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||||
|
|
||||||
|
# No `.env.*` is copied in (see .dockerignore), so this only decides which rules
|
||||||
|
# config.Load applies: production insists on a signing secret and never falls
|
||||||
|
# back to localhost values. Every other real value comes from the platform's
|
||||||
|
# environment settings, exactly as before.
|
||||||
|
# The clock every business rule is decided on.
|
||||||
|
#
|
||||||
|
# tzdata was already installed and nothing set TZ, so the container ran UTC.
|
||||||
|
# That was invisible while time.Now() only ever stamped records — nothing
|
||||||
|
# compared a stored time of day against the current one. Delivery windows are
|
||||||
|
# the first rule that does: a shop setting morning as 08:00-10:00 would have had
|
||||||
|
# it close at 10:00 UTC, which is 15:30 where the shop is standing.
|
||||||
|
ENV TZ=Asia/Kolkata
|
||||||
|
|
||||||
|
ENV APP_ENV=production
|
||||||
|
|
||||||
|
# Must match APP_PORT in the platform's environment (1009 in production).
|
||||||
EXPOSE 1009
|
EXPOSE 1009
|
||||||
|
|
||||||
CMD ["/app/server"]
|
CMD ["/app/server"]
|
||||||
|
|||||||
142
README.md
Normal file
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.
|
||||||
261
config/assistant_test.go
Normal file
261
config/assistant_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Why the assistant is off.
|
||||||
|
//
|
||||||
|
// "Off" was the same answer for four different mistakes, and the only symptom
|
||||||
|
// was a disabled composer. Nobody could tell "we have not switched it on" from
|
||||||
|
// "somebody misspelled a variable" — which is how it stayed off for days with
|
||||||
|
// both of us guessing.
|
||||||
|
|
||||||
|
func TestAFullyConfiguredAssistantIsOn(t *testing.T) {
|
||||||
|
cfg := AssistantConfig{
|
||||||
|
Provider: "openai", BaseURL: "https://api.groq.com/openai/v1",
|
||||||
|
APIKey: "k", Balanced: "openai/gpt-oss-120b",
|
||||||
|
}
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
t.Fatalf("a complete config was refused: %s", cfg.Why())
|
||||||
|
}
|
||||||
|
if cfg.Why() != "" {
|
||||||
|
t.Fatalf("an enabled assistant gave a reason: %q", cfg.Why())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachMissingPieceNamesItself(t *testing.T) {
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
cfg AssistantConfig
|
||||||
|
says string
|
||||||
|
}{
|
||||||
|
// Nothing set at all names the MODEL, not the provider. The provider is
|
||||||
|
// derived from the model now, so an empty one is a consequence rather
|
||||||
|
// than a cause — and sending an operator to set ASSISTANT_PROVIDER, a
|
||||||
|
// variable they no longer need, while the one they actually missed goes
|
||||||
|
// unmentioned, is the same "off for four reasons" problem in new words.
|
||||||
|
"nothing set at all": {AssistantConfig{}, "ASSISTANT_MODEL"},
|
||||||
|
"no provider": {
|
||||||
|
AssistantConfig{Balanced: "m", APIKey: "k"}, "ASSISTANT_PROVIDER"},
|
||||||
|
"unknown provider": {
|
||||||
|
AssistantConfig{Provider: "anthropik", Balanced: "m", APIKey: "k"}, "not one this server speaks"},
|
||||||
|
"no model": {AssistantConfig{Provider: "openai", APIKey: "k"}, "ASSISTANT_MODEL"},
|
||||||
|
"no api key": {AssistantConfig{Provider: "openai", Balanced: "m", BaseURL: "https://api.groq.com/openai/v1"}, "ASSISTANT_API_KEY"},
|
||||||
|
} {
|
||||||
|
why := tc.cfg.Why()
|
||||||
|
if why == "" {
|
||||||
|
t.Fatalf("%s: reported as working", name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(why, tc.says) {
|
||||||
|
t.Fatalf("%s: does not name the problem: %q", name, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALocalModelNeedsNoKey(t *testing.T) {
|
||||||
|
// Ollama and LM Studio need no credential, and demanding one would refuse
|
||||||
|
// the setup a developer is most likely to have on their own machine.
|
||||||
|
for _, base := range []string{
|
||||||
|
"http://localhost:11434/v1",
|
||||||
|
"http://127.0.0.1:1234/v1",
|
||||||
|
"http://host.docker.internal:11434/v1",
|
||||||
|
} {
|
||||||
|
cfg := AssistantConfig{Provider: "openai", BaseURL: base, Balanced: "llama3"}
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
t.Fatalf("%s was refused without a key: %s", base, cfg.Why())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHostedModelWithoutAKeyIsRefusedBeforeItFailsAtRuntime(t *testing.T) {
|
||||||
|
// Otherwise the first question a shopkeeper asks comes back as a 401 from
|
||||||
|
// the provider, which reads as the assistant being broken rather than as a
|
||||||
|
// variable nobody set.
|
||||||
|
cfg := AssistantConfig{Provider: "openai", BaseURL: "https://api.groq.com/openai/v1", Balanced: "m"}
|
||||||
|
if cfg.Enabled() {
|
||||||
|
t.Fatal("a hosted provider with no key reported as ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheTierFallbackDoesNotHideAMissingModel(t *testing.T) {
|
||||||
|
// `fast` and `deep` fall back to balanced, so a config with only those two
|
||||||
|
// set has no model at all for the default tier.
|
||||||
|
cfg := AssistantConfig{Provider: "openai", APIKey: "k", Fast: "small", Deep: "big"}
|
||||||
|
if cfg.Enabled() {
|
||||||
|
t.Fatal("an assistant with no balanced model reported as ready")
|
||||||
|
}
|
||||||
|
if cfg.ModelFor("fast") != "" && cfg.ModelFor("balanced") != "" {
|
||||||
|
t.Fatal("balanced resolved to something despite being unset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a secret is allowed to live.
|
||||||
|
//
|
||||||
|
// `.env`, `.env.local` and `.env.production` are all tracked by git, so a key
|
||||||
|
// written to any of them is a key published. There was nowhere else, and the
|
||||||
|
// standing instruction was to export it in the shell on every run — which is
|
||||||
|
// the kind of instruction people route around by editing a tracked file.
|
||||||
|
func TestASecretsFileIsReadBeforeAnyTrackedEnvFile(t *testing.T) {
|
||||||
|
order := envFileOrder("local")
|
||||||
|
|
||||||
|
if len(order) == 0 || order[0] != ".env.secrets" {
|
||||||
|
t.Fatalf(".env.secrets is not read first, so a tracked file wins: %v", order)
|
||||||
|
}
|
||||||
|
// godotenv does not overwrite, so being first IS what makes it authoritative.
|
||||||
|
// Being merely present would let .env.local decide the key instead.
|
||||||
|
for _, tracked := range []string{".env.local", ".env"} {
|
||||||
|
for i, name := range order {
|
||||||
|
if name == tracked && i == 0 {
|
||||||
|
t.Fatalf("%s is read first; a secret there would be committed", tracked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheEnvironmentsOwnFileBeatsTheSharedOne(t *testing.T) {
|
||||||
|
// `.env.production` must be consulted before the shared `.env`, or a
|
||||||
|
// production deployment silently takes the local defaults.
|
||||||
|
order := envFileOrder("production")
|
||||||
|
|
||||||
|
var production, shared int = -1, -1
|
||||||
|
for i, name := range order {
|
||||||
|
switch name {
|
||||||
|
case ".env.production":
|
||||||
|
production = i
|
||||||
|
case ".env":
|
||||||
|
shared = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if production < 0 || shared < 0 || production > shared {
|
||||||
|
t.Fatalf("the environment's own file does not take precedence: %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One variable, not four.
|
||||||
|
//
|
||||||
|
// The assistant sat switched off for days because `ASSISTANT_PROVIDER` had not
|
||||||
|
// been typed into a hosting platform's environment tab — a variable whose only
|
||||||
|
// correct value is "openai", because every endpoint this server speaks is
|
||||||
|
// OpenAI-compatible. The base URL and the model had one right answer too.
|
||||||
|
//
|
||||||
|
// So three of the four are constants now. The key is the only one that varies
|
||||||
|
// between deployments and the only one that cannot live in the repository.
|
||||||
|
func TestTheKeyAloneSwitchesTheAssistantOn(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"ASSISTANT_PROVIDER", "ASSISTANT_BASE_URL", "ASSISTANT_MODEL",
|
||||||
|
"ASSISTANT_MODEL_BALANCED", "ASSISTANT_MODEL_FAST", "ASSISTANT_MODEL_DEEP",
|
||||||
|
} {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
t.Setenv("ASSISTANT_API_KEY", "gsk_not-a-real-key")
|
||||||
|
|
||||||
|
cfg := AssistantConfig{
|
||||||
|
Provider: assistantProvider(),
|
||||||
|
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
|
||||||
|
APIKey: env("ASSISTANT_API_KEY", ""),
|
||||||
|
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
t.Fatalf("the key alone did not switch it on: %s", cfg.Why())
|
||||||
|
}
|
||||||
|
if cfg.Provider != "openai" {
|
||||||
|
t.Fatalf("provider defaulted to %q", cfg.Provider)
|
||||||
|
}
|
||||||
|
if cfg.ModelFor("fast") != defaultAssistantModel {
|
||||||
|
t.Fatalf("the fast tier fell through to %q", cfg.ModelFor("fast"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoKeyIsStillOffAndSaysWhich(t *testing.T) {
|
||||||
|
// The defaults must not make an unconfigured deployment look ready. Without
|
||||||
|
// a key every question would reach Groq and come back 401, which reads as
|
||||||
|
// the assistant being broken rather than as not being set up.
|
||||||
|
cfg := AssistantConfig{
|
||||||
|
Provider: defaultAssistantProvider,
|
||||||
|
BaseURL: defaultAssistantBaseURL,
|
||||||
|
Balanced: defaultAssistantModel,
|
||||||
|
}
|
||||||
|
if cfg.Enabled() {
|
||||||
|
t.Fatal("reported ready with no key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg.Why(), "ASSISTANT_API_KEY") {
|
||||||
|
t.Fatalf("did not name the one variable left to set: %q", cfg.Why())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachDefaultIsStillOverridable(t *testing.T) {
|
||||||
|
// Running against Ollama on a laptop must not need a code change.
|
||||||
|
t.Setenv("ASSISTANT_PROVIDER", "ollama")
|
||||||
|
t.Setenv("ASSISTANT_BASE_URL", "http://localhost:11434/v1")
|
||||||
|
t.Setenv("ASSISTANT_MODEL", "llama3")
|
||||||
|
|
||||||
|
cfg := AssistantConfig{
|
||||||
|
Provider: assistantProvider(),
|
||||||
|
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
|
||||||
|
APIKey: env("ASSISTANT_API_KEY", ""),
|
||||||
|
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Provider != "ollama" || cfg.Balanced != "llama3" {
|
||||||
|
t.Fatalf("an override was ignored: %+v", cfg)
|
||||||
|
}
|
||||||
|
// Local endpoints need no key, so this must be on without one.
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
t.Fatalf("a local model was refused: %s", cfg.Why())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The container reads its own configuration from a file beside the binary.
|
||||||
|
//
|
||||||
|
// The Dockerfile copies `.env.production` into the runtime image and sets
|
||||||
|
// APP_ENV=production, so `loadEnvFiles` reads it on boot. This asserts the
|
||||||
|
// mechanism rather than the Dockerfile — a COPY line is easy to check by eye
|
||||||
|
// and easy to believe wrongly, and the failure it produces is a server that
|
||||||
|
// starts fine with a variable silently unset.
|
||||||
|
func TestTheEnvironmentFileBesideTheBinaryIsRead(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
|
||||||
|
if err := os.WriteFile(".env.production",
|
||||||
|
[]byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("writing the fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("APP_ENV", "production")
|
||||||
|
// Registered with t.Setenv first so it is restored on return, then removed:
|
||||||
|
// godotenv does not overwrite a variable that is PRESENT, and an empty
|
||||||
|
// string is present. Setting it to "" would have tested nothing.
|
||||||
|
t.Setenv("ASSISTANT_API_KEY", "placeholder")
|
||||||
|
os.Unsetenv("ASSISTANT_API_KEY")
|
||||||
|
|
||||||
|
loadEnvFiles()
|
||||||
|
|
||||||
|
if os.Getenv("ASSISTANT_API_KEY") != "from-the-file" {
|
||||||
|
t.Fatal("the environment file beside the binary was not read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThePlatformStillWinsOverTheFile(t *testing.T) {
|
||||||
|
// godotenv never overwrites a variable already in the environment, so a
|
||||||
|
// value set on the hosting platform overrides the committed file without
|
||||||
|
// the file having to change. Both mechanisms work; neither fights the other.
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
|
||||||
|
if err := os.WriteFile(".env.production",
|
||||||
|
[]byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("writing the fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("APP_ENV", "production")
|
||||||
|
t.Setenv("ASSISTANT_API_KEY", "from-the-platform")
|
||||||
|
|
||||||
|
loadEnvFiles()
|
||||||
|
|
||||||
|
if got := os.Getenv("ASSISTANT_API_KEY"); got != "from-the-platform" {
|
||||||
|
t.Fatalf("the file overrode the platform: ASSISTANT_API_KEY=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
536
config/config.go
536
config/config.go
@@ -1,49 +1,537 @@
|
|||||||
|
// Package config is the one place the process reads its environment.
|
||||||
|
//
|
||||||
|
// Two jobs, in order:
|
||||||
|
//
|
||||||
|
// 1. Pick the right `.env` file for the environment we are in and load it.
|
||||||
|
// 2. Read every setting into a typed Config and refuse to start if anything
|
||||||
|
// required is missing — all of it, in one message, before a single
|
||||||
|
// connection is attempted.
|
||||||
|
//
|
||||||
|
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
|
||||||
|
// `.env.production` described an `APP_ENV` switch that did not exist, so the
|
||||||
|
// only way to run against production was to overwrite `.env` by hand, and the
|
||||||
|
// only way to find out a variable was missing was a `log.Fatalf` from inside
|
||||||
|
// `db.Connect()` — one variable per restart.
|
||||||
|
//
|
||||||
|
// # Which file loads
|
||||||
|
//
|
||||||
|
// `APP_ENV` names the environment and defaults to "local":
|
||||||
|
//
|
||||||
|
// go run . → .env.local, then .env
|
||||||
|
// APP_ENV=production go run . → .env.production, then .env
|
||||||
|
//
|
||||||
|
// `.env` is a shared base loaded after the environment file. godotenv never
|
||||||
|
// overwrites a variable that is already set, so the order of precedence is:
|
||||||
|
//
|
||||||
|
// real environment > .env.<APP_ENV> > .env
|
||||||
|
//
|
||||||
|
// Neither file has to exist. On the deployed host every value comes from the
|
||||||
|
// platform's environment settings (Dokploy today, ConfigMaps/Secrets under
|
||||||
|
// Kubernetes) and there is no file at all — which is exactly why the
|
||||||
|
// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the
|
||||||
|
// image carries no `.env.*`, so it cannot fall back to localhost values that
|
||||||
|
// happen to be lying around in the build context.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Environment names. Anything else is accepted (a staging file works the same
|
||||||
|
// way) but only these two change behaviour.
|
||||||
|
const (
|
||||||
|
EnvLocal = "local"
|
||||||
|
EnvProduction = "production"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is everything the process reads from its environment.
|
||||||
|
//
|
||||||
|
// A few settings are still read directly with os.Getenv at the point of use,
|
||||||
|
// because they are consulted per request or per connection rather than once
|
||||||
|
// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED
|
||||||
|
// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_*
|
||||||
|
// and POS_* settings in package messaging. They are listed and validated here
|
||||||
|
// so that a misconfiguration is still caught at startup.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Env string
|
// AppEnv is the value of APP_ENV: "local" or "production".
|
||||||
Port string
|
AppEnv string
|
||||||
DBName string
|
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
|
||||||
DBUser string
|
Port string
|
||||||
DBPassword string
|
|
||||||
DBPort string
|
DB DBConfig
|
||||||
DBHost string
|
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
|
||||||
UserContextKey string
|
Redis RedisConfig
|
||||||
|
S3 S3Config
|
||||||
|
MQTT MQTTConfig
|
||||||
|
Embedding EmbeddingConfig
|
||||||
|
// Assistant is the model behind Nearle Buddy. Empty provider = no typed
|
||||||
|
// questions; the tools still work.
|
||||||
|
Assistant AssistantConfig
|
||||||
|
// Mail. Optional: a deployment without it still onboards tenants and reports
|
||||||
|
// the invitation as unsent.
|
||||||
|
Mail MailConfig
|
||||||
|
|
||||||
|
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
|
||||||
|
// unset, matching utils/postoken.go.
|
||||||
|
POSTokenSecret string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
UserContextKey string
|
||||||
|
GeocoderAPIKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
// DBConfig is one Postgres connection.
|
||||||
|
type DBConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Name string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether a host was configured at all. Only meaningful for
|
||||||
|
// the optional catalogue connection; the main database is required.
|
||||||
|
func (d DBConfig) Enabled() bool { return d.Host != "" }
|
||||||
|
|
||||||
|
// RedisConfig is the shared Redis used for POS terminal presence. Optional:
|
||||||
|
// Host empty means presence is disabled and bills still commit.
|
||||||
|
type RedisConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
DB int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RedisConfig) Enabled() bool { return r.Host != "" }
|
||||||
|
|
||||||
|
// S3Config is the DigitalOcean Spaces bucket holding catalogue product images.
|
||||||
|
type S3Config struct {
|
||||||
|
Enabled bool // USE_S3=true
|
||||||
|
Endpoint string
|
||||||
|
Bucket string
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
Region string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty
|
||||||
|
// means the MQTT ingest and the console live stream stay quiet.
|
||||||
|
type MQTTConfig struct {
|
||||||
|
URL string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
ClientID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MQTTConfig) Enabled() bool { return m.URL != "" }
|
||||||
|
|
||||||
|
// EmbeddingConfig is the text-embedding model behind the scan-to-product
|
||||||
|
// search (services/scanService.go). It MUST be the model that filled the
|
||||||
|
// catalogue's `embedding` column — vectors from two different models are not
|
||||||
|
// comparable, and pgvector will happily rank garbage. Optional: with no
|
||||||
|
// provider the search falls back to plain text matching.
|
||||||
|
type EmbeddingConfig struct {
|
||||||
|
Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini"
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1
|
||||||
|
Dimensions int // 0 = the model's default
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" }
|
||||||
|
|
||||||
|
// AssistantConfig is the model behind Nearle Buddy.
|
||||||
|
//
|
||||||
|
// Optional, like the embedder. With no provider the assistant refuses typed
|
||||||
|
// questions and says so — the tools still work and still answer correctly,
|
||||||
|
// because they are ordinary Go functions; only the part that turns a sentence
|
||||||
|
// into a tool call is missing.
|
||||||
|
//
|
||||||
|
// ── Why three models and not one ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// An agent names a TIER, never a model. "Which branch is underperforming?"
|
||||||
|
// and "why is the cancel rate high?" want different amounts of thinking, and
|
||||||
|
// wiring a model name into an agent means changing every agent to change
|
||||||
|
// provider. The tiers are the stable vocabulary; this map is the only place a
|
||||||
|
// model name appears.
|
||||||
|
//
|
||||||
|
// `ASSISTANT_MODEL` alone sets all three, which is the sane default for a
|
||||||
|
// deployment that has not thought about it yet.
|
||||||
|
type AssistantConfig struct {
|
||||||
|
Provider string // "openai" — any OpenAI-compatible endpoint
|
||||||
|
BaseURL string // default https://api.openai.com/v1
|
||||||
|
APIKey string
|
||||||
|
// Tier → model name. Empty falls back to Balanced, which falls back to
|
||||||
|
// ASSISTANT_MODEL.
|
||||||
|
Fast string
|
||||||
|
Balanced string
|
||||||
|
Deep string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AssistantConfig) Enabled() bool { return a.Why() == "" }
|
||||||
|
|
||||||
|
// Why says what is missing, or "" when the assistant can run.
|
||||||
|
//
|
||||||
|
// A sentence rather than a bool, because "off" is the same answer for four
|
||||||
|
// different mistakes: no provider, no model, no key, a provider nobody
|
||||||
|
// recognises. Without this the only symptom is a disabled composer, and the
|
||||||
|
// difference between "we have not switched it on" and "somebody misspelled a
|
||||||
|
// variable" is invisible from the outside — which is exactly where this was
|
||||||
|
// stuck.
|
||||||
|
// The model is reported before the provider, and that order matters. Since
|
||||||
|
// `assistantProvider` derives the provider from the model, an empty provider
|
||||||
|
// means the model is empty too — and naming ASSISTANT_PROVIDER first would send
|
||||||
|
// an operator to set a variable they no longer need, while the one they
|
||||||
|
// actually missed went unmentioned.
|
||||||
|
func (a AssistantConfig) Why() string {
|
||||||
|
if a.Balanced == "" {
|
||||||
|
return "ASSISTANT_MODEL is not set; give it the provider's model name, " +
|
||||||
|
"for example openai/gpt-oss-120b"
|
||||||
|
}
|
||||||
|
switch a.Provider {
|
||||||
|
case "openai", "groq", "ollama", "together", "compatible":
|
||||||
|
case "":
|
||||||
|
// Not reachable through Load, which derives it. Reachable when
|
||||||
|
// something builds this struct by hand, and silence would be worse.
|
||||||
|
return "ASSISTANT_PROVIDER is not set and could not be derived"
|
||||||
|
default:
|
||||||
|
return "ASSISTANT_PROVIDER is " + a.Provider + ", which is not one this server speaks"
|
||||||
|
}
|
||||||
|
// A local provider needs no credential; a hosted one always does, and a
|
||||||
|
// missing key otherwise surfaces as a 401 from the provider on the first
|
||||||
|
// question rather than as a configuration problem.
|
||||||
|
if a.APIKey == "" && !isLocalEndpoint(a.BaseURL) {
|
||||||
|
where := a.BaseURL
|
||||||
|
if where == "" {
|
||||||
|
// Empty means the OpenAI default, which is emphatically not local.
|
||||||
|
// "and is not a local endpoint" is how that read before.
|
||||||
|
where = "the default https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
return "ASSISTANT_API_KEY is not set, and " + where + " is not a local endpoint"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalEndpoint reports whether a base URL is something running beside us.
|
||||||
|
//
|
||||||
|
// Ollama and LM Studio need no key, and demanding one would refuse the setup a
|
||||||
|
// developer is most likely to have on their own machine.
|
||||||
|
func isLocalEndpoint(baseURL string) bool {
|
||||||
|
url := strings.ToLower(baseURL)
|
||||||
|
return strings.Contains(url, "localhost") ||
|
||||||
|
strings.Contains(url, "127.0.0.1") ||
|
||||||
|
strings.Contains(url, "host.docker.internal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelFor resolves a tier to a model name, falling back rather than failing.
|
||||||
|
//
|
||||||
|
// A missing `fast` model should answer a cheap question with the balanced one,
|
||||||
|
// not refuse it. A deployment that sets one model gets one model everywhere.
|
||||||
|
func (a AssistantConfig) ModelFor(tier string) string {
|
||||||
|
switch tier {
|
||||||
|
case "fast":
|
||||||
|
if a.Fast != "" {
|
||||||
|
return a.Fast
|
||||||
|
}
|
||||||
|
case "deep":
|
||||||
|
if a.Deep != "" {
|
||||||
|
return a.Deep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.Balanced
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Nearle Buddy runs on unless a deployment says otherwise.
|
||||||
|
//
|
||||||
|
// These are in the code rather than in the environment because they are not
|
||||||
|
// secrets and not deployment-specific — they are what this product uses. Every
|
||||||
|
// variable that has one right answer is a variable somebody has to remember,
|
||||||
|
// get past a platform's UI, and then re-enter on the next environment; three of
|
||||||
|
// the four were exactly that, and the assistant sat switched off for days
|
||||||
|
// because one of them had not been typed.
|
||||||
|
//
|
||||||
|
// The API key is the one that genuinely varies and genuinely cannot live here.
|
||||||
|
const (
|
||||||
|
defaultAssistantProvider = "openai"
|
||||||
|
defaultAssistantBaseURL = "https://api.groq.com/openai/v1"
|
||||||
|
defaultAssistantModel = "openai/gpt-oss-120b"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assistantProvider reads the provider, defaulting to the one shape this
|
||||||
|
// server speaks.
|
||||||
|
//
|
||||||
|
// Every endpoint here is OpenAI-compatible — Groq, Ollama, Together and OpenAI
|
||||||
|
// itself — so the base URL is what actually distinguishes them. Naming a
|
||||||
|
// protocol you have no choice about is a variable that exists only to be
|
||||||
|
// forgotten.
|
||||||
|
func assistantProvider() string {
|
||||||
|
if named := strings.ToLower(strings.TrimSpace(env("ASSISTANT_PROVIDER", ""))); named != "" {
|
||||||
|
return named
|
||||||
|
}
|
||||||
|
return defaultAssistantProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistantFromEnv reads the assistant's settings, defaults and all.
|
||||||
|
//
|
||||||
|
// Exported and used by `Load` rather than written inline there, because the
|
||||||
|
// live tests need the SAME reading. They used to build this struct by hand from
|
||||||
|
// `os.Getenv`, which meant they skipped silently the moment a default was
|
||||||
|
// introduced — they were testing a configuration production no longer uses,
|
||||||
|
// and the one time that mattered was the day the provider stopped being
|
||||||
|
// required and nothing noticed.
|
||||||
|
//
|
||||||
|
// Three of the four fields have one right answer and come from the constants
|
||||||
|
// above. The key varies between deployments and is the only one that cannot
|
||||||
|
// live in this repository.
|
||||||
|
func AssistantFromEnv() AssistantConfig {
|
||||||
|
return AssistantConfig{
|
||||||
|
Provider: assistantProvider(),
|
||||||
|
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
|
||||||
|
APIKey: env("ASSISTANT_API_KEY", ""),
|
||||||
|
Fast: env("ASSISTANT_MODEL_FAST", ""),
|
||||||
|
// ASSISTANT_MODEL alone still sets every tier, for a deployment that
|
||||||
|
// wants one model everywhere but not this one.
|
||||||
|
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
|
||||||
|
Deep: env("ASSISTANT_MODEL_DEEP", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProduction is true under APP_ENV=production.
|
||||||
|
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
|
||||||
|
|
||||||
|
// Load picks and loads the environment files, reads every setting and
|
||||||
|
// validates them. The returned error lists every problem at once.
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
loadEnvFiles()
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Env: getEnv("ENV", "production"),
|
AppEnv: env("APP_ENV", EnvLocal),
|
||||||
Port: getEnv("APP_PORT", "1009"),
|
Port: env("APP_PORT", "1122"),
|
||||||
|
|
||||||
// ✅ STANDARDIZED DB ENV KEYS
|
DB: DBConfig{
|
||||||
DBName: getEnv("DB_NAME", ""),
|
Host: env("DB_HOST", ""),
|
||||||
DBUser: getEnv("DB_USER", ""),
|
Port: env("DB_PORT", "5433"),
|
||||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
Name: env("DB_NAME", ""),
|
||||||
DBPort: getEnv("DB_PORT", "5432"),
|
User: env("DB_USER", ""),
|
||||||
DBHost: getEnv("DB_HOST", "localhost"),
|
Password: env("DB_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
Catalogue: DBConfig{
|
||||||
|
Host: env("CATALOGUE_DB_HOST", ""),
|
||||||
|
Port: env("CATALOGUE_DB_PORT", "5432"),
|
||||||
|
Name: env("CATALOGUE_DB_NAME", ""),
|
||||||
|
User: env("CATALOGUE_DB_USER", ""),
|
||||||
|
Password: env("CATALOGUE_DB_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
Redis: RedisConfig{
|
||||||
|
Host: env("REDIS_HOST", ""),
|
||||||
|
Port: env("REDIS_PORT", "6379"),
|
||||||
|
User: env("REDIS_USER", "default"),
|
||||||
|
Password: env("REDIS_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
S3: S3Config{
|
||||||
|
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
|
||||||
|
Endpoint: env("S3_ENDPOINT", ""),
|
||||||
|
Bucket: env("S3_BUCKET", ""),
|
||||||
|
AccessKey: env("S3_ACCESS_KEY", ""),
|
||||||
|
SecretKey: env("S3_SECRET_KEY", ""),
|
||||||
|
Region: env("S3_REGION", ""),
|
||||||
|
},
|
||||||
|
MQTT: MQTTConfig{
|
||||||
|
URL: env("MQTT_URL", ""),
|
||||||
|
// MQTT_USERNAME is accepted because livehub.go read that name for
|
||||||
|
// a while, so an existing deployment may still set it.
|
||||||
|
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
|
||||||
|
Password: env("MQTT_PASSWORD", ""),
|
||||||
|
ClientID: env("MQTT_CLIENT_ID", ""),
|
||||||
|
},
|
||||||
|
|
||||||
UserContextKey: getEnv("USER_CONTEXT_KEY", "nearle"),
|
Embedding: EmbeddingConfig{
|
||||||
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
|
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
|
||||||
|
Model: env("EMBEDDING_MODEL", ""),
|
||||||
|
APIKey: env("EMBEDDING_API_KEY", ""),
|
||||||
|
BaseURL: env("EMBEDDING_BASE_URL", ""),
|
||||||
|
},
|
||||||
|
|
||||||
|
Assistant: AssistantFromEnv(),
|
||||||
|
Mail: MailFromEnv(),
|
||||||
|
|
||||||
|
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
|
||||||
|
JWTSecret: env("JWT_SECRET_KEY", ""),
|
||||||
|
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
|
||||||
|
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Correct validation
|
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
|
||||||
if cfg.DBPassword == "" {
|
cfg.Redis.DB = db
|
||||||
log.Println("Warning: DB_PASSWORD is not set")
|
} else {
|
||||||
|
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
|
||||||
|
n, err := strconv.Atoi(dims)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
|
||||||
|
}
|
||||||
|
cfg.Embedding.Dimensions = n
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustLoad is Load for main(): every problem is printed and the process exits.
|
||||||
|
func MustLoad() *Config {
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
|
||||||
|
}
|
||||||
|
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
|
||||||
|
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
// validate collects every problem rather than stopping at the first, so one
|
||||||
if v := os.Getenv(key); v != "" {
|
// restart is enough to learn everything that is wrong.
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
var problems []string
|
||||||
|
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
|
||||||
|
|
||||||
|
if c.DB.Host == "" {
|
||||||
|
missing("DB_HOST")
|
||||||
|
}
|
||||||
|
if c.DB.User == "" {
|
||||||
|
missing("DB_USER")
|
||||||
|
}
|
||||||
|
if c.DB.Password == "" {
|
||||||
|
missing("DB_PASSWORD")
|
||||||
|
}
|
||||||
|
if c.DB.Name == "" {
|
||||||
|
missing("DB_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional subsystems are either fully configured or absent. Half a
|
||||||
|
// configuration used to be skipped with a warning, which reads as "fine"
|
||||||
|
// in a log and turns into "why are there no images" a week later.
|
||||||
|
if c.Catalogue.Enabled() {
|
||||||
|
if c.Catalogue.User == "" {
|
||||||
|
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
|
||||||
|
}
|
||||||
|
if c.Catalogue.Password == "" {
|
||||||
|
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
|
||||||
|
}
|
||||||
|
if c.Catalogue.Name == "" {
|
||||||
|
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.S3.Enabled {
|
||||||
|
if c.S3.Endpoint == "" {
|
||||||
|
missing("S3_ENDPOINT (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.Bucket == "" {
|
||||||
|
missing("S3_BUCKET (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.AccessKey == "" {
|
||||||
|
missing("S3_ACCESS_KEY (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.SecretKey == "" {
|
||||||
|
missing("S3_SECRET_KEY (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.Region == "" {
|
||||||
|
missing("S3_REGION (USE_S3=true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Embedding.Enabled() {
|
||||||
|
switch c.Embedding.Provider {
|
||||||
|
case "openai", "gemini":
|
||||||
|
default:
|
||||||
|
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
|
||||||
|
}
|
||||||
|
if c.Embedding.Model == "" {
|
||||||
|
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
|
||||||
|
}
|
||||||
|
if c.Embedding.APIKey == "" {
|
||||||
|
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.IsProduction() {
|
||||||
|
// utils/postoken.go refuses to sign with a short secret at request
|
||||||
|
// time; catching it here means the first till login is not the first
|
||||||
|
// anyone hears of it.
|
||||||
|
secret := c.POSTokenSecret
|
||||||
|
if secret == "" {
|
||||||
|
secret = c.JWTSecret
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(secret) == "" {
|
||||||
|
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
|
||||||
|
} else if len(strings.TrimSpace(secret)) < 16 {
|
||||||
|
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
|
||||||
|
}
|
||||||
|
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
|
||||||
|
// Not fatal: a dump restored on another machine on the LAN is a valid
|
||||||
|
// local setup. But `.env.local` pointing at the live host is the
|
||||||
|
// mistake every comment in that file warns about, so say it out loud.
|
||||||
|
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
|
||||||
|
c.AppEnv, c.DB.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(problems) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New(strings.Join(problems, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
|
||||||
|
//
|
||||||
|
// APP_ENV is read from the real environment before any file, so a file cannot
|
||||||
|
// change which environment it is loaded for.
|
||||||
|
// `.env.secrets` is read FIRST and is the only one of these git does not track.
|
||||||
|
// godotenv never overwrites a value already set, so first read wins — which is
|
||||||
|
// what makes this file the place a key belongs. Every other file here is in the
|
||||||
|
// repository, so a secret written to one is a secret published; there was
|
||||||
|
// previously nowhere to put a key at all, and the answer was "export it in your
|
||||||
|
// shell every time", which is the kind of instruction people route around.
|
||||||
|
// envFileOrder is the read order, and the order is the rule: godotenv never
|
||||||
|
// overwrites a value already set, so whichever file names a variable first is
|
||||||
|
// the one that decides it.
|
||||||
|
func envFileOrder(appEnv string) []string {
|
||||||
|
return []string{".env.secrets", ".env." + appEnv, ".env"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnvFiles() {
|
||||||
|
for _, name := range envFileOrder(env("APP_ENV", EnvLocal)) {
|
||||||
|
if _, err := os.Stat(name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := godotenv.Load(name); err != nil {
|
||||||
|
log.Printf("config: could not read %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("config: loaded %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isLocalHost(host string) bool {
|
||||||
|
switch strings.ToLower(host) {
|
||||||
|
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(host, "127.")
|
||||||
|
}
|
||||||
|
|||||||
251
config/config_test.go
Normal file
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
config/mail.go
Normal file
153
config/mail.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sending email.
|
||||||
|
//
|
||||||
|
// ── Why this exists at all ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A newly onboarded merchant's admin account arrives with no password, and the
|
||||||
|
// only safe way to let them set one is a signed invitation sent to the primary
|
||||||
|
// email they gave us. Until this, the server could not send email: no library,
|
||||||
|
// no configuration, and `NotifyUser` is Firebase push rather than mail.
|
||||||
|
//
|
||||||
|
// ── Shaped like AssistantConfig, for the same reasons ───────────────────────
|
||||||
|
//
|
||||||
|
// Unconfigured is a deployment choice and not a fault, so `Enabled` reports it
|
||||||
|
// and `Why` says which variable is missing. A server with no mail still boots
|
||||||
|
// and still onboards tenants — the invitation is recorded as unsent rather than
|
||||||
|
// failing the creation, because a tenant that exists and cannot be reached is
|
||||||
|
// recoverable and a tenant that was rolled back by a mail outage is confusing.
|
||||||
|
type MailConfig struct {
|
||||||
|
// SMTP, because it is the one protocol every provider speaks. A transactional
|
||||||
|
// service (SES, SendGrid, Resend) is reached the same way, with its own host
|
||||||
|
// and an API key as the password — so choosing one later is configuration
|
||||||
|
// rather than code.
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
// Who the invitation appears to come from. Separate from the username
|
||||||
|
// because most providers authenticate as one identity and send as another,
|
||||||
|
// and using the login as the From address is how mail ends up in spam.
|
||||||
|
FromAddress string
|
||||||
|
FromName string
|
||||||
|
// Where the invitation link points. The merchant console, always — a
|
||||||
|
// merchant sets their password there and nowhere else — and a build
|
||||||
|
// variable rather than a constant because the site can move.
|
||||||
|
ConsoleURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MailConfig) Enabled() bool { return m.Why() == "" }
|
||||||
|
|
||||||
|
// Why says what is missing, or "" when mail can be sent.
|
||||||
|
//
|
||||||
|
// A sentence rather than a bool. "Off" is the same answer for five different
|
||||||
|
// mistakes, and the difference between "we have not set this up" and "somebody
|
||||||
|
// misspelled a variable" is invisible from outside — which is exactly how the
|
||||||
|
// assistant sat switched off for two days.
|
||||||
|
func (m MailConfig) Why() string {
|
||||||
|
if strings.TrimSpace(m.Host) == "" {
|
||||||
|
return "MAIL_HOST is not set, so no invitation can be sent"
|
||||||
|
}
|
||||||
|
if m.Port <= 0 {
|
||||||
|
return "MAIL_PORT is not a usable port number"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m.FromAddress) == "" {
|
||||||
|
return "MAIL_FROM is not set; an invitation needs a sender address"
|
||||||
|
}
|
||||||
|
// Username and password are deliberately NOT required. An internal relay
|
||||||
|
// that authenticates by network is a real deployment, and demanding
|
||||||
|
// credentials would refuse it.
|
||||||
|
if strings.TrimSpace(m.ConsoleURL) == "" {
|
||||||
|
return "MAIL_CONSOLE_URL is not set; the invitation would have nowhere to point"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address is host:port, as the SMTP client wants it.
|
||||||
|
func (m MailConfig) Address() string { return fmt.Sprintf("%s:%d", m.Host, m.Port) }
|
||||||
|
|
||||||
|
// InviteLink is where an invitation sends somebody.
|
||||||
|
//
|
||||||
|
// Built here rather than in the mailer so the shape is decided once, beside the
|
||||||
|
// console URL it depends on. The token is the whole credential, so it is the
|
||||||
|
// only thing in the query string — never an email address or a userid, which
|
||||||
|
// would put both halves of an account into a URL that lands in server logs,
|
||||||
|
// browser history and whatever proxy sits between.
|
||||||
|
func (m MailConfig) InviteLink(token string) string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(m.ConsoleURL), "/")
|
||||||
|
return base + "/set-password?t=" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailFromEnv reads the mail settings.
|
||||||
|
func MailFromEnv() MailConfig {
|
||||||
|
port, err := strconv.Atoi(strings.TrimSpace(env("MAIL_PORT", "587")))
|
||||||
|
if err != nil {
|
||||||
|
// Zero rather than the default, so `Why` reports it instead of the
|
||||||
|
// server quietly dialling a port nobody asked for.
|
||||||
|
port = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return MailConfig{
|
||||||
|
Host: env("MAIL_HOST", ""),
|
||||||
|
Port: port,
|
||||||
|
Username: env("MAIL_USERNAME", ""),
|
||||||
|
Password: smtpPassword(env("MAIL_HOST", ""), env("MAIL_PASSWORD", "")),
|
||||||
|
// A name is optional; an address is not.
|
||||||
|
FromAddress: env("MAIL_FROM", ""),
|
||||||
|
FromName: env("MAIL_FROM_NAME", "Nearle"),
|
||||||
|
ConsoleURL: env("MAIL_CONSOLE_URL", "https://app.nearledaily.com"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
smtpPassword takes the spaces out of a Google App Password.
|
||||||
|
|
||||||
|
Google shows a 16-character App Password formatted for reading — "abcd efgh
|
||||||
|
ijkl mnop" — and the spaces are presentation, not part of the secret. Pasted
|
||||||
|
verbatim they survive into the credential and Gmail refuses the login, which
|
||||||
|
Fiesta reports as "the mail server refused our credentials". That sends somebody
|
||||||
|
to revoke a perfectly good password and generate another one with the same four
|
||||||
|
spaces in it.
|
||||||
|
|
||||||
|
ONLY for Google's own SMTP hosts, and only when what is left is the 16
|
||||||
|
alphanumeric characters an App Password actually is. A password is a secret and
|
||||||
|
quietly editing one is normally the wrong thing: another relay's password may
|
||||||
|
legitimately contain a space, and stripping it there would turn a working
|
||||||
|
credential into a silent authentication failure — the exact bug this avoids,
|
||||||
|
pointed the other way.
|
||||||
|
*/
|
||||||
|
func smtpPassword(host, password string) string {
|
||||||
|
if !isGoogleSMTP(host) {
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
|
||||||
|
stripped := strings.Join(strings.Fields(password), "")
|
||||||
|
if stripped == password || len(stripped) != googleAppPasswordLength {
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
for _, r := range stripped {
|
||||||
|
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleAppPasswordLength is what Google issues: sixteen characters, shown in
|
||||||
|
// four groups of four.
|
||||||
|
const googleAppPasswordLength = 16
|
||||||
|
|
||||||
|
func isGoogleSMTP(host string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||||
|
case "smtp.gmail.com", "smtp-relay.gmail.com", "aspmx.l.google.com":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
95
config/mail_test.go
Normal file
95
config/mail_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Confirms docs/MAIL_SETUP.md is telling the truth about the committed `.env`:
|
||||||
|
// a sender is set, a host is not, and the server therefore reports mail OFF with
|
||||||
|
// a reason naming the variable — rather than trying and failing to send.
|
||||||
|
func TestCommittedEnvLeavesMailOffWithAReason(t *testing.T) {
|
||||||
|
t.Setenv("MAIL_HOST", "")
|
||||||
|
t.Setenv("MAIL_PORT", "587")
|
||||||
|
t.Setenv("MAIL_FROM", "care@nearledaily.com")
|
||||||
|
t.Setenv("MAIL_FROM_NAME", "Nearle")
|
||||||
|
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
|
||||||
|
|
||||||
|
cfg := MailFromEnv()
|
||||||
|
if cfg.Enabled() {
|
||||||
|
t.Fatal("mail reported as enabled with no host")
|
||||||
|
}
|
||||||
|
if cfg.Why() == "" || cfg.Why()[:9] != "MAIL_HOST" {
|
||||||
|
t.Fatalf("the reason does not name the missing variable: %q", cfg.Why())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And with the Postal host supplied from .env.secrets, it comes on and the
|
||||||
|
// link points at the MERCHANT console.
|
||||||
|
t.Setenv("MAIL_HOST", "postal.nearledaily.com")
|
||||||
|
on := MailFromEnv()
|
||||||
|
if !on.Enabled() {
|
||||||
|
t.Fatalf("still off with a host set: %s", on.Why())
|
||||||
|
}
|
||||||
|
if got := on.InviteLink("i1.abc.def"); got != "https://app.nearledaily.com/set-password?t=i1.abc.def" {
|
||||||
|
t.Fatalf("the invitation would point at %q", got)
|
||||||
|
}
|
||||||
|
if on.Address() != "postal.nearledaily.com:587" {
|
||||||
|
t.Fatalf("wrong SMTP address: %q", on.Address())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Google App Passwords ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAGoogleAppPasswordSurvivesBeingPastedWithItsSpaces(t *testing.T) {
|
||||||
|
// Google shows it as "abcd efgh ijkl mnop". The spaces are presentation.
|
||||||
|
// Pasted verbatim they reach Gmail, which refuses the login — reported as
|
||||||
|
// "the mail server refused our credentials", sending somebody to revoke a
|
||||||
|
// password that was fine.
|
||||||
|
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||||
|
t.Setenv("MAIL_PORT", "587")
|
||||||
|
t.Setenv("MAIL_USERNAME", "care@nearledaily.com")
|
||||||
|
t.Setenv("MAIL_PASSWORD", "abcd efgh ijkl mnop")
|
||||||
|
t.Setenv("MAIL_FROM", "care@nearledaily.com")
|
||||||
|
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
|
||||||
|
|
||||||
|
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
|
||||||
|
t.Fatalf("password reached the relay as %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAlreadyCleanAppPasswordIsUntouched(t *testing.T) {
|
||||||
|
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||||
|
t.Setenv("MAIL_PASSWORD", "abcdefghijklmnop")
|
||||||
|
|
||||||
|
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnotherRelaysPasswordIsNeverEdited(t *testing.T) {
|
||||||
|
// A secret is a secret. Another relay's password may legitimately contain a
|
||||||
|
// space, and stripping it there turns a working credential into a silent
|
||||||
|
// authentication failure — this bug pointed the other way.
|
||||||
|
for _, host := range []string{"smtp.sendgrid.net", "email-smtp.ap-south-1.amazonaws.com", "postal.nearledaily.com"} {
|
||||||
|
t.Setenv("MAIL_HOST", host)
|
||||||
|
t.Setenv("MAIL_PASSWORD", "two words here x")
|
||||||
|
|
||||||
|
if got := MailFromEnv().Password; got != "two words here x" {
|
||||||
|
t.Errorf("%s: password was edited to %q", host, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSomethingThatIsNotAnAppPasswordIsLeftAlone(t *testing.T) {
|
||||||
|
// Only the exact shape Google issues — sixteen alphanumerics — is treated
|
||||||
|
// as display formatting. Anything else is somebody's real password.
|
||||||
|
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||||
|
|
||||||
|
for _, password := range []string{
|
||||||
|
"short one", // not 16 after stripping
|
||||||
|
"a much longer pass phrase here", // not 16
|
||||||
|
"abcd efgh ijkl mno!", // punctuation: not an App Password
|
||||||
|
} {
|
||||||
|
t.Setenv("MAIL_PASSWORD", password)
|
||||||
|
if got := MailFromEnv().Password; got != password {
|
||||||
|
t.Errorf("%q was rewritten to %q", password, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
controllers/assistantController.go
Normal file
193
controllers/assistantController.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/middleware"
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nearle Buddy's HTTP surface.
|
||||||
|
//
|
||||||
|
// POST /v1/web/assistant/ask a question → an answer, and what it ran
|
||||||
|
// POST /v1/web/assistant/approve a card the person pressed → the change, made
|
||||||
|
// GET /v1/web/assistant/status is this switched on here?
|
||||||
|
//
|
||||||
|
// ── Where the caller comes from ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `middleware.WebAuth` parks the verified claims on the request, and this
|
||||||
|
// builds the tool caller from those and from nothing else. There is no tenant
|
||||||
|
// field on the request body — deliberately, so there is nothing for a model or
|
||||||
|
// a caller to fill in. The console asks "what is stuck?" and the server already
|
||||||
|
// knows whose shop that means.
|
||||||
|
type AssistantController struct {
|
||||||
|
assistant services.AssistantService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAssistantController(assistant services.AssistantService) *AssistantController {
|
||||||
|
return &AssistantController{assistant: assistant}
|
||||||
|
}
|
||||||
|
|
||||||
|
type assistantApproveRequest struct {
|
||||||
|
Agent string `json:"agent"`
|
||||||
|
// The card exactly as it was handed out. Opaque to the console — it is
|
||||||
|
// signed, and anything the browser changed stops it verifying.
|
||||||
|
Card string `json:"card"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type assistantAskRequest struct {
|
||||||
|
// Which agent to ask. The console sends the one matching the page the panel
|
||||||
|
// is sitting beside; empty means orders, the only one phase 2 ships.
|
||||||
|
Agent string `json:"agent"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status lets the console decide what to render before anybody types.
|
||||||
|
//
|
||||||
|
// The composer is disabled when this says no, which is the honest thing: a
|
||||||
|
// field that accepts text and then swallows it is worse than one that says it
|
||||||
|
// is not connected. The console has shown "Not connected yet" since it was
|
||||||
|
// built, and this is what finally answers that question at runtime rather than
|
||||||
|
// at build time.
|
||||||
|
func (ctl *AssistantController) Status(c *fiber.Ctx) error {
|
||||||
|
details := fiber.Map{"available": ctl.assistant.Available()}
|
||||||
|
// Named "reason" rather than "error": not having an assistant is a
|
||||||
|
// deployment choice, and the same field answers "we have not switched it
|
||||||
|
// on" and "somebody misspelled a variable" — which are the two states that
|
||||||
|
// looked identical from outside.
|
||||||
|
if why := ctl.assistant.Unavailable(); why != "" {
|
||||||
|
details["reason"] = why
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Success", "details": details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *AssistantController) Ask(c *fiber.Ctx) error {
|
||||||
|
var req assistantAskRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return assistantRefuse(c, http.StatusBadRequest, "Invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
caller, ok := callerFrom(c)
|
||||||
|
if !ok {
|
||||||
|
// Reachable only while WEB_AUTH_REQUIRED is off, where an untokened
|
||||||
|
// request still reaches handlers. Every other endpoint answers such a
|
||||||
|
// request; this one must not. Reading a shop's orders through a REST
|
||||||
|
// call takes knowing the endpoints and the fields; through an
|
||||||
|
// assistant it takes one sentence, so this surface holds the higher
|
||||||
|
// bar from its first day rather than inheriting the rollout's.
|
||||||
|
return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to use Nearle Buddy.")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := strings.TrimSpace(req.Agent)
|
||||||
|
if agent == "" {
|
||||||
|
agent = "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := services.WithTimeout(c.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
answer, err := ctl.assistant.Ask(ctx, agent, req.Question, caller)
|
||||||
|
if err != nil {
|
||||||
|
// "Not switched on here" is a deployment fact, not a fault, and it gets
|
||||||
|
// its own status so the console can disable the composer rather than
|
||||||
|
// showing an error the person can do nothing about.
|
||||||
|
if errors.Is(err, utils.ErrChatNotConfigured) {
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusServiceUnavailable, "status": false,
|
||||||
|
"message": "Nearle Buddy is not switched on for this deployment.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Asking too fast gets its own status, so the console and whatever
|
||||||
|
// watches it can tell "you are going too quickly" apart from "that
|
||||||
|
// question was malformed". The message already says how long to wait.
|
||||||
|
var tooFast services.ErrTooFast
|
||||||
|
if errors.As(err, &tooFast) {
|
||||||
|
return assistantRefuse(c, http.StatusTooManyRequests, err.Error())
|
||||||
|
}
|
||||||
|
// The provider's quota, as opposed to our own limiter above. Same status
|
||||||
|
// for the same reason — it is not a bad question, it is a busy minute —
|
||||||
|
// and the message is ours rather than Groq's, which names our billing
|
||||||
|
// account and the tokens-per-minute arithmetic behind it.
|
||||||
|
if errors.Is(err, utils.ErrBusy) {
|
||||||
|
return assistantRefuse(c, http.StatusTooManyRequests, utils.ErrBusy.Error())
|
||||||
|
}
|
||||||
|
return assistantRefuse(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Success", "details": answer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve performs a change the person pressed the button on.
|
||||||
|
//
|
||||||
|
// Its own endpoint, not a flag on /ask, because it is a different kind of act:
|
||||||
|
// no question, no model, no conversation. The card names the action and the
|
||||||
|
// session names the person, and the registry re-checks both against the live
|
||||||
|
// database before anything is written.
|
||||||
|
func (ctl *AssistantController) Approve(c *fiber.Ctx) error {
|
||||||
|
var req assistantApproveRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return assistantRefuse(c, http.StatusBadRequest, "Invalid request body")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Card) == "" {
|
||||||
|
return assistantRefuse(c, http.StatusBadRequest, "Nothing to approve.")
|
||||||
|
}
|
||||||
|
|
||||||
|
caller, ok := callerFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to approve this.")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := strings.TrimSpace(req.Agent)
|
||||||
|
if agent == "" {
|
||||||
|
agent = "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := services.WithTimeout(c.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
answer, err := ctl.assistant.Approve(ctx, agent, req.Card, caller)
|
||||||
|
if err != nil {
|
||||||
|
// A refused approval is a business outcome, not a server fault: the card
|
||||||
|
// expired, somebody else already approved it, the request was withdrawn.
|
||||||
|
// The person needs the reason, and the console renders it beside the
|
||||||
|
// card rather than as an error page.
|
||||||
|
return assistantRefuse(c, http.StatusConflict, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Success", "details": answer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerFrom turns a verified session into a tool caller.
|
||||||
|
//
|
||||||
|
// The one place the two vocabularies meet. Staff (`issuperadmin`) carry no
|
||||||
|
// tenant, and the registry lets them through — but a tool that reads a shop's
|
||||||
|
// data refuses them until they have picked one, because "every tenant at once"
|
||||||
|
// is not an answer to "what is stuck?".
|
||||||
|
func callerFrom(c *fiber.Ctx) (tools.Caller, bool) {
|
||||||
|
claims, ok := middleware.WebClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return tools.Caller{}, false
|
||||||
|
}
|
||||||
|
return tools.Caller{
|
||||||
|
Userid: claims.Userid,
|
||||||
|
Tenantid: claims.Tenantid,
|
||||||
|
Locationid: claims.Locationid,
|
||||||
|
Superadmin: claims.Superadmin,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func assistantRefuse(c *fiber.Ctx, code int, message string) error {
|
||||||
|
return c.Status(code).JSON(fiber.Map{"code": code, "status": false, "message": message})
|
||||||
|
}
|
||||||
443
controllers/assistantHTTP_test.go
Normal file
443
controllers/assistantHTTP_test.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"hash/crc32"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/config"
|
||||||
|
"nearle/middleware"
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nearle Buddy over HTTP, through the guard, as the console reaches it.
|
||||||
|
//
|
||||||
|
// Everything else tests one layer. The service tests call `Ask` directly with a
|
||||||
|
// caller already built; the live tests talk to a real model but never touch a
|
||||||
|
// route. Neither would notice the thing most likely to break on a deploy: the
|
||||||
|
// seam where a session token becomes a tool caller.
|
||||||
|
//
|
||||||
|
// That seam has four parts, and a mistake in any one of them produces a console
|
||||||
|
// showing an empty panel and a server logging nothing —
|
||||||
|
//
|
||||||
|
// the route sits under /v1/web, so WebAuth runs at all
|
||||||
|
// WebAuth verifies the token and parks the claims
|
||||||
|
// callerFrom reads those claims rather than the request body
|
||||||
|
// the answer comes back inside `details`, where the console's client looks
|
||||||
|
//
|
||||||
|
// No database: every tool is handed a fake, so this runs in CI beside the unit
|
||||||
|
// tests. The ones that need a model skip without a key.
|
||||||
|
|
||||||
|
const testSecret = "a-test-signing-secret-of-ample-length"
|
||||||
|
|
||||||
|
var testCaller = utils.WebClaims{Userid: 904, Tenantid: 1147}
|
||||||
|
|
||||||
|
// ── the shop these tests run against ────────────────────────────────────────
|
||||||
|
|
||||||
|
type fakeShop struct {
|
||||||
|
deliveries []models.Deliveryinfo
|
||||||
|
requests []models.StockRequest
|
||||||
|
// approved records what reached the write half, so the approval test can
|
||||||
|
// assert the change happened rather than that it was described.
|
||||||
|
approved []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeShop) GetDeliveries(models.DeliveryQuery) []models.Deliveryinfo { return f.deliveries }
|
||||||
|
|
||||||
|
func (f *fakeShop) GetStockRequests(tenantID, _ int, status, _ string, _, _ int) ([]models.StockRequest, error) {
|
||||||
|
// Honours the tenant on purpose. A fake that returned rows to anybody would
|
||||||
|
// let an ownership bug pass this test.
|
||||||
|
if tenantID != testCaller.Tenantid || !strings.EqualFold(status, "Pending") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return f.requests, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeShop) UpdateStockRequest(requestID int, status string) error {
|
||||||
|
f.approved = append(f.approved, status+" #"+strconv.Itoa(requestID))
|
||||||
|
for i := range f.requests {
|
||||||
|
if f.requests[i].Requestid == requestID {
|
||||||
|
// Drops out of the pending list, as the real update does. Without
|
||||||
|
// this, approving the same card twice would succeed twice.
|
||||||
|
f.requests = append(f.requests[:i], f.requests[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tools these tests do not exercise still have to exist, because the
|
||||||
|
// shipped agents name them and LoadAgents refuses an agent naming a tool that
|
||||||
|
// is absent. An empty answer is the honest fake: a shop with nothing to report.
|
||||||
|
func (f *fakeShop) GetLocationOrderSummary(int) ([]models.Ordersummarylocation, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeShop) GetProductStocks(string, string) ([]models.Productstocks, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeShop) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeShop) GetRevenueSummary(int, int, string, string) (*models.TenantRevenueSummary, error) {
|
||||||
|
return &models.TenantRevenueSummary{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeShop) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||||
|
return &models.PosSalesSummary{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newShop() *fakeShop {
|
||||||
|
now := time.Now()
|
||||||
|
stamp := func(minutesAgo int) string {
|
||||||
|
return now.Add(-time.Duration(minutesAgo) * time.Minute).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return &fakeShop{
|
||||||
|
deliveries: []models.Deliveryinfo{
|
||||||
|
{Deliveryid: 4412, Orderid: "ORD-4412", Orderstatus: "pending", Assigntime: stamp(41),
|
||||||
|
Ridername: "Varun", Locationname: "R Mart"},
|
||||||
|
{Deliveryid: 4421, Orderid: "ORD-4421", Orderstatus: "delivered", Assigntime: stamp(200)},
|
||||||
|
},
|
||||||
|
requests: []models.StockRequest{{
|
||||||
|
Requestid: 41, Productname: "Sona Masoori rice 25kg", Qty: 12,
|
||||||
|
Locationname: "R Mart", Status: "Pending", Created: now.Add(-36 * time.Hour),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the server, wired the way production wires it ───────────────────────────
|
||||||
|
|
||||||
|
func buildApp(t *testing.T, chat utils.Chat) (*fiber.App, *fakeShop) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
||||||
|
|
||||||
|
shop := newShop()
|
||||||
|
|
||||||
|
corpus, err := tools.LoadHelp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("help corpus: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := tools.New(tools.DiscardAudit{})
|
||||||
|
for _, tool := range []tools.Tool{
|
||||||
|
tools.StuckOrders(shop, nil),
|
||||||
|
tools.DeliveryProgress(shop),
|
||||||
|
tools.BranchPerformance(shop),
|
||||||
|
tools.PendingApprovals(shop, nil),
|
||||||
|
tools.LowStock(shop),
|
||||||
|
tools.TillsNotSyncing(shop),
|
||||||
|
tools.SalesByChannel(shop, shop, nil),
|
||||||
|
tools.Help(corpus),
|
||||||
|
tools.ApproveStockRequest(shop, shop),
|
||||||
|
} {
|
||||||
|
if err := registry.Register(tool); err != nil {
|
||||||
|
t.Fatalf("registering %s: %v", tool.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shipped agent definitions, not a hand-built stand-in. A typo in
|
||||||
|
// agents/inventory.yaml should fail here rather than on deploy.
|
||||||
|
agents, err := services.LoadAgents("", registry.Has)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agents: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assistant := services.NewAssistantService(registry, chat, agents)
|
||||||
|
// Mirrors facade.NewFacade: with no model, the reason the config gives is
|
||||||
|
// threaded through to the service so /status can name the missing variable.
|
||||||
|
// Built the same way here, or this would assert a string production never
|
||||||
|
// produces.
|
||||||
|
if setter, ok := assistant.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
|
||||||
|
setter.SetUnavailableReason(config.AssistantConfig{}.Why())
|
||||||
|
}
|
||||||
|
|
||||||
|
controller := NewAssistantController(assistant)
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
// nil is the branch-ownership checker, consulted only when a request names
|
||||||
|
// a branch. The assistant's body names none — that is the design — so
|
||||||
|
// nothing here can reach it.
|
||||||
|
app.Use(middleware.WebAuth(nil))
|
||||||
|
web := app.Group("/live/api/v1/web")
|
||||||
|
web.Get("/assistant/status", controller.Status)
|
||||||
|
web.Post("/assistant/ask", controller.Ask)
|
||||||
|
web.Post("/assistant/approve", controller.Approve)
|
||||||
|
|
||||||
|
return app, shop
|
||||||
|
}
|
||||||
|
|
||||||
|
// webSession mints a session for THIS test's own user.
|
||||||
|
//
|
||||||
|
// One user id across the file put every test in one rate-limit bucket — six
|
||||||
|
// questions and then 429 for ten seconds — so the suite passed test by test and
|
||||||
|
// failed when run together, which is the worst way round: green locally, red in
|
||||||
|
// CI, and the failure blamed on the model.
|
||||||
|
//
|
||||||
|
// A per-test user is also the truthful shape. The limiter is per person, and
|
||||||
|
// two tests are two people.
|
||||||
|
func webSession(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
claims := testCaller
|
||||||
|
// Stable across runs and distinct per test, so a failure names the same
|
||||||
|
// user every time. The fakes key on tenant, never on this.
|
||||||
|
claims.Userid = testCaller.Userid + int(crc32.ChecksumIEEE([]byte(t.Name()))%10_000)
|
||||||
|
|
||||||
|
token, _, err := utils.MintWebToken(claims, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting a session: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// envelope is the shape every Fiesta handler answers with, and the shape the
|
||||||
|
// console's client unwraps. Asserting on it rather than on the Go struct is the
|
||||||
|
// point: a controller returning the answer at the top level would pass a
|
||||||
|
// service-level test and hand the console `undefined`.
|
||||||
|
type envelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Status bool `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details services.AssistantAnswer `json:"details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
statusPath = "/live/api/v1/web/assistant/status"
|
||||||
|
askPath = "/live/api/v1/web/assistant/ask"
|
||||||
|
approvePath = "/live/api/v1/web/assistant/approve"
|
||||||
|
)
|
||||||
|
|
||||||
|
func post(t *testing.T, app *fiber.App, path, token, body string) (int, envelope, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", path, err)
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var out envelope
|
||||||
|
_ = json.Unmarshal(raw, &out)
|
||||||
|
return resp.StatusCode, out, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func quote(s string) string {
|
||||||
|
out, _ := json.Marshal(s)
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the guard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAnUntokenedQuestionIsRefusedOverHTTP(t *testing.T) {
|
||||||
|
// WEB_AUTH_REQUIRED defaults on now, so the middleware turns this away
|
||||||
|
// before the controller sees it. Either refusal is correct; what must never
|
||||||
|
// happen is an answer.
|
||||||
|
app, _ := buildApp(t, nil)
|
||||||
|
|
||||||
|
status, _, body := post(t, app, askPath, "", `{"agent":"orders","question":"what is stuck?"}`)
|
||||||
|
|
||||||
|
if status == fiber.StatusOK {
|
||||||
|
t.Fatalf("an untokened question was answered: %s", body)
|
||||||
|
}
|
||||||
|
if status != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d: %s", status, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATamperedTokenIsRefusedOverHTTP(t *testing.T) {
|
||||||
|
app, _ := buildApp(t, nil)
|
||||||
|
|
||||||
|
// Three characters at the end — the edit somebody would actually attempt.
|
||||||
|
broken := webSession(t)
|
||||||
|
broken = broken[:len(broken)-3] + "AAA"
|
||||||
|
|
||||||
|
status, _, body := post(t, app, askPath, broken, `{"question":"what is stuck?"}`)
|
||||||
|
if status != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("a tampered session was not refused: %d %s", status, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusNamesTheMissingVariable(t *testing.T) {
|
||||||
|
// Why the field exists: "available: false" alone is the same answer for "we
|
||||||
|
// have not switched it on" and "somebody misspelled a variable", and those
|
||||||
|
// need different actions from whoever is looking.
|
||||||
|
app, _ := buildApp(t, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", statusPath, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+webSession(t))
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("status: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Details struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
} `json:"details"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("status is not the envelope the console unwraps: %s", raw)
|
||||||
|
}
|
||||||
|
if out.Details.Available {
|
||||||
|
t.Fatal("reported available with no model configured")
|
||||||
|
}
|
||||||
|
if out.Details.Reason == "" {
|
||||||
|
t.Fatalf("said no without saying why: %s", raw)
|
||||||
|
}
|
||||||
|
// Names the variable, not merely the symptom. "no assistant model is
|
||||||
|
// configured" is what the service says on its own, and it is the answer
|
||||||
|
// that left this switched off without anybody being able to tell which
|
||||||
|
// variable was wrong.
|
||||||
|
if !strings.Contains(out.Details.Reason, "ASSISTANT_") {
|
||||||
|
t.Fatalf("the reason names no variable to go and set: %q", out.Details.Reason)
|
||||||
|
}
|
||||||
|
t.Logf("reason: %s", out.Details.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the live path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func liveHTTPChat(t *testing.T) utils.Chat {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Read exactly as production reads it, so this proves the shipped defaults
|
||||||
|
// work rather than quietly testing a configuration of its own.
|
||||||
|
cfg := config.AssistantFromEnv()
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
t.Skipf("no model configured: %s", cfg.Why())
|
||||||
|
}
|
||||||
|
|
||||||
|
chat, err := utils.NewChat(cfg)
|
||||||
|
if err != nil || chat == nil {
|
||||||
|
t.Skipf("gateway not built: %v", err)
|
||||||
|
}
|
||||||
|
return chat
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveAQuestionAnswersThroughTheWholeStack(t *testing.T) {
|
||||||
|
app, _ := buildApp(t, liveHTTPChat(t))
|
||||||
|
|
||||||
|
status, out, body := post(t, app, askPath, webSession(t),
|
||||||
|
`{"agent":"orders","question":"Which orders are stuck?"}`)
|
||||||
|
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("HTTP %d: %s", status, body)
|
||||||
|
}
|
||||||
|
if !out.Status {
|
||||||
|
t.Fatalf("envelope says failure: %s", out.Message)
|
||||||
|
}
|
||||||
|
// Inside `details`, where the console's client reads. A correct answer at
|
||||||
|
// the top level is still a broken console.
|
||||||
|
if strings.TrimSpace(out.Details.Reply) == "" {
|
||||||
|
t.Fatalf("no reply in details: %s", body)
|
||||||
|
}
|
||||||
|
if len(out.Details.Used) == 0 {
|
||||||
|
t.Fatalf("answered without running a tool — it invented it: %s", out.Details.Reply)
|
||||||
|
}
|
||||||
|
t.Logf("used: %+v", out.Details.Used)
|
||||||
|
t.Logf("reply: %s", out.Details.Reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveTheAnswerIsScopedToTheSessionsTenant(t *testing.T) {
|
||||||
|
// The claim the whole design rests on. The request body carries no tenant,
|
||||||
|
// so rows can only be reached through the token — and a session whose shop
|
||||||
|
// has nothing pending must not be handed a list.
|
||||||
|
app, shop := buildApp(t, liveHTTPChat(t))
|
||||||
|
shop.requests = nil
|
||||||
|
|
||||||
|
status, out, body := post(t, app, askPath, webSession(t),
|
||||||
|
`{"agent":"inventory","question":"What stock requests are waiting for approval?"}`)
|
||||||
|
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("HTTP %d: %s", status, body)
|
||||||
|
}
|
||||||
|
if strings.Contains(out.Details.Reply, "Sona Masoori") {
|
||||||
|
t.Fatalf("named a row this session cannot see: %s", out.Details.Reply)
|
||||||
|
}
|
||||||
|
t.Logf("reply: %s", out.Details.Reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the approval card, end to end ───────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestLiveAnApprovalCardRoundTripsAndWrites(t *testing.T) {
|
||||||
|
// The one path that has never run whole. The model proposes, the card comes
|
||||||
|
// back signed, the console sends it in unchanged, and only then does
|
||||||
|
// anything change. Each half has unit tests; this is the join.
|
||||||
|
app, shop := buildApp(t, liveHTTPChat(t))
|
||||||
|
token := webSession(t)
|
||||||
|
|
||||||
|
status, out, body := post(t, app, askPath, token,
|
||||||
|
`{"agent":"inventory","question":"Approve stock request 41."}`)
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("asking: HTTP %d: %s", status, body)
|
||||||
|
}
|
||||||
|
if out.Details.Awaiting == nil {
|
||||||
|
t.Fatalf("no approval card came back — nothing to press: %s", out.Details.Reply)
|
||||||
|
}
|
||||||
|
card := out.Details.Awaiting.Card
|
||||||
|
t.Logf("card: %s", out.Details.Awaiting.Summary)
|
||||||
|
|
||||||
|
// Nothing may have happened yet. A write at proposal time is the failure
|
||||||
|
// the whole two-step exists to prevent.
|
||||||
|
if len(shop.approved) != 0 {
|
||||||
|
t.Fatalf("the change was made before anybody agreed to it: %v", shop.approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, done, body := post(t, app, approvePath, token,
|
||||||
|
`{"agent":"inventory","card":`+quote(card)+`}`)
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("approving: HTTP %d: %s", status, body)
|
||||||
|
}
|
||||||
|
if len(shop.approved) != 1 || shop.approved[0] != "Approved #41" {
|
||||||
|
t.Fatalf("the write did not reach the service: %v", shop.approved)
|
||||||
|
}
|
||||||
|
t.Logf("after approval: %s", done.Details.Reply)
|
||||||
|
|
||||||
|
// Pressing twice must not approve twice. The card still verifies; the row
|
||||||
|
// is no longer pending, and the re-check at execute time is what notices.
|
||||||
|
status, _, _ = post(t, app, approvePath, token,
|
||||||
|
`{"agent":"inventory","card":`+quote(card)+`}`)
|
||||||
|
if status == fiber.StatusOK {
|
||||||
|
t.Fatal("the same card approved the same request twice")
|
||||||
|
}
|
||||||
|
if len(shop.approved) != 1 {
|
||||||
|
t.Fatalf("a second write got through: %v", shop.approved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAForgedCardIsRefused(t *testing.T) {
|
||||||
|
// No model needed: a card that does not verify must be refused before
|
||||||
|
// anything reads what it claims.
|
||||||
|
app, shop := buildApp(t, nil)
|
||||||
|
|
||||||
|
status, _, body := post(t, app, approvePath, webSession(t),
|
||||||
|
`{"agent":"inventory","card":"w1.bm90LWEtcmVhbC1jYXJk.c2lnbmF0dXJl"}`)
|
||||||
|
|
||||||
|
if status == fiber.StatusOK {
|
||||||
|
t.Fatalf("a forged card was accepted: %s", body)
|
||||||
|
}
|
||||||
|
if len(shop.approved) != 0 {
|
||||||
|
t.Fatalf("a forged card changed something: %v", shop.approved)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
controllers/deliverySlotController.go
Normal file
146
controllers/deliverySlotController.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeliverySlotController struct {
|
||||||
|
service services.DeliverySlotService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeliverySlotController(service services.DeliverySlotService) *DeliverySlotController {
|
||||||
|
return &DeliverySlotController{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
GET /v1/web/deliveryslots?tenantid&locationid
|
||||||
|
|
||||||
|
Everything a branch has configured, active or not, for the console's editor.
|
||||||
|
A branch that has set nothing returns an empty list — see the note on Available
|
||||||
|
about why that is never an error.
|
||||||
|
*/
|
||||||
|
func (ctl *DeliverySlotController) ListDeliverySlots(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
|
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slots, err := ctl.service.ListForBranch(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": slots,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
PUT /v1/web/deliveryslots
|
||||||
|
|
||||||
|
The branch's windows, all three together rather than one at a time: they are
|
||||||
|
edited as a set on one screen, and sending them together is what lets the
|
||||||
|
service reject the whole edit when one row is wrong instead of applying part of
|
||||||
|
it.
|
||||||
|
|
||||||
|
A business objection — an unreadable time, a window ending before it starts,
|
||||||
|
the same key twice — is 409 and not 500. It is an answer about the request, and
|
||||||
|
the message is written to be shown to the person who typed it.
|
||||||
|
*/
|
||||||
|
func (ctl *DeliverySlotController) SaveDeliverySlots(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Slots []models.DeliverySlots `json:"slots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "Invalid request body",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tenantid <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.service.Save(req.Tenantid, req.Locationid, req.Slots); err != nil {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Successfully Updated",
|
||||||
|
"status": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
GET /v1/mob/deliveryslots/available?tenantid&locationid
|
||||||
|
|
||||||
|
What the shopper may pick, already filtered and dated. The app renders this list
|
||||||
|
and does no time arithmetic of its own — see the service for why one clock has
|
||||||
|
to be authoritative.
|
||||||
|
|
||||||
|
An empty list is 200 with `details: []`, NOT an error. It means this branch has
|
||||||
|
set no windows, which is the state every shop is in today, and the app is
|
||||||
|
required to fall back to ordering without one. Returning 404 here would turn an
|
||||||
|
ordinary shop into a broken one.
|
||||||
|
*/
|
||||||
|
func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
|
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slots, err := ctl.service.Available(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": slots,
|
||||||
|
})
|
||||||
|
}
|
||||||
113
controllers/healthController.go
Normal file
113
controllers/healthController.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What is running here, and is it wired up?
|
||||||
|
//
|
||||||
|
// ── Why this exists ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// On 2026-09-24 the assistant sat switched off in production for most of a day,
|
||||||
|
// and neither of us could establish WHY from outside the container. Two
|
||||||
|
// questions had no answer:
|
||||||
|
//
|
||||||
|
// 1. which build is deployed? A redeploy can reuse a cached image, so
|
||||||
|
// "I pushed it" and "it is running" are different facts.
|
||||||
|
// 2. does the server have a model? `/assistant/status` knows, but it sits
|
||||||
|
// behind the session guard, and a 401 from `/v1/web` proves nothing —
|
||||||
|
// the middleware answers before routing, so a route that does not exist
|
||||||
|
// returns exactly the same 401 as one that does.
|
||||||
|
//
|
||||||
|
// Every diagnosis that day was guesswork for want of one request. Hours went
|
||||||
|
// into probing CORS headers and comparing nginx versions to infer a commit,
|
||||||
|
// which is what people do when a server will not simply say.
|
||||||
|
//
|
||||||
|
// ── What it deliberately does not say ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Booleans, never values. "The assistant has a model" is operational; WHICH
|
||||||
|
// model, at which endpoint, under which key is not, and the reason string on
|
||||||
|
// `/assistant/status` names environment variables — that stays behind the
|
||||||
|
// guard. Nothing here distinguishes a tenant, so there is nothing to scope.
|
||||||
|
//
|
||||||
|
// Unauthenticated on purpose. A health check that needs a credential cannot be
|
||||||
|
// used by the person trying to work out why credentials are not working, and
|
||||||
|
// that is precisely when it is wanted.
|
||||||
|
type HealthController struct {
|
||||||
|
assistant services.AssistantService
|
||||||
|
// hasDatabase is a construction-time fact, not a live ping. A query per
|
||||||
|
// health check is a query per uptime probe, and "configured" is the thing
|
||||||
|
// that actually differs between a broken deployment and a working one.
|
||||||
|
hasDatabase bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthController(assistant services.AssistantService, hasDatabase bool) *HealthController {
|
||||||
|
return &HealthController{assistant: assistant, hasDatabase: hasDatabase}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version is stamped at build time:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X nearle/controllers.Version=$(git rev-parse --short HEAD)"
|
||||||
|
//
|
||||||
|
// Left as "unknown" when nothing stamps it, which is honest — and itself worth
|
||||||
|
// seeing, because it means the image was not built by the pipeline that does.
|
||||||
|
var Version = "unknown"
|
||||||
|
|
||||||
|
// buildVersion falls back to whatever the toolchain recorded.
|
||||||
|
//
|
||||||
|
// `debug.ReadBuildInfo` carries the VCS revision for a build made inside a git
|
||||||
|
// checkout, so even an image built by hand usually knows its own commit. The
|
||||||
|
// ldflag is preferred because a Docker build copies the tree without `.git`.
|
||||||
|
func buildVersion() string {
|
||||||
|
if Version != "unknown" && strings.TrimSpace(Version) != "" {
|
||||||
|
return Version
|
||||||
|
}
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
if !ok {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
for _, setting := range info.Settings {
|
||||||
|
if setting.Key == "vcs.revision" && setting.Value != "" {
|
||||||
|
if len(setting.Value) > 7 {
|
||||||
|
return setting.Value[:7]
|
||||||
|
}
|
||||||
|
return setting.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *HealthController) Health(c *fiber.Ctx) error {
|
||||||
|
assistant := false
|
||||||
|
if ctl.assistant != nil {
|
||||||
|
assistant = ctl.assistant.Available()
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Success",
|
||||||
|
"details": fiber.Map{
|
||||||
|
"version": buildVersion(),
|
||||||
|
// Can this server issue console sessions at all?
|
||||||
|
//
|
||||||
|
// `attachWebSession` logs a minting failure and lets the login
|
||||||
|
// succeed without a token, so a server with no signing secret hands
|
||||||
|
// out sessions that cannot authenticate: the console renders, and
|
||||||
|
// every request after it comes back 401 with no `authorization`
|
||||||
|
// header on it. False here is that, stated once, instead of found
|
||||||
|
// by reading request headers on a Friday morning.
|
||||||
|
"sessions": utils.WebTokenConfigured(),
|
||||||
|
// True when a model is configured and the assistant can answer. False
|
||||||
|
// is the answer to "I set the key and redeployed, did it take?" —
|
||||||
|
// which took a day to establish without it.
|
||||||
|
"assistant": assistant,
|
||||||
|
"database": ctl.hasDatabase,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
194
controllers/health_test.go
Normal file
194
controllers/health_test.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
A server that can say what it is.
|
||||||
|
|
||||||
|
This exists because of a day spent unable to answer two questions about a
|
||||||
|
running deployment: which build is it, and does the assistant have a model. Both
|
||||||
|
were knowable inside the container and neither was reachable from outside —
|
||||||
|
`/assistant/status` sits behind the session guard, and a 401 from `/v1/web`
|
||||||
|
proves nothing, because the middleware answers before routing and a route that
|
||||||
|
does not exist returns the same 401 as one that does.
|
||||||
|
|
||||||
|
So the tests that matter here are about what it answers WITHOUT a session, and
|
||||||
|
about what it refuses to include.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func healthApp(t *testing.T, assistantReady bool, hasDatabase bool) *fiber.App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
controller := NewHealthController(stubAssistant{ready: assistantReady}, hasDatabase)
|
||||||
|
app.Get("/live/api/v1/health", controller.Health)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHealth(t *testing.T, app *fiber.App) (int, map[string]any, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("health: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Details map[string]any `json:"details"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||||
|
t.Fatalf("not the envelope the console unwraps: %s", raw)
|
||||||
|
}
|
||||||
|
return resp.StatusCode, envelope.Details, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthAnswersWithoutASession(t *testing.T) {
|
||||||
|
// The point. A health check that needs a credential cannot be used by the
|
||||||
|
// person working out why credentials are not working — which is exactly
|
||||||
|
// when somebody reaches for it.
|
||||||
|
status, details, body := readHealth(t, healthApp(t, true, true))
|
||||||
|
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("HTTP %d without a session: %s", status, body)
|
||||||
|
}
|
||||||
|
if details["version"] == nil {
|
||||||
|
t.Fatalf("no build id: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthSaysWhetherTheAssistantHasAModel(t *testing.T) {
|
||||||
|
// "I set the key and redeployed — did it take?" took a day to answer. This
|
||||||
|
// is that answer, in one unauthenticated request.
|
||||||
|
_, ready, _ := readHealth(t, healthApp(t, true, true))
|
||||||
|
if ready["assistant"] != true {
|
||||||
|
t.Fatalf("a configured assistant reported as %v", ready["assistant"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, off, body := readHealth(t, healthApp(t, false, true))
|
||||||
|
if off["assistant"] != false {
|
||||||
|
t.Fatalf("an unconfigured assistant reported as %v: %s", off["assistant"], body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthNeverLeaksTheConfiguration(t *testing.T) {
|
||||||
|
// Booleans, never values. WHICH model, at which endpoint, under which key is
|
||||||
|
// not operational information, and the `reason` string on /assistant/status
|
||||||
|
// names environment variables — that stays behind the guard.
|
||||||
|
_, _, body := readHealth(t, healthApp(t, false, true))
|
||||||
|
|
||||||
|
for _, secret := range []string{
|
||||||
|
"ASSISTANT_", "api.groq.com", "gsk_", "openai/gpt-oss", "POS_TOKEN", "password",
|
||||||
|
} {
|
||||||
|
if strings.Contains(strings.ToLower(body), strings.ToLower(secret)) {
|
||||||
|
t.Fatalf("%q is exposed on an unauthenticated endpoint: %s", secret, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthSurvivesAServerWithNothingWiredUp(t *testing.T) {
|
||||||
|
// A deployment with no database and no model must still ANSWER. This is the
|
||||||
|
// state in which somebody is most likely to ask, and a 500 here would leave
|
||||||
|
// them exactly where they started.
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/live/api/v1/health", NewHealthController(nil, false).Health)
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("health: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != fiber.StatusOK {
|
||||||
|
t.Fatalf("a bare server could not report its own health: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
var envelope struct {
|
||||||
|
Details map[string]any `json:"details"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &envelope)
|
||||||
|
|
||||||
|
if envelope.Details["assistant"] != false || envelope.Details["database"] != false {
|
||||||
|
t.Fatalf("a bare server claimed to be wired up: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnstampedBuildSaysSoRatherThanGuessing(t *testing.T) {
|
||||||
|
// "unknown" is informative: it means nothing stamped the image, so the
|
||||||
|
// version cannot be trusted to date it. Inventing one would be worse than
|
||||||
|
// admitting it.
|
||||||
|
original := Version
|
||||||
|
Version = "unknown"
|
||||||
|
defer func() { Version = original }()
|
||||||
|
|
||||||
|
got := buildVersion()
|
||||||
|
// Either the toolchain recorded a revision, or it says unknown. What it must
|
||||||
|
// not do is return an empty string, which renders as a blank field and reads
|
||||||
|
// like the endpoint is broken.
|
||||||
|
if strings.TrimSpace(got) == "" {
|
||||||
|
t.Fatal("the build id is blank")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStampedBuildIsReported(t *testing.T) {
|
||||||
|
original := Version
|
||||||
|
Version = "abc1234"
|
||||||
|
defer func() { Version = original }()
|
||||||
|
|
||||||
|
_, details, body := readHealth(t, healthApp(t, true, true))
|
||||||
|
if details["version"] != "abc1234" {
|
||||||
|
t.Fatalf("the stamped build id was not reported: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubAssistant is only ever asked one question.
|
||||||
|
type stubAssistant struct{ ready bool }
|
||||||
|
|
||||||
|
func (s stubAssistant) Available() bool { return s.ready }
|
||||||
|
func (s stubAssistant) Unavailable() string { return "" }
|
||||||
|
func (s stubAssistant) Ask(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) {
|
||||||
|
return services.AssistantAnswer{}, nil
|
||||||
|
}
|
||||||
|
func (s stubAssistant) Approve(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) {
|
||||||
|
return services.AssistantAnswer{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthSaysWhetherSessionsCanBeIssued(t *testing.T) {
|
||||||
|
// The failure this exists for: `attachWebSession` logs a minting failure and
|
||||||
|
// lets the login succeed anyway, so a server with no signing secret issues
|
||||||
|
// sessions that cannot authenticate. The console renders, every request
|
||||||
|
// after it 401s with no `authorization` header, and nothing says why.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", "")
|
||||||
|
t.Setenv("JWT_SECRET_KEY", "")
|
||||||
|
_, broken, body := readHealth(t, healthApp(t, true, true))
|
||||||
|
if broken["sessions"] != false {
|
||||||
|
t.Fatalf("a server that cannot sign a session claimed it could: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", "a-secret-of-quite-sufficient-length")
|
||||||
|
_, working, _ := readHealth(t, healthApp(t, true, true))
|
||||||
|
if working["sessions"] != true {
|
||||||
|
t.Fatal("a server with a signing secret reported it could not issue sessions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthDoesNotLeakTheSigningSecret(t *testing.T) {
|
||||||
|
// A boolean about the secret, never the secret.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", "correct-horse-battery-staple")
|
||||||
|
_, _, body := readHealth(t, healthApp(t, true, true))
|
||||||
|
|
||||||
|
if strings.Contains(body, "correct-horse") {
|
||||||
|
t.Fatalf("the signing secret is on an unauthenticated endpoint: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
270
controllers/mcpController.go
Normal file
270
controllers/mcpController.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The MCP door.
|
||||||
|
//
|
||||||
|
// A second way into the same registry. An outside client — Claude Desktop, an
|
||||||
|
// IDE, another service — speaks Model Context Protocol and reaches exactly the
|
||||||
|
// tools Nearle Buddy reaches, through exactly the same checks.
|
||||||
|
//
|
||||||
|
// ── Why it is a door and not a second implementation ────────────────────────
|
||||||
|
//
|
||||||
|
// `tools/list` is `Registry.Definitions`, and `tools/call` is `Registry.Call`.
|
||||||
|
// Nothing here knows what a tool does, what a tenant is, or how a scope is
|
||||||
|
// enforced. If this file grew its own idea of any of those, the two doors would
|
||||||
|
// drift and one of them would be the unguarded one — which is the usual way a
|
||||||
|
// system with two entrances ends up with one that skips the checks.
|
||||||
|
//
|
||||||
|
// ── The session is the same session ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Mounted under `/v1/web`, so `middleware.WebAuth` has already verified a
|
||||||
|
// console token and parked the claims before this runs. There is no second
|
||||||
|
// credential and no API key: whoever holds a console session gets exactly what
|
||||||
|
// that session gets, and somebody with no session gets nothing.
|
||||||
|
//
|
||||||
|
// ── Read-only, deliberately ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Write tools are filtered out of both `tools/list` and `tools/call`. A write
|
||||||
|
// resolves into an approval card, and the card is a thing a PERSON reads in the
|
||||||
|
// console — the quantity, the branch, the id — before pressing a button. An MCP
|
||||||
|
// client has no way to render that, and handing it a card to approve on its own
|
||||||
|
// would turn a human gate into a JSON field. So the door offers the reads and
|
||||||
|
// says plainly that changes happen in the console.
|
||||||
|
type MCPController struct {
|
||||||
|
registry *tools.Registry
|
||||||
|
agents map[string]services.Agent
|
||||||
|
assistant services.AssistantService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMCPController(registry *tools.Registry, agents map[string]services.Agent) *MCPController {
|
||||||
|
return &MCPController{registry: registry, agents: agents}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The protocol version this speaks. Sent back on initialize so a client that
|
||||||
|
// expects something else can say so rather than failing later on a shape it
|
||||||
|
// did not anticipate.
|
||||||
|
const mcpProtocolVersion = "2024-11-05"
|
||||||
|
|
||||||
|
/* ── JSON-RPC 2.0 ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type rpcRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
Error *rpcError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The JSON-RPC codes this uses. Only the ones with a real meaning here — a
|
||||||
|
// server that returns -32603 for everything tells a client nothing.
|
||||||
|
const (
|
||||||
|
rpcParseError = -32700
|
||||||
|
rpcInvalidRequest = -32600
|
||||||
|
rpcMethodNotFound = -32601
|
||||||
|
rpcInvalidParams = -32602
|
||||||
|
rpcInternalError = -32603
|
||||||
|
)
|
||||||
|
|
||||||
|
func rpcOK(c *fiber.Ctx, id json.RawMessage, result any) error {
|
||||||
|
// HTTP 200 even for a JSON-RPC error, which is the protocol's own
|
||||||
|
// convention: the transport succeeded, and the error is in the envelope.
|
||||||
|
return c.Status(http.StatusOK).JSON(rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcFail(c *fiber.Ctx, id json.RawMessage, code int, message string) error {
|
||||||
|
return c.Status(http.StatusOK).JSON(rpcResponse{
|
||||||
|
JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: message},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The endpoint ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// Handle serves one JSON-RPC request.
|
||||||
|
func (ctl *MCPController) Handle(c *fiber.Ctx) error {
|
||||||
|
var req rpcRequest
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return rpcFail(c, nil, rpcParseError, "that is not valid JSON")
|
||||||
|
}
|
||||||
|
if req.Method == "" {
|
||||||
|
return rpcFail(c, req.ID, rpcInvalidRequest, "no method")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A notification — a request with no id — expects no response at all.
|
||||||
|
// `initialized` is the one every client sends after the handshake, and
|
||||||
|
// answering it with a result is a protocol error on our side.
|
||||||
|
if len(req.ID) == 0 {
|
||||||
|
return c.SendStatus(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
caller, ok := callerFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return rpcFail(c, req.ID, rpcInvalidRequest,
|
||||||
|
"this door needs a console session; sign in to Nearle and use that token")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "initialize":
|
||||||
|
return rpcOK(c, req.ID, fiber.Map{
|
||||||
|
"protocolVersion": mcpProtocolVersion,
|
||||||
|
// Tools only. No resources, no prompts, no sampling — claiming a
|
||||||
|
// capability this does not have makes a client fail on a call that
|
||||||
|
// looked supported.
|
||||||
|
"capabilities": fiber.Map{"tools": fiber.Map{}},
|
||||||
|
"serverInfo": fiber.Map{"name": "nearle", "version": "1"},
|
||||||
|
"instructions": "Read-only access to this merchant's own shop data. " +
|
||||||
|
"Changes are made in the Nearle console, where they are confirmed by a person.",
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tools/list":
|
||||||
|
return rpcOK(c, req.ID, fiber.Map{"tools": ctl.list(c)})
|
||||||
|
|
||||||
|
case "tools/call":
|
||||||
|
return ctl.call(c, req, caller)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return rpcFail(c, req.ID, rpcMethodNotFound, "this server does not do "+req.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// list is Definitions, with writes removed and the key renamed.
|
||||||
|
//
|
||||||
|
// MCP spells it `inputSchema`; the registry speaks `input_schema` because that
|
||||||
|
// is what reads clearly and what the model gateway already converts from. The
|
||||||
|
// rename happens here rather than in the registry so neither door dictates the
|
||||||
|
// other's vocabulary.
|
||||||
|
func (ctl *MCPController) list(c *fiber.Ctx) []fiber.Map {
|
||||||
|
agent := ctl.agentFor(c)
|
||||||
|
defined := ctl.registry.Definitions(tools.Agent{Name: agent.Name, Tools: agent.Tools})
|
||||||
|
|
||||||
|
out := make([]fiber.Map, 0, len(defined))
|
||||||
|
for _, definition := range defined {
|
||||||
|
name, _ := definition["name"].(string)
|
||||||
|
// A write is not described at all, rather than described and refused.
|
||||||
|
// A client told about a tool it will always be denied reads that as the
|
||||||
|
// server malfunctioning.
|
||||||
|
if ctl.isWrite(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fiber.Map{
|
||||||
|
"name": definition["name"],
|
||||||
|
"description": definition["description"],
|
||||||
|
"inputSchema": definition["input_schema"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *MCPController) call(c *fiber.Ctx, req rpcRequest, caller tools.Caller) error {
|
||||||
|
var params struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args map[string]any `json:"arguments"`
|
||||||
|
}
|
||||||
|
if len(req.Params) > 0 {
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
return rpcFail(c, req.ID, rpcInvalidParams, "arguments are not valid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(params.Name) == "" {
|
||||||
|
return rpcFail(c, req.ID, rpcInvalidParams, "no tool named")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked before the registry sees it. The registry would refuse a write
|
||||||
|
// anyway — it returns a proposal rather than performing one — but a card
|
||||||
|
// handed to a client with nothing to render it is worse than a plain "not
|
||||||
|
// here", and this keeps the two doors' answers honest about why.
|
||||||
|
if ctl.isWrite(params.Name) {
|
||||||
|
return rpcFail(c, req.ID, rpcInvalidParams,
|
||||||
|
params.Name+" changes data, and changes are confirmed by a person in the Nearle console")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := ctl.agentFor(c)
|
||||||
|
ctx, cancel := services.WithTimeout(c.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := ctl.registry.Call(ctx, tools.Agent{Name: agent.Name, Tools: agent.Tools},
|
||||||
|
params.Name, params.Args, caller)
|
||||||
|
if err != nil {
|
||||||
|
// A refusal is returned as a tool result with `isError`, not as a
|
||||||
|
// JSON-RPC error. The distinction is the protocol's: a transport fault
|
||||||
|
// is an RPC error, and "that tool needs a branch" is an answer the
|
||||||
|
// client should show its user.
|
||||||
|
if errors.Is(err, tools.ErrUnknownTool) || errors.Is(err, tools.ErrNotAllowed) {
|
||||||
|
return rpcFail(c, req.ID, rpcMethodNotFound, err.Error())
|
||||||
|
}
|
||||||
|
return rpcOK(c, req.ID, fiber.Map{
|
||||||
|
"isError": true,
|
||||||
|
"content": []fiber.Map{{"type": "text", "text": err.Error()}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows go back as JSON text, which is what MCP carries and what a model
|
||||||
|
// on the other end reads most reliably. `note` and `covers` ride alongside
|
||||||
|
// rather than inside, so an instruction about truncation cannot be mistaken
|
||||||
|
// for a row.
|
||||||
|
payload := fiber.Map{"rows": result.Rows, "count": result.Count}
|
||||||
|
if result.Scope != "" {
|
||||||
|
payload["covers"] = result.Scope
|
||||||
|
}
|
||||||
|
if result.Truncated {
|
||||||
|
payload["truncated"] = true
|
||||||
|
}
|
||||||
|
if result.Note != "" {
|
||||||
|
payload["note"] = result.Note
|
||||||
|
}
|
||||||
|
if result.Source != "" {
|
||||||
|
payload["see"] = result.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return rpcFail(c, req.ID, rpcInternalError, "the result could not be encoded")
|
||||||
|
}
|
||||||
|
return rpcOK(c, req.ID, fiber.Map{
|
||||||
|
"content": []fiber.Map{{"type": "text", "text": string(encoded)}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWrite reports whether a tool changes anything.
|
||||||
|
func (ctl *MCPController) isWrite(name string) bool {
|
||||||
|
tool, ok := ctl.registry.Tool(name)
|
||||||
|
return ok && tool.Scope == tools.ScopeWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentFor picks which agent's allow-list applies.
|
||||||
|
//
|
||||||
|
// An MCP client has no page to sit beside, so there is no route to read one
|
||||||
|
// from. It gets `console` — the broadest of the read agents, matching what a
|
||||||
|
// person sees on the overview — and it is still an allow-list rather than
|
||||||
|
// "every tool": a door with no agent at all would be wider than any of the ones
|
||||||
|
// the console offers.
|
||||||
|
func (ctl *MCPController) agentFor(*fiber.Ctx) services.Agent {
|
||||||
|
if agent, ok := ctl.agents["console"]; ok {
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
// Named rather than defaulted to everything: a deployment whose agent files
|
||||||
|
// do not define `console` gets a door that lists nothing, which is visible,
|
||||||
|
// rather than one that offers the lot.
|
||||||
|
return services.Agent{Name: "mcp"}
|
||||||
|
}
|
||||||
306
controllers/mcp_test.go
Normal file
306
controllers/mcp_test.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nearle/middleware"
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The MCP door, held to the same rules as the console's.
|
||||||
|
//
|
||||||
|
// The point of these is not that JSON-RPC is spelled correctly — it is that a
|
||||||
|
// second entrance did not arrive with its own, looser idea of who may read what.
|
||||||
|
|
||||||
|
func readTool(name string) tools.Tool {
|
||||||
|
return tools.Tool{
|
||||||
|
Name: name,
|
||||||
|
Description: "a read tool with a description long enough to choose by, for testing",
|
||||||
|
Scope: tools.ScopeRead,
|
||||||
|
Schema: tools.Schema{Fields: []tools.Field{{
|
||||||
|
Name: "limit", Description: "how many", Kind: tools.KindInt, Min: 1, Max: 50, Default: 10,
|
||||||
|
}}},
|
||||||
|
Handler: func(_ context.Context, req tools.Request) (tools.Result, error) {
|
||||||
|
return tools.Result{
|
||||||
|
Rows: []map[string]any{{"id": 1}}, Count: 1,
|
||||||
|
Scope: "all branches", Source: "/admin/dispatch",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeToolFor(t *testing.T, name string) tools.Tool {
|
||||||
|
t.Helper()
|
||||||
|
return tools.WriteTool(
|
||||||
|
tools.Tool{
|
||||||
|
Name: name,
|
||||||
|
Description: "a write tool with a description long enough to choose by, for testing",
|
||||||
|
Schema: tools.Schema{},
|
||||||
|
},
|
||||||
|
func(context.Context, tools.Request) (tools.Proposal, error) {
|
||||||
|
return tools.Proposal{Summary: "change something"}, nil
|
||||||
|
},
|
||||||
|
func(context.Context, tools.Request) (tools.Result, error) {
|
||||||
|
t.Fatal("a write executed through the MCP door")
|
||||||
|
return tools.Result{}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpApp mounts the door with a session already verified, as WebAuth would.
|
||||||
|
func mcpApp(t *testing.T, claims *utils.WebClaims, toolset ...tools.Tool) *fiber.App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
registry := tools.New(nil)
|
||||||
|
names := make([]string, 0, len(toolset))
|
||||||
|
for _, tool := range toolset {
|
||||||
|
if err := registry.Register(tool); err != nil {
|
||||||
|
t.Fatalf("registering %s: %v", tool.Name, err)
|
||||||
|
}
|
||||||
|
names = append(names, tool.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
agents := map[string]services.Agent{"console": {Name: "console", Tools: names}}
|
||||||
|
ctl := NewMCPController(registry, agents)
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/mcp", func(c *fiber.Ctx) error {
|
||||||
|
if claims != nil {
|
||||||
|
c.Locals(middleware.WebLocalsKey, *claims)
|
||||||
|
}
|
||||||
|
return ctl.Handle(c)
|
||||||
|
})
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpc(t *testing.T, app *fiber.App, body string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == fiber.StatusAccepted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decoding: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = &utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172}
|
||||||
|
|
||||||
|
/* ── The handshake ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestInitializeClaimsOnlyWhatItCanDo(t *testing.T) {
|
||||||
|
// Claiming a capability this does not have makes a client fail later, on a
|
||||||
|
// call that looked supported.
|
||||||
|
app := mcpApp(t, session, readTool("stuck"))
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"initialize"}`)
|
||||||
|
|
||||||
|
result, _ := out["result"].(map[string]any)
|
||||||
|
caps, _ := result["capabilities"].(map[string]any)
|
||||||
|
if _, ok := caps["tools"]; !ok {
|
||||||
|
t.Fatalf("tools not offered: %v", caps)
|
||||||
|
}
|
||||||
|
for _, unsupported := range []string{"resources", "prompts", "sampling"} {
|
||||||
|
if _, claimed := caps[unsupported]; claimed {
|
||||||
|
t.Fatalf("claimed %q, which this server does not do", unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANotificationGetsNoResponse(t *testing.T) {
|
||||||
|
// `initialized` arrives with no id after every handshake. Answering it with
|
||||||
|
// a result is a protocol error on our side.
|
||||||
|
app := mcpApp(t, session, readTool("stuck"))
|
||||||
|
if out := rpc(t, app, `{"jsonrpc":"2.0","method":"notifications/initialized"}`); out != nil {
|
||||||
|
t.Fatalf("a notification was answered: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnknownMethodIsRefusedByName(t *testing.T) {
|
||||||
|
app := mcpApp(t, session, readTool("stuck"))
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"resources/list"}`)
|
||||||
|
|
||||||
|
rpcErr, _ := out["error"].(map[string]any)
|
||||||
|
if rpcErr == nil {
|
||||||
|
t.Fatalf("an unsupported method succeeded: %v", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rpcErr["message"].(string), "resources/list") {
|
||||||
|
t.Fatalf("the refusal does not say what was asked for: %v", rpcErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The same door, the same guard ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestNoSessionMeansNoTools(t *testing.T) {
|
||||||
|
// There is no API key and no second credential. Whoever holds a console
|
||||||
|
// session gets what that session gets; somebody with none gets nothing.
|
||||||
|
app := mcpApp(t, nil, readTool("stuck"))
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`)
|
||||||
|
|
||||||
|
if out["error"] == nil {
|
||||||
|
t.Fatalf("an unauthenticated call was answered: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheDoorOffersOnlyTheAgentsAllowList(t *testing.T) {
|
||||||
|
// The registry's allow-list, not a second one written here.
|
||||||
|
registry := tools.New(nil)
|
||||||
|
_ = registry.Register(readTool("stuck"))
|
||||||
|
_ = registry.Register(readTool("secret"))
|
||||||
|
|
||||||
|
agents := map[string]services.Agent{"console": {Name: "console", Tools: []string{"stuck"}}}
|
||||||
|
ctl := NewMCPController(registry, agents)
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/mcp", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals(middleware.WebLocalsKey, *session)
|
||||||
|
return ctl.Handle(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||||
|
result, _ := out["result"].(map[string]any)
|
||||||
|
listed, _ := result["tools"].([]any)
|
||||||
|
if len(listed) != 1 {
|
||||||
|
t.Fatalf("the door listed %d tools, not the agent's one", len(listed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// And calling the one it did not list is refused.
|
||||||
|
denied := rpc(t, app, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"secret"}}`)
|
||||||
|
if denied["error"] == nil {
|
||||||
|
t.Fatalf("a tool off the allow-list was callable: %v", denied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCallerComesFromTheSessionNotTheRequest(t *testing.T) {
|
||||||
|
// Same property as the console door: the model, or whatever is driving this
|
||||||
|
// client, has no say in whose data is read.
|
||||||
|
var seen tools.Caller
|
||||||
|
tool := readTool("stuck")
|
||||||
|
tool.Handler = func(_ context.Context, req tools.Request) (tools.Result, error) {
|
||||||
|
seen = req.Caller
|
||||||
|
return tools.Result{Count: 0, Scope: "all branches"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
app := mcpApp(t, session, tool)
|
||||||
|
rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"tenantid":916}}}`)
|
||||||
|
|
||||||
|
if seen.Tenantid != 1147 {
|
||||||
|
t.Fatalf("the tool ran for tenant %d", seen.Tenantid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Read-only ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAWriteIsNotEvenListed(t *testing.T) {
|
||||||
|
// Described and then refused reads to a client as the server malfunctioning.
|
||||||
|
app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something"))
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||||
|
|
||||||
|
result, _ := out["result"].(map[string]any)
|
||||||
|
for _, listed := range result["tools"].([]any) {
|
||||||
|
entry, _ := listed.(map[string]any)
|
||||||
|
if entry["name"] == "change_something" {
|
||||||
|
t.Fatal("a write tool was offered over MCP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWriteCannotBeCalledAndTheRefusalSaysWhere(t *testing.T) {
|
||||||
|
// The write's execute half fails the test if it runs. The refusal has to
|
||||||
|
// point somewhere useful, or a person is stuck.
|
||||||
|
app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something"))
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"change_something"}}`)
|
||||||
|
|
||||||
|
rpcErr, _ := out["error"].(map[string]any)
|
||||||
|
if rpcErr == nil {
|
||||||
|
t.Fatalf("a write was accepted over MCP: %v", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rpcErr["message"].(string), "console") {
|
||||||
|
t.Fatalf("the refusal does not say where changes happen: %v", rpcErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Results ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAResultCarriesItsRowsAndItsCaveats(t *testing.T) {
|
||||||
|
tool := readTool("stuck")
|
||||||
|
tool.Handler = func(context.Context, tools.Request) (tools.Result, error) {
|
||||||
|
return tools.Result{
|
||||||
|
Rows: []map[string]any{{"id": 1}}, Count: 60, Truncated: true,
|
||||||
|
Note: "60 jobs are waiting; the 50 longest are listed.",
|
||||||
|
Scope: "all branches", Source: "/admin/dispatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
app := mcpApp(t, session, tool)
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`)
|
||||||
|
|
||||||
|
result, _ := out["result"].(map[string]any)
|
||||||
|
content, _ := result["content"].([]any)
|
||||||
|
first, _ := content[0].(map[string]any)
|
||||||
|
text, _ := first["text"].(string)
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||||||
|
t.Fatalf("the content is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"rows", "count", "covers", "truncated", "note", "see"} {
|
||||||
|
if _, ok := payload[want]; !ok {
|
||||||
|
t.Fatalf("the result dropped %q: %v", want, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARefusedToolIsAResultNotATransportError(t *testing.T) {
|
||||||
|
// The protocol's own distinction: a transport fault is an RPC error, and
|
||||||
|
// "that tool needs a branch" is an answer the client should show its user.
|
||||||
|
app := mcpApp(t, session, readTool("stuck"))
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"limit":999}}}`)
|
||||||
|
|
||||||
|
if out["error"] != nil {
|
||||||
|
t.Fatalf("a bad argument was reported as a transport fault: %v", out["error"])
|
||||||
|
}
|
||||||
|
result, _ := out["result"].(map[string]any)
|
||||||
|
if result["isError"] != true {
|
||||||
|
t.Fatalf("a refusal was reported as success: %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSchemaIsSpelledTheWayMCPExpects(t *testing.T) {
|
||||||
|
// The registry says `input_schema`; MCP says `inputSchema`. The rename lives
|
||||||
|
// at the door so neither side dictates the other's vocabulary.
|
||||||
|
app := mcpApp(t, session, readTool("stuck"))
|
||||||
|
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||||
|
|
||||||
|
result, _ := out["result"].(map[string]any)
|
||||||
|
first, _ := result["tools"].([]any)[0].(map[string]any)
|
||||||
|
if _, ok := first["inputSchema"]; !ok {
|
||||||
|
t.Fatalf("no inputSchema on a listed tool: %v", first)
|
||||||
|
}
|
||||||
|
if _, stillSnake := first["input_schema"]; stillSnake {
|
||||||
|
t.Fatal("the registry's spelling leaked through the door")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedJSONIsRefusedWithoutPanicking(t *testing.T) {
|
||||||
|
app := mcpApp(t, session, readTool("stuck"))
|
||||||
|
for _, body := range []string{"", "{", "not json", `{"jsonrpc":"2.0","id":1}`} {
|
||||||
|
out := rpc(t, app, body)
|
||||||
|
if out != nil && out["error"] == nil && out["result"] == nil {
|
||||||
|
t.Fatalf("%q produced neither a result nor an error", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,117 +16,126 @@ import (
|
|||||||
|
|
||||||
type OrderController struct {
|
type OrderController struct {
|
||||||
orderService services.OrderService
|
orderService services.OrderService
|
||||||
|
// Asked whether a chosen delivery window is still open. Held here rather
|
||||||
|
// than reimplemented, so the app's list and this check can never disagree
|
||||||
|
// about where a window ends.
|
||||||
|
deliverySlotService services.DeliverySlotService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOrderController(orderService services.OrderService) *OrderController {
|
func NewOrderController(
|
||||||
return &OrderController{orderService: orderService}
|
orderService services.OrderService,
|
||||||
|
deliverySlotService services.DeliverySlotService,
|
||||||
|
) *OrderController {
|
||||||
|
return &OrderController{
|
||||||
|
orderService: orderService,
|
||||||
|
deliverySlotService: deliverySlotService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
||||||
|
|
||||||
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||||
cid, _ := strconv.Atoi(c.Query("customerid"))
|
cid, _ := strconv.Atoi(c.Query("customerid"))
|
||||||
mid, _ := strconv.Atoi(c.Query("moduleid"))
|
mid, _ := strconv.Atoi(c.Query("moduleid"))
|
||||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||||
uid, _ := strconv.Atoi(c.Query("appuserid"))
|
uid, _ := strconv.Atoi(c.Query("appuserid"))
|
||||||
lid, _ := strconv.Atoi(c.Query("locationid"))
|
lid, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
configid, _ := strconv.Atoi(c.Query("configid"))
|
configid, _ := strconv.Atoi(c.Query("configid"))
|
||||||
|
|
||||||
stat := c.Query("status")
|
stat := c.Query("status")
|
||||||
fdate := c.Query("fromdate")
|
fdate := c.Query("fromdate")
|
||||||
tdate := c.Query("todate")
|
tdate := c.Query("todate")
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||||
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||||
|
|
||||||
if pageno <= 0 {
|
if pageno <= 0 {
|
||||||
pageno = 1
|
pageno = 1
|
||||||
}
|
}
|
||||||
if pagesize <= 0 {
|
if pagesize <= 0 {
|
||||||
pagesize = 10
|
pagesize = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dynamic query struct
|
// Build dynamic query struct
|
||||||
query := models.DeliveryQuery{
|
query := models.DeliveryQuery{
|
||||||
Tenantid: tid,
|
Tenantid: tid,
|
||||||
Partnerid: pid,
|
Partnerid: pid,
|
||||||
Customerid: cid,
|
Customerid: cid,
|
||||||
Moduleid: mid,
|
Moduleid: mid,
|
||||||
Applocationid: aid,
|
Applocationid: aid,
|
||||||
Locationid: lid,
|
Locationid: lid,
|
||||||
UserID: uid,
|
UserID: uid,
|
||||||
Appuserid: uid,
|
Appuserid: uid,
|
||||||
Configid: configid,
|
Configid: configid,
|
||||||
Fromdate: fdate,
|
Fromdate: fdate,
|
||||||
ToDate: tdate,
|
ToDate: tdate,
|
||||||
Status: stat,
|
Status: stat,
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
Pageno: pageno,
|
Pageno: pageno,
|
||||||
Pagesize: pagesize,
|
Pagesize: pagesize,
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
orders []models.OrderInfo
|
orders []models.OrderInfo
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// 🔥 DYNAMIC ROUTING LOGIC
|
// 🔥 DYNAMIC ROUTING LOGIC
|
||||||
// --------------------------
|
// --------------------------
|
||||||
|
|
||||||
if tid != 0 && lid != 0 {
|
if tid != 0 && lid != 0 {
|
||||||
// ⭐ Both tenant & location → special handler
|
// ⭐ Both tenant & location → special handler
|
||||||
orders, err = ctl.orderService.GetTenantLocationOrders(query)
|
orders, err = ctl.orderService.GetTenantLocationOrders(query)
|
||||||
|
|
||||||
} else if tid != 0 {
|
} else if tid != 0 {
|
||||||
// Tenant only
|
// Tenant only
|
||||||
orders, err = ctl.orderService.GetTenantOrders(query)
|
orders, err = ctl.orderService.GetTenantOrders(query)
|
||||||
|
|
||||||
} else if pid != 0 {
|
} else if pid != 0 {
|
||||||
// Partner
|
// Partner
|
||||||
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
|
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
|
||||||
|
|
||||||
} else if cid != 0 {
|
} else if cid != 0 {
|
||||||
// Customer
|
// Customer
|
||||||
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
|
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
|
||||||
|
|
||||||
} else if aid != 0 {
|
} else if aid != 0 {
|
||||||
// App-location orders
|
// App-location orders
|
||||||
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
|
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
|
||||||
|
|
||||||
} else if uid != 0 {
|
} else if uid != 0 {
|
||||||
// User orders
|
// User orders
|
||||||
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
|
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
|
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
|
||||||
// Refuse instead of silently returning every order in the database.
|
// Refuse instead of silently returning every order in the database.
|
||||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
"status": false,
|
"status": false,
|
||||||
"code": http.StatusBadRequest,
|
"code": http.StatusBadRequest,
|
||||||
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
|
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
"status": false,
|
"status": false,
|
||||||
"code": http.StatusInternalServerError,
|
"code": http.StatusInternalServerError,
|
||||||
"message": err.Error(),
|
"message": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": true,
|
"status": true,
|
||||||
"code": http.StatusOK,
|
"code": http.StatusOK,
|
||||||
"message": "Success",
|
"message": "Success",
|
||||||
"details": orders,
|
"details": orders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
||||||
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||||
@@ -160,7 +169,6 @@ func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
|
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
|
||||||
tenantIDStr := c.Query("tenantid")
|
tenantIDStr := c.Query("tenantid")
|
||||||
tenantID, _ := strconv.Atoi(tenantIDStr)
|
tenantID, _ := strconv.Atoi(tenantIDStr)
|
||||||
@@ -350,6 +358,29 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
|||||||
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The chosen delivery window, re-decided here.
|
||||||
|
//
|
||||||
|
// The app sends back what /v1/mob/deliveryslots/available handed it, but
|
||||||
|
// that group carries NO SESSION — anything arriving is a claim, not a fact.
|
||||||
|
// The common case is innocent and still has to be caught: a shopper leaves
|
||||||
|
// the checkout screen open while the window closes, then taps pay.
|
||||||
|
//
|
||||||
|
// 409 rather than 400: the request was well formed and was true when it was
|
||||||
|
// built. The message is written to be shown to the shopper as-is.
|
||||||
|
//
|
||||||
|
// An order naming NO window passes straight through. That is every order
|
||||||
|
// placed before this shipped and every order from a branch that has set no
|
||||||
|
// windows, and it must stay ordinary.
|
||||||
|
if err := ctl.deliverySlotService.ValidateForOrder(
|
||||||
|
data.Tenantid, data.Locationid, data.Deliveryslotid, data.Deliveryslotdate,
|
||||||
|
); err != nil {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// An order that does not state its config is an APP order, because that is
|
// An order that does not state its config is an APP order, because that is
|
||||||
// the only kind this endpoint takes.
|
// the only kind this endpoint takes.
|
||||||
//
|
//
|
||||||
@@ -592,7 +623,7 @@ func (ctl *OrderController) GetTimeSeries(c *fiber.Ctx) error {
|
|||||||
"status": false,
|
"status": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if granularity == "" {
|
if granularity == "" {
|
||||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
"code": http.StatusBadRequest,
|
"code": http.StatusBadRequest,
|
||||||
|
|||||||
@@ -73,6 +73,40 @@ func (ctl *PartnerController) GetPartners(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRiderShift opens a working window in a delivery region.
|
||||||
|
//
|
||||||
|
// Riders cannot be hired without one, and until this existed the table could
|
||||||
|
// only be read — a region that shipped with no shift rows was a region no rider
|
||||||
|
// could ever be added to, with nothing in the product able to change that.
|
||||||
|
//
|
||||||
|
// The region comes from the body rather than the query because this is a write
|
||||||
|
// and the whole shift is one object; `getridershifts` beside it reads the same
|
||||||
|
// id from a param, which is the existing convention for reads here.
|
||||||
|
func (ctl *PartnerController) CreateRiderShift(c *fiber.Ctx) error {
|
||||||
|
|
||||||
|
var shift models.Ridershifts
|
||||||
|
if err := c.BodyParser(&shift); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusBadRequest, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.partnerService.CreateRiderShift(shift)
|
||||||
|
if err != nil {
|
||||||
|
// 400, not 500. Every failure here is something the person typed — a
|
||||||
|
// region that is not configured, a window that already exists, a time
|
||||||
|
// that is not a time — and each message says which.
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusBadRequest, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"status": true, "code": http.StatusCreated,
|
||||||
|
"message": "Shift created", "details": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error {
|
func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error {
|
||||||
|
|
||||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||||
|
|||||||
@@ -771,6 +771,20 @@ func posClaimError(c *fiber.Ctx, err error) error {
|
|||||||
// once the console can hold a session.
|
// once the console can hold a session.
|
||||||
|
|
||||||
// posWebScope reads and checks the tenant and outlet a console request names.
|
// posWebScope reads and checks the tenant and outlet a console request names.
|
||||||
|
// posTenantScope is the guard for things that belong to a whole business
|
||||||
|
// rather than to one of its shops — shift windows, so far.
|
||||||
|
//
|
||||||
|
// No ownership query, because there is nothing to own: `middleware.WebAuth`
|
||||||
|
// pins the tenant from the signed session and refuses a request naming another
|
||||||
|
// one, so reaching here with a tenant id at all means it is this caller's.
|
||||||
|
// Naming an outlet is what needs checking, and that is `posWebScope` below.
|
||||||
|
func (ctl *PosController) posTenantScope(tenantID int) error {
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return fmt.Errorf("tenantid is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
|
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
|
||||||
if tenantID <= 0 {
|
if tenantID <= 0 {
|
||||||
return fmt.Errorf("tenantid is required")
|
return fmt.Errorf("tenantid is required")
|
||||||
@@ -921,9 +935,18 @@ func (ctl *PosController) WebListStaffShifts(c *fiber.Ctx) error {
|
|||||||
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||||
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
|
||||||
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
// Tenant-scoped, because a shift belongs to the business rather than to one
|
||||||
|
// of its shops. An outlet may still be named to narrow the list, and is
|
||||||
|
// checked for ownership when it is — omitting it is not a way to read
|
||||||
|
// somebody else's, because the tenant comes from the signed session.
|
||||||
|
if err := ctl.posTenantScope(tenantID); err != nil {
|
||||||
return posBadRequest(c, err)
|
return posBadRequest(c, err)
|
||||||
}
|
}
|
||||||
|
if locationID > 0 {
|
||||||
|
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
shifts, err := ctl.posService.ListStaffShifts(tenantID, locationID,
|
shifts, err := ctl.posService.ListStaffShifts(tenantID, locationID,
|
||||||
strings.EqualFold(c.Query("include_inactive"), "true"))
|
strings.EqualFold(c.Query("include_inactive"), "true"))
|
||||||
@@ -944,9 +967,19 @@ func (ctl *PosController) WebCreateStaffShift(c *fiber.Ctx) error {
|
|||||||
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
// A shift with no outlet belongs to the tenant and every branch it owns,
|
||||||
|
// which is the ordinary case — a business that works 07:00–15:00 works
|
||||||
|
// those hours at every shop, and entering them per outlet is how the third
|
||||||
|
// branch quietly ends up on 07:00–15:30. An outlet is named only when one
|
||||||
|
// shop really does differ, and is checked for ownership then.
|
||||||
|
if err := ctl.posTenantScope(req.Tenantid); err != nil {
|
||||||
return posBadRequest(c, err)
|
return posBadRequest(c, err)
|
||||||
}
|
}
|
||||||
|
if req.Locationid > 0 {
|
||||||
|
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
shift, err := ctl.posService.CreateStaffShift(req.Tenantid, req.Locationid, req)
|
shift, err := ctl.posService.CreateStaffShift(req.Tenantid, req.Locationid, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -982,3 +1015,20 @@ func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
|
|||||||
"message": "Shift updated", "details": shift,
|
"message": "Shift updated", "details": shift,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthAdoption reports how much of the till fleet is carrying a session token.
|
||||||
|
//
|
||||||
|
// The answer to "is it safe to set POS_AUTH_REQUIRED=true yet". Every outlet it
|
||||||
|
// lists is a till that would stop being able to ring a bill the moment
|
||||||
|
// enforcement goes on.
|
||||||
|
//
|
||||||
|
// Behind the web session guard on purpose: that list is also a map of which
|
||||||
|
// shops are reachable without a credential today.
|
||||||
|
func (ctl *PosController) AuthAdoption(c *fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": "Success",
|
||||||
|
"details": middleware.PosAdoptionReport(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,7 +191,14 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctl.productService.CreateProduct(product); err != nil {
|
// The created row, not the parsed body.
|
||||||
|
//
|
||||||
|
// This returned the struct it had just parsed off the request, which by
|
||||||
|
// definition carried `productid: 0` — the id is assigned by the database a
|
||||||
|
// moment later and was never read back. Every caller that needed the id
|
||||||
|
// went and looked the product up again by SKU.
|
||||||
|
created, err := ctl.productService.CreateProduct(product)
|
||||||
|
if err != nil {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"code": http.StatusInternalServerError,
|
"code": http.StatusInternalServerError,
|
||||||
"message": "Failed to create product",
|
"message": "Failed to create product",
|
||||||
@@ -203,7 +210,7 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
|||||||
"code": http.StatusCreated,
|
"code": http.StatusCreated,
|
||||||
"message": "Product created successfully",
|
"message": "Product created successfully",
|
||||||
"status": true,
|
"status": true,
|
||||||
"data": product,
|
"data": created,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,3 +1008,44 @@ func (ctl *ProductController) RelinkCatalogue(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report})
|
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
//
|
||||||
|
// Scoped twice over: `middleware.WebAuth` refuses a request naming a tenant the
|
||||||
|
// session does not own — it reads `tenantid` from the body as well as the query
|
||||||
|
// — and the repository's UPDATE carries the tenant in its WHERE clause. A write
|
||||||
|
// that changes what a shopper sees should not rest on one guard being mounted
|
||||||
|
// correctly.
|
||||||
|
func (ctl *ProductController) SetShowHealthScore(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
// A POINTER so a body that forgot the field is refused rather than read
|
||||||
|
// as "turn it off". The whole point of this endpoint is the difference
|
||||||
|
// between the two.
|
||||||
|
Showhealthscore *bool `json:"showhealthscore"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.Showhealthscore == nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "showhealthscore is required: send true or false.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.productService.SetShowHealthScore(req.Tenantid, req.Productid, *req.Showhealthscore); err != nil {
|
||||||
|
// 409, not 500. "No such product for this business" is a fact the
|
||||||
|
// caller can act on, not a fault in the server.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
210
controllers/resendInvite_test.go
Normal file
210
controllers/resendInvite_test.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/middleware"
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Who may re-issue a first-password link, and for whom.
|
||||||
|
|
||||||
|
This endpoint mints a credential, so most of what matters is what it refuses.
|
||||||
|
The service layer refuses the business cases — an account that already has a
|
||||||
|
password, a tenant whose primary email matches no login — and those are covered
|
||||||
|
in `services/resendInvite_test.go`. This file is about the door: who gets
|
||||||
|
through it, and which account a request actually names.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// resendService answers both resends and records which was called. Only the two
|
||||||
|
// methods under test are real; the rest of TenantService is embedded nil, which
|
||||||
|
// panics if anything else is reached — exactly the signal wanted.
|
||||||
|
type resendService struct {
|
||||||
|
services.TenantService
|
||||||
|
byTenant int
|
||||||
|
byUser int
|
||||||
|
outcome services.InviteOutcome
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *resendService) ResendInvite(tenantID int) (services.InviteOutcome, error) {
|
||||||
|
s.byTenant = tenantID
|
||||||
|
return s.outcome, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *resendService) ResendInviteToUser(userID int) (services.InviteOutcome, error) {
|
||||||
|
s.byUser = userID
|
||||||
|
return s.outcome, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func resendApp(t *testing.T, service *resendService) *fiber.App {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
// The real guard, mounted as routes.go mounts it: this endpoint sits behind
|
||||||
|
// the session, and the handler then requires a platform account on top.
|
||||||
|
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
|
||||||
|
app.Post("/live/api/v1/web/tenants/resendinvite", NewTenantController(service).ResendInvite)
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
// staffToken is a signed session for a Nearle staff account.
|
||||||
|
//
|
||||||
|
// `Superadmin` is the signal, and it is minted from `app_users.issuperadmin` —
|
||||||
|
// not from the tenant being zero and not from a role id. Both of those look
|
||||||
|
// equivalent and are not: `app_roles` calls roleid 1 "Super admin" and
|
||||||
|
// onboarding wrote 1 for every shop owner, and a zero tenant is what an
|
||||||
|
// unfilled column looks like. See `utils.WebClaims`.
|
||||||
|
func staffToken(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := utils.MintWebToken(utils.WebClaims{
|
||||||
|
Userid: 12, Roleid: 1, Configid: 1, Superadmin: true,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// merchantToken is a signed session for a shop's own admin.
|
||||||
|
func merchantToken(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := utils.MintWebToken(utils.WebClaims{
|
||||||
|
Userid: 904, Tenantid: 1147, Roleid: 3, Configid: 1,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAs(t *testing.T, app *fiber.App, token, body string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/live/api/v1/web/tenants/resendinvite",
|
||||||
|
strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resendinvite: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMerchantCannotResendAnything(t *testing.T) {
|
||||||
|
// A merchant's session is pinned to their own tenant, so the worst they could
|
||||||
|
// do is re-invite themselves — and the service refuses that, because an
|
||||||
|
// account signing in to ask already has a password. Refusing here as well
|
||||||
|
// means the endpoint does not rely on two other checks to make the wrong case
|
||||||
|
// impossible.
|
||||||
|
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||||
|
app := resendApp(t, service)
|
||||||
|
|
||||||
|
status, body := postAs(t, app, merchantToken(t), `{"tenantid":1147}`)
|
||||||
|
|
||||||
|
if status != 403 {
|
||||||
|
t.Fatalf("a merchant was let through: %d %s", status, body)
|
||||||
|
}
|
||||||
|
if service.byTenant != 0 || service.byUser != 0 {
|
||||||
|
t.Fatal("the service was reached by a caller who should have been refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNearleStaffCanResendToATenantsOwner(t *testing.T) {
|
||||||
|
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||||
|
app := resendApp(t, service)
|
||||||
|
|
||||||
|
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
|
||||||
|
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("refused Nearle staff: %d %s", status, body)
|
||||||
|
}
|
||||||
|
if service.byTenant != 1147 {
|
||||||
|
t.Fatalf("resent for tenant %d, want 1147", service.byTenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUseridNamesOnePersonRatherThanTheOwner(t *testing.T) {
|
||||||
|
// The reason this parameter exists. Staff added after onboarding, and the
|
||||||
|
// login every branch spawns, are created with no password too — and a
|
||||||
|
// business has many of them, so "the tenant's invitation" cannot reach them.
|
||||||
|
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||||
|
app := resendApp(t, service)
|
||||||
|
|
||||||
|
status, body := postAs(t, app, staffToken(t), `{"userid":7781}`)
|
||||||
|
|
||||||
|
if status != 200 {
|
||||||
|
t.Fatalf("refused: %d %s", status, body)
|
||||||
|
}
|
||||||
|
if service.byUser != 7781 {
|
||||||
|
t.Fatalf("resent for user %d, want 7781", service.byUser)
|
||||||
|
}
|
||||||
|
if service.byTenant != 0 {
|
||||||
|
t.Fatal("emailed the owner when a person was named")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUseridWinsOverATenantid(t *testing.T) {
|
||||||
|
// A caller that sent a person's id meant that person. Falling back to the
|
||||||
|
// owner would be the wrong mailbox with nothing on the response to say so.
|
||||||
|
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||||
|
app := resendApp(t, service)
|
||||||
|
|
||||||
|
if status, body := postAs(t, app, staffToken(t), `{"tenantid":1147,"userid":7781}`); status != 200 {
|
||||||
|
t.Fatalf("refused: %d %s", status, body)
|
||||||
|
}
|
||||||
|
if service.byUser != 7781 || service.byTenant != 0 {
|
||||||
|
t.Fatalf("resolved to the wrong account: user=%d tenant=%d", service.byUser, service.byTenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEmptyBodyIsRefusedRatherThanSentToTenantZero(t *testing.T) {
|
||||||
|
// `{}` parses cleanly into two zeroes. Without this check it would reach the
|
||||||
|
// service as tenant 0 and come back "tenant 0 has no account matching its
|
||||||
|
// primary email address", which describes nothing the caller did.
|
||||||
|
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||||
|
app := resendApp(t, service)
|
||||||
|
|
||||||
|
status, body := postAs(t, app, staffToken(t), `{}`)
|
||||||
|
|
||||||
|
if status != 400 {
|
||||||
|
t.Fatalf("an empty request was accepted: %d %s", status, body)
|
||||||
|
}
|
||||||
|
if service.byTenant != 0 || service.byUser != 0 {
|
||||||
|
t.Fatal("the service was called with nothing to act on")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "tenantid") || !strings.Contains(body, "userid") {
|
||||||
|
t.Errorf("the refusal does not say what to send: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailThatDidNotLeaveIsReportedAsAFailure(t *testing.T) {
|
||||||
|
// The operator pressed a button expecting an email to go. "Success" with no
|
||||||
|
// mail sent is the one answer they cannot act on.
|
||||||
|
service := &resendService{outcome: services.InviteOutcome{
|
||||||
|
Sent: false, Reason: "MAIL_HOST is not set",
|
||||||
|
}}
|
||||||
|
app := resendApp(t, service)
|
||||||
|
|
||||||
|
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
|
||||||
|
|
||||||
|
if status != 409 {
|
||||||
|
t.Fatalf("an unsent invitation was reported as sent: %d %s", status, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "MAIL_HOST") {
|
||||||
|
t.Errorf("the reason was lost: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
302
controllers/setPassword_test.go
Normal file
302
controllers/setPassword_test.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/middleware"
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
fiberv1 "github.com/gofiber/fiber"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Setting a first password, with no session and no way to get one.
|
||||||
|
|
||||||
|
A branch login created by `createtenantlocation` arrives with an empty password.
|
||||||
|
The console signs in, is told to set one, and does — and until now it did that
|
||||||
|
through `PUT /users/update`, which is behind the session guard. Once
|
||||||
|
WEB_AUTH_REQUIRED began defaulting on, that answered
|
||||||
|
|
||||||
|
401 "a session token is required; sign in again"
|
||||||
|
|
||||||
|
to somebody who could not sign in, because signing in needs the password they
|
||||||
|
were trying to set. Every such account was unusable, and the 401 read as an
|
||||||
|
authentication bug rather than a deadlock.
|
||||||
|
|
||||||
|
`publicWebPaths` had named `/users/setpassword` since the guard was written. The
|
||||||
|
path was reserved; the handler never existed, so it answered 404.
|
||||||
|
|
||||||
|
The tests that matter are about the two halves: it must be reachable WITHOUT a
|
||||||
|
session, and it must refuse everything except the one case it exists for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type fakePasswords struct {
|
||||||
|
// set records what reached the write, so a refusal can be shown to have
|
||||||
|
// refused rather than merely reported.
|
||||||
|
set []string
|
||||||
|
lastUserid int
|
||||||
|
refuseIt error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePasswords) SetInitialPassword(userid int, password string) error {
|
||||||
|
f.lastUserid = userid
|
||||||
|
if f.refuseIt != nil {
|
||||||
|
return f.refuseIt
|
||||||
|
}
|
||||||
|
f.set = append(f.set, password)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest of UserService, unused here.
|
||||||
|
func (f *fakePasswords) GetAllUsers(int, int, int, int, string) ([]models.UserInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakePasswords) GetUserByID(int) (models.UserInfo, error) { return models.UserInfo{}, nil }
|
||||||
|
func (f *fakePasswords) Login(models.User) (models.UserInfo, error) {
|
||||||
|
return models.UserInfo{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakePasswords) TenantLogin(models.User) (models.TenantUserInfo, error) {
|
||||||
|
return models.TenantUserInfo{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakePasswords) UpdateStaff(models.User) error { return nil }
|
||||||
|
func (f *fakePasswords) AppLogin(models.User) (models.TenantUserInfo, fiberv1.Map, error) {
|
||||||
|
return models.TenantUserInfo{}, fiberv1.Map{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, services.InviteOutcome, error) {
|
||||||
|
return models.UserInfo{}, services.InviteOutcome{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func passwordApp(t *testing.T, service *fakePasswords) *fiber.App {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
// The real guard, mounted exactly as routes.go mounts it. The point of this
|
||||||
|
// file is which side of it this endpoint lands on.
|
||||||
|
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
|
||||||
|
app.Post("/live/api/v1/web/users/setpassword", NewUserController(service).SetPassword)
|
||||||
|
app.Put("/live/api/v1/web/users/update", NewUserController(service).UpdateStaff)
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(t *testing.T, app *fiber.App, method, path, body string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFirstPasswordCanBeSetWithoutASession(t *testing.T) {
|
||||||
|
// The whole point. There is no session to present and no way to obtain one.
|
||||||
|
service := &fakePasswords{}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||||
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||||
|
|
||||||
|
if status == fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("the guard blocked the one call that cannot present a token: %s", body)
|
||||||
|
}
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("HTTP %d: %s", status, body)
|
||||||
|
}
|
||||||
|
if len(service.set) != 1 || service.set[0] != "opensesame" {
|
||||||
|
t.Fatalf("the password did not reach the service: %v", service.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheGeneralUpdateStaysBehindTheGuard(t *testing.T) {
|
||||||
|
// The reason this is a new endpoint rather than `/users/update` being
|
||||||
|
// opened up: that one writes whatever struct it is handed, so unauthenticated
|
||||||
|
// it would let anybody change any field of any user.
|
||||||
|
service := &fakePasswords{}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
status, body := send(t, app, "PUT", "/live/api/v1/web/users/update",
|
||||||
|
`{"userid":904,"roleid":1,"tenantid":9}`)
|
||||||
|
|
||||||
|
if status != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("an untokened user update was not refused: %d %s", status, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAccountThatAlreadyHasOneIsRefusedAsAConflict(t *testing.T) {
|
||||||
|
// 409, never 401. Nothing here is an authentication failure — the caller is
|
||||||
|
// not supposed to have a session — and a 401 would send the console into its
|
||||||
|
// sign-out-and-reload path on the one screen with nothing to sign out of.
|
||||||
|
service := &fakePasswords{refuseIt: errAlreadySet{}}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||||
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||||
|
|
||||||
|
if status != fiber.StatusConflict {
|
||||||
|
t.Fatalf("expected 409, got %d: %s", status, body)
|
||||||
|
}
|
||||||
|
if len(service.set) != 0 {
|
||||||
|
t.Fatalf("a refused call still wrote: %v", service.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRefusalDoesNotSayWhichAccountsExist(t *testing.T) {
|
||||||
|
// "No such user" and "already has a password" must read identically, or
|
||||||
|
// this becomes a way to ask whether a userid exists and whether it has been
|
||||||
|
// set up — unauthenticated, one request at a time.
|
||||||
|
service := &fakePasswords{refuseIt: errAlreadySet{}}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
_, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||||
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||||
|
|
||||||
|
for _, leak := range []string{"not found", "no such", "does not exist"} {
|
||||||
|
if strings.Contains(strings.ToLower(body), leak) {
|
||||||
|
t.Fatalf("the refusal distinguishes a missing account: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMalformedBodyIsRefusedWithoutPanicking(t *testing.T) {
|
||||||
|
app := passwordApp(t, &fakePasswords{})
|
||||||
|
|
||||||
|
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"userid":`)
|
||||||
|
if status != fiber.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAnswerIsTheEnvelopeTheConsoleUnwraps(t *testing.T) {
|
||||||
|
// A handler answering at the top level passes a service test and hands the
|
||||||
|
// console `undefined`.
|
||||||
|
_, body := send(t, passwordApp(t, &fakePasswords{}), "POST",
|
||||||
|
"/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Status bool `json:"status"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
||||||
|
t.Fatalf("not an envelope: %s", body)
|
||||||
|
}
|
||||||
|
if !envelope.Status || envelope.Code != fiber.StatusOK {
|
||||||
|
t.Fatalf("success did not read as success: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errAlreadySet struct{}
|
||||||
|
|
||||||
|
func (errAlreadySet) Error() string {
|
||||||
|
return "that account cannot have its password set here — it may already have one"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePasswords) TenantWebLogin(models.User) (models.TenantUserInfo, map[string]interface{}) {
|
||||||
|
return models.TenantUserInfo{}, map[string]interface{}{}
|
||||||
|
}
|
||||||
|
func (f *fakePasswords) DeleteUser(int) error { return nil }
|
||||||
|
|
||||||
|
// invite mints a real invitation for the test's account.
|
||||||
|
//
|
||||||
|
// A helper rather than a literal, because the token is signed: a hand-written
|
||||||
|
// string would test the refusal path and nothing else, and the point of these
|
||||||
|
// is what happens when a genuine invitation arrives.
|
||||||
|
func invite(t *testing.T, userid int) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := utils.MintInviteToken(utils.InviteClaims{Userid: userid, Tenantid: 1147}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting an invitation: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The invitation replaced a userid, and that was a security fix rather than a
|
||||||
|
tidy-up.
|
||||||
|
|
||||||
|
`applogin` answers a POST carrying an email and no password with 409 and the
|
||||||
|
userid, for any account that has not set one. So the recipe was: know a
|
||||||
|
merchant's primary email — usually printed on their shopfront — POST it, receive
|
||||||
|
their userid, set their password, own the business's admin account. No guessing
|
||||||
|
at any step, and the empty-password check was no defence because an un-set-up
|
||||||
|
account is exactly what such an attacker wants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestAUseridIsNoLongerEnoughToSetAPassword(t *testing.T) {
|
||||||
|
// The hole, asserted closed. A body carrying a userid and no invitation
|
||||||
|
// must not set anything, whatever the userid is.
|
||||||
|
service := &fakePasswords{}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||||
|
`{"userid":904,"password":"opensesame"}`)
|
||||||
|
|
||||||
|
if status == fiber.StatusOK {
|
||||||
|
t.Fatalf("a bare userid still set a password: %s", body)
|
||||||
|
}
|
||||||
|
if len(service.set) != 0 {
|
||||||
|
t.Fatalf("a bare userid reached the service: %v", service.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnInvitationSetsThePasswordForTheAccountItNames(t *testing.T) {
|
||||||
|
service := &fakePasswords{}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||||
|
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||||
|
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("HTTP %d: %s", status, body)
|
||||||
|
}
|
||||||
|
if len(service.set) != 1 || service.set[0] != "opensesame" {
|
||||||
|
t.Fatalf("the password did not reach the service: %v", service.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheUseridComesFromTheSignatureNotTheRequest(t *testing.T) {
|
||||||
|
// An invitation for 904 with a `userid` field claiming 999 must set 904's
|
||||||
|
// password. If the body could override it, the token would be decoration.
|
||||||
|
service := &fakePasswords{}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||||
|
`{"token":"`+invite(t, 904)+`","userid":999,"password":"opensesame"}`)
|
||||||
|
|
||||||
|
if status != fiber.StatusOK {
|
||||||
|
t.Fatalf("a valid invitation was refused: %d", status)
|
||||||
|
}
|
||||||
|
if service.lastUserid != 904 {
|
||||||
|
t.Fatalf("the request's userid won: set the password for %d", service.lastUserid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAForgedInvitationIsRefused(t *testing.T) {
|
||||||
|
service := &fakePasswords{}
|
||||||
|
app := passwordApp(t, service)
|
||||||
|
|
||||||
|
for _, token := range []string{"", "i1.forged.signature", "not-a-token", "w1.a.b"} {
|
||||||
|
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||||
|
`{"token":"`+token+`","password":"opensesame"}`)
|
||||||
|
if status == fiber.StatusOK {
|
||||||
|
t.Fatalf("%q was accepted as an invitation", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(service.set) != 0 {
|
||||||
|
t.Fatalf("a forged invitation wrote: %v", service.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"nearle/middleware"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -44,6 +45,25 @@ func (ctl *TenantController) SearchTenant(c *fiber.Ctx) error {
|
|||||||
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
|
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
|
||||||
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||||
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||||
|
|
||||||
|
// Paging is defaulted, not required.
|
||||||
|
//
|
||||||
|
// The repository builds LIMIT/OFFSET from these directly, so a caller that
|
||||||
|
// omitted either — or sent pageno=0 — got an empty result reported as
|
||||||
|
// `code 200, status true, message "Success"`. "There are no tenants on the
|
||||||
|
// platform" and "you forgot a query parameter" are very different answers
|
||||||
|
// and this endpoint gave the first for the second.
|
||||||
|
//
|
||||||
|
// Defaulted rather than rejected with a 400: every existing caller that
|
||||||
|
// works today keeps working, and a platform list with no paging asked for
|
||||||
|
// has an obvious right answer — the first page.
|
||||||
|
if pageno < 1 {
|
||||||
|
pageno = 1
|
||||||
|
}
|
||||||
|
if pagesize < 1 {
|
||||||
|
pagesize = 50
|
||||||
|
}
|
||||||
|
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||||
tenanttype := c.Query("tenanttype")
|
tenanttype := c.Query("tenanttype")
|
||||||
@@ -327,7 +347,8 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctl.tenantService.CreateStaff(data); err != nil {
|
invite, err := ctl.tenantService.CreateStaff(data)
|
||||||
|
if err != nil {
|
||||||
// A rejected PIN, a missing name, a role nobody set — these are things
|
// A rejected PIN, a missing name, a role nobody set — these are things
|
||||||
// the person filling in the form can fix, so they come back as 400 with
|
// the person filling in the form can fix, so they come back as 400 with
|
||||||
// the reason. This answered 500 with a body claiming 409, which told a
|
// the reason. This answered 500 with a body claiming 409, which told a
|
||||||
@@ -339,10 +360,17 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The person was hired either way. Whether they were emailed their
|
||||||
|
// first-password link is reported beside that rather than folded into
|
||||||
|
// `status`: this account is created with no password and the link is the only
|
||||||
|
// way in, so an operator who is not told cannot know they have added somebody
|
||||||
|
// who cannot sign in.
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"code": http.StatusCreated,
|
"code": http.StatusCreated,
|
||||||
"message": "Staff created successfully",
|
"message": "Staff created successfully",
|
||||||
"status": true,
|
"status": true,
|
||||||
|
"invited": invite.Sent,
|
||||||
|
"invitereason": invite.Reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +445,7 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := ctl.tenantService.CreateTenantUser(data)
|
result, invite, err := ctl.tenantService.CreateTenantUser(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "Tenant Already Exists" {
|
if err.Error() == "Tenant Already Exists" {
|
||||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
@@ -434,11 +462,21 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tenant was created either way. The invitation is reported beside it
|
||||||
|
// rather than folded into `status`, because a merchant who exists and has
|
||||||
|
// not been emailed is a task for the operator — resend, or correct the
|
||||||
|
// address — and not a failed onboarding to be retried.
|
||||||
|
//
|
||||||
|
// `invited: false` with a reason is the state the platform console shows on
|
||||||
|
// the tenant, so it never has to guess whether the email went.
|
||||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
"code": 201,
|
"code": 201,
|
||||||
"status": true,
|
"status": true,
|
||||||
"message": "Successfully Created",
|
"message": "Successfully Created",
|
||||||
"details": result,
|
"details": result,
|
||||||
|
"invited": invite.Sent,
|
||||||
|
// Omitted when it sent, so a successful onboarding carries no apology.
|
||||||
|
"invitereason": invite.Reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,3 +795,79 @@ func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
|
|||||||
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResendInvite re-issues a merchant's first-password link.
|
||||||
|
//
|
||||||
|
// ── Why this is platform staff only ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It mints a credential. `middleware.WebAuth` already pins a merchant's session
|
||||||
|
// to their own tenant, so a shop could at most re-invite itself — but the
|
||||||
|
// account it would be inviting is the one signing in to ask, which can only
|
||||||
|
// happen if that account already has a password, and the service refuses that
|
||||||
|
// case outright.
|
||||||
|
//
|
||||||
|
// So the only caller this is for is Nearle's own staff, chasing a merchant who
|
||||||
|
// never received the mail. Saying so explicitly is better than relying on two
|
||||||
|
// other checks to make the wrong case impossible.
|
||||||
|
func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
|
||||||
|
claims, ok := middleware.WebClaimsFrom(c)
|
||||||
|
if !ok || !claims.IsPlatformAccount() {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false,
|
||||||
|
"message": "Only Nearle staff can resend an invitation.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either a tenant — meaning its owner, the one account onboarding created —
|
||||||
|
// or one named person. Staff added later and the login every branch spawns
|
||||||
|
// are created with no password too, and a business has many of them, so
|
||||||
|
// "the tenant's invitation" cannot reach them.
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.Tenantid <= 0 && req.Userid <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "Send a tenantid to re-invite the owner, or a userid to re-invite one person.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `userid` wins when both arrive. It is the more specific of the two, and a
|
||||||
|
// caller that sent a person's id meant that person — silently emailing the
|
||||||
|
// owner instead would be the wrong mailbox with no sign anything was off.
|
||||||
|
var (
|
||||||
|
outcome services.InviteOutcome
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if req.Userid > 0 {
|
||||||
|
outcome, err = ctl.tenantService.ResendInviteToUser(req.Userid)
|
||||||
|
} else {
|
||||||
|
outcome, err = ctl.tenantService.ResendInvite(req.Tenantid)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// 409, not 500. Every failure here is a business fact the operator can
|
||||||
|
// act on — no such tenant, an address that matches no login, a merchant
|
||||||
|
// already set up — rather than a fault in the server.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !outcome.Sent {
|
||||||
|
// The tenant is fine and the mail did not go. Reported as a failure
|
||||||
|
// because the operator pressed a button expecting an email to leave,
|
||||||
|
// and the reason names what to fix.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": outcome.Reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,63 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// attachWebSession hands a signed-in console user their session token.
|
||||||
|
//
|
||||||
|
// Added to the login response rather than served from a second endpoint, so the
|
||||||
|
// console receives it on the call it already makes and nothing changes about
|
||||||
|
// when or how it signs in.
|
||||||
|
//
|
||||||
|
// The claims come from the user's own record, which is the whole point: until
|
||||||
|
// now the console asserted its tenant on every request and was believed, and
|
||||||
|
// sealing it under a signature here is what makes `middleware.WebAuth` able to
|
||||||
|
// refuse a request naming somebody else's.
|
||||||
|
//
|
||||||
|
// `Issuperadmin` is copied across as the ONLY source of cross-tenant access.
|
||||||
|
// Not the role — `app_roles` calls roleid 1 "Super admin" and tenant onboarding
|
||||||
|
// wrote 1 for every shop owner, so trusting the role would promote every
|
||||||
|
// merchant on the platform.
|
||||||
|
//
|
||||||
|
// A failure to mint is logged and swallowed, deliberately, while
|
||||||
|
// WEB_AUTH_REQUIRED is off: a deployment that has not set a signing key yet must
|
||||||
|
// still be able to sign in, or shipping this takes the console down everywhere
|
||||||
|
// the secret is missing. Once enforcement is on, no token means no session —
|
||||||
|
// which is then the correct and loud failure.
|
||||||
|
//
|
||||||
|
// The parameter is the underlying map type rather than `fiber.Map`, because the
|
||||||
|
// two login paths do not agree on which fiber that is: `AppLogin` returns the
|
||||||
|
// v1 package's `Map` and `TenantWebLogin` the v2 one. Both are
|
||||||
|
// `map[string]any`, so taking that accepts either without dragging the
|
||||||
|
// old import into this file.
|
||||||
|
func attachWebSession(resp map[string]any, info models.TenantUserInfo) {
|
||||||
|
token, expires, err := utils.MintWebToken(utils.WebClaims{
|
||||||
|
Userid: info.Userid,
|
||||||
|
Tenantid: info.Tenantid,
|
||||||
|
Locationid: info.Locationid,
|
||||||
|
Roleid: info.Roleid,
|
||||||
|
Configid: info.Configid,
|
||||||
|
Superadmin: info.Issuperadmin,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("login: could not issue a console session for user %d: %v", info.Userid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp["token"] = token
|
||||||
|
resp["tokenexpiresat"] = expires.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
userService services.UserService
|
userService services.UserService
|
||||||
}
|
}
|
||||||
@@ -179,7 +226,7 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_, resp, err := ctl.userService.AppLogin(user)
|
info, resp, err := ctl.userService.AppLogin(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Use resp.Code if present, fallback to 409
|
// Use resp.Code if present, fallback to 409
|
||||||
code := http.StatusConflict
|
code := http.StatusConflict
|
||||||
@@ -189,6 +236,8 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
|
|||||||
return c.Status(code).JSON(resp)
|
return c.Status(code).JSON(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachWebSession(resp, info)
|
||||||
|
|
||||||
// ✅ Always return resp
|
// ✅ Always return resp
|
||||||
return c.Status(http.StatusOK).JSON(resp)
|
return c.Status(http.StatusOK).JSON(resp)
|
||||||
}
|
}
|
||||||
@@ -206,7 +255,7 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Call service
|
// Call service
|
||||||
info, err := ctl.userService.CreateUser(user)
|
info, invite, err := ctl.userService.CreateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
"code": http.StatusConflict,
|
"code": http.StatusConflict,
|
||||||
@@ -215,11 +264,17 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The account was created either way. Whether its first-password invitation
|
||||||
|
// was emailed is reported beside it rather than folded into `status`: the
|
||||||
|
// account has no password and the link is the only way to set one, so an
|
||||||
|
// operator who is not told has hired somebody who cannot sign in.
|
||||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
"code": http.StatusCreated,
|
"code": http.StatusCreated,
|
||||||
"status": true,
|
"status": true,
|
||||||
"message": "Success",
|
"message": "Success",
|
||||||
"details": info,
|
"details": info,
|
||||||
|
"invited": invite.Sent,
|
||||||
|
"invitereason": invite.Reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +299,7 @@ func (ctl *UserController) TenantWebLogin(c *fiber.Ctx) error {
|
|||||||
// Include tenant user info if login successful (code 200)
|
// Include tenant user info if login successful (code 200)
|
||||||
if code == fiber.StatusOK {
|
if code == fiber.StatusOK {
|
||||||
resp["details"] = info
|
resp["details"] = info
|
||||||
|
attachWebSession(resp, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(code).JSON(resp)
|
return c.Status(code).JSON(resp)
|
||||||
@@ -274,3 +330,72 @@ func (ctl *UserController) DeleteUser(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPassword gives a never-used account its first password.
|
||||||
|
//
|
||||||
|
// ── Why this endpoint exists ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Because the flow was impossible without it. A branch login created by
|
||||||
|
// `createtenantlocation` arrives with an empty password; the console signs in,
|
||||||
|
// is told to set one, and does so — through `PUT /users/update`, which sits
|
||||||
|
// behind the session guard. So the call answered "a session token is required;
|
||||||
|
// sign in again" to a person who could not sign in, because they had no
|
||||||
|
// password yet. Every such account was unusable.
|
||||||
|
//
|
||||||
|
// `publicWebPaths` has named `/users/setpassword` since the guard was written.
|
||||||
|
// The path was reserved and the handler never built, so it answered 404 and the
|
||||||
|
// console went on using the guarded one.
|
||||||
|
//
|
||||||
|
// ── Why not simply open up `/users/update` ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// It writes whatever struct it is handed. Unauthenticated, it would let anybody
|
||||||
|
// change any field of any user — their email, their role, their tenant. This
|
||||||
|
// takes two fields and can only act on an account with no password, which is
|
||||||
|
// what makes it safe to leave open. See the repository for the rest.
|
||||||
|
func (ctl *UserController) SetPassword(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
// The invitation, exactly as it arrived in the emailed link. The userid
|
||||||
|
// is read out of the signature and never out of the request — see below.
|
||||||
|
Token string `json:"token"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusBadRequest, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Why this takes a token and no longer takes a userid ─────────────────
|
||||||
|
//
|
||||||
|
// It used to accept `{userid, password}`, and that was an account takeover
|
||||||
|
// waiting to be noticed. `applogin` answers a POST carrying an email and NO
|
||||||
|
// password with 409 and the userid, for any account that has not set one —
|
||||||
|
// which is how the console's own setup step learned it. So the whole recipe
|
||||||
|
// was: know a merchant's primary email, which is usually printed on their
|
||||||
|
// shopfront, POST it here, receive their userid, then set their password
|
||||||
|
// and own the business's admin account. No guessing at any step.
|
||||||
|
//
|
||||||
|
// The invitation closes it. It is signed with the deployment's key, names
|
||||||
|
// the account in a payload the server produced, and expires. Knowing an
|
||||||
|
// email is no longer enough, and neither is knowing a userid.
|
||||||
|
claims, err := utils.ParseInviteToken(req.Token, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusConflict, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.userService.SetInitialPassword(claims.Userid, req.Password); err != nil {
|
||||||
|
// 409, not 401. Nothing about this is an authentication failure — the
|
||||||
|
// caller is not supposed to have a session — and answering 401 would
|
||||||
|
// send the console into its sign-out-and-reload path on the one screen
|
||||||
|
// where there is nothing to sign out of.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusConflict, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": true, "code": http.StatusOK,
|
||||||
|
"message": "Password set. Sign in with it.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"nearle/config"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
@@ -23,14 +23,18 @@ var (
|
|||||||
// DATABASE CONNECTION
|
// DATABASE CONNECTION
|
||||||
// --------------------
|
// --------------------
|
||||||
|
|
||||||
func Connect() {
|
// Connect opens the main database and then the optional catalogue database
|
||||||
|
// and image store. Everything it needs has already been validated by
|
||||||
|
// config.Load, so a missing variable can no longer surface here as a
|
||||||
|
// log.Fatal halfway through boot.
|
||||||
|
func Connect(cfg *config.Config) {
|
||||||
dsn := fmt.Sprintf(
|
dsn := fmt.Sprintf(
|
||||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
|
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
|
||||||
mustEnv("DB_HOST"),
|
cfg.DB.Host,
|
||||||
mustEnv("DB_USER"),
|
cfg.DB.User,
|
||||||
mustEnv("DB_PASSWORD"),
|
cfg.DB.Password,
|
||||||
mustEnv("DB_NAME"),
|
cfg.DB.Name,
|
||||||
getEnv("DB_PORT", "5433"),
|
cfg.DB.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -42,17 +46,16 @@ func Connect() {
|
|||||||
setupDB(DB)
|
setupDB(DB)
|
||||||
fmt.Println("✅ Database connected")
|
fmt.Println("✅ Database connected")
|
||||||
|
|
||||||
connectCatalogueDB()
|
connectCatalogueDB(cfg.Catalogue)
|
||||||
connectImageStore()
|
connectImageStore(cfg.S3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectCatalogueDB opens the read-only connection to the catalogue
|
// connectCatalogueDB opens the read-only connection to the catalogue
|
||||||
// (pgvector) database. If its env vars are not set, catalogue endpoints
|
// (pgvector) database. If its env vars are not set, catalogue endpoints
|
||||||
// are simply unavailable — this must never block startup of the main app.
|
// are simply unavailable — this must never block startup of the main app.
|
||||||
func connectCatalogueDB() {
|
func connectCatalogueDB(c config.DBConfig) {
|
||||||
host := getEnv("CATALOGUE_DB_HOST", "")
|
if !c.Enabled() {
|
||||||
if host == "" {
|
fmt.Println("⚠️ CATALOGUE_DB_HOST not set, skipping catalogue DB connection")
|
||||||
fmt.Println("⚠️ Catalogue DB env vars not set, skipping catalogue DB connection")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +65,9 @@ func connectCatalogueDB() {
|
|||||||
// quoting/comment syntax.
|
// quoting/comment syntax.
|
||||||
dsnURL := url.URL{
|
dsnURL := url.URL{
|
||||||
Scheme: "postgres",
|
Scheme: "postgres",
|
||||||
User: url.UserPassword(mustEnv("CATALOGUE_DB_USER"), mustEnv("CATALOGUE_DB_PASSWORD")),
|
User: url.UserPassword(c.User, c.Password),
|
||||||
Host: fmt.Sprintf("%s:%s", host, getEnv("CATALOGUE_DB_PORT", "5432")),
|
Host: fmt.Sprintf("%s:%s", c.Host, c.Port),
|
||||||
Path: "/" + mustEnv("CATALOGUE_DB_NAME"),
|
Path: "/" + c.Name,
|
||||||
}
|
}
|
||||||
q := dsnURL.Query()
|
q := dsnURL.Query()
|
||||||
q.Set("sslmode", "disable")
|
q.Set("sslmode", "disable")
|
||||||
@@ -108,22 +111,3 @@ func CloseDB() {
|
|||||||
}
|
}
|
||||||
fmt.Println("Connection closed Successfully")
|
fmt.Println("Connection closed Successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------
|
|
||||||
// ENV HELPERS
|
|
||||||
// --------------------
|
|
||||||
|
|
||||||
func mustEnv(key string) string {
|
|
||||||
val := os.Getenv(key)
|
|
||||||
if val == "" {
|
|
||||||
log.Fatalf("Missing required env variable: %s", key)
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
|
||||||
if val := os.Getenv(key); val != "" {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"nearle/config"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -33,23 +34,20 @@ var ImageStore *imageStore
|
|||||||
|
|
||||||
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
|
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
|
||||||
// used to resolve catalogue product images. Like the catalogue DB, this must
|
// used to resolve catalogue product images. Like the catalogue DB, this must
|
||||||
// never block or fail app startup — if S3 env vars are absent, image URLs
|
// never block or fail app startup — with USE_S3 unset, image URLs are simply
|
||||||
// are simply omitted from catalogue responses.
|
// omitted from catalogue responses. (USE_S3=true with a key missing is caught
|
||||||
func connectImageStore() {
|
// by config.Load before we get here.)
|
||||||
if getEnv("USE_S3", "") != "true" {
|
func connectImageStore(c config.S3Config) {
|
||||||
fmt.Println("⚠️ S3 not enabled, skipping image store")
|
if !c.Enabled {
|
||||||
|
fmt.Println("⚠️ USE_S3 not set, skipping image store")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := getEnv("S3_ENDPOINT", "")
|
endpoint := c.Endpoint
|
||||||
bucket := getEnv("S3_BUCKET", "")
|
bucket := c.Bucket
|
||||||
accessKey := getEnv("S3_ACCESS_KEY", "")
|
accessKey := c.AccessKey
|
||||||
secretKey := getEnv("S3_SECRET_KEY", "")
|
secretKey := c.SecretKey
|
||||||
region := getEnv("S3_REGION", "")
|
region := c.Region
|
||||||
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
|
|
||||||
fmt.Println("⚠️ S3 env vars incomplete, skipping image store")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com).
|
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com).
|
||||||
// The SDK's virtual-hosted-style client re-prepends the bucket to whatever
|
// The SDK's virtual-hosted-style client re-prepends the bucket to whatever
|
||||||
|
|||||||
19
db/redis.go
19
db/redis.go
@@ -3,9 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"nearle/config"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -31,23 +29,18 @@ var RedisCtx = context.Background()
|
|||||||
// Redis is optional here: without it the POS health board goes dark, but bills
|
// Redis is optional here: without it the POS health board goes dark, but bills
|
||||||
// still arrive and commit. That is the right failure — losing presence is an
|
// still arrive and commit. That is the right failure — losing presence is an
|
||||||
// inconvenience, losing a sale is not — so this never aborts startup.
|
// inconvenience, losing a sale is not — so this never aborts startup.
|
||||||
func InitRedis() {
|
func InitRedis(c config.RedisConfig) {
|
||||||
host := strings.TrimSpace(os.Getenv("REDIS_HOST"))
|
if !c.Enabled() {
|
||||||
if host == "" {
|
|
||||||
log.Println("redis: REDIS_HOST not set, POS presence disabled")
|
log.Println("redis: REDIS_HOST not set, POS presence disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
port := getEnv("REDIS_PORT", "6379")
|
host, port, dbIndex := c.Host, c.Port, c.DB
|
||||||
dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0"))
|
|
||||||
if err != nil {
|
|
||||||
dbIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
Rdb = redis.NewClient(&redis.Options{
|
Rdb = redis.NewClient(&redis.Options{
|
||||||
Addr: host + ":" + port,
|
Addr: host + ":" + port,
|
||||||
Username: getEnv("REDIS_USER", "default"),
|
Username: c.User,
|
||||||
Password: os.Getenv("REDIS_PASSWORD"),
|
Password: c.Password,
|
||||||
DB: dbIndex,
|
DB: dbIndex,
|
||||||
|
|
||||||
// Short on purpose. A degraded Redis must fail fast rather than tie up
|
// Short on purpose. A degraded Redis must fail fast rather than tie up
|
||||||
|
|||||||
218
docs/DELIVERY_SLOTS_APP.md
Normal file
218
docs/DELIVERY_SLOTS_APP.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Delivery windows — the app contract
|
||||||
|
|
||||||
|
Shoppers now choose when their order arrives: one of three windows a day —
|
||||||
|
morning, afternoon, evening — set per branch by the shop.
|
||||||
|
|
||||||
|
Two things to build: show the choice at checkout, and send it with the order.
|
||||||
|
Everything else is done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What a shopper may pick
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://fiesta.nearle.app/live/api/v1/mob/deliveryslots/available?tenantid=&locationid=
|
||||||
|
```
|
||||||
|
|
||||||
|
No authentication. Call it at checkout, once the branch is known.
|
||||||
|
|
||||||
|
**Real response** (branch 1179, taken at 19:02 IST):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": [
|
||||||
|
{ "deliveryslotid": 3, "slotkey": "evening", "name": "Evening",
|
||||||
|
"starttime": "17:00", "endtime": "20:00",
|
||||||
|
"slotdate": "2026-10-06", "istomorrow": false },
|
||||||
|
{ "deliveryslotid": 1, "slotkey": "morning", "name": "Morning",
|
||||||
|
"starttime": "08:00", "endtime": "10:00",
|
||||||
|
"slotdate": "2026-10-07", "istomorrow": true },
|
||||||
|
{ "deliveryslotid": 2, "slotkey": "afternoon", "name": "Afternoon",
|
||||||
|
"starttime": "12:00", "endtime": "15:00",
|
||||||
|
"slotdate": "2026-10-07", "istomorrow": true },
|
||||||
|
{ "deliveryslotid": 3, "slotkey": "evening", "name": "Evening",
|
||||||
|
"starttime": "17:00", "endtime": "20:00",
|
||||||
|
"slotdate": "2026-10-07", "istomorrow": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Morning and afternoon are absent from today because both had ended by 19:02.
|
||||||
|
**That filtering is already done — render the list as given.**
|
||||||
|
|
||||||
|
### Do no time arithmetic
|
||||||
|
|
||||||
|
Do not compare `starttime`/`endtime` against the device clock to decide what to
|
||||||
|
show. The server owns that rule, and the device's clock, timezone and locale are
|
||||||
|
all things we do not control. If the app re-derives it, the two will disagree
|
||||||
|
and the shopper will be offered a window the server then rejects.
|
||||||
|
|
||||||
|
The fields are there to display — "Evening, 5–8pm" — not to filter on.
|
||||||
|
|
||||||
|
### Ordering
|
||||||
|
|
||||||
|
Already sorted: today's remaining windows first, then tomorrow's, each by start
|
||||||
|
time. Render in the order given.
|
||||||
|
|
||||||
|
`istomorrow` is there so you can put "Tomorrow" beside a name without comparing
|
||||||
|
dates yourself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. An empty list is normal
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 200, "message": "Success", "status": true, "details": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is not an error, and it is the common case today.** Most branches have
|
||||||
|
not set windows yet, and they are trading normally right now.
|
||||||
|
|
||||||
|
When `details` is empty:
|
||||||
|
|
||||||
|
- Do not show the window picker
|
||||||
|
- Do not show an error, a retry, or "this shop is closed"
|
||||||
|
- **Let the order go through with no window**, exactly as before this feature
|
||||||
|
|
||||||
|
The whole rollout depends on this. A branch with no windows is an ordinary
|
||||||
|
branch, and treating it as broken would take every shop on the platform offline.
|
||||||
|
|
||||||
|
It is always `[]`, never `null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sending the choice
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://fiesta.nearle.app/live/api/v1/mob/orders/createorder
|
||||||
|
```
|
||||||
|
|
||||||
|
Two new **optional** fields on the existing body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deliveryslotid": 3,
|
||||||
|
"deliveryslotdate": "2026-10-06"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Send both or neither. Copy them straight from the chosen entry — do not
|
||||||
|
recompute the date.
|
||||||
|
|
||||||
|
Omitting them creates an order with no window, which is valid and unchanged
|
||||||
|
from today's behaviour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The one error to handle
|
||||||
|
|
||||||
|
A window takes orders **right up until it ends**, then stops. So a shopper who
|
||||||
|
opens checkout at 09:58 and pays at 10:02 has chosen a window that closed while
|
||||||
|
they were deciding.
|
||||||
|
|
||||||
|
The server re-checks on every order and answers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 409,
|
||||||
|
"status": false,
|
||||||
|
"message": "the morning window has closed for today — please choose another"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**On 409:** re-fetch `available`, show the fresh list, ask again. The `message`
|
||||||
|
is written to be shown to the shopper as-is.
|
||||||
|
|
||||||
|
Other 409 messages from the same check, all safe to display:
|
||||||
|
|
||||||
|
- `that delivery window is not one this shop offers`
|
||||||
|
- `the evening window is not currently available` — the shop switched it off
|
||||||
|
- `that delivery window has already passed`
|
||||||
|
- `a delivery date is required with a delivery window`
|
||||||
|
|
||||||
|
This is worth handling properly rather than as a generic failure. It is the one
|
||||||
|
case that will happen to real people in normal use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Reading it back
|
||||||
|
|
||||||
|
Orders carry what was chosen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "deliveryslotid": 3, "deliveryslotdate": "2026-10-06" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Both absent or `0`/empty on orders placed without a window. Show the window on
|
||||||
|
the confirmation screen and in order history; treat absence as "no window was
|
||||||
|
asked for", never as missing data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What the window means
|
||||||
|
|
||||||
|
**A preference, not a promise.**
|
||||||
|
|
||||||
|
- Every order is accepted. A window never fills up and never blocks a sale.
|
||||||
|
- There is no capacity limit, and no "slots remaining".
|
||||||
|
- It tells the shop when to group the drop, and the shopper roughly when to
|
||||||
|
expect it.
|
||||||
|
|
||||||
|
Please do not word it in the app as a guaranteed delivery time. "Arrives
|
||||||
|
between 5 and 8pm" is right; "Guaranteed by 8pm" is not something the backend
|
||||||
|
can honour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Done on our side
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `deliveryslots` table, per branch | ✅ live |
|
||||||
|
| `GET /v1/mob/deliveryslots/available` | ✅ live, filtering and dating already applied |
|
||||||
|
| `orders.deliveryslotid` + `deliveryslotdate` | ✅ live |
|
||||||
|
| `createorder` accepts and validates both | ✅ live, 409 on a closed window |
|
||||||
|
| Server timezone (IST) | ✅ fixed — windows close on the shop's clock |
|
||||||
|
| Shops set their windows at onboarding | ✅ live in both consoles |
|
||||||
|
| Shops edit them later (Store profile → Settings) | ✅ live |
|
||||||
|
| Window shown on the order in the console | ✅ live |
|
||||||
|
|
||||||
|
Verified end to end on live data: saved through the console, served to the app
|
||||||
|
already filtered, with today's closed windows correctly absent.
|
||||||
|
|
||||||
|
**Not done:** grouping the dispatch queue by window. That is a console concern
|
||||||
|
and does not affect anything above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. A branch you can test against
|
||||||
|
|
||||||
|
**Tenant `1141`, branch `1179`** — three windows configured:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Morning | 08:00–10:00 |
|
||||||
|
| Afternoon | 12:00–15:00 |
|
||||||
|
| Evening | 17:00–20:00 |
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /live/api/v1/mob/deliveryslots/available?tenantid=1141&locationid=1179
|
||||||
|
```
|
||||||
|
|
||||||
|
Call it at different times of day and the list shortens as windows close —
|
||||||
|
that is the easiest way to see the rule working.
|
||||||
|
|
||||||
|
For the empty-list path, use any other branch: most have no windows set, which
|
||||||
|
is exactly the case you need to handle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
The rule lives in one place server-side (`services/deliverySlotService.go`), so
|
||||||
|
if anything about open/closed looks wrong, it is one function and not a
|
||||||
|
disagreement between us. Ask rather than working around it in the app — a
|
||||||
|
workaround on the device is how the two clocks drift apart.
|
||||||
103
docs/ENVIRONMENT.md
Normal file
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.
|
||||||
176
docs/MAIL_SETUP.md
Normal file
176
docs/MAIL_SETUP.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Mail setup — Google Workspace SMTP, sending as care@nearledaily.com
|
||||||
|
|
||||||
|
What this is for: the first-password invitation. Every back-office account on
|
||||||
|
Fiesta is created with an empty password, and the link in this email is the only
|
||||||
|
way to set one — the sign-in screen no longer offers a form, because a public one
|
||||||
|
meant that knowing a merchant's email address was enough to claim their account.
|
||||||
|
|
||||||
|
So this is not newsletter plumbing. **If the mail lands in spam, a business that
|
||||||
|
was just onboarded cannot sign in**, and the first anyone hears of it is a phone
|
||||||
|
call. Step 3 is the one that decides that, and it is the one people skip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The decisions
|
||||||
|
|
||||||
|
| | | why |
|
||||||
|
|---|---|---|
|
||||||
|
| Relay | Google Workspace SMTP | no server to run, no IP to warm, no port 25 exception to beg for. At a few dozen invitations a month that is the whole argument |
|
||||||
|
| Sender | `care@nearledaily.com` | the link points at `app.nearledaily.com`; a password mail whose sender and destination are different domains is the shape of a phishing mail |
|
||||||
|
| `care@` not `no-reply@` | | somebody replying "I never got this" is the most useful reply this system can receive, and it should reach a person |
|
||||||
|
| Auth | an App Password, never the login password | it can be revoked on its own if it leaks |
|
||||||
|
|
||||||
|
This replaces an earlier plan to self-host Postal. Postal is the better answer at
|
||||||
|
volume; it is the wrong answer for tens of emails a month, because the work is
|
||||||
|
not the software — it is IP reputation, rDNS and blocklists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Google Workspace on nearledaily.com
|
||||||
|
|
||||||
|
1. Sign up at workspace.google.com with `nearledaily.com`. Business Starter is
|
||||||
|
enough.
|
||||||
|
2. Verify the domain with the TXT record Google gives you.
|
||||||
|
3. Create `care@nearledaily.com`. It is a real mailbox and **somebody has to read
|
||||||
|
it** — merchant replies and bounce notices both land there, and a bounce is how
|
||||||
|
you learn an invitation never arrived.
|
||||||
|
|
||||||
|
## Step 2 — DNS on nearledaily.com
|
||||||
|
|
||||||
|
| Record | Name | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| MX | `nearledaily.com` | `smtp.google.com` (priority 1) |
|
||||||
|
| TXT (SPF) | `nearledaily.com` | `v=spf1 include:_spf.google.com ~all` |
|
||||||
|
| TXT (DKIM) | `google._domainkey` | the key from Step 3 |
|
||||||
|
| TXT (DMARC) | `_dmarc` | `v=DMARC1; p=none; rua=mailto:care@nearledaily.com` |
|
||||||
|
|
||||||
|
- **One SPF record only.** If the domain already has one, merge
|
||||||
|
`include:_spf.google.com` into it. Two SPF records is a permerror and fails
|
||||||
|
every check.
|
||||||
|
- **Remove old MX records** if the domain receives mail somewhere else today, or
|
||||||
|
that mail keeps going to the old place.
|
||||||
|
- **Keep DMARC at `p=none`** for a couple of weeks, read the reports, then move to
|
||||||
|
`p=quarantine`. Going straight to `p=reject` is how you find a misaligned
|
||||||
|
sender by losing its mail.
|
||||||
|
|
||||||
|
## Step 3 — DKIM (the step people skip)
|
||||||
|
|
||||||
|
Admin console → Apps → Google Workspace → Gmail → **Authenticate email**.
|
||||||
|
|
||||||
|
1. **Generate new record** (2048-bit), add the TXT record it prints to DNS.
|
||||||
|
2. Wait for DNS to propagate — minutes to hours.
|
||||||
|
3. Come back and click **Start authentication**.
|
||||||
|
|
||||||
|
Until you click that last button the mail is unsigned, and unsigned mail carrying
|
||||||
|
a password link goes to spam.
|
||||||
|
|
||||||
|
## Step 4 — An App Password for Fiesta
|
||||||
|
|
||||||
|
1. Sign in as `care@nearledaily.com` → Google Account → Security → turn on
|
||||||
|
**2-Step Verification**.
|
||||||
|
2. Security → **App passwords** → create one named `Fiesta`. You get 16
|
||||||
|
characters.
|
||||||
|
3. That is what Fiesta uses. Never the account's real password.
|
||||||
|
|
||||||
|
No App passwords option? An admin has to allow it, or set up Admin console →
|
||||||
|
Gmail → Routing → **SMTP relay service** with "require SMTP authentication" and
|
||||||
|
"require TLS". In that case `MAIL_HOST` becomes `smtp-relay.gmail.com`.
|
||||||
|
|
||||||
|
## Step 5 — Point Fiesta at it
|
||||||
|
|
||||||
|
Credentials go in **`.env.secrets`**, which is read first and is the only env
|
||||||
|
file git ignores. Never in `.env` — that one is tracked and shared.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MAIL_HOST=smtp.gmail.com
|
||||||
|
MAIL_USERNAME=care@nearledaily.com
|
||||||
|
MAIL_PASSWORD=<16-character app password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Already set in `.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_FROM=care@nearledaily.com
|
||||||
|
MAIL_FROM_NAME=Nearle
|
||||||
|
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Google shows the App Password as four groups — `abcd efgh ijkl mnop`. Paste it
|
||||||
|
with or without the spaces; Fiesta strips them for Google SMTP hosts only, and
|
||||||
|
only when what remains is the sixteen alphanumerics an App Password actually is.
|
||||||
|
Another relay's password is never edited.
|
||||||
|
|
||||||
|
`MAIL_CONSOLE_URL` is the **merchant** console and never the platform one — a
|
||||||
|
merchant sets their password at `app.nearledaily.com/set-password` and nowhere
|
||||||
|
else.
|
||||||
|
|
||||||
|
Restart. The log says which state it is in:
|
||||||
|
|
||||||
|
```
|
||||||
|
mail: sending as care@nearledaily.com via smtp.gmail.com:587
|
||||||
|
mail: OFF — <reason naming the missing variable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**On a hosted deployment these belong in the platform's own environment**
|
||||||
|
(Dokploy), not in a file in the repo. A `.env` committed to the repository is
|
||||||
|
overwritten at build time — that is how the nutrition service shipped switched
|
||||||
|
off.
|
||||||
|
|
||||||
|
### What Fiesta does with them
|
||||||
|
|
||||||
|
`utils/mail.go` upgrades to TLS with STARTTLS before authenticating, and
|
||||||
|
**refuses to send at all if a relay offers no encryption while credentials are
|
||||||
|
configured**. Go's own `smtp.PlainAuth` would decline to hand over the password
|
||||||
|
anyway, so nothing leaks either way — but it reports that as the server refusing
|
||||||
|
the credentials, which sends somebody to check the password when the problem is
|
||||||
|
the connection. It also closes a downgrade, where an attacker strips STARTTLS
|
||||||
|
from the greeting.
|
||||||
|
|
||||||
|
## Step 6 — Check the DNS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dig TXT nearledaily.com +short # SPF, with _spf.google.com
|
||||||
|
dig TXT google._domainkey.nearledaily.com +short # DKIM key
|
||||||
|
dig TXT _dmarc.nearledaily.com +short # DMARC
|
||||||
|
dig MX nearledaily.com +short # smtp.google.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Google's Check MX tool at toolbox.googleapps.com does the same job.
|
||||||
|
|
||||||
|
## Step 7 — Prove it end to end
|
||||||
|
|
||||||
|
Not "the config looks right" — watch one arrive.
|
||||||
|
|
||||||
|
1. Send a test to a [mail-tester.com](https://mail-tester.com) address. Aim for
|
||||||
|
9/10 or better **before** a real merchant sees one.
|
||||||
|
2. Onboard a test merchant with an address you can read.
|
||||||
|
3. Confirm it is in the **inbox, not spam**. In Gmail, "Show original" should
|
||||||
|
show SPF, DKIM and DMARC all PASS.
|
||||||
|
4. Follow the link, set a password, sign in at `app.nearledaily.com`.
|
||||||
|
5. Press **Resend invite**. It must refuse, naming the business:
|
||||||
|
*"… has already set a password — send them to the sign-in page instead."*
|
||||||
|
That refusal is what stops this becoming a password reset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worth knowing
|
||||||
|
|
||||||
|
- **Limit:** about 2,000 messages a day per user. Onboarding runs at a few dozen
|
||||||
|
a month, so this is not a constraint.
|
||||||
|
- **Bounces** arrive as "Delivery failed" in the `care@` inbox. Nothing in Fiesta
|
||||||
|
watches for them, so somebody has to read that mailbox after onboarding.
|
||||||
|
- **Not for bulk.** Google does not permit marketing sends through Workspace. If
|
||||||
|
newsletters are ever wanted, that is a separate provider — not this mailbox.
|
||||||
|
- **If the App Password leaks:** revoke it in Google Account → Security, issue a
|
||||||
|
new one, update `.env.secrets`. Nothing else has to change.
|
||||||
|
|
||||||
|
## What the merchant receives
|
||||||
|
|
||||||
|
Plain text, deliberately. A password link arriving as an image-heavy HTML
|
||||||
|
template is the shape of a phishing mail, and plain text renders identically
|
||||||
|
everywhere. The body names the business, puts the link on its own line, and says
|
||||||
|
it expires in seven days — because an invitation found three weeks later needs to
|
||||||
|
explain itself rather than look broken.
|
||||||
|
|
||||||
|
The wording is `inviteMessage` in `services/inviteService.go`.
|
||||||
115
docs/NUTRITION_API.md
Normal file
115
docs/NUTRITION_API.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Nutrition and health score — the app contract
|
||||||
|
|
||||||
|
`GET /live/api/v1/mob/products/getproductbyvariant?tenantid=&productid=&variantid=`
|
||||||
|
|
||||||
|
Each product in `details[]` may now carry two extra keys. Both come from the
|
||||||
|
catalogue-intelligence service (`mcp.nearle.ai.in`) — the same records behind the
|
||||||
|
health score card in the console — fetched server-side, so the app needs no
|
||||||
|
second host, no second failure mode, and no copy of the rules below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## nutrition
|
||||||
|
|
||||||
|
```json
|
||||||
|
"nutrition": {
|
||||||
|
"per": "100g",
|
||||||
|
"servingsize": "1 mini (11 g)",
|
||||||
|
"items": [
|
||||||
|
{ "name": "Energy", "value": 545, "unit": "kcal" },
|
||||||
|
{ "name": "Protein", "value": 7.5, "unit": "g" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Absent when unknown.** Not `null`, not `{}`. A missing key means "we do not
|
||||||
|
know", never "this food has no nutrition".
|
||||||
|
- `items` is never empty when `nutrition` is present.
|
||||||
|
- `per` is `"100g"` for everything the service returns today. `servingsize` is
|
||||||
|
often absent — show the basis only when it is there.
|
||||||
|
- `value` may be a decimal. `unit` is free text and may be absent.
|
||||||
|
- Rows appear only when the service stated them. A null field is omitted; a
|
||||||
|
stated zero is kept, because "no fibre" is a fact and a dash is not.
|
||||||
|
|
||||||
|
## healthscore
|
||||||
|
|
||||||
|
```json
|
||||||
|
"healthscore": {
|
||||||
|
"score": 65,
|
||||||
|
"band": "good",
|
||||||
|
"label": "Healthy",
|
||||||
|
"positives": ["Good source of protein (7.5 g per 100 g)."],
|
||||||
|
"cautions": ["High in saturated fat (14.4 g per 100 g)."],
|
||||||
|
"diettags": ["High Fiber", "Vegetarian"],
|
||||||
|
"allergens": [],
|
||||||
|
"allergensunconfirmed": true,
|
||||||
|
"caveat": "Matched to a reference product with 61% confidence — treat these figures as a guide.",
|
||||||
|
"source": { "label": "openfoodfacts", "url": "https://..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Absent when there is nothing safe to show** — unscored, not food, or no
|
||||||
|
record at all. All three read as "not rated yet".
|
||||||
|
- `band` is one of `excellent` | `good` | `fair` | `poor`, for styling. `label`
|
||||||
|
is what a shopper reads. Use the label; do not re-derive it.
|
||||||
|
- `score` is 0–100, already rounded.
|
||||||
|
- `positives`, `cautions` and `diettags` are sentences the service wrote for a
|
||||||
|
person. Render as given.
|
||||||
|
|
||||||
|
### Two rules the app MUST honour
|
||||||
|
|
||||||
|
**`caveat`, when present, has to be on screen.** It means the underlying source
|
||||||
|
match was weak — most are; the service matches down to 0.32 confidence. A
|
||||||
|
nutrition table presented as fact on a 61% match is a claim the data does not
|
||||||
|
support.
|
||||||
|
|
||||||
|
**`allergensunconfirmed: true` means an empty `allergens` list must NOT be
|
||||||
|
rendered as "contains none".** Say "not confirmed — check the packet". Silence
|
||||||
|
standing in for "none" is the one failure here that can put somebody in hospital.
|
||||||
|
A declared allergen is always sent and must always be shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why the judgement is server-side
|
||||||
|
|
||||||
|
The service returns a raw number and, from this endpoint, no band. Deciding which
|
||||||
|
band, whether the match is strong enough to state plainly, and whether the
|
||||||
|
product is even food is a set of rules that already exists in the console. Two
|
||||||
|
implementations would drift and disagree about the same product on two screens.
|
||||||
|
|
||||||
|
The edibility guard is the sharpest of them. The upstream per-product endpoint is
|
||||||
|
**not** gated for it: on 4 Sep 2026 it rated Godrej Hit insecticide 80/100 with
|
||||||
|
`data_status: "verified"`, and soap and shampoo both scored 37.5. Those records
|
||||||
|
now read "unavailable", and the guard stays — this tenant sells soap and
|
||||||
|
toothpaste beside its biscuits.
|
||||||
|
|
||||||
|
## Coverage today
|
||||||
|
|
||||||
|
Measured 29 Sep 2026 against tenant 1147: **6 of 15 catalogue-linked products
|
||||||
|
have nutrition**, and fewer have a score. The Patanjali ghee this work started
|
||||||
|
from has neither.
|
||||||
|
|
||||||
|
Test with **product 7101, Balaji Wafers Simply Salted** — a full panel and a
|
||||||
|
65/100 score.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./scratch/nutritionproof # three real products
|
||||||
|
go run ./scratch/nutritionproof <brand> <image_id> # any product
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known bad data upstream
|
||||||
|
|
||||||
|
Balaji Wafers reports `sodium_mg: 0.967` — under 1 mg per 100 g, for salted
|
||||||
|
crisps, where 500–900 mg is normal. The `Salt` figure of 0.002 g is wrong the
|
||||||
|
same way. It looks like a grams/milligrams mix-up at the source.
|
||||||
|
|
||||||
|
Fiesta passes the value through as given rather than scaling it: silently
|
||||||
|
"correcting" a food label is how wrong data becomes invisible. It will look wrong
|
||||||
|
in the app until the agent team fixes the unit.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`NUTRITION_BASE=https://mcp.nearle.ai.in/api`, in `.env`. Unset means neither key
|
||||||
|
is ever sent and nothing else changes. Lookups are cached six hours, capped at
|
||||||
|
three seconds, and every failure costs that product its panel rather than the
|
||||||
|
response.
|
||||||
778
docs/PORTFOLIO.md
Normal file
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).
|
||||||
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.
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package facade
|
package facade
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"nearle/config"
|
||||||
"nearle/controllers"
|
"nearle/controllers"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -22,6 +27,18 @@ type Facade struct {
|
|||||||
PosController *controllers.PosController
|
PosController *controllers.PosController
|
||||||
LiveController *controllers.LiveController
|
LiveController *controllers.LiveController
|
||||||
CatalogueUploadController *controllers.CatalogueUploadController
|
CatalogueUploadController *controllers.CatalogueUploadController
|
||||||
|
ScanController *controllers.ScanController
|
||||||
|
DeliverySlotController *controllers.DeliverySlotController
|
||||||
|
AssistantController *controllers.AssistantController
|
||||||
|
HealthController *controllers.HealthController
|
||||||
|
MCPController *controllers.MCPController
|
||||||
|
|
||||||
|
// Tools is what Nearle Buddy is allowed to do.
|
||||||
|
//
|
||||||
|
// Held on the facade because the assistant is not a module with a
|
||||||
|
// repository of its own — it is a door onto the services already built
|
||||||
|
// here, and every tool handler calls one of them rather than the database.
|
||||||
|
Tools *tools.Registry
|
||||||
|
|
||||||
// Held so the NATS consumer can reach the ingest without going through
|
// Held so the NATS consumer can reach the ingest without going through
|
||||||
// HTTP. Unexported: everything else should use the controller.
|
// HTTP. Unexported: everything else should use the controller.
|
||||||
@@ -32,11 +49,30 @@ type Facade struct {
|
|||||||
// catalogueDB is a separate connection to the pgvector catalogue database;
|
// catalogueDB is a separate connection to the pgvector catalogue database;
|
||||||
// it may be nil if catalogue env vars are not configured, in which case
|
// it may be nil if catalogue env vars are not configured, in which case
|
||||||
// catalogue endpoints will error at query time rather than at startup.
|
// catalogue endpoints will error at query time rather than at startup.
|
||||||
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
// embedder may be nil too: scan-to-order then matches on words alone.
|
||||||
|
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig, nutritionBase string) *Facade {
|
||||||
|
|
||||||
|
// The invitation, built first because two modules need it.
|
||||||
|
//
|
||||||
|
// Every back-office account on this platform is created with NO password —
|
||||||
|
// the onboarded merchant, every person added to the directory, and the login
|
||||||
|
// each branch spawns — and since the sign-in screen stopped offering to set
|
||||||
|
// one, the emailed link is the only way in. So whichever module creates an
|
||||||
|
// account has to be able to send it.
|
||||||
|
//
|
||||||
|
// `mailer` may be nil: a deployment with no mail configured still creates
|
||||||
|
// everything, and each response says the invitation was not sent and names
|
||||||
|
// the variable, rather than failing the create.
|
||||||
|
//
|
||||||
|
// The tenant repository supplies the business name for the mail's first line
|
||||||
|
// (`services.TenantNamer`), which is why it is built here rather than in the
|
||||||
|
// tenant module below.
|
||||||
|
tenantRepo := repositories.NewTenantRepository(db)
|
||||||
|
inviteService := services.NewInviteService(mailer, mailCfg, tenantRepo)
|
||||||
|
|
||||||
// User Module
|
// User Module
|
||||||
userRepo := repositories.NewUserRepository(db)
|
userRepo := repositories.NewUserRepository(db)
|
||||||
userService := services.NewUserService(userRepo)
|
userService := services.NewUserService(userRepo, inviteService)
|
||||||
userController := controllers.NewUserController(userService)
|
userController := controllers.NewUserController(userService)
|
||||||
|
|
||||||
// Catalogue Module (separate pgvector DB — never the main `db`). Built
|
// Catalogue Module (separate pgvector DB — never the main `db`). Built
|
||||||
@@ -47,14 +83,29 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
catalogueController := controllers.NewCatalogueController(catalogueService)
|
catalogueController := controllers.NewCatalogueController(catalogueService)
|
||||||
|
|
||||||
// Product Module
|
// Product Module
|
||||||
|
//
|
||||||
|
// The nutrition service is the catalogue-intelligence host — the same one
|
||||||
|
// behind the health score card in the console — read by the product screen
|
||||||
|
// for its nutrition panel. Nil when NUTRITION_BASE is unset, which serves
|
||||||
|
// every product screen exactly as before, without a panel.
|
||||||
productRepo := repositories.NewProductRepository(db)
|
productRepo := repositories.NewProductRepository(db)
|
||||||
productService := services.NewProductService(productRepo, catalogueService)
|
productService := services.NewProductService(
|
||||||
|
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
|
||||||
productController := controllers.NewProductController(productService)
|
productController := controllers.NewProductController(productService)
|
||||||
|
|
||||||
|
// When each branch delivers.
|
||||||
|
//
|
||||||
|
// BEFORE the order controller, which takes it: order creation re-checks a
|
||||||
|
// chosen window against the same rule the app was shown, so the two cannot
|
||||||
|
// drift. No dependency the other way — this service knows nothing of orders.
|
||||||
|
deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
|
||||||
|
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
|
||||||
|
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService)
|
||||||
|
|
||||||
// Order Module
|
// Order Module
|
||||||
orderRepo := repositories.NewOrderRepository(db)
|
orderRepo := repositories.NewOrderRepository(db)
|
||||||
orderService := services.NewOrderService(orderRepo)
|
orderService := services.NewOrderService(orderRepo)
|
||||||
orderController := controllers.NewOrderController(orderService)
|
orderController := controllers.NewOrderController(orderService, deliverySlotService)
|
||||||
|
|
||||||
// Deliveries Module
|
// Deliveries Module
|
||||||
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
||||||
@@ -67,8 +118,11 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
utilsController := controllers.NewUtilsController(utilsService)
|
utilsController := controllers.NewUtilsController(utilsService)
|
||||||
|
|
||||||
//Tenant Module
|
//Tenant Module
|
||||||
tenantRepo := repositories.NewTenantRepository(db)
|
//
|
||||||
tenantService := services.NewTenantService(tenantRepo)
|
// Onboarding, adding a person and commissioning a branch all create an
|
||||||
|
// account with no password, so all three send an invitation. `tenantRepo` and
|
||||||
|
// `inviteService` are built above, where the reasoning is.
|
||||||
|
tenantService := services.NewTenantService(tenantRepo, inviteService)
|
||||||
tenantController := controllers.NewTenantController(tenantService)
|
tenantController := controllers.NewTenantController(tenantService)
|
||||||
|
|
||||||
//Partner Module
|
//Partner Module
|
||||||
@@ -109,6 +163,79 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
|
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
|
||||||
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService)
|
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService)
|
||||||
|
|
||||||
|
// Scan Module — a label from the customer's camera to "buy it here".
|
||||||
|
// Reads both databases: the catalogue to recognise the product, nearledb
|
||||||
|
// for who the customer is and what their outlets have on the shelf.
|
||||||
|
scanRepo := repositories.NewScanRepository(db, catalogueDB)
|
||||||
|
scanService := services.NewScanService(scanRepo, embedder)
|
||||||
|
scanController := controllers.NewScanController(scanService)
|
||||||
|
|
||||||
|
// The assistant registry. Built last, because every tool it holds is a thin
|
||||||
|
// wrapper over a service constructed above.
|
||||||
|
//
|
||||||
|
// A registration error panics rather than being logged. A duplicate name or
|
||||||
|
// a tool with no description is a programming mistake, and a server that
|
||||||
|
// starts with a tool silently absent answers real questions with "I cannot
|
||||||
|
// do that" for a reason nobody can see from the outside.
|
||||||
|
// The help corpus, checked before it is registered. A passage carrying one
|
||||||
|
// shop's figures stops the server rather than reaching another shop's screen.
|
||||||
|
helpCorpus, err := tools.LoadHelp()
|
||||||
|
if err != nil {
|
||||||
|
panic("assistant help: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit trail goes to the database and to the log. See
|
||||||
|
// services/assistantAudit.go for why both.
|
||||||
|
auditRepo := repositories.NewAssistantAuditRepository(db)
|
||||||
|
toolRegistry := tools.New(services.NewDBAudit(auditRepo))
|
||||||
|
for _, tool := range []tools.Tool{
|
||||||
|
tools.StuckOrders(deliveriesService, nil),
|
||||||
|
tools.DeliveryProgress(deliveriesService),
|
||||||
|
tools.BranchPerformance(orderService),
|
||||||
|
tools.PendingApprovals(stockRequestService, nil),
|
||||||
|
tools.LowStock(productService),
|
||||||
|
tools.TillsNotSyncing(posService),
|
||||||
|
tools.SalesByChannel(orderService, posService, nil),
|
||||||
|
tools.Help(helpCorpus),
|
||||||
|
tools.ApproveStockRequest(stockRequestService, stockRequestService),
|
||||||
|
} {
|
||||||
|
if err := toolRegistry.Register(tool); err != nil {
|
||||||
|
panic("assistant tools: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearle Buddy. `chat` may be nil — a deployment with no model configured
|
||||||
|
// still gets the registry and the endpoint, and the endpoint answers "not
|
||||||
|
// switched on here" rather than a 500. The tools themselves are ordinary
|
||||||
|
// Go functions and work either way; only turning a sentence into a tool
|
||||||
|
// call needs a model.
|
||||||
|
// Agent definitions, validated against the registry above. A typo in a tool
|
||||||
|
// name stops the server rather than producing an agent that quietly cannot
|
||||||
|
// do one of the things it claims — which is invisible at runtime, because the
|
||||||
|
// model simply reports it could not look something up.
|
||||||
|
agents, err := services.LoadAgents(agentsDir, toolRegistry.Has)
|
||||||
|
if err != nil {
|
||||||
|
panic("assistant agents: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("assistant: %d agents loaded %v", len(agents), services.AgentNames(agents))
|
||||||
|
|
||||||
|
assistantService := services.NewAssistantService(toolRegistry, chat, agents)
|
||||||
|
// Why there is no model, if there is not. Passed through so /assistant/status
|
||||||
|
// can name the missing variable instead of just saying no.
|
||||||
|
if setter, ok := assistantService.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
|
||||||
|
setter.SetUnavailableReason(assistantWhy)
|
||||||
|
}
|
||||||
|
assistantController := controllers.NewAssistantController(assistantService)
|
||||||
|
|
||||||
|
// What is running here. Unauthenticated, booleans only — see healthController.go
|
||||||
|
// for why a server that cannot say which build it is costs a day.
|
||||||
|
healthController := controllers.NewHealthController(assistantService, db != nil)
|
||||||
|
|
||||||
|
// The second door. Same registry, same agents, same session — see
|
||||||
|
// controllers/mcpController.go for why it is a door rather than a service.
|
||||||
|
mcpController := controllers.NewMCPController(toolRegistry, agents)
|
||||||
|
|
||||||
return &Facade{
|
return &Facade{
|
||||||
UserController: userController,
|
UserController: userController,
|
||||||
ProductController: productController,
|
ProductController: productController,
|
||||||
@@ -123,6 +250,12 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
PosController: posController,
|
PosController: posController,
|
||||||
LiveController: liveController,
|
LiveController: liveController,
|
||||||
CatalogueUploadController: catalogueUploadController,
|
CatalogueUploadController: catalogueUploadController,
|
||||||
|
ScanController: scanController,
|
||||||
|
DeliverySlotController: deliverySlotController,
|
||||||
|
AssistantController: assistantController,
|
||||||
|
HealthController: healthController,
|
||||||
|
MCPController: mcpController,
|
||||||
|
Tools: toolRegistry,
|
||||||
posService: posService,
|
posService: posService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
go.mod
24
go.mod
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/gofiber/fiber v1.14.6
|
github.com/gofiber/fiber v1.14.6
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/redis/go-redis/v9 v9.18.0
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
|
github.com/valyala/fasthttp v1.50.0
|
||||||
golang.org/x/oauth2 v0.12.0
|
golang.org/x/oauth2 v0.12.0
|
||||||
google.golang.org/api v0.143.0
|
google.golang.org/api v0.143.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
@@ -42,8 +43,8 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||||
github.com/aws/smithy-go v1.27.3 // indirect
|
github.com/aws/smithy-go v1.27.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
|
||||||
github.com/gofiber/utils v0.0.10 // indirect
|
github.com/gofiber/utils v0.0.10 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // indirect
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
@@ -54,7 +55,6 @@ require (
|
|||||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||||
github.com/gorilla/schema v1.1.0 // indirect
|
github.com/gorilla/schema v1.1.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
@@ -62,28 +62,17 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/compress v1.19.0 // indirect
|
github.com/klauspost/compress v1.19.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // indirect
|
github.com/rivo/uniseg v0.4.4 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
github.com/stretchr/testify v1.8.4 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.3.0 // indirect
|
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
|
||||||
github.com/spf13/afero v1.10.0 // indirect
|
|
||||||
github.com/spf13/cast v1.5.1 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.50.0 // indirect
|
|
||||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
golang.org/x/time v0.3.0 // indirect
|
golang.org/x/time v0.3.0 // indirect
|
||||||
@@ -94,15 +83,12 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect
|
||||||
google.golang.org/grpc v1.58.2 // indirect
|
google.golang.org/grpc v1.58.2 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gofiber/fiber/v2 v2.50.0
|
github.com/gofiber/fiber/v2 v2.50.0
|
||||||
github.com/jinzhu/copier v0.4.0
|
github.com/jinzhu/copier v0.4.0
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
|
||||||
github.com/spf13/viper v1.17.0
|
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
389
go.sum
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.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
|
||||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
|
||||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
|
||||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
|
||||||
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
|
||||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
|
||||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
|
||||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
|
||||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
|
||||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
|
||||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
|
||||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
|
||||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
|
||||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
|
||||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
|
||||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
|
||||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
|
||||||
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
|
||||||
cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
|
cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
|
||||||
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
|
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
|
||||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
|
||||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
|
||||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
|
||||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
|
||||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
|
||||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
|
||||||
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
|
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
|
||||||
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
|
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
|
||||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
|
||||||
cloud.google.com/go/firestore v1.13.0 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk=
|
cloud.google.com/go/firestore v1.13.0 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk=
|
||||||
cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8=
|
cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8=
|
||||||
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
|
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
|
||||||
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
|
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
|
||||||
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
|
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
|
||||||
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
|
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
|
||||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
|
||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
|
||||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
|
||||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
|
||||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
|
||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
|
||||||
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
|
||||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
|
||||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
|
||||||
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
@@ -100,13 +62,8 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
|
|||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
@@ -118,16 +75,7 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
|
|
||||||
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
|
||||||
github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
|
github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
|
||||||
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
|
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
|
||||||
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
|
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
|
||||||
@@ -135,67 +83,34 @@ github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmh
|
|||||||
github.com/gofiber/utils v0.0.10 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U=
|
github.com/gofiber/utils v0.0.10 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U=
|
||||||
github.com/gofiber/utils v0.0.10/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo=
|
github.com/gofiber/utils v0.0.10/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
|
||||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
|
||||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
|
||||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
|
||||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
|
||||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
|
||||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
|
||||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
|
||||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -203,21 +118,12 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
|||||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
|
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
|
||||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
|
||||||
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -234,24 +140,11 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
|
||||||
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
|
||||||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
@@ -261,12 +154,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -276,37 +163,16 @@ github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQ
|
|||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
|
||||||
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
|
||||||
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
|
||||||
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
|
|
||||||
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
|
|
||||||
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
|
|
||||||
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
|
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
|
|
||||||
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
|
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
|
||||||
@@ -315,302 +181,74 @@ github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE
|
|||||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
|
||||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
|
||||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
|
||||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
|
||||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
|
||||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
|
||||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
|
||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
|
||||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
|
||||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
|
||||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
|
golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
|
||||||
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
|
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
|
||||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
|
||||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
|
||||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
|
||||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
|
||||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
|
||||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
|
||||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
|
||||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
|
||||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
|
||||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
|
||||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
|
||||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
|
||||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
|
||||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
|
||||||
google.golang.org/api v0.143.0 h1:o8cekTkqhywkbZT6p1UHJPZ9+9uuCAJs/KYomxZB8fA=
|
google.golang.org/api v0.143.0 h1:o8cekTkqhywkbZT6p1UHJPZ9+9uuCAJs/KYomxZB8fA=
|
||||||
google.golang.org/api v0.143.0/go.mod h1:FoX9DO9hT7DLNn97OuoZAGSDuNAXdJRuGK98rSUgurk=
|
google.golang.org/api v0.143.0/go.mod h1:FoX9DO9hT7DLNn97OuoZAGSDuNAXdJRuGK98rSUgurk=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
|
||||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
|
||||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
|
||||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
|
||||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA=
|
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA=
|
||||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
|
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI=
|
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI=
|
||||||
@@ -618,21 +256,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
|
||||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
|
||||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
|
||||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
|
||||||
google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I=
|
google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I=
|
||||||
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
|
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
@@ -643,20 +270,12 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
|||||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -665,12 +284,4 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
|
|||||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
|
||||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
|
||||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
|
||||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
|
||||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
|
||||||
|
|||||||
@@ -39,11 +39,41 @@ inside a git repository. `.gitignore` excludes `*.sql` here for that reason.
|
|||||||
|
|
||||||
## Getting something to test against
|
## Getting something to test against
|
||||||
|
|
||||||
An empty schema boots but has no tenants, so there is nothing to sign in as.
|
`nearledb/02-seed.sql` is committed and applied automatically, so a fresh
|
||||||
Two options:
|
volume already has a merchant to sign into. It invents one rather than copying
|
||||||
|
one, which is why it can live here at all.
|
||||||
|
|
||||||
- **Onboard a tenant through the console** once it is pointed at localhost.
|
| Account | Password | Opens |
|
||||||
That exercises the real path and is usually what you want.
|
|---|---|---|
|
||||||
- **Copy a few rows** you actually need — a tenant, its locations, its
|
| `super@nearle.invalid` | `localdev` | Nearle Admin — the platform workspace |
|
||||||
app_users — with `pg_dump --data-only --table=...`. Check what you are
|
| `admin@testmart.invalid` | `localdev` | Store Admin — all of Testmart's branches |
|
||||||
copying: `app_users.password` is stored in clear.
|
| `main@testmart.invalid` | `localdev` | Store user — Testmart Main only |
|
||||||
|
|
||||||
|
It also seeds the role ladder, three aisles under category 2, and a second
|
||||||
|
merchant (`Halfmart`) deliberately left in the broken `categoryid = 0` shape as
|
||||||
|
a permanent regression fixture. The sequences are moved past the seeded ids at
|
||||||
|
the end, so the first row you create locally does not come back as id 1.
|
||||||
|
|
||||||
|
If you need something it does not cover:
|
||||||
|
|
||||||
|
- **Onboard a tenant through the console.** That exercises the real path and is
|
||||||
|
usually what you want.
|
||||||
|
- **Copy a few rows** you actually need with `pg_dump --data-only --table=...`.
|
||||||
|
Check what you are copying: `app_users.password` is stored in clear.
|
||||||
|
|
||||||
|
## The catalogue database
|
||||||
|
|
||||||
|
`cataloguedb/02-seed.sql` is committed too, and also entirely invented. The
|
||||||
|
real catalogue is another team's scrape of real retailers and a dump of it does
|
||||||
|
not belong on a laptop.
|
||||||
|
|
||||||
|
Without it the catalogue database exists but holds no catalogue: every
|
||||||
|
`brand_*` table is missing, `getbrands` answers 500, and the global catalogue
|
||||||
|
screen, the import flow and `importcatalogueproduct` cannot be exercised at
|
||||||
|
all. The seed gives you two brands:
|
||||||
|
|
||||||
|
- `brand_testbrand` — every column the reader knows about, four products, one
|
||||||
|
of them deliberately with no images.
|
||||||
|
- `brand_sparsebrand` — only `id`, `product_name` and a price, to keep the
|
||||||
|
degraded-but-still-listed path covered. Brands are discovered by table name,
|
||||||
|
so adding another is just another `brand_*` table.
|
||||||
|
|||||||
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');
|
||||||
@@ -161,4 +161,72 @@ INSERT INTO productstocks (
|
|||||||
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
|
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
|
||||||
ON CONFLICT (productstockid) DO NOTHING;
|
ON CONFLICT (productstockid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── The platform operator ───────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Without this there is nobody who can open the Nearle Admin workspace, which
|
||||||
|
-- is the one this console was built for first. `resolveRole` checks
|
||||||
|
-- `issuperadmin` BEFORE roleid — deliberately, because the flag is derived by
|
||||||
|
-- the server and a roleid is just a number in a row — so no amount of role 1
|
||||||
|
-- gets you in without it, and every local session landed in Store Admin
|
||||||
|
-- instead. The accounts above are one per role and this was the role they were
|
||||||
|
-- missing.
|
||||||
|
--
|
||||||
|
-- Not attached to either merchant in spirit, only in columns: a platform
|
||||||
|
-- operator has to carry a tenantid because the column is not nullable, and
|
||||||
|
-- nothing in the admin workspace reads it.
|
||||||
|
INSERT INTO app_users (
|
||||||
|
userid, authname, firstname, lastname, email, dialcode, contactno,
|
||||||
|
configid, roleid, password, tenantid, locationid, applocationid,
|
||||||
|
status, issuperadmin
|
||||||
|
) VALUES
|
||||||
|
(9299, 'super@nearle.invalid', 'Nearle', 'Operator', 'super@nearle.invalid',
|
||||||
|
'+91', '9000009999', 1, 1, 'localdev', 9001, 9101, 9001, 'Active', true)
|
||||||
|
ON CONFLICT (userid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── The role ladder ─────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `getstaffs` LEFT JOINs app_roles for `rolename`, so an empty table is not an
|
||||||
|
-- error — every person on Users & access simply reads "—" where their role
|
||||||
|
-- should be. The ids are the ones the rest of the system already assumes:
|
||||||
|
-- 1 and 3 reach Store Admin, 4 is a branch manager, 7 and 8 are till accounts
|
||||||
|
-- and are excluded from every back-office query by the backend itself.
|
||||||
|
INSERT INTO app_roles (roleid, rolename, configid) VALUES
|
||||||
|
(1, 'Super admin', 1),
|
||||||
|
(3, 'Admin', 1),
|
||||||
|
(4, 'Manager', 1),
|
||||||
|
(7, 'Supervisor', 1),
|
||||||
|
(8, 'Cashier', 1)
|
||||||
|
ON CONFLICT (roleid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Aisles under the category the customer app browses ──────────────────────
|
||||||
|
--
|
||||||
|
-- categoryid 2 is the only category the app lists, and the aisle a shopper
|
||||||
|
-- reads is the SUBCATEGORY. With none of these the sheet importer has nothing
|
||||||
|
-- to resolve a row's category against, so every imported product falls back to
|
||||||
|
-- subcategoryid 0 and lands under "Uncategorized".
|
||||||
|
INSERT INTO productsubcategories (subcatid, categoryid, tenantid, subcatname, status, sortorder)
|
||||||
|
VALUES
|
||||||
|
(9601, 2, 9001, 'Rice & Grains', 'Active', 1),
|
||||||
|
(9602, 2, 9001, 'Oils & Ghee', 'Active', 2),
|
||||||
|
(9603, 2, 9001, 'Snacks', 'Active', 3)
|
||||||
|
ON CONFLICT (subcatid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Move the sequences past the seeded ids ──────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Everything above inserts an explicit id, which does NOT advance the sequence
|
||||||
|
-- behind that column. So the first tenant, outlet or product created against a
|
||||||
|
-- fresh local database came back as id 1 — harmless here, but it means local
|
||||||
|
-- ids look nothing like the ones the same code produces in production, and a
|
||||||
|
-- seed that ever collides with a sequence value fails on a duplicate key.
|
||||||
|
--
|
||||||
|
-- `GREATEST(..., 1)` because setval refuses a value below the sequence minimum,
|
||||||
|
-- and a table the seed does not touch is legitimately empty.
|
||||||
|
SELECT setval('tenants_tenantid_seq', GREATEST((SELECT COALESCE(MAX(tenantid),0) FROM tenants), 1));
|
||||||
|
SELECT setval('tenantlocations_locationid_seq', GREATEST((SELECT COALESCE(MAX(locationid),0) FROM tenantlocations), 1));
|
||||||
|
SELECT setval('app_users_userid_seq', GREATEST((SELECT COALESCE(MAX(userid),0) FROM app_users), 1));
|
||||||
|
SELECT setval('products_productid_seq', GREATEST((SELECT COALESCE(MAX(productid),0) FROM products), 1));
|
||||||
|
SELECT setval('productlocations_productlocationid_seq', GREATEST((SELECT COALESCE(MAX(productlocationid),0) FROM productlocations), 1));
|
||||||
|
SELECT setval('productstocks_productstockid_seq', GREATEST((SELECT COALESCE(MAX(productstockid),0) FROM productstocks), 1));
|
||||||
|
SELECT setval('customers_customerid_seq', GREATEST((SELECT COALESCE(MAX(customerid),0) FROM customers), 1));
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
341
main.go
341
main.go
@@ -1,14 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"nearle/config"
|
||||||
"nearle/db"
|
"nearle/db"
|
||||||
"nearle/facade"
|
"nearle/facade"
|
||||||
"nearle/messaging"
|
"nearle/messaging"
|
||||||
|
"nearle/middleware"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"nearle/routes"
|
"nearle/routes"
|
||||||
|
"nearle/utils"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,33 +22,77 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
// corsSettings is a function so it can be tested.
|
||||||
godotenv.Load()
|
//
|
||||||
|
// Inline, it could only be checked by starting the server and pointing a real
|
||||||
|
// browser at it — which is how the missing Authorization header reached
|
||||||
|
// production in the first place.
|
||||||
|
func corsSettings() cors.Config {
|
||||||
|
return cors.Config{
|
||||||
|
AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Authorization",
|
||||||
|
AllowOrigins: "*",
|
||||||
|
AllowCredentials: false,
|
||||||
|
AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Loads `.env.<APP_ENV>` (default `.env.local`) and `.env`, then checks
|
||||||
|
// every required setting at once. Nothing below runs against a half
|
||||||
|
// configured environment — see config/config.go for the precedence rules.
|
||||||
|
cfg := config.MustLoad()
|
||||||
|
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
|
|
||||||
app.Use(cors.New(cors.Config{
|
// Cross-origin access.
|
||||||
AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Access-Control-Allow-Origin",
|
//
|
||||||
AllowOrigins: "*",
|
// The console is served from app.nearledaily.com and calls this host
|
||||||
AllowCredentials: true,
|
// directly, so every request it makes is cross-origin and the browser
|
||||||
AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS",
|
// decides whether to allow it from the headers below.
|
||||||
}))
|
//
|
||||||
|
// ── Authorization has to be listed ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It was not, and adding the session token to the console broke every call
|
||||||
|
// the moment it shipped. A request carrying `Authorization` is no longer a
|
||||||
|
// "simple" request, so the browser stops and asks permission first — and the
|
||||||
|
// answer has to name that header explicitly. It was never needed before
|
||||||
|
// because the console sent nothing but `Accept` and `Content-Type`.
|
||||||
|
//
|
||||||
|
// The failure is worth recognising again: the preflight returns 204 and
|
||||||
|
// looks healthy in a terminal, the server logs nothing, and only the browser
|
||||||
|
// refuses. `curl` cannot reproduce it, because curl does not enforce CORS.
|
||||||
|
//
|
||||||
|
// ── Credentials off, wildcard on ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `AllowOrigins: "*"` with `AllowCredentials: true` is not a valid pair: a
|
||||||
|
// browser rejects a credentialed response that carries a wildcard origin.
|
||||||
|
// That combination was here already and was harmless only because nothing
|
||||||
|
// used credentials — it would have become a second, identical-looking bug
|
||||||
|
// the day anything did.
|
||||||
|
//
|
||||||
|
// Credentials means cookies and TLS client certs, and this backend uses
|
||||||
|
// neither: authentication is a Bearer token, which is an ordinary header and
|
||||||
|
// needs no credentialed mode. Nothing in the console, the app or the POS
|
||||||
|
// sets `credentials: 'include'`, so turning it off costs nothing and makes
|
||||||
|
// the pair legal.
|
||||||
|
//
|
||||||
|
// The wildcard itself is worth revisiting — it lets any site on the internet
|
||||||
|
// call this API from a browser, and the tenant guard is what stops that
|
||||||
|
// mattering. Narrowing it to the known console origins is a separate change,
|
||||||
|
// and one that breaks local development if the list is got wrong.
|
||||||
|
app.Use(cors.New(corsSettings()))
|
||||||
|
|
||||||
fmt.Println("🌐 Connecting to databases...")
|
fmt.Println("🌐 Connecting to databases...")
|
||||||
db.Connect()
|
db.Connect(cfg)
|
||||||
fmt.Println("✅ Database connections established!")
|
fmt.Println("✅ Database connections established!")
|
||||||
|
|
||||||
// Shared with the express backend. POS terminal presence lives here under a
|
// Shared with the express backend. POS terminal presence lives here under a
|
||||||
// TTL; optional, because losing the health board is an inconvenience and
|
// TTL; optional, because losing the health board is an inconvenience and
|
||||||
// losing a sale is not.
|
// losing a sale is not.
|
||||||
db.InitRedis()
|
db.InitRedis(cfg.Redis)
|
||||||
|
|
||||||
// Ensure schema is updated
|
// Ensure schema is updated
|
||||||
db.DB.AutoMigrate(&models.StockRequest{})
|
db.DB.AutoMigrate(&models.StockRequest{})
|
||||||
@@ -56,6 +104,24 @@ func main() {
|
|||||||
log.Fatal("POS schema migration failed:", err)
|
log.Fatal("POS schema migration failed:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What Nearle Buddy did, and on whose behalf. Its own table: these rows are
|
||||||
|
// written on a different schedule from anything else and are the only record
|
||||||
|
// of an assistant acting for a merchant.
|
||||||
|
//
|
||||||
|
// Logged and carried on rather than fatal, unlike the migrations around it,
|
||||||
|
// and the difference is deliberate. Those create tables the product cannot
|
||||||
|
// trade without — a POS order has nowhere to land if its table is missing.
|
||||||
|
// This one serves an assistant that may not even be switched on, and taking
|
||||||
|
// the whole backend down over it would stop every shop taking orders to
|
||||||
|
// protect a log.
|
||||||
|
//
|
||||||
|
// The degradation is already built: `DBAudit` writes to the log as well as
|
||||||
|
// the table, and reports each failed insert as AUDIT ROW LOST. So a missing
|
||||||
|
// table costs the queryable trail and nothing else, loudly.
|
||||||
|
if err := db.DB.AutoMigrate(&models.AssistantAudit{}); err != nil {
|
||||||
|
log.Printf("assistant: audit table unavailable, the trail is log-only: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Shift windows for till staff. Additive — `app_users.shiftid` already
|
// Shift windows for till staff. Additive — `app_users.shiftid` already
|
||||||
// existed and pointed at the rider table, so an account with no shift is
|
// existed and pointed at the rider table, so an account with no shift is
|
||||||
// simply unassigned rather than broken.
|
// simply unassigned rather than broken.
|
||||||
@@ -78,6 +144,113 @@ func main() {
|
|||||||
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the global catalogue knew about this product, kept.
|
||||||
|
//
|
||||||
|
// The import copies eight of the catalogue's eighteen fields onto the
|
||||||
|
// tenant's product and left the other ten behind — among them the FSSAI
|
||||||
|
// licence, the nutrition lines, the highlights, the provider list, the
|
||||||
|
// price range and the variant key. The console needs exactly those to
|
||||||
|
// decide what to charge, so `ProductDrawer` went back to the catalogue for
|
||||||
|
// them on every open.
|
||||||
|
//
|
||||||
|
// That lookup is not a substitute for storing them. A tenant's product is a
|
||||||
|
// SNAPSHOT and outlives its source row: the catalogue is re-scraped, a
|
||||||
|
// variant is retired, and the licence number and the nutrition panel for a
|
||||||
|
// product the shop is still selling are gone with no way back. Measured
|
||||||
|
// locally by retiring one row — the product survived, everything the drawer
|
||||||
|
// shows about it did not.
|
||||||
|
//
|
||||||
|
// One jsonb column rather than six typed ones, and rather than the
|
||||||
|
// `productspecs` table that has sat unused since the schema was written.
|
||||||
|
// The value is a snapshot of somebody else's record, read as a whole and
|
||||||
|
// displayed as a whole — it is never joined, aggregated or filtered — and
|
||||||
|
// the catalogue grows fields faster than this side can add migrations.
|
||||||
|
// Postgres can still reach inside it (`cataloguefacts->>'fssai_license'`)
|
||||||
|
// on the day somebody needs to. `productimages` beside it made the same
|
||||||
|
// call for the same reason.
|
||||||
|
//
|
||||||
|
// Not fatal on failure, exactly like the column above: a product without
|
||||||
|
// its catalogue facts is the product we have today.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS cataloguefacts jsonb`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether this shop shows a health score for this product.
|
||||||
|
//
|
||||||
|
// The shopkeeper's call, not ours. The score comes from a third party that
|
||||||
|
// matches a reference product by name — often at under 60% confidence — so a
|
||||||
|
// merchant who knows the packet in front of them may reasonably decide the
|
||||||
|
// rating does not describe what they are selling, and should be able to take
|
||||||
|
// it off their own shelf without taking it off everybody's.
|
||||||
|
//
|
||||||
|
// DEFAULT TRUE, so every product already imported keeps showing exactly what
|
||||||
|
// it shows today. A new column defaulting to false would silently strip the
|
||||||
|
// health score from every shelf on the platform, which is a change nobody
|
||||||
|
// asked for dressed up as a migration.
|
||||||
|
//
|
||||||
|
// Only the score. `nutrition` is unaffected and always sent: the figures are
|
||||||
|
// what the packet says, while the score is somebody's judgement of them.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS showhealthscore boolean NOT NULL DEFAULT true`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a shop delivers, and which window an order chose.
|
||||||
|
//
|
||||||
|
// Three named windows a day per BRANCH — see models/deliveryslot.go for why
|
||||||
|
// the scope is the branch and not the company.
|
||||||
|
//
|
||||||
|
// ── A branch with no rows here still trades ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Every tenant on the platform the day this ships has no slots, and all of
|
||||||
|
// them must keep taking orders exactly as before. No backfill, no defaults
|
||||||
|
// written here: absence means "order without a slot", and the app is
|
||||||
|
// required to treat an empty list as ordinary rather than as a closed shop.
|
||||||
|
// Seeding every existing branch with invented timings would have each one
|
||||||
|
// promising hours nobody agreed to.
|
||||||
|
if err := db.DB.Exec(`CREATE TABLE IF NOT EXISTS deliveryslots (
|
||||||
|
slotid SERIAL PRIMARY KEY,
|
||||||
|
tenantid INTEGER NOT NULL,
|
||||||
|
locationid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
slotkey TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
starttime TEXT NOT NULL,
|
||||||
|
endtime TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not create deliveryslots, delivery windows will be unavailable:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per key per branch. A shop has ONE morning, and a duplicate would
|
||||||
|
// show the shopper the same window twice with different hours — so the
|
||||||
|
// upsert that writes these leans on this constraint rather than on a
|
||||||
|
// read-then-write that two requests could interleave.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS deliveryslots_branch_key
|
||||||
|
ON deliveryslots (tenantid, locationid, slotkey)`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add the deliveryslots uniqueness index, a branch may end up with duplicate windows:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window an order chose, and the day it falls on.
|
||||||
|
//
|
||||||
|
// Both nullable, and both stay empty for every order placed without a slot —
|
||||||
|
// which is every order today and every order from a branch that never sets
|
||||||
|
// timings. Nothing downstream may require them.
|
||||||
|
//
|
||||||
|
// The DATE is not redundant. "evening" cannot say tonight or tomorrow night,
|
||||||
|
// and an order placed after the last window closes is for the next day.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotid INTEGER`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add orders.deliveryslotid, orders will not record a delivery window:", err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotdate DATE`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// When a product became visible to a store, and the only thing that decides
|
// When a product became visible to a store, and the only thing that decides
|
||||||
// whether it is.
|
// whether it is.
|
||||||
//
|
//
|
||||||
@@ -172,6 +345,60 @@ func main() {
|
|||||||
log.Println("productvariants.variantid given a key generator (one time)")
|
log.Println("productvariants.variantid given a key generator (one time)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key generators for the two partner tables, for exactly the reason above.
|
||||||
|
//
|
||||||
|
// `partnerinfo.partnerid` and `partnerlocations.partnerlocationid` are both
|
||||||
|
// NOT NULL with no default and no identity, so GORM — which sends nothing
|
||||||
|
// for a key it expects the database to mint — had every insert refused with
|
||||||
|
// a not-null violation. `createpartner` therefore could not write a partner
|
||||||
|
// OR its regions: the endpoint exists, the form exists, and the row could
|
||||||
|
// never land. The five partners on the platform were all inserted by hand,
|
||||||
|
// which is the symptom rather than a choice.
|
||||||
|
//
|
||||||
|
// This matters more than one broken button. `GetPartners` now separates the
|
||||||
|
// partners registered through this console from the ones another product
|
||||||
|
// left in the shared `partnerinfo` by joining `partnerlocations` — and only
|
||||||
|
// a successful create writes that table. Without a key generator no partner
|
||||||
|
// can ever be registered, so nothing would ever have a link row and the
|
||||||
|
// Rider partners page would be empty forever.
|
||||||
|
//
|
||||||
|
// Both sequences start above the ids already there, so the hand-inserted
|
||||||
|
// rows keep theirs.
|
||||||
|
for _, key := range []struct{ table, column string }{
|
||||||
|
{"partnerinfo", "partnerid"},
|
||||||
|
{"partnerlocations", "partnerlocationid"},
|
||||||
|
} {
|
||||||
|
var keyed int64
|
||||||
|
if err := db.DB.Raw(`
|
||||||
|
SELECT COUNT(1) FROM information_schema.columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
AND (column_default IS NOT NULL OR is_identity = 'YES')`,
|
||||||
|
key.table, key.column).Scan(&keyed).Error; err != nil {
|
||||||
|
log.Fatalf("could not check %s.%s: %v", key.table, key.column, err)
|
||||||
|
}
|
||||||
|
if keyed > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := key.table + "_" + key.column + "_seq"
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS %s START WITH 1 OWNED BY %s.%s`,
|
||||||
|
seq, key.table, key.column)).Error; err != nil {
|
||||||
|
log.Fatalf("could not create %s: %v", seq, err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`SELECT setval('%s', COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false)`,
|
||||||
|
seq, key.column, key.table)).Error; err != nil {
|
||||||
|
log.Fatalf("could not position %s: %v", seq, err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')`,
|
||||||
|
key.table, key.column, seq)).Error; err != nil {
|
||||||
|
log.Fatalf("could not default %s.%s: %v", key.table, key.column, err)
|
||||||
|
}
|
||||||
|
log.Printf("%s.%s given a key generator (one time)", key.table, key.column)
|
||||||
|
}
|
||||||
|
|
||||||
// The catalogue's own stable key for an imported product.
|
// The catalogue's own stable key for an imported product.
|
||||||
//
|
//
|
||||||
// `catalogueid` was never able to be this. The catalogue is rebuilt by
|
// `catalogueid` was never able to be this. The catalogue is rebuilt by
|
||||||
@@ -263,7 +490,74 @@ func main() {
|
|||||||
log.Fatal("could not add catalogueuploads.sheetrows:", err)
|
log.Fatal("could not add catalogueuploads.sheetrows:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
// The model behind scan-to-order. Optional: without EMBEDDING_PROVIDER the
|
||||||
|
// search matches on words, which works but ranks less well.
|
||||||
|
embedder, err := utils.NewEmbedder(cfg.Embedding)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("embedding provider:", err)
|
||||||
|
}
|
||||||
|
if embedder == nil {
|
||||||
|
log.Println("scan: EMBEDDING_PROVIDER not set, product search is text-only")
|
||||||
|
} else {
|
||||||
|
log.Printf("scan: product search uses %s/%s", cfg.Embedding.Provider, cfg.Embedding.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model behind Nearle Buddy. Optional in the same way: without
|
||||||
|
// ASSISTANT_PROVIDER the tools still work and the panel says the assistant
|
||||||
|
// is not switched on, rather than the console showing a field that accepts
|
||||||
|
// text and swallows it.
|
||||||
|
chat, err := utils.NewChat(cfg.Assistant)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("assistant provider:", err)
|
||||||
|
}
|
||||||
|
if chat == nil {
|
||||||
|
log.Printf("assistant: OFF — %s", cfg.Assistant.Why())
|
||||||
|
} else {
|
||||||
|
log.Printf("assistant: %s, balanced tier is %s", cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASSISTANT_AGENTS_DIR replaces the compiled-in agent definitions wholesale.
|
||||||
|
// Empty uses the embedded ones, so a deployment cannot be broken by a missing
|
||||||
|
// directory.
|
||||||
|
// Mail, for the invitation a newly onboarded merchant is sent.
|
||||||
|
//
|
||||||
|
// Optional in the same way as the model and the embedder: without it the
|
||||||
|
// server still boots and still onboards tenants, and the create response
|
||||||
|
// says the invitation was not sent and which variable is missing. Refusing
|
||||||
|
// to start would make a mail relay a hard dependency of creating a shop,
|
||||||
|
// which it is not.
|
||||||
|
mailer, err := utils.NewMailer(cfg.Mail)
|
||||||
|
if err != nil {
|
||||||
|
// A configured-but-invalid sender, as opposed to no mail at all. That
|
||||||
|
// fails every message, so it is worth stopping for rather than
|
||||||
|
// discovering one silent invitation at a time.
|
||||||
|
log.Fatal("mail:", err)
|
||||||
|
}
|
||||||
|
if mailer == nil {
|
||||||
|
log.Printf("mail: OFF — %s", cfg.Mail.Why())
|
||||||
|
} else {
|
||||||
|
log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NUTRITION_BASE is the catalogue-intelligence host — the same service the
|
||||||
|
// console reads its health score card from. Unset means product screens
|
||||||
|
// carry no nutrition panel, and nothing else changes.
|
||||||
|
//
|
||||||
|
// Logged for the same reason mail is, and learned the same way: with it
|
||||||
|
// unset, `getproductbyvariant` simply omits `nutrition` and `healthscore`,
|
||||||
|
// which is indistinguishable from a product the service has not scored.
|
||||||
|
// A deploy that silently does nothing is one somebody has to reverse
|
||||||
|
// engineer from the outside, and this line is the difference.
|
||||||
|
nutritionBase := strings.TrimSpace(os.Getenv("NUTRITION_BASE"))
|
||||||
|
if nutritionBase == "" {
|
||||||
|
log.Printf("nutrition: OFF — NUTRITION_BASE is not set, so no product carries a nutrition panel or health score")
|
||||||
|
} else {
|
||||||
|
log.Printf("nutrition: reading panels and scores from %s", nutritionBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat,
|
||||||
|
os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail,
|
||||||
|
nutritionBase)
|
||||||
|
|
||||||
routes.RegisterRoutes(app, f)
|
routes.RegisterRoutes(app, f)
|
||||||
|
|
||||||
@@ -293,17 +587,20 @@ func main() {
|
|||||||
repositories.SetCatalogueNotifier(posMqtt)
|
repositories.SetCatalogueNotifier(posMqtt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start server
|
// How much of the till fleet is carrying a session token, in the log every
|
||||||
|
// half hour.
|
||||||
//
|
//
|
||||||
// The port comes from APP_PORT, defaulting to the 1122 this has always
|
// `POS_AUTH_REQUIRED` is off, and the only thing between here and switching
|
||||||
// served on. It was hardcoded, which left `config.Load()`'s Port field
|
// it on is that number — nothing was recording it, so an untokened till was
|
||||||
// dead and the Dockerfile's `EXPOSE 1009` describing a port nothing
|
// waved through in silence and the risk of flipping the flag could only be
|
||||||
// listened on — and made running a second copy locally, on a free port,
|
// measured by flipping it. The same figures are on
|
||||||
// impossible without editing this line.
|
// `GET /v1/web/pos/authadoption`, behind the session guard.
|
||||||
port := os.Getenv("APP_PORT")
|
go middleware.LogPosAdoption(context.Background())
|
||||||
if port == "" {
|
|
||||||
port = "1122"
|
// Start server on APP_PORT (1122 locally, 1009 in production — see the
|
||||||
}
|
// env files). Running a second copy beside something else is a one-line
|
||||||
|
// change there rather than here.
|
||||||
|
port := cfg.Port
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("🚀 listening on :%s", port)
|
log.Printf("🚀 listening on :%s", port)
|
||||||
if err := app.Listen(":" + port); err != nil {
|
if err := app.Listen(":" + port); err != nil {
|
||||||
|
|||||||
113
main_test.go
Normal file
113
main_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cross-origin access, checked the way a browser checks it.
|
||||||
|
//
|
||||||
|
// These exist because this went wrong in production and nothing caught it.
|
||||||
|
// Adding the session token to the console made every request non-simple, so
|
||||||
|
// browsers began asking permission first — and the answer did not name the
|
||||||
|
// `Authorization` header, so every call was blocked.
|
||||||
|
//
|
||||||
|
// The reason it reached production is worth keeping in mind while reading
|
||||||
|
// these: the preflight returns 204 and looks perfectly healthy from a terminal,
|
||||||
|
// the server logs nothing unusual, and `curl` cannot reproduce it because curl
|
||||||
|
// does not enforce CORS. The only thing that noticed was a browser.
|
||||||
|
|
||||||
|
// preflight asks the question a browser asks before a cross-origin request.
|
||||||
|
func preflight(t *testing.T, requestHeaders string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use(cors.New(corsSettings()))
|
||||||
|
app.Get("/probe", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest("OPTIONS", "/probe", nil)
|
||||||
|
req.Header.Set("Origin", "https://app.nearledaily.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||||
|
if requestHeaders != "" {
|
||||||
|
req.Header.Set("Access-Control-Request-Headers", requestHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("preflight: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, name := range []string{
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"Access-Control-Allow-Credentials",
|
||||||
|
} {
|
||||||
|
out[name] = resp.Header.Get(name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheBrowserIsAllowedToSendTheSessionToken(t *testing.T) {
|
||||||
|
// The bug itself. Without `Authorization` in this list the console cannot
|
||||||
|
// make a single authenticated call, and the error surfaces only in a
|
||||||
|
// browser console as a CORS failure.
|
||||||
|
headers := preflight(t, "authorization")["Access-Control-Allow-Headers"]
|
||||||
|
|
||||||
|
if !strings.Contains(strings.ToLower(headers), "authorization") {
|
||||||
|
t.Fatalf("the console may not send its session token: %q", headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHeadersTheConsoleAlreadySentStillWork(t *testing.T) {
|
||||||
|
// Adding one header must not quietly drop the others.
|
||||||
|
headers := strings.ToLower(preflight(t, "content-type")["Access-Control-Allow-Headers"])
|
||||||
|
|
||||||
|
for _, needed := range []string{"content-type", "accept", "origin"} {
|
||||||
|
if !strings.Contains(headers, needed) {
|
||||||
|
t.Fatalf("%q is no longer allowed: %q", needed, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWildcardOriginIsNotPairedWithCredentials(t *testing.T) {
|
||||||
|
// Not a valid combination: a browser rejects a credentialed response
|
||||||
|
// carrying a wildcard origin. It was here already and was harmless only
|
||||||
|
// because nothing used credentials — it would have become a second bug
|
||||||
|
// that looked exactly like the first, the day anything did.
|
||||||
|
got := preflight(t, "authorization")
|
||||||
|
|
||||||
|
if got["Access-Control-Allow-Origin"] == "*" &&
|
||||||
|
strings.EqualFold(got["Access-Control-Allow-Credentials"], "true") {
|
||||||
|
t.Fatal("wildcard origin with credentials allowed — browsers reject this pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryMethodTheConsoleUsesIsAllowed(t *testing.T) {
|
||||||
|
// The console writes with POST, PUT and DELETE. A missing one fails only
|
||||||
|
// on the screens that use it, which is the kind of gap that ships.
|
||||||
|
methods := strings.ToUpper(preflight(t, "authorization")["Access-Control-Allow-Methods"])
|
||||||
|
|
||||||
|
for _, method := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
|
||||||
|
if !strings.Contains(methods, method) {
|
||||||
|
t.Fatalf("%s is not allowed cross-origin: %q", method, methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAResponseHeaderIsNotListedAsAnAllowedRequestHeader(t *testing.T) {
|
||||||
|
// `Access-Control-Allow-Origin` was in the allowed REQUEST headers, which is
|
||||||
|
// a category error: it is something the server sends back, never something a
|
||||||
|
// browser asks to send. Harmless, but it reads as though somebody added
|
||||||
|
// names until the error went away.
|
||||||
|
headers := strings.ToLower(preflight(t, "authorization")["Access-Control-Allow-Headers"])
|
||||||
|
|
||||||
|
if strings.Contains(headers, "access-control-allow-origin") {
|
||||||
|
t.Fatalf("a response header is listed as an allowed request header: %q", headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,15 @@ func StartLiveHub() *LiveHub {
|
|||||||
SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise.
|
SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise.
|
||||||
SetOrderMatters(false)
|
SetOrderMatters(false)
|
||||||
|
|
||||||
if user := strings.TrimSpace(os.Getenv("MQTT_USERNAME")); user != "" {
|
// MQTT_USER is the name the ingest consumer and the env files use. This
|
||||||
|
// read MQTT_USERNAME for a while, so the live stream connected to the
|
||||||
|
// production broker with no credentials at all; MQTT_USERNAME is still
|
||||||
|
// honoured for any deployment that set it.
|
||||||
|
user := strings.TrimSpace(os.Getenv("MQTT_USER"))
|
||||||
|
if user == "" {
|
||||||
|
user = strings.TrimSpace(os.Getenv("MQTT_USERNAME"))
|
||||||
|
}
|
||||||
|
if user != "" {
|
||||||
opts.SetUsername(user)
|
opts.SetUsername(user)
|
||||||
opts.SetPassword(os.Getenv("MQTT_PASSWORD"))
|
opts.SetPassword(os.Getenv("MQTT_PASSWORD"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
|
|||||||
token := bearerToken(c)
|
token := bearerToken(c)
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
// Counted before anything else happens to it. This is the number
|
||||||
|
// that decides when POS_AUTH_REQUIRED can be switched on, and
|
||||||
|
// nothing else in the system was recording it — the request was
|
||||||
|
// simply waved through in silence. See posauthadoption.go.
|
||||||
|
recordPosUntokened(requestedLocation(c), c.Path())
|
||||||
|
|
||||||
if posAuthRequired() {
|
if posAuthRequired() {
|
||||||
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
|
return posUnauthorized(c, "a session token is required; sign in at /pos/login")
|
||||||
}
|
}
|
||||||
@@ -96,6 +102,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A till that has adopted the new sign-in. Counted only once the token
|
||||||
|
// has verified AND the outlet check has passed, so the figure means
|
||||||
|
// "requests this guard would still serve with enforcement on" rather
|
||||||
|
// than "requests that carried something token-shaped".
|
||||||
|
recordPosToken()
|
||||||
|
|
||||||
c.Locals(PosLocalsKey, claims)
|
c.Locals(PosLocalsKey, claims)
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
249
middleware/posauthadoption.go
Normal file
249
middleware/posauthadoption.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
How much of the till fleet is carrying a session token.
|
||||||
|
|
||||||
|
── Why this exists ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
|
||||||
|
switching it on is a number nobody has: how many terminals still call the POS
|
||||||
|
routes with no token. Flipping the flag blind is the one action on this surface
|
||||||
|
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
|
||||||
|
bill is not a reversible inconvenience.
|
||||||
|
|
||||||
|
So this counts, and names the outlets that are still untokened, so the flag gets
|
||||||
|
flipped on evidence rather than on hope.
|
||||||
|
|
||||||
|
── What it deliberately is not ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
Not persisted. It lives in memory and resets on restart, which is honest about
|
||||||
|
what it measures: adoption since this process started, not all time. A restart
|
||||||
|
mid-observation means starting the week again, and that is a smaller cost than a
|
||||||
|
migration and a table for a number that stops mattering the day the flag is on.
|
||||||
|
|
||||||
|
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
|
||||||
|
here can change whether a request is served.
|
||||||
|
|
||||||
|
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
|
||||||
|
name ten thousand outlets and grow this without bound. Past the cap new outlets
|
||||||
|
are counted in the totals and not listed individually, which keeps the answer
|
||||||
|
useful without making it a way to spend the server's memory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// posAdoptionCap is how many distinct untokened outlets are named individually.
|
||||||
|
// The real fleet is dozens; anything beyond this is noise or somebody probing.
|
||||||
|
const posAdoptionCap = 200
|
||||||
|
|
||||||
|
type posOutletSeen struct {
|
||||||
|
Locationid int
|
||||||
|
Requests int64
|
||||||
|
FirstSeen time.Time
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var posAdoption = struct {
|
||||||
|
sync.Mutex
|
||||||
|
since time.Time
|
||||||
|
tokened int64
|
||||||
|
untokened int64
|
||||||
|
// Untokened requests by the outlet they named, and by the route they hit.
|
||||||
|
outlets map[int]*posOutletSeen
|
||||||
|
paths map[string]int64
|
||||||
|
// True once the cap was reached, so the report can say it is partial
|
||||||
|
// rather than quietly under-reporting.
|
||||||
|
truncated bool
|
||||||
|
}{
|
||||||
|
since: time.Now(),
|
||||||
|
outlets: map[int]*posOutletSeen{},
|
||||||
|
paths: map[string]int64{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPosToken notes one request that arrived with a usable token.
|
||||||
|
func recordPosToken() {
|
||||||
|
posAdoption.Lock()
|
||||||
|
posAdoption.tokened++
|
||||||
|
posAdoption.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPosUntokened notes one request that arrived with none, and where it
|
||||||
|
// claimed to be. `locationid` is 0 when the route named no outlet.
|
||||||
|
func recordPosUntokened(locationid int, path string) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
posAdoption.Lock()
|
||||||
|
defer posAdoption.Unlock()
|
||||||
|
|
||||||
|
posAdoption.untokened++
|
||||||
|
posAdoption.paths[path]++
|
||||||
|
|
||||||
|
if locationid <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if seen, ok := posAdoption.outlets[locationid]; ok {
|
||||||
|
seen.Requests++
|
||||||
|
seen.LastSeen = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(posAdoption.outlets) >= posAdoptionCap {
|
||||||
|
posAdoption.truncated = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
posAdoption.outlets[locationid] = &posOutletSeen{
|
||||||
|
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoptionOutlet is one outlet still calling without a token.
|
||||||
|
type PosAdoptionOutlet struct {
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
FirstSeen string `json:"firstseen"`
|
||||||
|
LastSeen string `json:"lastseen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoptionPath is one route, and how often it was reached untokened.
|
||||||
|
type PosAdoptionPath struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
|
||||||
|
type PosAdoption struct {
|
||||||
|
// Whether an untokened request is currently refused.
|
||||||
|
Enforced bool `json:"enforced"`
|
||||||
|
// When counting started — process start, not all time.
|
||||||
|
Since string `json:"since"`
|
||||||
|
// Requests seen on the POS surface since then.
|
||||||
|
Tokened int64 `json:"tokened"`
|
||||||
|
Untokened int64 `json:"untokened"`
|
||||||
|
// 0–100. 100 means every request in this window carried a token, which is
|
||||||
|
// the condition for flipping the flag.
|
||||||
|
AdoptedPercent float64 `json:"adoptedpercent"`
|
||||||
|
// The outlets still calling without one, busiest first. These are the tills
|
||||||
|
// that would stop working the moment enforcement is switched on.
|
||||||
|
Outlets []PosAdoptionOutlet `json:"outlets"`
|
||||||
|
// Which routes they are reaching, busiest first.
|
||||||
|
Paths []PosAdoptionPath `json:"paths"`
|
||||||
|
// True when more outlets were seen than are listed — see posAdoptionCap.
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
// Plain-language reading of the above, for whoever has to make the call.
|
||||||
|
Verdict string `json:"verdict"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosAdoptionReport is the snapshot, safe to call at any time.
|
||||||
|
func PosAdoptionReport() PosAdoption {
|
||||||
|
posAdoption.Lock()
|
||||||
|
defer posAdoption.Unlock()
|
||||||
|
|
||||||
|
report := PosAdoption{
|
||||||
|
Enforced: posAuthRequired(),
|
||||||
|
Since: posAdoption.since.Format(time.RFC3339),
|
||||||
|
Tokened: posAdoption.tokened,
|
||||||
|
Untokened: posAdoption.untokened,
|
||||||
|
Truncated: posAdoption.truncated,
|
||||||
|
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
|
||||||
|
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
|
||||||
|
}
|
||||||
|
|
||||||
|
total := posAdoption.tokened + posAdoption.untokened
|
||||||
|
if total > 0 {
|
||||||
|
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, seen := range posAdoption.outlets {
|
||||||
|
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
|
||||||
|
Locationid: seen.Locationid,
|
||||||
|
Requests: seen.Requests,
|
||||||
|
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
|
||||||
|
LastSeen: seen.LastSeen.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Busiest first: the outlet ringing the most bills is the one that hurts
|
||||||
|
// most if enforcement switches on before it has adopted.
|
||||||
|
sort.Slice(report.Outlets, func(i, j int) bool {
|
||||||
|
return report.Outlets[i].Requests > report.Outlets[j].Requests
|
||||||
|
})
|
||||||
|
|
||||||
|
for path, count := range posAdoption.paths {
|
||||||
|
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
|
||||||
|
}
|
||||||
|
sort.Slice(report.Paths, func(i, j int) bool {
|
||||||
|
return report.Paths[i].Requests > report.Paths[j].Requests
|
||||||
|
})
|
||||||
|
|
||||||
|
report.Verdict = posAdoptionVerdict(report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// posAdoptionVerdict says what the numbers mean, because the number on its own
|
||||||
|
// invites the wrong reading in both directions: a clean window that is only an
|
||||||
|
// hour long proves nothing, and one stubborn outlet is not a reason to leave
|
||||||
|
// the whole surface open.
|
||||||
|
func posAdoptionVerdict(r PosAdoption) string {
|
||||||
|
switch {
|
||||||
|
case r.Enforced:
|
||||||
|
return "Enforcement is already on: an untokened request is refused."
|
||||||
|
case r.Tokened+r.Untokened == 0:
|
||||||
|
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
|
||||||
|
case r.Untokened == 0:
|
||||||
|
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
|
||||||
|
case len(r.Outlets) == 0:
|
||||||
|
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
|
||||||
|
default:
|
||||||
|
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// posAdoptionLogEvery is how often the summary reaches the log.
|
||||||
|
//
|
||||||
|
// Long, because this is a slow-moving fact — a fleet adopts over days, not
|
||||||
|
// minutes — and a log line nobody needs every minute is a log line people learn
|
||||||
|
// to scroll past.
|
||||||
|
const posAdoptionLogEvery = 30 * time.Minute
|
||||||
|
|
||||||
|
// LogPosAdoption prints the summary on a timer until ctx is done.
|
||||||
|
//
|
||||||
|
// In the log as well as on the endpoint because the two get used by different
|
||||||
|
// people at different moments: somebody already reading Dokploy's log because a
|
||||||
|
// till is misbehaving should not have to know an endpoint exists.
|
||||||
|
//
|
||||||
|
// Outlet ids only, never names or counts of takings — a log is the one place
|
||||||
|
// this data ends up somewhere nobody chose to put it.
|
||||||
|
func LogPosAdoption(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(posAdoptionLogEvery)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.Tokened+report.Untokened == 0 {
|
||||||
|
continue // nothing happened; saying so every half hour is noise
|
||||||
|
}
|
||||||
|
if report.Untokened == 0 {
|
||||||
|
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
|
||||||
|
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlets := make([]string, 0, len(report.Outlets))
|
||||||
|
for _, o := range report.Outlets {
|
||||||
|
outlets = append(outlets, strconv.Itoa(o.Locationid))
|
||||||
|
}
|
||||||
|
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
|
||||||
|
report.AdoptedPercent, report.Tokened+report.Untokened,
|
||||||
|
report.Untokened, strings.Join(outlets, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
297
middleware/posauthadoption_test.go
Normal file
297
middleware/posauthadoption_test.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Counting the till fleet's adoption of the session token.
|
||||||
|
|
||||||
|
This exists to answer one question — is it safe to set POS_AUTH_REQUIRED=true —
|
||||||
|
and the cost of answering it wrong is a cashier at a counter who cannot ring a
|
||||||
|
bill. So these are mostly about the figure being honest: not flattering, not
|
||||||
|
alarmist, and never able to change whether a request is served.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// resetAdoption puts the counters back, since they are process-wide.
|
||||||
|
func resetAdoption(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
posAdoption.Lock()
|
||||||
|
posAdoption.since = time.Now()
|
||||||
|
posAdoption.tokened = 0
|
||||||
|
posAdoption.untokened = 0
|
||||||
|
posAdoption.outlets = map[int]*posOutletSeen{}
|
||||||
|
posAdoption.paths = map[string]int64{}
|
||||||
|
posAdoption.truncated = false
|
||||||
|
posAdoption.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUntokenedTillIsNamedByItsOutlet(t *testing.T) {
|
||||||
|
// The whole point. Without this list, switching enforcement on is a guess
|
||||||
|
// about which shops stop trading.
|
||||||
|
resetAdoption(t)
|
||||||
|
|
||||||
|
recordPosUntokened(1185, "/live/api/v1/pos/orders")
|
||||||
|
recordPosUntokened(1185, "/live/api/v1/pos/orders")
|
||||||
|
recordPosUntokened(1170, "/live/api/v1/pos/catalogue")
|
||||||
|
recordPosToken()
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.Untokened != 3 || report.Tokened != 1 {
|
||||||
|
t.Fatalf("counts wrong: %+v", report)
|
||||||
|
}
|
||||||
|
if len(report.Outlets) != 2 {
|
||||||
|
t.Fatalf("outlets: %+v", report.Outlets)
|
||||||
|
}
|
||||||
|
// Busiest first — the outlet ringing the most bills is the one that hurts
|
||||||
|
// most if enforcement goes on before it has adopted.
|
||||||
|
if report.Outlets[0].Locationid != 1185 || report.Outlets[0].Requests != 2 {
|
||||||
|
t.Errorf("not ordered by traffic: %+v", report.Outlets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAdoptedPercentageIsOfEverything(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
recordPosToken()
|
||||||
|
}
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
if got := PosAdoptionReport().AdoptedPercent; got != 75 {
|
||||||
|
t.Fatalf("adopted = %v%%, want 75", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoTrafficIsNotAHundredPercent(t *testing.T) {
|
||||||
|
// A fleet nobody has used is not a fleet that has adopted. Reporting 100%
|
||||||
|
// here is the single most dangerous rounding this file could do — it would
|
||||||
|
// green-light the flag on an empty window.
|
||||||
|
resetAdoption(t)
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.AdoptedPercent != 0 {
|
||||||
|
t.Fatalf("empty window reported as %v%%", report.AdoptedPercent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(report.Verdict, "nothing to conclude") {
|
||||||
|
t.Errorf("verdict oversells an empty window: %q", report.Verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACleanWindowStillSaysToKeepWatching(t *testing.T) {
|
||||||
|
// Zero untokened requests in one hour is not an adopted fleet — a shop that
|
||||||
|
// is shut has no traffic either. The verdict has to say so, because the
|
||||||
|
// number on its own reads as permission.
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosToken()
|
||||||
|
|
||||||
|
verdict := PosAdoptionReport().Verdict
|
||||||
|
if !strings.Contains(verdict, "trading days") {
|
||||||
|
t.Errorf("a one-request window was treated as proof: %q", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUntokenedFleetIsNotDescribedAsReady(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosToken()
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
verdict := PosAdoptionReport().Verdict
|
||||||
|
if !strings.Contains(verdict, "stop being able to trade") {
|
||||||
|
t.Errorf("the consequence is not stated: %q", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARequestThatNamesNoOutletIsStillCounted(t *testing.T) {
|
||||||
|
// `/pos/staff` deliberately takes no location parameter. Such a request is
|
||||||
|
// still an untokened till, and dropping it would understate the problem.
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosUntokened(0, "/live/api/v1/pos/staff")
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if report.Untokened != 1 {
|
||||||
|
t.Fatalf("not counted: %+v", report)
|
||||||
|
}
|
||||||
|
if len(report.Outlets) != 0 {
|
||||||
|
t.Errorf("invented an outlet for a request that named none: %+v", report.Outlets)
|
||||||
|
}
|
||||||
|
if len(report.Paths) != 1 || report.Paths[0].Path != "/live/api/v1/pos/staff" {
|
||||||
|
t.Errorf("the route was lost: %+v", report.Paths)
|
||||||
|
}
|
||||||
|
if !strings.Contains(report.Verdict, "cannot be traced") {
|
||||||
|
t.Errorf("verdict does not explain the blind spot: %q", report.Verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutletsCannotGrowWithoutBound(t *testing.T) {
|
||||||
|
// `store_id` comes off the wire. Without a cap an untokened caller could
|
||||||
|
// name ten thousand outlets and spend the server's memory doing it.
|
||||||
|
resetAdoption(t)
|
||||||
|
for i := 1; i <= posAdoptionCap+50; i++ {
|
||||||
|
recordPosUntokened(i, "/pos/orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if len(report.Outlets) > posAdoptionCap {
|
||||||
|
t.Fatalf("listed %d outlets, cap is %d", len(report.Outlets), posAdoptionCap)
|
||||||
|
}
|
||||||
|
if report.Untokened != int64(posAdoptionCap+50) {
|
||||||
|
// The total must stay true even when the list is trimmed.
|
||||||
|
t.Errorf("total under-reported: %d", report.Untokened)
|
||||||
|
}
|
||||||
|
if !report.Truncated {
|
||||||
|
t.Error("a trimmed list was presented as complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstAndLastSeenAreBothKept(t *testing.T) {
|
||||||
|
// "This till stopped calling untokened three days ago" and "it did so a
|
||||||
|
// minute ago" are different facts, and only one of them means it has been
|
||||||
|
// updated. So the first sighting must stick and the last must move.
|
||||||
|
//
|
||||||
|
// The clock is wound back rather than slept through: the report formats to
|
||||||
|
// RFC3339, which is second-precision, and a test that waits a second to
|
||||||
|
// prove an assignment is a second every run forever.
|
||||||
|
resetAdoption(t)
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
posAdoption.Lock()
|
||||||
|
seen := posAdoption.outlets[1185]
|
||||||
|
seen.FirstSeen = seen.FirstSeen.Add(-48 * time.Hour)
|
||||||
|
seen.LastSeen = seen.LastSeen.Add(-48 * time.Hour)
|
||||||
|
posAdoption.Unlock()
|
||||||
|
|
||||||
|
recordPosUntokened(1185, "/pos/orders")
|
||||||
|
|
||||||
|
out := PosAdoptionReport().Outlets[0]
|
||||||
|
if out.FirstSeen == "" || out.LastSeen == "" {
|
||||||
|
t.Fatalf("timestamps missing: %+v", out)
|
||||||
|
}
|
||||||
|
if out.Requests != 2 {
|
||||||
|
t.Errorf("requests = %d, want 2", out.Requests)
|
||||||
|
}
|
||||||
|
if out.FirstSeen == out.LastSeen {
|
||||||
|
t.Errorf("last seen never moved: %+v", out)
|
||||||
|
}
|
||||||
|
if out.FirstSeen > out.LastSeen {
|
||||||
|
// RFC3339 sorts lexically, so this comparison is meaningful.
|
||||||
|
t.Errorf("first seen is after last seen: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheReportSaysWhetherEnforcementIsAlreadyOn(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
t.Setenv("POS_AUTH_REQUIRED", "true")
|
||||||
|
|
||||||
|
report := PosAdoptionReport()
|
||||||
|
if !report.Enforced {
|
||||||
|
t.Fatal("enforcement is on and the report says otherwise")
|
||||||
|
}
|
||||||
|
if !strings.Contains(report.Verdict, "already on") {
|
||||||
|
t.Errorf("verdict ignores that the work is done: %q", report.Verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The guard still behaves exactly as it did ───────────────────────────── */
|
||||||
|
|
||||||
|
func TestCountingNeverChangesWhetherARequestIsServed(t *testing.T) {
|
||||||
|
// This whole file is instrumentation. If it can refuse a request, or let
|
||||||
|
// one through that should have been refused, it has become the thing it was
|
||||||
|
// built to make safe.
|
||||||
|
//
|
||||||
|
// Both sides of the flag, against the real middleware.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
required string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"off: an untokened till still trades", "", 200},
|
||||||
|
{"on: an untokened till is refused", "true", 401},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resetAdoption(t)
|
||||||
|
t.Setenv("POS_AUTH_REQUIRED", tc.required)
|
||||||
|
|
||||||
|
got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=1185", "")
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("status %d, want %d", got, tc.want)
|
||||||
|
}
|
||||||
|
// Counted either way: the figure is about what the fleet is doing,
|
||||||
|
// not about what the flag currently allows.
|
||||||
|
if report := PosAdoptionReport(); report.Untokened != 1 {
|
||||||
|
t.Errorf("untokened = %d, want 1", report.Untokened)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyARequestThatWouldSurviveEnforcementCountsAsAdopted(t *testing.T) {
|
||||||
|
// A token that verifies but names somebody else's outlet is refused, and
|
||||||
|
// must NOT be counted as adopted — otherwise a misconfigured till inflates
|
||||||
|
// the very number used to decide the flag is safe to set.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
|
||||||
|
t.Setenv("POS_AUTH_REQUIRED", "")
|
||||||
|
resetAdoption(t)
|
||||||
|
|
||||||
|
token := posTokenFor(t, 1147, 1185)
|
||||||
|
if got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=9999", token); got != 403 {
|
||||||
|
t.Fatalf("a token was allowed to name another tenant's outlet: %d", got)
|
||||||
|
}
|
||||||
|
if report := PosAdoptionReport(); report.Tokened != 0 {
|
||||||
|
t.Errorf("a refused request was counted as adopted: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Harness ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const posTestSecret = "a-pos-signing-secret-of-ample-length"
|
||||||
|
|
||||||
|
// posLocations answers the tenant-owns-outlet question without a database.
|
||||||
|
// Only LocationAllowed is real; anything else the guard touched would panic,
|
||||||
|
// which is the signal wanted.
|
||||||
|
type posLocations struct {
|
||||||
|
services.PosService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (posLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
|
||||||
|
// Tenant 1147 owns 1185 and nothing else, which is all these tests need.
|
||||||
|
return tenantID == 1147 && locationID == 1185, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func posTokenFor(t *testing.T, tenantID, locationID int) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := utils.MintPosToken(utils.PosClaims{
|
||||||
|
Tenantid: tenantID, Locationid: locationID, Configid: 1,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting a terminal session: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func callPos(t *testing.T, method, target, token string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use("/live/api/v1/pos", PosAuth(posLocations{}))
|
||||||
|
app.All("/live/api/v1/pos/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, target, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
332
middleware/webauth.go
Normal file
332
middleware/webauth.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/services"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authorisation for the console.
|
||||||
|
//
|
||||||
|
// The `/web` surface has never had any. The console keeps its login record in
|
||||||
|
// per-tab `sessionStorage` and sends no `Authorization` header, so every
|
||||||
|
// endpoint under `/v1/web` reads `tenantid` off the query string and believes
|
||||||
|
// it. Changing one number in a URL reads another merchant's orders, stock,
|
||||||
|
// staff and takings.
|
||||||
|
//
|
||||||
|
// This is the same hole `posauth.go` was written to close on the POS surface,
|
||||||
|
// and it is closed the same way, in the same order:
|
||||||
|
//
|
||||||
|
// 1. the caller holds a token this server signed, and
|
||||||
|
// 2. the tenant they are naming is the tenant inside that token.
|
||||||
|
//
|
||||||
|
// The second is the one that matters. A valid session is not a licence to name
|
||||||
|
// any tenant — it is a licence to name *your* tenant.
|
||||||
|
//
|
||||||
|
// ── Why this could not wait for the assistant ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Nearle Buddy answers questions over this same data. Behind REST, reading
|
||||||
|
// another merchant's books takes knowing the endpoints, knowing the fields and
|
||||||
|
// iterating. Behind an assistant it is one sentence — "summarise the top ten
|
||||||
|
// tenants by revenue" — and the model assembles the cross-tenant answer itself,
|
||||||
|
// accurately and helpfully, because the data was in scope. The permission rules
|
||||||
|
// the assistant needs have nothing to stand on until this exists.
|
||||||
|
//
|
||||||
|
// ── What this does NOT yet do ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It verifies what a request NAMES: the tenant, and the branch. It does not yet
|
||||||
|
// make handlers derive their scope from the session rather than from the wire.
|
||||||
|
//
|
||||||
|
// It also does not validate `partnerid`, `customerid` or `appuserid`, and that
|
||||||
|
// one is not an oversight — it is blocked. A delivery partner serves several
|
||||||
|
// merchants at once (`insights.ts` records partner 60 answering with deliveries
|
||||||
|
// spanning twelve shops), so scoping a read by partner is a cross-tenant read by
|
||||||
|
// design. Refusing the parameter outright would be wrong: `RiderDrawer` and
|
||||||
|
// `AssignBar` are merchant screens and both send it legitimately, for a partner
|
||||||
|
// assigned to that merchant.
|
||||||
|
//
|
||||||
|
// Closing it properly needs a check this codebase does not have — "is this
|
||||||
|
// partner assigned to this tenant?" — in the shape of `LocationAllowed`, which
|
||||||
|
// answers the same question for branches. Until that exists, a handler scoping
|
||||||
|
// on one of these three is trusting the caller, and the assistant is kept away
|
||||||
|
// from them entirely: no tool accepts any of these as an argument, and the
|
||||||
|
// registry refuses to register one that tries.
|
||||||
|
|
||||||
|
// WebLocalsKey names where the verified claims are parked for handlers.
|
||||||
|
const WebLocalsKey = "webclaims"
|
||||||
|
|
||||||
|
// webAuthRequired reports whether a request without a valid token is refused.
|
||||||
|
//
|
||||||
|
// Defaults to ON. It did not always: this shipped defaulting to off, because
|
||||||
|
// the console was live and its sign-in did not yet hand back a token, so
|
||||||
|
// enforcing first would have locked every merchant out of a working product.
|
||||||
|
//
|
||||||
|
// That rollout is finished. Sign-in mints a token, the console sends it on
|
||||||
|
// every call, and it expires cleanly. Leaving the default off after that point
|
||||||
|
// was not caution, it was an open door nobody had got round to shutting — and
|
||||||
|
// it was measured wide open: a `getorders` with no credential at all returned a
|
||||||
|
// real merchant's orders to anyone on the internet.
|
||||||
|
//
|
||||||
|
// ── The way out, if this goes wrong ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `WEB_AUTH_REQUIRED=false` restores the old behaviour, immediately and without
|
||||||
|
// a deploy. That is the escape hatch, and it exists because flipping a default
|
||||||
|
// that can lock people out should always be reversible by one person in one
|
||||||
|
// minute. A token that is SENT is still always verified either way — the flag
|
||||||
|
// only decides what happens to a request carrying none.
|
||||||
|
func webAuthRequired() bool {
|
||||||
|
setting := strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED"))
|
||||||
|
if setting == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !strings.EqualFold(setting, "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicWebPaths are the endpoints that must work before anybody has a token.
|
||||||
|
//
|
||||||
|
// Sign-in, chiefly: guarding the login route with a session token means nobody
|
||||||
|
// can ever obtain one. Kept as suffixes rather than full paths so the group
|
||||||
|
// prefix can move without silently locking the door.
|
||||||
|
var publicWebPaths = []string{
|
||||||
|
"/users/applogin",
|
||||||
|
"/users/weblogin",
|
||||||
|
"/tenant/weblogin",
|
||||||
|
// First-password-set runs before a session exists, from a link in the
|
||||||
|
// invitation mail.
|
||||||
|
"/users/setpassword",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPublicWebPath(path string) bool {
|
||||||
|
lower := strings.ToLower(path)
|
||||||
|
for _, suffix := range publicWebPaths {
|
||||||
|
if strings.HasSuffix(lower, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// webLocationChecker is the only question this middleware asks of the database:
|
||||||
|
// does this tenant own this branch? Narrowed to one method so the guard can be
|
||||||
|
// tested without a database, and so it cannot quietly grow a second dependency.
|
||||||
|
type webLocationChecker interface {
|
||||||
|
LocationAllowed(tenantID, locationID int) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebAuth verifies the console session and pins the request to its tenant.
|
||||||
|
func WebAuth(pos services.PosService) fiber.Handler { return webAuthWith(pos) }
|
||||||
|
|
||||||
|
func webAuthWith(locations webLocationChecker) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if isPublicWebPath(c.Path()) {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
token := webBearerToken(c)
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
if webAuthRequired() {
|
||||||
|
return webUnauthorized(c, "a session token is required; sign in again")
|
||||||
|
}
|
||||||
|
// A console that predates tokens. Allowed through unpinned, which is
|
||||||
|
// exactly the state this middleware exists to end — see
|
||||||
|
// webAuthRequired.
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := utils.ParseWebToken(token, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
// Always refused, flag or no flag. A token that does not verify is a
|
||||||
|
// stronger signal than no token at all: nothing sends a broken one by
|
||||||
|
// accident.
|
||||||
|
return webUnauthorized(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearle's own staff work across every tenant and legitimately name any
|
||||||
|
// of them. Checked once, here, rather than at each test below, so the
|
||||||
|
// exemption is a single visible branch instead of three.
|
||||||
|
if !claims.IsPlatformAccount() {
|
||||||
|
if requested := requestedTenant(c); requested > 0 && requested != claims.Tenantid {
|
||||||
|
return webForbidden(c, "this session cannot reach tenant "+strconv.Itoa(requested))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request can also scope by branch alone, naming no tenant at all,
|
||||||
|
// so pinning the tenant is not enough on its own.
|
||||||
|
if requested := requestedWebLocation(c); requested > 0 && requested != claims.Locationid {
|
||||||
|
allowed, err := locations.LocationAllowed(claims.Tenantid, requested)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
||||||
|
"code": http.StatusServiceUnavailable, "status": false,
|
||||||
|
"message": "could not verify branch access",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return webForbidden(c, "this session cannot reach branch "+strconv.Itoa(requested))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Locals(WebLocalsKey, claims)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webBearerToken reads the session out of the request.
|
||||||
|
//
|
||||||
|
// `Authorization: Bearer …` only. The POS reader next door also accepts
|
||||||
|
// `X-Pos-Token`, because shop routers between a till and this server strip
|
||||||
|
// Authorization headers on plain HTTP and a terminal that cannot authenticate
|
||||||
|
// is a shop that cannot trade. The console has no such problem — it is a
|
||||||
|
// browser on HTTPS — so it gets the one form, and a second accepted header is
|
||||||
|
// a second thing to get wrong.
|
||||||
|
func webBearerToken(c *fiber.Ctx) string {
|
||||||
|
header := strings.TrimSpace(c.Get("Authorization"))
|
||||||
|
if header == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if after, found := strings.CutPrefix(header, "Bearer "); found {
|
||||||
|
return strings.TrimSpace(after)
|
||||||
|
}
|
||||||
|
if !strings.Contains(header, " ") {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedTenant reads the tenant a request is naming, from wherever it put it.
|
||||||
|
//
|
||||||
|
// Query first, because that is where every `/web` list endpoint carries it, then
|
||||||
|
// the body, because the writes do not: `createdeliveries`, `publishproduct` and
|
||||||
|
// the rest post JSON. Checking only the query would leave every call that
|
||||||
|
// CHANGES another tenant's data unguarded, which is the wrong half to skip.
|
||||||
|
func requestedTenant(c *fiber.Ctx) int {
|
||||||
|
for _, key := range []string{"tenantid", "tenant_id"} {
|
||||||
|
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
||||||
|
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bodyScopeID(c, "tenantid", "tenant_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestedWebLocation reads the branch a request is naming.
|
||||||
|
//
|
||||||
|
// Separate from the POS reader's `requestedLocation` because the two surfaces
|
||||||
|
// spell it differently: POS routes use `store_id`, the console uses
|
||||||
|
// `locationid`. Both spellings are read here anyway — a shared endpoint is
|
||||||
|
// cheaper to allow for than to discover.
|
||||||
|
func requestedWebLocation(c *fiber.Ctx) int {
|
||||||
|
for _, key := range []string{"locationid", "location_id", "store_id"} {
|
||||||
|
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
||||||
|
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bodyScopeID(c, "locationid", "location_id", "store_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyScopeID pulls a scoping id out of a JSON request body.
|
||||||
|
//
|
||||||
|
// Decoded loosely rather than into a request type, on purpose: this runs before
|
||||||
|
// the handler and must not refuse anything the handler would have accepted. A
|
||||||
|
// body that will not parse here is left for the handler to reject with its own
|
||||||
|
// message, and a request shape that changes later must not silently stop being
|
||||||
|
// authorised.
|
||||||
|
//
|
||||||
|
// `c.Body()` returns buffered bytes, so reading here does not consume the
|
||||||
|
// stream the handler goes on to parse.
|
||||||
|
//
|
||||||
|
// An ARRAY body — `createdeliveries` posts one — is walked too. A batch naming
|
||||||
|
// another tenant in its elements is precisely the call worth guarding, and a
|
||||||
|
// probe that only understood objects would wave it through.
|
||||||
|
func bodyScopeID(c *fiber.Ctx, keys ...string) int {
|
||||||
|
body := c.Body()
|
||||||
|
if len(body) == 0 || len(body) > 8<<20 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw json.RawMessage = body
|
||||||
|
trimmed := strings.TrimLeft(string(body), " \t\r\n")
|
||||||
|
if strings.HasPrefix(trimmed, "[") {
|
||||||
|
var elements []json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &elements); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, element := range elements {
|
||||||
|
if id := scopeIDFromObject(element, keys); id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return scopeIDFromObject(raw, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopeIDFromObject(raw json.RawMessage, keys []string) int {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if id := asScopeID(fields[key]); id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// asScopeID reads an id that may have been sent as a number or as a string.
|
||||||
|
//
|
||||||
|
// Both spellings are on the wire today — the console sends numbers, some app
|
||||||
|
// callers send strings — and a probe that understood only one would return 0
|
||||||
|
// for the other, which reads as "named no tenant" and waves the request past
|
||||||
|
// the check.
|
||||||
|
func asScopeID(raw json.RawMessage) int {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var number int
|
||||||
|
if err := json.Unmarshal(raw, &number); err == nil {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil {
|
||||||
|
if id, err := strconv.Atoi(strings.TrimSpace(text)); err == nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func webUnauthorized(c *fiber.Ctx, message string) error {
|
||||||
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"code": http.StatusUnauthorized, "status": false, "message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func webForbidden(c *fiber.Ctx, message string) error {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false, "message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebClaimsFrom returns the verified session on a request, if it carried one.
|
||||||
|
//
|
||||||
|
// The second return distinguishes "no token" from "a token claiming tenant 0",
|
||||||
|
// which is a platform account and a real answer. A handler that treated the two
|
||||||
|
// alike would give an unauthenticated caller the one session that reads
|
||||||
|
// everything.
|
||||||
|
func WebClaimsFrom(c *fiber.Ctx) (utils.WebClaims, bool) {
|
||||||
|
claims, ok := c.Locals(WebLocalsKey).(utils.WebClaims)
|
||||||
|
return claims, ok
|
||||||
|
}
|
||||||
334
middleware/webauth_test.go
Normal file
334
middleware/webauth_test.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const webTestSecret = "a-test-signing-key-long-enough"
|
||||||
|
|
||||||
|
// fakeLocations answers the tenant-owns-branch question without a database.
|
||||||
|
//
|
||||||
|
// `owned` is the branch the tenant genuinely has; anything else is refused, and
|
||||||
|
// `fails` makes the lookup itself error so the unavailable path can be reached.
|
||||||
|
type fakeLocations struct {
|
||||||
|
tenant int
|
||||||
|
owned int
|
||||||
|
fails bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
|
||||||
|
if f.fails {
|
||||||
|
return false, errFakeLookup
|
||||||
|
}
|
||||||
|
return tenantID == f.tenant && locationID == f.owned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeErr struct{}
|
||||||
|
|
||||||
|
func (fakeErr) Error() string { return "lookup unavailable" }
|
||||||
|
|
||||||
|
var errFakeLookup = fakeErr{}
|
||||||
|
|
||||||
|
// call runs one request through the middleware and reports the status.
|
||||||
|
//
|
||||||
|
// The handler behind it always succeeds, so any non-200 came from the guard.
|
||||||
|
func call(t *testing.T, locations webLocationChecker, token, method, target, body string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Use("/live/api/v1/web", webAuthWith(locations))
|
||||||
|
app.All("/live/api/v1/web/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFor(t *testing.T, claims utils.WebClaims) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := utils.MintWebToken(claims, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The hole this exists to close ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestASessionCannotNameAnotherTenant(t *testing.T) {
|
||||||
|
// One number in a URL. Before this middleware it read another merchant's
|
||||||
|
// orders, stock, staff and takings.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
own := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||||
|
if own != fiber.StatusOK {
|
||||||
|
t.Fatalf("a session was refused its own tenant: %d", own)
|
||||||
|
}
|
||||||
|
|
||||||
|
other := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if other != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("tenant 916 was readable with a tenant 1147 session: %d", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWriteCannotNameAnotherTenantInItsBody(t *testing.T) {
|
||||||
|
// The half that would be easy to skip. Reads carry `tenantid` in the query;
|
||||||
|
// the calls that CHANGE things post JSON, so a query-only check leaves every
|
||||||
|
// write unguarded.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
body := `{"tenantid":916,"productname":"Milk Bikis"}`
|
||||||
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", body)
|
||||||
|
if got != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("a write into tenant 916 was allowed: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABatchCannotSmuggleAnotherTenantInAnArray(t *testing.T) {
|
||||||
|
// `createdeliveries` posts an array. A probe that only understood objects
|
||||||
|
// would wave through exactly the call that creates work in another
|
||||||
|
// merchant's shop.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
body := `[{"orderheaderid":1,"tenantid":916}]`
|
||||||
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/deliveries/createdeliveries", body)
|
||||||
|
if got != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("a batch naming tenant 916 was allowed: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATenantSentAsAStringIsStillChecked(t *testing.T) {
|
||||||
|
// Both spellings are on the wire. A probe that understood only numbers
|
||||||
|
// returns 0 for `"916"`, which reads as "named no tenant" and passes.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", `{"tenantid":"916"}`)
|
||||||
|
if got != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("a string tenant id slipped past: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scoping by branch alone ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestABranchMustBelongToTheSessionsTenant(t *testing.T) {
|
||||||
|
// A request can scope by branch and name no tenant at all, so pinning the
|
||||||
|
// tenant is not sufficient on its own.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
locations := fakeLocations{tenant: 1147, owned: 1173}
|
||||||
|
|
||||||
|
mine := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1173", "")
|
||||||
|
if mine != fiber.StatusOK {
|
||||||
|
t.Fatalf("a second branch of my own tenant was refused: %d", mine)
|
||||||
|
}
|
||||||
|
|
||||||
|
theirs := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
||||||
|
if theirs != fiber.StatusForbidden {
|
||||||
|
t.Fatalf("another tenant's branch was readable: %d", theirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSessionsOwnBranchNeedsNoLookup(t *testing.T) {
|
||||||
|
// `fails: true` errors on any lookup, so reaching OK proves the home branch
|
||||||
|
// short-circuits before asking.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1172", "")
|
||||||
|
if got != fiber.StatusOK {
|
||||||
|
t.Fatalf("the session's own branch was refused: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedBranchLookupIsNotAPass(t *testing.T) {
|
||||||
|
// If the check cannot run, the answer is "cannot verify", never "allowed".
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
||||||
|
if got != fiber.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("a broken lookup did not refuse: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tokens ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestABrokenTokenIsAlwaysRefused(t *testing.T) {
|
||||||
|
// Refused whatever the flag says. Nothing sends a broken token by accident.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "w1.rubbish.signature", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||||
|
if got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("a forged token was not refused: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATillsTokenIsNotAConsoleSessionHere(t *testing.T) {
|
||||||
|
// A POS token is the same shape signed with the same key. If it verified
|
||||||
|
// here its `Locationid` would land where `Tenantid` is read.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
pos, _, err := utils.MintPosToken(utils.PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minting a POS token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, pos, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||||
|
if got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("a cashier's token was accepted on the console: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The staged rollout ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestWithoutTheFlagAnUntokenedRequestStillWorks(t *testing.T) {
|
||||||
|
// The console in production sends no token yet. Locking it out before
|
||||||
|
// sign-in issues one would break a working product.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusOK {
|
||||||
|
t.Fatalf("an untokened request was refused while enforcement is off: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithTheFlagAnUntokenedRequestIsRefused(t *testing.T) {
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("enforcement is on and an untokened request passed: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInStillWorksWithEnforcementOn(t *testing.T) {
|
||||||
|
// Guarding the login route with a session token means nobody can ever get
|
||||||
|
// one. This is the test that catches a locked-out deployment.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/live/api/v1/web/users/applogin",
|
||||||
|
"/live/api/v1/web/tenant/weblogin",
|
||||||
|
} {
|
||||||
|
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
|
||||||
|
t.Fatalf("%s was locked behind a session: %d", path, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The platform account ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestPlatformStaffMayNameAnyTenant(t *testing.T) {
|
||||||
|
// Nearle's own staff work across tenants and the console's /nearle pages
|
||||||
|
// depend on it.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
session := tokenFor(t, utils.WebClaims{Userid: 12, Superadmin: true, Roleid: 1})
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusOK {
|
||||||
|
t.Fatalf("a platform session was refused tenant 916: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoTokenIsNotAPlatformAccount(t *testing.T) {
|
||||||
|
// Tenant 0 is the session that reads everything, and Go's zero value is 0.
|
||||||
|
// A handler reading claims off a request that carried none would hand an
|
||||||
|
// anonymous caller exactly that session.
|
||||||
|
app := fiber.New()
|
||||||
|
var found bool
|
||||||
|
app.Get("/probe", func(c *fiber.Ctx) error {
|
||||||
|
_, found = WebClaimsFrom(c)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
if _, err := app.Test(httptest.NewRequest("GET", "/probe", nil)); err != nil {
|
||||||
|
t.Fatalf("probing: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Fatal("claims were reported present on a request that carried none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The default, after the rollout ────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestEnforcementIsOnByDefault(t *testing.T) {
|
||||||
|
// It shipped defaulting to off so a live console could adopt tokens without
|
||||||
|
// its users being locked out. That finished, and the default was measured
|
||||||
|
// still open: a getorders with no credential returned a real merchant's
|
||||||
|
// orders to anyone.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("an untokened request was served with no setting present: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnforcementCanBeTurnedOffWithoutADeploy(t *testing.T) {
|
||||||
|
// The escape hatch. Flipping a default that can lock people out has to be
|
||||||
|
// reversible by one person in one minute.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||||
|
|
||||||
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if got != fiber.StatusOK {
|
||||||
|
t.Fatalf("the escape hatch does not work: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyTheWordFalseOpensTheDoor(t *testing.T) {
|
||||||
|
// A typo must fail closed. "no", "0" and "off" all look like they might
|
||||||
|
// disable it, and a deployment that meant to disable it and did not is far
|
||||||
|
// safer than one that meant to enable it and did not.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
for _, setting := range []string{"no", "0", "off", "FALSE ", "nope"} {
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", setting)
|
||||||
|
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||||
|
if setting == "FALSE " && got != fiber.StatusOK {
|
||||||
|
t.Fatalf("a trimmed, case-insensitive false was not honoured: %d", got)
|
||||||
|
}
|
||||||
|
if setting != "FALSE " && got != fiber.StatusUnauthorized {
|
||||||
|
t.Fatalf("%q opened the door: %d", setting, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignInStillWorksWithTheNewDefault(t *testing.T) {
|
||||||
|
// The test that catches a locked-out deployment. Guarding the login route
|
||||||
|
// means nobody can ever obtain a token.
|
||||||
|
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||||
|
t.Setenv("WEB_AUTH_REQUIRED", "")
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/live/api/v1/web/users/applogin",
|
||||||
|
"/live/api/v1/web/tenant/weblogin",
|
||||||
|
} {
|
||||||
|
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
|
||||||
|
t.Fatalf("%s was locked behind a session: %d", path, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
models/assistantaudit.go
Normal file
52
models/assistantaudit.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AssistantAudit is one attempt to use an assistant tool.
|
||||||
|
//
|
||||||
|
// A new table rather than a column on anything: these rows are written on a
|
||||||
|
// different schedule, read by different people, and are the only record of what
|
||||||
|
// an assistant did on a merchant's behalf. Nothing else in the schema has that
|
||||||
|
// job.
|
||||||
|
//
|
||||||
|
// ── Refusals are the interesting rows ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every call is recorded, including the ones the registry said no to. A trail of
|
||||||
|
// successes answers "did anything try to read another tenant?" with silence,
|
||||||
|
// which reads exactly like "no".
|
||||||
|
//
|
||||||
|
// ── What is NOT stored ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Not the question, and not the answer. The question is a shopkeeper's own words
|
||||||
|
// and can carry anything they typed; the answer contains rows about their
|
||||||
|
// business. Neither is needed to review what the assistant DID — the tool, the
|
||||||
|
// arguments and the outcome are the act — and storing them would make this table
|
||||||
|
// a second copy of the data it exists to police.
|
||||||
|
type AssistantAudit struct {
|
||||||
|
ID int64 `json:"id" gorm:"primaryKey;autoIncrement;column:id"`
|
||||||
|
At time.Time `json:"at" gorm:"column:at;index"`
|
||||||
|
Agent string `json:"agent" gorm:"column:agent;size:64"`
|
||||||
|
Tool string `json:"tool" gorm:"column:tool;size:64;index"`
|
||||||
|
Scope string `json:"scope" gorm:"column:scope;size:16"`
|
||||||
|
Userid int `json:"userid" gorm:"column:userid;index"`
|
||||||
|
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||||
|
// The arguments the handler actually received — validated and defaulted,
|
||||||
|
// not as the model sent them. What ran is what is worth being able to read
|
||||||
|
// back; what was asked for is only interesting when it differs, and the
|
||||||
|
// refusal row records that.
|
||||||
|
Args string `json:"args" gorm:"column:args;type:jsonb"`
|
||||||
|
// ok | refused | failed | proposed | approved.
|
||||||
|
//
|
||||||
|
// `refused` is the guard saying no and `failed` is the handler breaking;
|
||||||
|
// collapsing them would hide a broken tool inside a count of things working
|
||||||
|
// as designed. `proposed` and `approved` are the two halves of a write, and
|
||||||
|
// a `proposed` with no matching `approved` is somebody deciding not to.
|
||||||
|
Outcome string `json:"outcome" gorm:"column:outcome;size:16;index"`
|
||||||
|
Detail string `json:"detail" gorm:"column:detail"`
|
||||||
|
Rows int `json:"rows" gorm:"column:rows"`
|
||||||
|
// Milliseconds. Integer rather than an interval type so it can be averaged
|
||||||
|
// and sorted without anybody having to know the database's duration syntax.
|
||||||
|
Tookms int64 `json:"tookms" gorm:"column:tookms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AssistantAudit) TableName() string { return "assistantaudit" }
|
||||||
@@ -166,6 +166,18 @@ type Ridersummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Deliveryinfo struct {
|
type Deliveryinfo struct {
|
||||||
|
// The delivery window the customer asked for, joined from the order.
|
||||||
|
//
|
||||||
|
// Zero on every delivery whose order named no window, which is most of
|
||||||
|
// them — treat absence as "none was asked for", never as missing data.
|
||||||
|
// Read-only: the window lives on orders, not here.
|
||||||
|
Deliveryslotid int `json:"deliveryslotid" gorm:"->"`
|
||||||
|
Deliveryslotdate string `json:"deliveryslotdate" gorm:"->"`
|
||||||
|
Slotkey string `json:"slotkey" gorm:"->"`
|
||||||
|
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
|
||||||
|
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
|
||||||
|
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
|
||||||
|
|
||||||
Deliveryid int `json:"deliveryid"`
|
Deliveryid int `json:"deliveryid"`
|
||||||
Orderheaderid int `json:"orderheaderid"`
|
Orderheaderid int `json:"orderheaderid"`
|
||||||
Applocationid int `json:"applocationid"`
|
Applocationid int `json:"applocationid"`
|
||||||
|
|||||||
111
models/deliveryslot.go
Normal file
111
models/deliveryslot.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
When a shop delivers.
|
||||||
|
|
||||||
|
A branch offers at most three windows a day — morning, afternoon, evening — and
|
||||||
|
the shopper picks one at checkout. The window is a PREFERENCE, not a promise:
|
||||||
|
every order is accepted, there is no capacity limit, and a slot never fills up.
|
||||||
|
It tells the shop when to group a drop, and it tells the shopper roughly when to
|
||||||
|
expect one.
|
||||||
|
|
||||||
|
── Per branch, not per tenant ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
Timings belong to a shop floor, not a company. A tenant with an outlet in a
|
||||||
|
market and another in an office park will run different hours, and discovering
|
||||||
|
that after the fact would mean migrating live rows. `locationid` is on the table
|
||||||
|
from the start for that reason, and onboarding simply fills it with the primary
|
||||||
|
branch it just created.
|
||||||
|
|
||||||
|
── A branch with no slots is not broken ────────────────────────────────────
|
||||||
|
|
||||||
|
Every tenant trading today has no slots at all, and must keep taking orders.
|
||||||
|
Absence means "order without a slot", exactly as before — never "this shop is
|
||||||
|
closed". The whole rollout rests on that, so nothing here may treat an empty
|
||||||
|
list as an error.
|
||||||
|
*/
|
||||||
|
type DeliverySlots struct {
|
||||||
|
Slotid int `json:"deliveryslotid" gorm:"primaryKey;autoIncrement;column:slotid"`
|
||||||
|
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||||
|
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
||||||
|
|
||||||
|
// Which of the three this is. Fixed rather than free-form: the app shows
|
||||||
|
// them in a known order and may want an icon per slot, and a shop inventing
|
||||||
|
// a fourth would have nowhere to appear.
|
||||||
|
Slotkey string `json:"slotkey" gorm:"column:slotkey"`
|
||||||
|
|
||||||
|
// What the shopper reads. Separate from `slotkey` so a shop can say
|
||||||
|
// "Before work" without breaking the app's ordering.
|
||||||
|
Name string `json:"name" gorm:"column:name"`
|
||||||
|
|
||||||
|
// "HH:MM", 24-hour, in the shop's local time.
|
||||||
|
//
|
||||||
|
// Stored as text, not as a timestamp, because this is a time of DAY that
|
||||||
|
// recurs — it has no date until an order attaches one. A timestamp column
|
||||||
|
// would invite a timezone conversion on every read, which is the one thing
|
||||||
|
// this must not do: the shopkeeper typed 08:00 meaning eight in the morning
|
||||||
|
// where they are standing.
|
||||||
|
Starttime string `json:"starttime" gorm:"column:starttime"`
|
||||||
|
|
||||||
|
// Also the CUT-OFF. There is deliberately no separate cutoff column: a slot
|
||||||
|
// accepts orders right up to the moment it ends, and then stops being
|
||||||
|
// offered. Morning 08:00–10:00 takes an order at 09:59 and not at 10:01.
|
||||||
|
Endtime string `json:"endtime" gorm:"column:endtime"`
|
||||||
|
|
||||||
|
// "active" or "inactive". A shop that stops doing evenings turns the slot
|
||||||
|
// off rather than deleting it, so orders already placed against it still
|
||||||
|
// resolve to something with a name.
|
||||||
|
Status string `json:"status" gorm:"column:status"`
|
||||||
|
|
||||||
|
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
||||||
|
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DeliverySlots) TableName() string {
|
||||||
|
return "deliveryslots"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three keys, in the order a shopper reads them.
|
||||||
|
const (
|
||||||
|
SlotMorning = "morning"
|
||||||
|
SlotAfternoon = "afternoon"
|
||||||
|
SlotEvening = "evening"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlotKeys is the whole set, in display order. Used to validate input and to
|
||||||
|
// seed a new branch.
|
||||||
|
var SlotKeys = []string{SlotMorning, SlotAfternoon, SlotEvening}
|
||||||
|
|
||||||
|
// IsSlotKey reports whether a key is one of the three.
|
||||||
|
func IsSlotKey(key string) bool {
|
||||||
|
return slices.Contains(SlotKeys, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A slot offered to a shopper, with the day it falls on.
|
||||||
|
|
||||||
|
`DeliverySlots` describes a window that recurs; this is one concrete occurrence
|
||||||
|
of it. The app needs the date because "evening" alone cannot distinguish tonight
|
||||||
|
from tomorrow night, and once today's last window closes the next thing on offer
|
||||||
|
is tomorrow morning.
|
||||||
|
|
||||||
|
The app does NO time arithmetic. It renders what this list contains, and the
|
||||||
|
list already excludes anything that has closed.
|
||||||
|
*/
|
||||||
|
type AvailableDeliverySlot struct {
|
||||||
|
Slotid int `json:"deliveryslotid"`
|
||||||
|
Slotkey string `json:"slotkey"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Starttime string `json:"starttime"`
|
||||||
|
Endtime string `json:"endtime"`
|
||||||
|
// "YYYY-MM-DD", the day this window falls on.
|
||||||
|
Slotdate string `json:"slotdate"`
|
||||||
|
// True when `Slotdate` is not today. Saves the app comparing dates to
|
||||||
|
// decide whether to write "Tomorrow" beside the name.
|
||||||
|
IsTomorrow bool `json:"istomorrow"`
|
||||||
|
}
|
||||||
164
models/healthscore.go
Normal file
164
models/healthscore.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
/*
|
||||||
|
The health score, as the customer app renders it.
|
||||||
|
|
||||||
|
The figures come from the catalogue-intelligence service — the same record the
|
||||||
|
nutrition panel comes from, and the same one behind the health score card in the
|
||||||
|
console. See services/nutritionService.go.
|
||||||
|
|
||||||
|
── Why the judgement is made here and not in the app ───────────────────────
|
||||||
|
|
||||||
|
The service returns a raw number and, from this endpoint, no band. Deciding what
|
||||||
|
that number means — which band, whether the match is sure enough to state
|
||||||
|
plainly, whether the product is even food — is a set of rules that already exists
|
||||||
|
in the console. Sending the raw number and letting the app re-derive them would
|
||||||
|
mean two implementations of the same judgement, drifting apart, disagreeing about
|
||||||
|
the same product on two screens. The rules travel with the answer instead.
|
||||||
|
*/
|
||||||
|
type HealthScore struct {
|
||||||
|
// 0–100, rounded. The service sends one decimal and nobody reads it.
|
||||||
|
Score int `json:"score"`
|
||||||
|
// "excellent" | "good" | "fair" | "poor" — for styling.
|
||||||
|
Band string `json:"band"`
|
||||||
|
// What a shopper reads, rather than what a nutritionist would call it.
|
||||||
|
Label string `json:"label"`
|
||||||
|
|
||||||
|
// Sentences the service already wrote for a person. Rendered as given.
|
||||||
|
Positives []string `json:"positives,omitempty"`
|
||||||
|
Cautions []string `json:"cautions,omitempty"`
|
||||||
|
Diettags []string `json:"diettags,omitempty"`
|
||||||
|
|
||||||
|
// Declared allergens. A false positive sends somebody to read the packet; a
|
||||||
|
// false negative sends them to hospital, so a declared one is always shown.
|
||||||
|
Allergens []string `json:"allergens,omitempty"`
|
||||||
|
// True when an EMPTY allergen list must NOT be read as "contains none".
|
||||||
|
//
|
||||||
|
// The service accepts a source match down to 0.32 confidence, and
|
||||||
|
// `data_status: "verified"` speaks to the numbers being real, not to the
|
||||||
|
// record being this product. What must never happen is silence standing in
|
||||||
|
// for "none" — which is exactly what an empty list rendered as nothing looks
|
||||||
|
// like. An app MUST say "not confirmed" rather than draw nothing here.
|
||||||
|
Allergensunconfirmed bool `json:"allergensunconfirmed,omitempty"`
|
||||||
|
|
||||||
|
// Set when the match is not sure enough to state plainly. When present the
|
||||||
|
// app must show it: a nutrition table presented as fact on a 61% match is a
|
||||||
|
// claim the data does not support.
|
||||||
|
Caveat string `json:"caveat,omitempty"`
|
||||||
|
|
||||||
|
// Where the figures came from, for a shopper who wants to check.
|
||||||
|
Source *HealthSource `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthSource struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LowConfidence is the line below which a match is a guide, not a fact.
|
||||||
|
//
|
||||||
|
// The same 0.7 the console uses. Sampling 40 scored products: 37 matched below
|
||||||
|
// 0.7 and 26 below 0.5, so this fires often — which is the point.
|
||||||
|
const LowConfidence = 0.7
|
||||||
|
|
||||||
|
/*
|
||||||
|
BandFor turns a score into a band.
|
||||||
|
|
||||||
|
The service's own `health_band` wins when it sends one. It does NOT send one
|
||||||
|
from the per-product endpoint — only from the list — so for this response the
|
||||||
|
fallback is not an edge case, it is the only path, which makes these thresholds
|
||||||
|
load-bearing rather than cosmetic.
|
||||||
|
|
||||||
|
They are the SERVICE'S thresholds, not ours. Derived from its output and since
|
||||||
|
confirmed by that team in writing:
|
||||||
|
|
||||||
|
excellent >= 80
|
||||||
|
good 60 – 79.9
|
||||||
|
fair 40 – 59.9 confirmed 40, not 50
|
||||||
|
poor < 40
|
||||||
|
|
||||||
|
Delete this fallback once `health_band` is on the per-product response — it is
|
||||||
|
on their list. Until then, picking our own numbers would be one API disagreeing
|
||||||
|
with itself depending which endpoint a screen called.
|
||||||
|
*/
|
||||||
|
func BandFor(score float64, sent string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(sent)) {
|
||||||
|
case "excellent", "good", "fair", "poor":
|
||||||
|
return strings.ToLower(strings.TrimSpace(sent))
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case score >= 80:
|
||||||
|
return "excellent"
|
||||||
|
case score >= 60:
|
||||||
|
return "good"
|
||||||
|
case score >= 40:
|
||||||
|
return "fair"
|
||||||
|
default:
|
||||||
|
return "poor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BandLabel is what a shopper reads.
|
||||||
|
func BandLabel(band string) string {
|
||||||
|
switch band {
|
||||||
|
case "excellent":
|
||||||
|
return "Very healthy"
|
||||||
|
case "good":
|
||||||
|
return "Healthy"
|
||||||
|
case "fair":
|
||||||
|
return "Okay"
|
||||||
|
default:
|
||||||
|
return "Less healthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
foodCategoryWords mean "this is food or drink".
|
||||||
|
|
||||||
|
An ALLOWLIST, and the asymmetry of the two failure modes is why. Withholding a
|
||||||
|
score on real food costs a shopper a badge they never had. Showing one on
|
||||||
|
something inedible is a different order of mistake, and the service has made it:
|
||||||
|
measured 4 Sep 2026, `GET /nutrition/Godrej/godrej_hit_spray_1101d017` returned
|
||||||
|
`health_score: 80.0, data_status: "verified"` — an "excellent" rating for
|
||||||
|
insecticide. Palmolive soap and Pantene shampoo both scored 37.5 the same way.
|
||||||
|
|
||||||
|
Re-measured 29 Sep 2026: those records now answer `unavailable`, so the purge
|
||||||
|
their team described has run. The guard stays anyway. It costs nothing when the
|
||||||
|
data is clean, and the tenant this was built for stocks soap, shampoo and
|
||||||
|
toothpaste alongside its food.
|
||||||
|
*/
|
||||||
|
var foodCategoryWords = []string{
|
||||||
|
"beverage", "drink", "juice", "water", "tea", "coffee",
|
||||||
|
"chocolate", "candy", "confection", "sweet", "dessert",
|
||||||
|
"dairy", "milk", "cheese", "butter", "ghee", "curd", "yogurt",
|
||||||
|
"snack", "biscuit", "cookie", "wafer", "chips", "namkeen",
|
||||||
|
"atta", "staple", "flour", "rice", "dal", "pulse", "grain", "cereal",
|
||||||
|
"pasta", "noodle", "bread", "bakery",
|
||||||
|
"oil", "masala", "spice", "sauce", "pickle", "jam", "honey",
|
||||||
|
"food", "nutrition", "breakfast", "fruit", "vegetable", "egg", "meat",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEdible reports whether a score is attached to something a person eats.
|
||||||
|
//
|
||||||
|
// An unrecognised category is treated as NOT food. On screen that reads as "not
|
||||||
|
// scored yet", which is honest — we genuinely do not know — and is what most
|
||||||
|
// products show anyway.
|
||||||
|
func IsEdible(category string) bool {
|
||||||
|
value := strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// "General" carries soap and household goods alongside anything else the
|
||||||
|
// scraper could not place. Ambiguous is not good enough for this decision.
|
||||||
|
if value == "general" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, word := range foodCategoryWords {
|
||||||
|
if strings.Contains(value, word) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
52
models/nutrition.go
Normal file
52
models/nutrition.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
/*
|
||||||
|
The nutrition panel, as the customer app renders it.
|
||||||
|
|
||||||
|
The figures come from the catalogue-intelligence service — the same one behind
|
||||||
|
the health score card in the console — and this is the shape they reach the app
|
||||||
|
in. See services/nutritionService.go for the fetch and the mapping.
|
||||||
|
|
||||||
|
── Why the field names are ugly ────────────────────────────────────────────
|
||||||
|
|
||||||
|
`servingsize`, not `serving_size` or `servingSize`. This is the shape the app
|
||||||
|
developer asked for, and an API is a promise to a client already written against
|
||||||
|
it. Consistency with the rest of Fiesta — itself inconsistent, `productid`
|
||||||
|
beside `image_id` beside `sku_source` — is worth less than not breaking the
|
||||||
|
caller.
|
||||||
|
*/
|
||||||
|
type NutritionPanel struct {
|
||||||
|
// What the figures are measured against. "100g" for everything the service
|
||||||
|
// returns today: its top-level values are per 100g, which is what the
|
||||||
|
// console's own panel prints beneath them.
|
||||||
|
Per string `json:"per,omitempty"`
|
||||||
|
// What the pack calls one serving — "1 mini (11 g)". Absent when the
|
||||||
|
// service did not state one, rather than defaulted: a serving size is a
|
||||||
|
// claim about the food, and a guessed one is a false claim.
|
||||||
|
Servingsize string `json:"servingsize,omitempty"`
|
||||||
|
// Never nil when this panel exists — see HasValues. An app receiving
|
||||||
|
// `items: null` has to branch; one receiving `[]` does not, and a panel with
|
||||||
|
// no rows should not have been sent at all.
|
||||||
|
Items []NutritionItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NutritionItem is one line of the panel.
|
||||||
|
type NutritionItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
// The figure. A float because saturated fat is 18.7g as often as it is 19g,
|
||||||
|
// and rounding it to please a type would be editing a label.
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
// "kcal", "g", "mg". Free text on purpose: `extended_nutrients` carries its
|
||||||
|
// own units from the source, and a closed list here would mean refusing to
|
||||||
|
// carry whatever the label actually says.
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasValues reports whether this panel is worth sending.
|
||||||
|
//
|
||||||
|
// A panel with no rows is not a panel — it is an empty box on a product page,
|
||||||
|
// which a shopper reads as "this food has no nutrition" rather than "we do not
|
||||||
|
// know yet". The endpoint omits it instead.
|
||||||
|
func (p *NutritionPanel) HasValues() bool {
|
||||||
|
return p != nil && len(p.Items) > 0
|
||||||
|
}
|
||||||
@@ -130,23 +130,39 @@ type OrderInfo struct {
|
|||||||
Deliverylat FlexibleString `json:"deliverylat"`
|
Deliverylat FlexibleString `json:"deliverylat"`
|
||||||
Deliverylong FlexibleString `json:"deliverylong"`
|
Deliverylong FlexibleString `json:"deliverylong"`
|
||||||
Deliverytype string `json:"deliverytype"`
|
Deliverytype string `json:"deliverytype"`
|
||||||
Paymenttype int `json:"paymenttype"`
|
// The delivery window the customer asked for.
|
||||||
Tenantname string `json:"tenantname"`
|
//
|
||||||
Tenanttoken string `json:"tenanttoken"`
|
// ON THIS STRUCT, not only on Orders. GetTenantOrders — which is what the
|
||||||
Tenantsuburb string `json:"tenantsuburb"`
|
// console and the app both read — scans into OrderInfo, so fields added to
|
||||||
Tenantcity string `json:"tenantcity"`
|
// Orders alone never reach the list. That was the whole of the
|
||||||
Tenantcontactno string `json:"tenantcontactno"`
|
// products.showhealthscore bug: written correctly, selected correctly,
|
||||||
Tenantpostcode string `json:"tenantpostcode"`
|
// absent from the response, and every reading taken from it meaningless.
|
||||||
Locationname string `json:"locationname"`
|
//
|
||||||
Locationsuburb string `json:"locationsuburb"`
|
// The last four are joined from deliveryslots and read-only, so a shop that
|
||||||
Locationcity string `json:"locationcity"`
|
// renames a window sees the new name on orders already placed.
|
||||||
Locationcontactno string `json:"locationcontactno"`
|
Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
|
||||||
Rider string `json:"rider"`
|
Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
|
||||||
Ridercontactno string `json:"ridercontactno"`
|
Slotkey string `json:"slotkey" gorm:"->"`
|
||||||
Riderkms FlexibleString `json:"riderkms"`
|
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
|
||||||
Smsdelivery int `json:"smsdelivery"`
|
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
|
||||||
Customertoken string `json:"customertoken"`
|
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
|
||||||
Ridertoken string `json:"ridertoken"`
|
Paymenttype int `json:"paymenttype"`
|
||||||
|
Tenantname string `json:"tenantname"`
|
||||||
|
Tenanttoken string `json:"tenanttoken"`
|
||||||
|
Tenantsuburb string `json:"tenantsuburb"`
|
||||||
|
Tenantcity string `json:"tenantcity"`
|
||||||
|
Tenantcontactno string `json:"tenantcontactno"`
|
||||||
|
Tenantpostcode string `json:"tenantpostcode"`
|
||||||
|
Locationname string `json:"locationname"`
|
||||||
|
Locationsuburb string `json:"locationsuburb"`
|
||||||
|
Locationcity string `json:"locationcity"`
|
||||||
|
Locationcontactno string `json:"locationcontactno"`
|
||||||
|
Rider string `json:"rider"`
|
||||||
|
Ridercontactno string `json:"ridercontactno"`
|
||||||
|
Riderkms FlexibleString `json:"riderkms"`
|
||||||
|
Smsdelivery int `json:"smsdelivery"`
|
||||||
|
Customertoken string `json:"customertoken"`
|
||||||
|
Ridertoken string `json:"ridertoken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryQuery struct {
|
type DeliveryQuery struct {
|
||||||
@@ -233,19 +249,39 @@ type Ordermonths struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Orders struct {
|
type Orders struct {
|
||||||
Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"`
|
Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"`
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
Locationid int `json:"locationid"`
|
Locationid int `json:"locationid"`
|
||||||
Applocationid int `json:"applocationid"`
|
Applocationid int `json:"applocationid"`
|
||||||
Moduleid int `json:"moduleid"`
|
Moduleid int `json:"moduleid"`
|
||||||
Partnerid int `json:"partnerid"`
|
Partnerid int `json:"partnerid"`
|
||||||
Configid int `json:"configid"`
|
Configid int `json:"configid"`
|
||||||
Categoryid int `json:"categoryid"`
|
Categoryid int `json:"categoryid"`
|
||||||
Subcategoryid int `json:"subcategoryid"`
|
Subcategoryid int `json:"subcategoryid"`
|
||||||
Orderid string `json:"orderid"`
|
Orderid string `json:"orderid"`
|
||||||
Orderdate string `json:"orderdate,omitempty"`
|
Orderdate string `json:"orderdate,omitempty"`
|
||||||
Deliverytime string `json:"deliverytime"`
|
Deliverytime string `json:"deliverytime"`
|
||||||
Deliverytype string `json:"deliverytype"`
|
Deliverytype string `json:"deliverytype"`
|
||||||
|
// The window the shopper chose, and the day it falls on.
|
||||||
|
//
|
||||||
|
// Both stay zero for every order placed without one — which is every order
|
||||||
|
// before this shipped, and every order from a branch that has set no
|
||||||
|
// windows. Nothing downstream may require them.
|
||||||
|
//
|
||||||
|
// Distinct from `Deliverytime` above, which is a TIMESTAMP of what happened
|
||||||
|
// and is defaulted to now() a few lines into CreateOrderv3. These two say
|
||||||
|
// what was asked for; that one says what occurred.
|
||||||
|
Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
|
||||||
|
Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
|
||||||
|
// Joined from deliveryslots, not stored on the order.
|
||||||
|
//
|
||||||
|
// So a shop that renames "Evening" to "After work" sees the new name on
|
||||||
|
// orders already placed — the window they chose has not changed, only what
|
||||||
|
// it is called. Read-only: nothing writes these back.
|
||||||
|
Slotkey string `json:"slotkey" gorm:"->"`
|
||||||
|
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
|
||||||
|
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
|
||||||
|
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
|
||||||
Orderstatus string `json:"orderstatus"`
|
Orderstatus string `json:"orderstatus"`
|
||||||
Pending string `json:"pending"`
|
Pending string `json:"pending"`
|
||||||
Processing string `json:"processing"`
|
Processing string `json:"processing"`
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ type Partnerinfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Ridershifts struct {
|
type Ridershifts struct {
|
||||||
Shiftid int `json:"shiftid" gorm:"Primary_Key"`
|
Shiftid int `json:"shiftid" gorm:"Primary_Key"`
|
||||||
|
// The region this shift belongs to. The column has always been on the table
|
||||||
|
// — `GetRiderShifts` filters on it — but there was no field for it here, so
|
||||||
|
// nothing could write one. That is why shifts could only ever be read.
|
||||||
|
Applocationid int `json:"applocationid"`
|
||||||
Shiftdate string `json:"shiftdate"`
|
Shiftdate string `json:"shiftdate"`
|
||||||
Starttime string `json:"starttime"`
|
Starttime string `json:"starttime"`
|
||||||
Endtime string `json:"endtime"`
|
Endtime string `json:"endtime"`
|
||||||
@@ -296,10 +300,20 @@ type NewPartner struct {
|
|||||||
Where they work — ONE district, not a set.
|
Where they work — ONE district, not a set.
|
||||||
|
|
||||||
`Applocationid` is the home region and goes on the partner row itself,
|
`Applocationid` is the home region and goes on the partner row itself,
|
||||||
because `GetPartners` filters on it and the rider app reads it.
|
because the rider app reads it. The same region is also written to
|
||||||
`Applocationids` is every region they cover and goes to
|
`partnerlocations`, which is the table that may hold SEVERAL — a partner
|
||||||
`partnerlocations` — one partner routinely serves several cities, and
|
routinely serves more than one city, and that is why the link table
|
||||||
that is the whole reason the link table exists.
|
exists, and partners with two are live — partner 44 covers regions 1 and
|
||||||
|
2. Nothing on THIS path creates one: `regionsOf` returns this single
|
||||||
|
field and the console's form offers one district, never a set. So a
|
||||||
|
multi-region partner can be read and must be handled, but cannot yet be
|
||||||
|
made here.
|
||||||
|
|
||||||
|
`GetPartners` reads the link table rather than this field, for two
|
||||||
|
reasons. It is the column allowed to grow, so a partner who covers a
|
||||||
|
second city will be found there without another change. And
|
||||||
|
`partnerinfo` is shared with another product that writes no link rows,
|
||||||
|
so having one is what marks a partner as ours.
|
||||||
*/
|
*/
|
||||||
Applocationid int `json:"applocationid"`
|
Applocationid int `json:"applocationid"`
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -155,6 +155,78 @@ type Products struct {
|
|||||||
// `catalogueProductColumns` casts its text[] columns to text.
|
// `catalogueProductColumns` casts its text[] columns to text.
|
||||||
Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"`
|
Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"`
|
||||||
|
|
||||||
|
// The catalogue's own record of this product, as it stood at import.
|
||||||
|
//
|
||||||
|
// Holds the fields the snapshot does not have columns for — fssai_license,
|
||||||
|
// highlights, nutrients, providers, price_range, variant_key, title,
|
||||||
|
// sku_source, search_query — so the console can show them without asking
|
||||||
|
// the catalogue again. It asked on every drawer open, and got nothing back
|
||||||
|
// the moment a re-scrape retired the source row, taking a licence number
|
||||||
|
// and a nutrition panel off a product the shop was still selling.
|
||||||
|
//
|
||||||
|
// Empty for anything that did not come from the catalogue: a sheet-imported
|
||||||
|
// product has no such record, and the drawer falls back to the live lookup
|
||||||
|
// for those exactly as before.
|
||||||
|
//
|
||||||
|
// A string for the same reason `Productimages` is one — GORM's raw
|
||||||
|
// scan-into-struct silently drops slice- and map-kind destination fields.
|
||||||
|
Cataloguefacts string `json:"cataloguefacts,omitempty" gorm:"column:cataloguefacts;type:jsonb"`
|
||||||
|
|
||||||
|
// The nutrition panel, for the product screen in the customer app.
|
||||||
|
//
|
||||||
|
// `gorm:"-"`: not a column. It is unpacked from Cataloguefacts above, which
|
||||||
|
// is where the import snapshots it — a second column holding the same facts
|
||||||
|
// is a second thing to keep in step, and this one has no writer of its own.
|
||||||
|
//
|
||||||
|
// ABSENT rather than null when a product has no nutrition. Most products on
|
||||||
|
// the platform have none today, and `"nutrition": null` on every row of a
|
||||||
|
// mobile response is payload spent saying nothing. An app should read a
|
||||||
|
// missing key as "not known", never as "this food has no nutrition".
|
||||||
|
//
|
||||||
|
// Set by the service, not the repository — see decorateNutrition.
|
||||||
|
Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"`
|
||||||
|
|
||||||
|
// The health score, for the same product screen and from the same record.
|
||||||
|
//
|
||||||
|
// `gorm:"-"`, absent when there is nothing safe to show, and independent of
|
||||||
|
// Nutrition above — a product can be scored with no figures published, and
|
||||||
|
// carry figures with no score.
|
||||||
|
//
|
||||||
|
// WITHHELD on anything that is not food. The upstream per-product endpoint
|
||||||
|
// is not gated for edibility and has rated insecticide 80/100; see
|
||||||
|
// models.IsEdible.
|
||||||
|
Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"`
|
||||||
|
|
||||||
|
// Whether this shop shows a health score for this product.
|
||||||
|
//
|
||||||
|
// A real column, unlike the two above. The shopkeeper's call: the score
|
||||||
|
// comes from a third party matching a reference product by name, often
|
||||||
|
// under 60% confidence, and a merchant who knows the packet may reasonably
|
||||||
|
// decide the rating does not describe what they sell.
|
||||||
|
//
|
||||||
|
// Defaults true, so every product imported before this column existed keeps
|
||||||
|
// showing what it shows today.
|
||||||
|
//
|
||||||
|
// Gates `Healthscore` and NOTHING else. `Nutrition` is always sent — the
|
||||||
|
// figures are what the packet says, the score is a judgement of them, and a
|
||||||
|
// merchant turning off the judgement is not disputing the grams.
|
||||||
|
//
|
||||||
|
// The PLATFORM console ignores this: Nearle staff see every score on every
|
||||||
|
// product, because the decision being made there is whether the data is good
|
||||||
|
// enough to publish at all.
|
||||||
|
//
|
||||||
|
// NO `default` IN THE GORM TAG, and that is not an oversight. GORM skips a
|
||||||
|
// zero-value field whose tag names a default, so `false` was never written:
|
||||||
|
// the INSERT omitted the column, the database default of true applied, and a
|
||||||
|
// product imported with the score switched off came back switched on. It
|
||||||
|
// shipped that way and was found by importing one and reading it back.
|
||||||
|
//
|
||||||
|
// The DEFAULT lives on the column instead, set by the migration in main.go.
|
||||||
|
// That still covers what it is for — rows that predate the column, and any
|
||||||
|
// INSERT that genuinely omits it — without teaching GORM to drop a
|
||||||
|
// deliberate false on the way past.
|
||||||
|
Showhealthscore bool `json:"showhealthscore" gorm:"column:showhealthscore"`
|
||||||
|
|
||||||
Productdesc string `json:"productdesc,omitempty"`
|
Productdesc string `json:"productdesc,omitempty"`
|
||||||
Productsku string `json:"productsku,omitempty"`
|
Productsku string `json:"productsku,omitempty"`
|
||||||
Brandid int `json:"brandid,omitempty"`
|
Brandid int `json:"brandid,omitempty"`
|
||||||
@@ -209,35 +281,57 @@ type Products struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Locationproducts struct {
|
type Locationproducts struct {
|
||||||
Productid int `json:"productid"`
|
Productid int `json:"productid"`
|
||||||
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
|
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
|
||||||
Productlocationid int `json:"productlocationid" gorm:"->"`
|
Productlocationid int `json:"productlocationid" gorm:"->"`
|
||||||
Tenantid int `json:"tenantid,omitempty"`
|
Tenantid int `json:"tenantid,omitempty"`
|
||||||
Categoryid int `json:"categoryid"`
|
Categoryid int `json:"categoryid"`
|
||||||
Categoryname string `json:"categoryname" gorm:"->"`
|
Categoryname string `json:"categoryname" gorm:"->"`
|
||||||
Subcategoryid int `json:"subcategoryid,omitempty"`
|
Subcategoryid int `json:"subcategoryid,omitempty"`
|
||||||
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
|
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
|
||||||
Catalogueid int `json:"catalogueid,omitempty"`
|
Catalogueid int `json:"catalogueid,omitempty"`
|
||||||
Addonid int `json:"addonid,omitempty"`
|
Addonid int `json:"addonid,omitempty"`
|
||||||
Discountid int `json:"discountid,omitempty"`
|
Discountid int `json:"discountid,omitempty"`
|
||||||
Pricingid int `json:"pricingid,omitempty"`
|
Pricingid int `json:"pricingid,omitempty"`
|
||||||
Productname string `json:"productname,omitempty"`
|
Productname string `json:"productname,omitempty"`
|
||||||
Productimage string `json:"productimage,omitempty"`
|
Productimage string `json:"productimage,omitempty"`
|
||||||
Productdesc string `json:"productdesc,omitempty"`
|
Productdesc string `json:"productdesc,omitempty"`
|
||||||
Productsku string `json:"productsku,omitempty"`
|
Productsku string `json:"productsku,omitempty"`
|
||||||
Brandid int `json:"brandid,omitempty"`
|
|
||||||
Productbrand string `json:"productbrand,omitempty"`
|
// Three columns this read used to leave in the table.
|
||||||
Productunit string `json:"productunit"`
|
//
|
||||||
Unitvalue string `json:"unitvalue"`
|
// All three are stored on `products` and none of them reached the store
|
||||||
Toppicks string `json:"toppicks,omitempty"`
|
// catalogue screen, because this struct had no field to scan them into —
|
||||||
Productcost float64 `json:"productcost,omitempty"`
|
// so the console could not use what the import had gone to the trouble of
|
||||||
Taxamount float64 `json:"taxamount,omitempty"`
|
// saving:
|
||||||
Taxpercent float64 `json:"taxpercent,omitempty"`
|
//
|
||||||
Producttax int `json:"producttax" gorm:"default:0"`
|
// Imageid the catalogue's durable key, and what HealthScorePanel
|
||||||
Productstock int `json:"productstock" gorm:"default:0"`
|
// joins on. Absent, the panel reads it as "this product
|
||||||
Productcombo int `json:"productcombo" gorm:"default:0"`
|
// never came from the catalogue" and renders nothing — for
|
||||||
Variants int `json:"variants" gorm:"default:0"`
|
// EVERY product, including ones that plainly did.
|
||||||
Quantity int `json:"quantity"`
|
// Productimages the rest of a product's photos. `imagesOf()` parses this
|
||||||
|
// and always got undefined, so the gallery fell back to
|
||||||
|
// the single `productimage` and the extra images — 90 of
|
||||||
|
// nestle's 123 products have them — were never shown.
|
||||||
|
// Cataloguefacts the licence, nutrition, highlights, providers and price
|
||||||
|
// range kept at import so they survive a re-scrape.
|
||||||
|
Imageid string `json:"imageid,omitempty"`
|
||||||
|
Productimages string `json:"productimages,omitempty"`
|
||||||
|
Cataloguefacts string `json:"cataloguefacts,omitempty"`
|
||||||
|
|
||||||
|
Brandid int `json:"brandid,omitempty"`
|
||||||
|
Productbrand string `json:"productbrand,omitempty"`
|
||||||
|
Productunit string `json:"productunit"`
|
||||||
|
Unitvalue string `json:"unitvalue"`
|
||||||
|
Toppicks string `json:"toppicks,omitempty"`
|
||||||
|
Productcost float64 `json:"productcost,omitempty"`
|
||||||
|
Taxamount float64 `json:"taxamount,omitempty"`
|
||||||
|
Taxpercent float64 `json:"taxpercent,omitempty"`
|
||||||
|
Producttax int `json:"producttax" gorm:"default:0"`
|
||||||
|
Productstock int `json:"productstock" gorm:"default:0"`
|
||||||
|
Productcombo int `json:"productcombo" gorm:"default:0"`
|
||||||
|
Variants int `json:"variants" gorm:"default:0"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
// Price is the per-store selling price from productlocations.price — the one
|
// Price is the per-store selling price from productlocations.price — the one
|
||||||
// CreateProductLocation upserts. Read-only here: it comes from the joined
|
// CreateProductLocation upserts. Read-only here: it comes from the joined
|
||||||
// productlocations row, not from products. Without it a store could set a
|
// productlocations row, not from products. Without it a store could set a
|
||||||
@@ -248,6 +342,19 @@ type Locationproducts struct {
|
|||||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||||
Othercost float64 `json:"othercost,omitempty"`
|
Othercost float64 `json:"othercost,omitempty"`
|
||||||
Approve int `json:"approve" gorm:"default:0"`
|
Approve int `json:"approve" gorm:"default:0"`
|
||||||
|
// Whether this product's health score is shown to shoppers.
|
||||||
|
//
|
||||||
|
// It has to be HERE and not only on Products, because this is the struct the
|
||||||
|
// admin catalogue reads. Without it the console received no value at all,
|
||||||
|
// the drawer's switch rendered "on" for every product including the ones
|
||||||
|
// that were off, and a product already hidden showed no panel and so no way
|
||||||
|
// to turn it back on. The column was being written correctly the whole time
|
||||||
|
// and simply never read back — which also made every "it did not save"
|
||||||
|
// reading taken from this endpoint meaningless.
|
||||||
|
//
|
||||||
|
// No `omitempty`: a false has to survive the trip, and omitempty would drop
|
||||||
|
// exactly the value this field exists to carry.
|
||||||
|
Showhealthscore bool `json:"showhealthscore"`
|
||||||
// Productstatus string `json:"productstatus" gorm:"default:available"`
|
// Productstatus string `json:"productstatus" gorm:"default:available"`
|
||||||
Status string `json:"status" gorm:"default:outofstock"`
|
Status string `json:"status" gorm:"default:outofstock"`
|
||||||
|
|
||||||
@@ -428,6 +535,14 @@ type ImportCatalogueProductRequest struct {
|
|||||||
Retailprice float64 `json:"retailprice"`
|
Retailprice float64 `json:"retailprice"`
|
||||||
Productcost float64 `json:"productcost"`
|
Productcost float64 `json:"productcost"`
|
||||||
Taxpercent float64 `json:"taxpercent"`
|
Taxpercent float64 `json:"taxpercent"`
|
||||||
|
|
||||||
|
// Whether this shop will show the product's health score.
|
||||||
|
//
|
||||||
|
// A POINTER so "not sent" and "sent as false" stay different answers. Every
|
||||||
|
// caller that predates this field omits it, and a bare bool would read those
|
||||||
|
// as a deliberate no and strip the score from every import made by an older
|
||||||
|
// console. Nil means "they did not say", which is treated as yes.
|
||||||
|
Showhealthscore *bool `json:"showhealthscore"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Productlocations struct {
|
type Productlocations struct {
|
||||||
|
|||||||
206
models/scan.go
Normal file
206
models/scan.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// Scan-to-order.
|
||||||
|
//
|
||||||
|
// A customer points the app at a packet, Google Lens (on the phone) reads a
|
||||||
|
// label off it, and the app asks: "which of MY shops has this, in what sizes,
|
||||||
|
// and which one should I buy from?" These are the shapes on both sides of
|
||||||
|
// that conversation.
|
||||||
|
|
||||||
|
// ScanLookupRequest is what the app sends once Lens has produced a label.
|
||||||
|
type ScanLookupRequest struct {
|
||||||
|
Customerid int `json:"customerid"`
|
||||||
|
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
||||||
|
// and capped by the service. Not required when Brand and Catalogueid
|
||||||
|
// name a product outright.
|
||||||
|
Label string `json:"label"`
|
||||||
|
// A product the customer has already chosen, by its catalogue key —
|
||||||
|
// which is how the app resolves a `candidates` list from an earlier
|
||||||
|
// ambiguous lookup, and how a deep link or a re-order skips recognition
|
||||||
|
// altogether. When both are set the label is ignored and no catalogue
|
||||||
|
// search runs.
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Catalogueid int64 `json:"catalogueid"`
|
||||||
|
// Where the customer is right now. Optional: without it the customer's
|
||||||
|
// saved primary address is used, and without that stores are listed in
|
||||||
|
// registration order with no distance.
|
||||||
|
Latitude FlexibleString `json:"latitude"`
|
||||||
|
Longitude FlexibleString `json:"longitude"`
|
||||||
|
// The tenants the app believes the customer has scanned into. Optional and
|
||||||
|
// never trusted on its own: the server intersects it with the
|
||||||
|
// tenantcustomers table and reports anything it dropped.
|
||||||
|
Tenantids []int `json:"tenantids"`
|
||||||
|
// How many stores to return. 0 = all registered stores that stock it.
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanStore is one of the customer's registered outlets.
|
||||||
|
type ScanStore struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Tenantname string `json:"tenantname"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Locationname string `json:"locationname"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
// Kilometres from the customer, or -1 when either side has no usable
|
||||||
|
// coordinates. Never omitted: a missing number is easy to misread as 0.
|
||||||
|
DistanceKm float64 `json:"distance_km"`
|
||||||
|
// Delivery reach in the outlet's own units, straight from tenantlocations.
|
||||||
|
Deliveryradius int `json:"deliveryradius"`
|
||||||
|
Deliverymins int `json:"deliverymins"`
|
||||||
|
Open bool `json:"open"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanOption is one thing the customer can actually put in the basket at one
|
||||||
|
// store: the matched product itself, or one of its sizes. Each is a real
|
||||||
|
// product row with its own price and stock, which is why they are flat.
|
||||||
|
type ScanOption struct {
|
||||||
|
// Where this is sold.
|
||||||
|
//
|
||||||
|
// On the option and not only on the enclosing store, because an order line
|
||||||
|
// carries both and the app would otherwise have to reach back up the
|
||||||
|
// response to build one. `Locationid` is the real outlet and never 0.
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
Productname string `json:"productname"`
|
||||||
|
|
||||||
|
// The pack size, both ways round.
|
||||||
|
//
|
||||||
|
// `Size` is the printable "500 g" the app has always shown. The two parts
|
||||||
|
// are sent beside it because an order line needs the unit on its own, and
|
||||||
|
// pulling it back out of the label means parsing a string a shop typed.
|
||||||
|
Size string `json:"size"`
|
||||||
|
Unitvalue string `json:"unitvalue"`
|
||||||
|
Productunit string `json:"productunit"`
|
||||||
|
|
||||||
|
// What the customer pays: the outlet's own price, or the product's retail
|
||||||
|
// price where the outlet has not set one.
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
// What the shop paid. NOT a price to charge — it is `products.productcost`,
|
||||||
|
// the same field the product screens return, and billing against it would
|
||||||
|
// sell at cost.
|
||||||
|
Productcost float64 `json:"productcost"`
|
||||||
|
|
||||||
|
// Carried on the order header, so the app has them without a second read.
|
||||||
|
Categoryid int `json:"categoryid"`
|
||||||
|
Subcategoryid int `json:"subcategoryid"`
|
||||||
|
|
||||||
|
Stock int `json:"stock"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
|
||||||
|
// The same URL under both names: `image` is what this endpoint has always
|
||||||
|
// sent, `productimage` is what every other product response calls it and
|
||||||
|
// what an order line is built from.
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Productimage string `json:"productimage,omitempty"`
|
||||||
|
|
||||||
|
// Is this the product that matched, or a size hanging under it?
|
||||||
|
IsVariant bool `json:"is_variant"`
|
||||||
|
Variantname string `json:"variantname,omitempty"`
|
||||||
|
// How the row was tied back to the catalogue: "imageid",
|
||||||
|
// "brand+catalogueid", "name" or "variant-of:<productid>".
|
||||||
|
MatchedBy string `json:"matched_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanStoreOffer is one store and what it can sell.
|
||||||
|
type ScanStoreOffer struct {
|
||||||
|
ScanStore
|
||||||
|
// Nearest store with at least one option in stock. Exactly one offer
|
||||||
|
// carries this, and only when something is in stock somewhere.
|
||||||
|
Recommended bool `json:"recommended"`
|
||||||
|
// Any option in stock here.
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Options []ScanOption `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanCatalogueMatch is what the catalogue search settled on.
|
||||||
|
type ScanCatalogueMatch struct {
|
||||||
|
Brand string `json:"brand"`
|
||||||
|
Catalogueid int64 `json:"catalogueid"`
|
||||||
|
Imageid string `json:"imageid,omitempty"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Size string `json:"size,omitempty"`
|
||||||
|
VariantKey string `json:"variant_key,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
// "vector+text", "text" or "direct" — how the score was produced. The app
|
||||||
|
// can be more cautious with a text-only match; "direct" means the caller
|
||||||
|
// named the product by its catalogue key and nothing was recognised.
|
||||||
|
Method string `json:"method"`
|
||||||
|
// Set only on entries of `candidates`: at least one of the customer's
|
||||||
|
// registered stores has this product in stock right now. Candidates are
|
||||||
|
// ordered with the available ones first, so a "did you mean?" list can
|
||||||
|
// show what is actually buyable before what is not.
|
||||||
|
Available bool `json:"available,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanLookupResponse is the answer to a scan.
|
||||||
|
type ScanLookupResponse struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
// The best catalogue product for the label, and the sizes of it the
|
||||||
|
// catalogue knows about (each a separate catalogue row).
|
||||||
|
//
|
||||||
|
// Match is nil when nothing was recognised, and also when several
|
||||||
|
// products matched equally well — see Ambiguous.
|
||||||
|
Match *ScanCatalogueMatch `json:"match"`
|
||||||
|
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
|
||||||
|
// Several products fit the label and no one of them is a clear winner —
|
||||||
|
// which is what a bare brand name ("britannia") or a generic word
|
||||||
|
// ("biscuits") produces, and Lens returns those often because a
|
||||||
|
// wordmark is the most legible thing on a packet.
|
||||||
|
//
|
||||||
|
// When true: Match is nil, Stores is empty, and Candidates holds the
|
||||||
|
// products to offer as "did you mean?". Picking one means calling
|
||||||
|
// /lookup again with that candidate's `brand` and `catalogueid`.
|
||||||
|
//
|
||||||
|
// Guessing instead would mean showing a confident price for a product
|
||||||
|
// the customer did not photograph.
|
||||||
|
Ambiguous bool `json:"ambiguous"`
|
||||||
|
Candidates []ScanCatalogueMatch `json:"candidates"`
|
||||||
|
// 0..1. Below ~0.5 the app should confirm with the customer before
|
||||||
|
// showing prices. With Ambiguous set this is the leader's score, which
|
||||||
|
// by definition the runner-up nearly equals.
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
// Registered stores that stock the product, nearest first, in-stock
|
||||||
|
// first. Empty with Available=false when none does.
|
||||||
|
Stores []ScanStoreOffer `json:"stores"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
// The locationid of the store marked Recommended, or 0.
|
||||||
|
RecommendedLocationid int `json:"recommended_locationid"`
|
||||||
|
// Tenant ids the app sent that the customer is not actually registered
|
||||||
|
// with. Empty normally; non-empty means the app's local list is stale.
|
||||||
|
UnregisteredTenantids []int `json:"unregistered_tenantids,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanConfirmRequest is sent when the customer taps a store and a size.
|
||||||
|
type ScanConfirmRequest struct {
|
||||||
|
Customerid int `json:"customerid"`
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
Latitude FlexibleString `json:"latitude"`
|
||||||
|
Longitude FlexibleString `json:"longitude"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanConfirmResponse says whether the pick still holds, and where to go if
|
||||||
|
// it does not.
|
||||||
|
type ScanConfirmResponse struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
// "in_stock", "insufficient_stock", "out_of_stock", "not_sold_here",
|
||||||
|
// "store_not_registered".
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Store *ScanStore `json:"store,omitempty"`
|
||||||
|
Option *ScanOption `json:"option,omitempty"`
|
||||||
|
Requested int `json:"requested"`
|
||||||
|
// The next-nearest registered store with enough of the same product, when
|
||||||
|
// the chosen one has run out. Nil when there is none.
|
||||||
|
Alternative *ScanStoreOffer `json:"alternative,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
64
models/showHealthScoreTag_test.go
Normal file
64
models/showHealthScoreTag_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
`showhealthscore` must not carry a GORM default.
|
||||||
|
|
||||||
|
GORM skips a zero-value field whose tag names a default — the documented
|
||||||
|
behaviour is that a `false`, `0` or `""` is left out of the INSERT so the
|
||||||
|
database default applies. For a boolean whose whole purpose is being set to
|
||||||
|
false, that means the one value anybody would set it to is the one that cannot
|
||||||
|
be written.
|
||||||
|
|
||||||
|
It shipped that way. A product imported with the health score switched off came
|
||||||
|
back switched on, and it took importing one and reading it back to find out,
|
||||||
|
because every layer above reported success: the console sent `false`, the
|
||||||
|
request carried `false`, the handler read `false`, GORM dropped it, and the
|
||||||
|
column default wrote `true`.
|
||||||
|
|
||||||
|
The DEFAULT belongs on the column, set by the migration in main.go. That covers
|
||||||
|
rows predating the column and any INSERT that genuinely omits it, without
|
||||||
|
teaching the ORM to discard a deliberate false on the way past.
|
||||||
|
*/
|
||||||
|
func TestShowHealthScoreCarriesNoGormDefault(t *testing.T) {
|
||||||
|
field, ok := reflect.TypeFor[Products]().FieldByName("Showhealthscore")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Products.Showhealthscore has moved or been renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := field.Tag.Get("gorm")
|
||||||
|
if strings.Contains(strings.ToLower(tag), "default") {
|
||||||
|
t.Fatalf(
|
||||||
|
"gorm tag %q names a default. GORM then skips this field when it is false, "+
|
||||||
|
"so a product whose health score is switched off is written as switched on. "+
|
||||||
|
"The column default is set by the migration in main.go instead.",
|
||||||
|
tag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The column still has to be named, since the Go field is one word and the
|
||||||
|
// column is too but GORM's default naming would make it `show_health_score`.
|
||||||
|
if !strings.Contains(tag, "column:showhealthscore") {
|
||||||
|
t.Errorf("gorm tag %q no longer names the column", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowHealthScoreIsAPlainBoolOnTheWire(t *testing.T) {
|
||||||
|
// Not a pointer, and not `omitempty`. Every product says what it is: the
|
||||||
|
// console reads it to set the switch, and an absent key would be
|
||||||
|
// indistinguishable from false on a screen that has to show one or the
|
||||||
|
// other.
|
||||||
|
field, _ := reflect.TypeFor[Products]().FieldByName("Showhealthscore")
|
||||||
|
|
||||||
|
if field.Type.Kind() != reflect.Bool {
|
||||||
|
t.Errorf("Showhealthscore is %s, want bool", field.Type.Kind())
|
||||||
|
}
|
||||||
|
if tag := field.Tag.Get("json"); tag != "showhealthscore" {
|
||||||
|
t.Errorf("json tag is %q — an omitempty here would hide every `false`", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,14 @@ type Tenantinfo struct {
|
|||||||
Allocationid int `json:"allocationid"`
|
Allocationid int `json:"allocationid"`
|
||||||
Allocationtype string `json:"allocationtype"`
|
Allocationtype string `json:"allocationtype"`
|
||||||
Allocationmode int `json:"allocationmode"`
|
Allocationmode int `json:"allocationmode"`
|
||||||
|
|
||||||
|
// How many outlets this merchant has.
|
||||||
|
//
|
||||||
|
// Only `GetAllTenants` fills this; it is 0 everywhere else, which is why it
|
||||||
|
// is last and optional rather than part of the record proper. The console's
|
||||||
|
// store list previously derived it by counting duplicate rows, and this
|
||||||
|
// endpoint has never returned duplicates — see the note on the query.
|
||||||
|
Branchcount int `json:"branchcount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tenantlocations struct {
|
type Tenantlocations struct {
|
||||||
@@ -190,6 +198,22 @@ type StaffInfo struct {
|
|||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
Locationid int `json:"locationid"`
|
Locationid int `json:"locationid"`
|
||||||
Locationname string `json:"locationname"`
|
Locationname string `json:"locationname"`
|
||||||
|
// Whether this login still works, straight off `app_users.status`.
|
||||||
|
// Without it every row on the console's Users & access screen read
|
||||||
|
// "Unknown", because the field was never selected or sent.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// Whether they have ever chosen a password.
|
||||||
|
//
|
||||||
|
// Every back-office account is created with an empty one and emailed a link
|
||||||
|
// to set it. Until that link is used the person is in this list, in every
|
||||||
|
// branch picker, and cannot sign in — and `Status` does not say so: an
|
||||||
|
// Active account with no password is refused at the login screen like any
|
||||||
|
// other. `false` is the row that needs an action, which is why the directory
|
||||||
|
// reads it and offers a resend there and nowhere else.
|
||||||
|
//
|
||||||
|
// Computed in the query. `Password` is also on this struct, which is its own
|
||||||
|
// problem, but nothing should have to look at it to answer this.
|
||||||
|
IsSetUp bool `json:"issetup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tenantuser struct {
|
type Tenantuser struct {
|
||||||
|
|||||||
99
repositories/assistantAuditRepository.go
Normal file
99
repositories/assistantAuditRepository.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Where the assistant's audit rows land.
|
||||||
|
//
|
||||||
|
// Deliberately thin: one insert and one read. The registry decides what an entry
|
||||||
|
// means; this only has to keep it.
|
||||||
|
type AssistantAuditRepository interface {
|
||||||
|
Record(entry models.AssistantAudit) error
|
||||||
|
// Recent reads the trail back for one tenant, newest first.
|
||||||
|
//
|
||||||
|
// Scoped by tenant even though this is a review surface, because "who looked
|
||||||
|
// at what" is itself a merchant's data — a trail readable across tenants
|
||||||
|
// would be a nicer version of the hole the trail exists to detect.
|
||||||
|
Recent(tenantID, limit int) ([]models.AssistantAudit, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type assistantAuditRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewAssistantAuditRepository(db *gorm.DB) AssistantAuditRepository {
|
||||||
|
return &assistantAuditRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *assistantAuditRepository) Record(entry models.AssistantAudit) error {
|
||||||
|
if r.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.Create(&entry).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *assistantAuditRepository) Recent(tenantID, limit int) ([]models.AssistantAudit, error) {
|
||||||
|
if r.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
var rows []models.AssistantAudit
|
||||||
|
err := r.db.Where("tenantid = ?", tenantID).
|
||||||
|
Order("at DESC").Limit(limit).Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeAuditArgs renders arguments for storage.
|
||||||
|
//
|
||||||
|
// Keys sorted, so two identical calls store identical JSON and a query looking
|
||||||
|
// for one of them finds both. Go randomises map iteration, and without this the
|
||||||
|
// same call would be unsearchable across rows.
|
||||||
|
//
|
||||||
|
// A value that will not encode becomes a string rather than failing the write:
|
||||||
|
// losing the audit row entirely to save one unencodable argument is the wrong
|
||||||
|
// trade, and the row is still the record that the call happened.
|
||||||
|
func EncodeAuditArgs(args map[string]any) string {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
ordered := make(map[string]json.RawMessage, len(args))
|
||||||
|
keys := make([]string, 0, len(args))
|
||||||
|
for key := range args {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
raw, err := json.Marshal(args[key])
|
||||||
|
if err != nil {
|
||||||
|
raw, _ = json.Marshal("<unencodable>")
|
||||||
|
}
|
||||||
|
ordered[key] = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-marshalled through an ordered slice of pairs so the output is stable;
|
||||||
|
// a map would be re-randomised on the way out.
|
||||||
|
var out []byte
|
||||||
|
out = append(out, '{')
|
||||||
|
for i, key := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
out = append(out, ',')
|
||||||
|
}
|
||||||
|
name, _ := json.Marshal(key)
|
||||||
|
out = append(out, name...)
|
||||||
|
out = append(out, ':')
|
||||||
|
out = append(out, ordered[key]...)
|
||||||
|
}
|
||||||
|
out = append(out, '}')
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditDuration converts a duration for storage, rounding to the millisecond.
|
||||||
|
func AuditDuration(d time.Duration) int64 { return d.Round(time.Millisecond).Milliseconds() }
|
||||||
@@ -208,3 +208,35 @@ func TestNormaliseBrandKeyRefusesToInventAKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every word of the label was once required, so one word the catalogue does
|
||||||
|
// not use ("Parle G biscuit pack") kept the right product out of the result
|
||||||
|
// altogether and left the vector search to answer alone.
|
||||||
|
func TestMinTokenHitsAsksForMostWordsNotAllOfThem(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ tokens, want int }{
|
||||||
|
{1, 1}, // one word: it has to be there
|
||||||
|
{2, 2}, // "Parle G" — both, and both are in Parle-G
|
||||||
|
{3, 2}, // "Milk Bikis pack" — the pack is allowed to be missing
|
||||||
|
{4, 3}, // "Parle G biscuit pack"
|
||||||
|
{5, 4},
|
||||||
|
{6, 4},
|
||||||
|
} {
|
||||||
|
if got := minTokenHits(tc.tokens); got != tc.want {
|
||||||
|
t.Errorf("%d tokens: need %d, want %d", tc.tokens, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A threshold that could fall to 1 would let any single common word drag in
|
||||||
|
// whole brand tables; one that stayed at n would be the bug all over again.
|
||||||
|
func TestMinTokenHitsStaysBetweenTwoAndAll(t *testing.T) {
|
||||||
|
for n := 3; n <= 30; n++ {
|
||||||
|
got := minTokenHits(n)
|
||||||
|
if got < 2 {
|
||||||
|
t.Fatalf("%d tokens: %d is too loose", n, got)
|
||||||
|
}
|
||||||
|
if got >= n {
|
||||||
|
t.Fatalf("%d tokens: %d still demands every word", n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured
|
|||||||
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
||||||
// scan-into-struct silently drops slice-kind destination fields, so they
|
// scan-into-struct silently drops slice-kind destination fields, so they
|
||||||
// are read as text here and parsed into []string in scanProductRow.
|
// are read as text here and parsed into []string in scanProductRow.
|
||||||
|
|
||||||
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
|
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
|
||||||
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
|
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
|
||||||
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
|
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
|
||||||
|
|||||||
@@ -120,13 +120,26 @@ const (
|
|||||||
a.riderslat,a.riderslon,a.deliveryamt,a.kms,a.actualkms,a.riderkms,a.deliverycharges,a.deliverytype,a.paymenttype,a.smsdelivery,
|
a.riderslat,a.riderslon,a.deliveryamt,a.kms,a.actualkms,a.riderkms,a.deliverycharges,a.deliverytype,a.paymenttype,a.smsdelivery,
|
||||||
a.expecteddeliverytime,a.profit,a.transitminutes,a.calculationdistancekm,
|
a.expecteddeliverytime,a.profit,a.transitminutes,a.calculationdistancekm,
|
||||||
a.notes,a.ordernotes,b.tenantname,b.primarycontact as tenantcontactno,b.tenanttoken,b.suburb as tenantsuburb,b.city as tenantcity,
|
a.notes,a.ordernotes,b.tenantname,b.primarycontact as tenantcontactno,b.tenanttoken,b.suburb as tenantsuburb,b.city as tenantcity,
|
||||||
c.firstname AS ridername,c.userfcmtoken,e.locationname,e.suburb AS locationsuburb,e.contactno AS locationcontactno
|
c.firstname AS ridername,c.userfcmtoken,e.locationname,e.suburb AS locationsuburb,e.contactno AS locationcontactno,
|
||||||
|
-- The delivery window, read from the ORDER.
|
||||||
|
--
|
||||||
|
-- The deliveries table has no window column and deliberately gets none:
|
||||||
|
-- the window is a fact about what the customer asked for, and copying it
|
||||||
|
-- here would be a second truth that can drift from the first. The
|
||||||
|
-- dispatch board is exactly where drift would be noticed and exactly where
|
||||||
|
-- it would cost the most, so it is joined.
|
||||||
|
o.deliveryslotid, o.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
|
||||||
|
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
|
||||||
FROM deliveries a
|
FROM deliveries a
|
||||||
INNER JOIN tenants b ON a.tenantid=b.tenantid
|
INNER JOIN tenants b ON a.tenantid=b.tenantid
|
||||||
INNER JOIN app_users c ON a.userid=c.userid
|
INNER JOIN app_users c ON a.userid=c.userid
|
||||||
INNER JOIN tenantlocations e ON a.locationid=e.locationid
|
INNER JOIN tenantlocations e ON a.locationid=e.locationid
|
||||||
INNER JOIN app_location f ON a.applocationid = f.applocationid
|
INNER JOIN app_location f ON a.applocationid = f.applocationid
|
||||||
INNER JOIN app_locationconfig g ON f.applocationid = g.applocationid`
|
INNER JOIN app_locationconfig g ON f.applocationid = g.applocationid
|
||||||
|
-- Both LEFT. Most deliveries have no window, and every one of them must
|
||||||
|
-- still appear on the board — an INNER JOIN here would empty dispatch.
|
||||||
|
LEFT JOIN orders o ON a.orderheaderid = o.orderheaderid
|
||||||
|
LEFT JOIN deliveryslots s ON o.deliveryslotid = s.slotid`
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *deliveriesRepository) CreateDeliveries(data []models.Deliveries) error {
|
func (r *deliveriesRepository) CreateDeliveries(data []models.Deliveries) error {
|
||||||
|
|||||||
97
repositories/deliverySlotRepository.go
Normal file
97
repositories/deliverySlotRepository.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeliverySlotRepository interface {
|
||||||
|
ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error)
|
||||||
|
FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error)
|
||||||
|
Save(slots []models.DeliverySlots) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type deliverySlotRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeliverySlotRepository(db *gorm.DB) DeliverySlotRepository {
|
||||||
|
return &deliverySlotRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered by start time so every caller — the console's editor and the app's
|
||||||
|
// list alike — sees morning before evening without sorting it again.
|
||||||
|
func (r *deliverySlotRepository) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
|
||||||
|
slots := make([]models.DeliverySlots, 0, len(models.SlotKeys))
|
||||||
|
|
||||||
|
err := r.db.Table("deliveryslots").
|
||||||
|
Where("tenantid = ? AND locationid = ?", tenantID, locationID).
|
||||||
|
Order("starttime ASC").
|
||||||
|
Find(&slots).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return slots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
One window, scoped to the branch that claims it.
|
||||||
|
|
||||||
|
The tenant and location are in the WHERE and not checked afterwards: a slot id
|
||||||
|
arrives from the app, which carries no session, so it is a claim about which
|
||||||
|
shop it belongs to. Looking it up by id alone and trusting the row would let one
|
||||||
|
shop's checkout name another shop's window.
|
||||||
|
*/
|
||||||
|
func (r *deliverySlotRepository) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) {
|
||||||
|
var slot models.DeliverySlots
|
||||||
|
|
||||||
|
err := r.db.Table("deliveryslots").
|
||||||
|
Where("slotid = ? AND tenantid = ? AND locationid = ?", slotID, tenantID, locationID).
|
||||||
|
First(&slot).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// Not an error: "this shop does not offer that" is an answer, and the
|
||||||
|
// service turns it into something a shopper can read.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &slot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Write a branch's windows, all of them or none.
|
||||||
|
|
||||||
|
── Upsert, not delete-then-insert ──────────────────────────────────────────
|
||||||
|
|
||||||
|
Slot ids are referenced by `orders.deliveryslotid`. Replacing the rows would
|
||||||
|
renumber them, and every order already placed would point at a window that no
|
||||||
|
longer means what it did — or at nothing. The unique index on
|
||||||
|
(tenantid, locationid, slotkey) is what makes the conflict target work, so a
|
||||||
|
branch keeps one morning however many times it is edited.
|
||||||
|
|
||||||
|
── One transaction ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
A shop editing all three and getting two is worse than getting none: the two
|
||||||
|
that took are live and serving shoppers, and nothing on screen says which.
|
||||||
|
*/
|
||||||
|
func (r *deliverySlotRepository) Save(slots []models.DeliverySlots) error {
|
||||||
|
if len(slots) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return tx.Table("deliveryslots").
|
||||||
|
Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{
|
||||||
|
{Name: "tenantid"}, {Name: "locationid"}, {Name: "slotkey"},
|
||||||
|
},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"name", "starttime", "endtime", "status", "updated"}),
|
||||||
|
}).
|
||||||
|
Create(&slots).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -102,14 +102,30 @@ const (
|
|||||||
a.deliveryid AS deliverycustomerid, a.deliveryid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
|
a.deliveryid AS deliverycustomerid, a.deliveryid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
|
||||||
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery, b.customertoken,
|
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery, b.customertoken,
|
||||||
c.tenantname, c.tenanttoken, c.primarycontact AS tenantcontactno, c.postcode AS tenantpostcode, c.suburb AS tenantsuburb, c.city AS tenantcity,
|
c.tenantname, c.tenanttoken, c.primarycontact AS tenantcontactno, c.postcode AS tenantpostcode, c.suburb AS tenantsuburb, c.city AS tenantcity,
|
||||||
d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity
|
d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity,
|
||||||
|
-- The delivery window the customer asked for.
|
||||||
|
--
|
||||||
|
-- Selected EXPLICITLY, like everything else here: this select names its
|
||||||
|
-- columns, so a field added to the Go struct and not added to this line
|
||||||
|
-- is simply absent from every row, with nothing anywhere saying so. That
|
||||||
|
-- exact gap shipped once on products.showhealthscore and made every
|
||||||
|
-- reading taken from the list meaningless.
|
||||||
|
--
|
||||||
|
-- The NAME is joined rather than stored on the order, so a shop that
|
||||||
|
-- renames "Evening" to "After work" sees the new name on old orders --
|
||||||
|
-- the window they chose has not changed, only what it is called.
|
||||||
|
a.deliveryslotid, a.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
|
||||||
|
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
|
||||||
FROM orders a
|
FROM orders a
|
||||||
LEFT JOIN customers b ON a.customerid = b.customerid
|
LEFT JOIN customers b ON a.customerid = b.customerid
|
||||||
LEFT JOIN tenants c ON a.tenantid = c.tenantid
|
LEFT JOIN tenants c ON a.tenantid = c.tenantid
|
||||||
LEFT JOIN tenantlocations d ON a.locationid = d.locationid
|
LEFT JOIN tenantlocations d ON a.locationid = d.locationid
|
||||||
|
|
||||||
LEFT JOIN app_location h ON a.applocationid = h.applocationid
|
LEFT JOIN app_location h ON a.applocationid = h.applocationid
|
||||||
LEFT JOIN app_locationconfig i ON a.applocationid = i.applocationid`
|
LEFT JOIN app_locationconfig i ON a.applocationid = i.applocationid
|
||||||
|
-- LEFT, because the overwhelming majority of orders have no window and
|
||||||
|
-- must still appear. An INNER JOIN here would silently empty the list.
|
||||||
|
LEFT JOIN deliveryslots s ON a.deliveryslotid = s.slotid`
|
||||||
|
|
||||||
orderdetails = `SELECT DISTINCT a.orderheaderid, a.applocationid,
|
orderdetails = `SELECT DISTINCT a.orderheaderid, a.applocationid,
|
||||||
a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
|
a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
|
||||||
@@ -122,12 +138,22 @@ const (
|
|||||||
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity,a.paymenttype, a.smsdelivery, a.orderamount,
|
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity,a.paymenttype, a.smsdelivery, a.orderamount,
|
||||||
b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno, b.postcode AS tenantpostcode, b.suburb AS tenantsuburb,b.city AS tenantcity,
|
b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno, b.postcode AS tenantpostcode, b.suburb AS tenantsuburb,b.city AS tenantcity,
|
||||||
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode, c.suburb AS locationsuburb, c.city AS locationcity,
|
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode, c.suburb AS locationsuburb, c.city AS locationcity,
|
||||||
d.locationname AS applocation
|
d.locationname AS applocation,
|
||||||
|
-- The delivery window, same as the 'base' select above.
|
||||||
|
--
|
||||||
|
-- BOTH selects need it. 'base' backs the console's list; this one backs the
|
||||||
|
-- partner, customer, user and admin reads -- including the customer app's
|
||||||
|
-- own order history, which is where a shopper expects to see the window
|
||||||
|
-- they picked. Fixing one and not the other is how a field ends up present
|
||||||
|
-- on some screens and silently absent on others.
|
||||||
|
a.deliveryslotid, a.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
|
||||||
|
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
|
||||||
FROM orders a
|
FROM orders a
|
||||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid`
|
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||||
|
LEFT JOIN deliveryslots s ON a.deliveryslotid = s.slotid`
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) {
|
func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type PartnerRepository interface {
|
|||||||
GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error)
|
GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error)
|
||||||
GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error)
|
GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error)
|
||||||
GetRiderShifts(aid int) ([]models.Ridershifts, error)
|
GetRiderShifts(aid int) ([]models.Ridershifts, error)
|
||||||
|
CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error)
|
||||||
GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error)
|
GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error)
|
||||||
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
|
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
|
||||||
GetRiderInfo(userid int) (models.RiderInfo, error)
|
GetRiderInfo(userid int) (models.RiderInfo, error)
|
||||||
@@ -87,30 +88,67 @@ func (r *partnerRepository) GetPartners(aid, pid, uid int) ([]models.Partnerinfo
|
|||||||
var q1 string
|
var q1 string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
|
// Every variant joins partnerlocations, and that join is the whole point.
|
||||||
|
//
|
||||||
|
// ── It is what separates our partners from somebody else's ──────────────
|
||||||
|
//
|
||||||
|
// `partnerinfo` is shared. It has no column saying which product a row
|
||||||
|
// belongs to — no configid, no appid — so a partner created by another app
|
||||||
|
// on this database is indistinguishable from ours by its own fields, and
|
||||||
|
// this read used to return every Active row on the platform. The console
|
||||||
|
// made that worse rather than better: it asks `getapplocations` for EVERY
|
||||||
|
// region and then fetches partners region by region, so the applocationid
|
||||||
|
// filter below never narrowed anything.
|
||||||
|
//
|
||||||
|
// `partnerlocations` is the difference. Only `CreatePartner` writes it —
|
||||||
|
// one row per region, in the same transaction as the partner — so a row in
|
||||||
|
// that table means "registered through this console". The partners that
|
||||||
|
// predate it were inserted by hand and have none, which is why two of them
|
||||||
|
// are called "Test".
|
||||||
|
//
|
||||||
|
// ── The region filter reads the link table, not the home region ─────────
|
||||||
|
//
|
||||||
|
// `partnerinfo.applocationid` is the HOME region — CreatePartner writes
|
||||||
|
// `regions[0]` there — while partnerlocations holds every region covered.
|
||||||
|
// Those are not the same thing, and not only in theory: partner 44,
|
||||||
|
// Xpress-Cbe-Main, has a home region of 1 and link rows for 1 AND 2, so
|
||||||
|
// filtering on the partner row hid them from every Madurai query. That is
|
||||||
|
// the case the link table exists for.
|
||||||
|
//
|
||||||
|
// DISTINCT because such a partner has one row per region in the join and is
|
||||||
|
// still one partner. Only partnerinfo columns are selected, so there is
|
||||||
|
// nothing per-region for it to fail to collapse.
|
||||||
|
//
|
||||||
|
// A caller fanning out over regions and concatenating the answers still has
|
||||||
|
// to dedupe — the same partner is legitimately in two of them. The console's
|
||||||
|
// `useAllPartners` does; it listed Xpress-Cbe-Main twice until it did.
|
||||||
|
const columns = `select distinct p.partnerid,p.applocationid,p.partnertypeid,p.partnername,
|
||||||
|
p.primarycontact,p.primaryemail,p.contactno,p.address,p.suburb,p.state,p.city,p.partnerimage
|
||||||
|
from partnerinfo p
|
||||||
|
inner join partnerlocations l on l.partnerid = p.partnerid
|
||||||
|
where p.status='Active'`
|
||||||
|
|
||||||
if pid != 0 {
|
if pid != 0 {
|
||||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
// Scoped the same way on purpose: asking for a partner by id must not
|
||||||
contactno,address,suburb,state,city,partnerimage
|
// be a way round the separation above.
|
||||||
from partnerinfo where status='Active' and partnerid=?`
|
q1 = columns + ` and p.partnerid=?`
|
||||||
args = append(args, pid)
|
args = append(args, pid)
|
||||||
|
|
||||||
} else if aid != 0 {
|
} else if aid != 0 {
|
||||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
q1 = columns + ` and l.applocationid=?`
|
||||||
contactno,address,suburb,state,city,partnerimage
|
|
||||||
from partnerinfo where status='Active' and applocationid=?`
|
|
||||||
args = append(args, aid)
|
args = append(args, aid)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
q1 = columns
|
||||||
contactno,address,suburb,state,city,partnerimage
|
|
||||||
from partnerinfo where status='Active'`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
q1 += ` order by p.partnername, p.partnerid`
|
||||||
|
|
||||||
err := r.db.Raw(q1, args...).Find(&data).Error
|
err := r.db.Raw(q1, args...).Find(&data).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
print(q1)
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,13 +653,17 @@ them are named "Test".
|
|||||||
|
|
||||||
Where a partner works is recorded twice, on purpose and not by accident:
|
Where a partner works is recorded twice, on purpose and not by accident:
|
||||||
|
|
||||||
partnerinfo.applocationid their home region — `GetPartners` filters on it
|
partnerinfo.applocationid their home region — the rider app reads it
|
||||||
and the rider app reads it
|
|
||||||
partnerlocations every region they cover
|
partnerlocations every region they cover
|
||||||
|
|
||||||
Both are kept in step here. Writing only the first would confine a partner to
|
Both are kept in step here. Writing only the first would confine a partner to
|
||||||
one city, and writing only the second would hide them from every existing
|
one city, and writing only the second would hide them from the rider app.
|
||||||
query. */
|
|
||||||
|
`GetPartners` reads the SECOND: it joins partnerlocations, which both scopes a
|
||||||
|
region query to every city a partner actually covers and — because only this
|
||||||
|
function writes that table — separates partners registered here from the ones
|
||||||
|
another product put in the shared `partnerinfo`. So the link rows are not
|
||||||
|
bookkeeping; they are what makes a partner ours. */
|
||||||
|
|
||||||
// CreatePartner onboards a delivery partner and records the regions they cover.
|
// CreatePartner onboards a delivery partner and records the regions they cover.
|
||||||
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
|
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
|
||||||
@@ -986,3 +1028,178 @@ func (r *partnerRepository) regionByName(db *gorm.DB, name string) int {
|
|||||||
WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id)
|
WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rider shifts ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A shift is the window a rider works, and `CreateRider` refuses a rider
|
||||||
|
// without one — `getriders` joins `ridershifts` through `ridersettings.shiftid`,
|
||||||
|
// so a rider on a shift that does not exist is a rider nobody can see.
|
||||||
|
//
|
||||||
|
// Until now the table could only be READ. There was no endpoint, no service
|
||||||
|
// method and not even a field for `applocationid` on the model, so a region
|
||||||
|
// that shipped without shift rows could never have a rider added to it at all:
|
||||||
|
// the console showed "No shifts set up for this region" and there was nothing
|
||||||
|
// anybody could do from the product to change that. Till staff had
|
||||||
|
// `createstaffshift` from the beginning; riders were simply missed.
|
||||||
|
|
||||||
|
// riderShiftClock is a start or end time as the column stores it.
|
||||||
|
//
|
||||||
|
// Accepts `9:00`, `09:00` and `09:00:00` and normalises to `HH:MM`. The rows
|
||||||
|
// inserted by hand over the years use all three spellings, and `GetRiderShifts`
|
||||||
|
// builds its label by concatenating the two columns raw — so `9:00-17:00` and
|
||||||
|
// `09:00-17:00` are two different labels for one window in the same dropdown.
|
||||||
|
func riderShiftClock(raw string) (string, error) {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
return "", errors.New("a shift needs a start and an end time")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(text, ":")
|
||||||
|
if len(parts) < 2 || len(parts) > 3 {
|
||||||
|
return "", fmt.Errorf("%q is not a time — write it as HH:MM", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
hour, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
if err != nil || hour < 0 || hour > 23 {
|
||||||
|
return "", fmt.Errorf("%q is not a time — the hour must be 0 to 23", raw)
|
||||||
|
}
|
||||||
|
minute, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil || minute < 0 || minute > 59 {
|
||||||
|
return "", fmt.Errorf("%q is not a time — the minutes must be 0 to 59", raw)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%02d:%02d", hour, minute), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// riderShiftHours is how long the window runs, in hours.
|
||||||
|
//
|
||||||
|
// Computed rather than asked for, because it is the one field a person gets
|
||||||
|
// wrong and nothing downstream checks: `shifthours` feeds rider pay, and a
|
||||||
|
// window of 09:00–17:00 recorded as 4 hours underpays every rider on it.
|
||||||
|
//
|
||||||
|
// A window that ends before it starts crosses midnight and is measured that
|
||||||
|
// way — a 22:00–06:00 night shift is eight hours, not minus sixteen.
|
||||||
|
func riderShiftHours(start, end string) float32 {
|
||||||
|
toMinutes := func(clock string) int {
|
||||||
|
parts := strings.Split(clock, ":")
|
||||||
|
hour, _ := strconv.Atoi(parts[0])
|
||||||
|
minute, _ := strconv.Atoi(parts[1])
|
||||||
|
return hour*60 + minute
|
||||||
|
}
|
||||||
|
|
||||||
|
span := toMinutes(end) - toMinutes(start)
|
||||||
|
if span <= 0 {
|
||||||
|
span += 24 * 60
|
||||||
|
}
|
||||||
|
return float32(span) / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRiderShift checks everything that does not need the database.
|
||||||
|
//
|
||||||
|
// Split out so the rules are testable without one, and returns the shift with
|
||||||
|
// its times normalised and its hours worked out rather than reporting on a copy
|
||||||
|
// the caller then has to rebuild.
|
||||||
|
func validateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
|
||||||
|
if shift.Applocationid == 0 {
|
||||||
|
return shift, errors.New("a shift needs a delivery region")
|
||||||
|
}
|
||||||
|
|
||||||
|
start, err := riderShiftClock(shift.Starttime)
|
||||||
|
if err != nil {
|
||||||
|
return shift, err
|
||||||
|
}
|
||||||
|
end, err := riderShiftClock(shift.Endtime)
|
||||||
|
if err != nil {
|
||||||
|
return shift, err
|
||||||
|
}
|
||||||
|
if start == end {
|
||||||
|
return shift, errors.New("a shift cannot start and end at the same time")
|
||||||
|
}
|
||||||
|
|
||||||
|
shift.Starttime = start
|
||||||
|
shift.Endtime = end
|
||||||
|
// Always recomputed, never taken from the request. A caller that sends its
|
||||||
|
// own number is a caller that can disagree with the window it just sent.
|
||||||
|
shift.Shifthours = riderShiftHours(start, end)
|
||||||
|
|
||||||
|
if shift.Basefare < 0 || shift.Additionalcharges < 0 || shift.Fuelcharge < 0 {
|
||||||
|
return shift, errors.New("pay cannot be negative")
|
||||||
|
}
|
||||||
|
return shift, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRiderShift opens a shift window in one region.
|
||||||
|
func (r *partnerRepository) CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
|
||||||
|
shift, err := validateRiderShift(shift)
|
||||||
|
if err != nil {
|
||||||
|
return models.Ridershifts{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same guard `CreateRider` applies to a rider's region, for the same reason:
|
||||||
|
// `getriders` joins app_locationconfig, so a shift in a region with no
|
||||||
|
// config row would be offered in the dropdown and then hide every rider put
|
||||||
|
// on it.
|
||||||
|
var configs int64
|
||||||
|
if err := r.db.Table("app_locationconfig").
|
||||||
|
Where("applocationid = ?", shift.Applocationid).Count(&configs).Error; err != nil {
|
||||||
|
return models.Ridershifts{}, err
|
||||||
|
}
|
||||||
|
if configs == 0 {
|
||||||
|
return models.Ridershifts{}, fmt.Errorf(
|
||||||
|
"delivery region %d is not configured, so a shift there would hide every rider on it", shift.Applocationid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dropdown labels a shift by its times alone, so a duplicate window is
|
||||||
|
// two identical-looking choices and no way to tell which one a rider is on.
|
||||||
|
var clash int64
|
||||||
|
if err := r.db.Table("ridershifts").
|
||||||
|
Where("applocationid = ? AND starttime = ? AND endtime = ?",
|
||||||
|
shift.Applocationid, shift.Starttime, shift.Endtime).
|
||||||
|
Count(&clash).Error; err != nil {
|
||||||
|
return models.Ridershifts{}, err
|
||||||
|
}
|
||||||
|
if clash > 0 {
|
||||||
|
return models.Ridershifts{}, fmt.Errorf(
|
||||||
|
"a %s-%s shift already exists in this region", shift.Starttime, shift.Endtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A column map with RETURNING, the same way CreatePartner writes its row,
|
||||||
|
// rather than inserting the struct.
|
||||||
|
//
|
||||||
|
// Inserting the struct would carry `shiftid` at zero into the statement and
|
||||||
|
// leave whether the sequence is used to how GORM reads a `Primary_Key` tag
|
||||||
|
// written in the v1 spelling — which is exactly the kind of thing that works
|
||||||
|
// on one driver and writes a row with id 0 on another. Naming the columns
|
||||||
|
// removes the question: the id is the database's to assign.
|
||||||
|
row := map[string]any{
|
||||||
|
"applocationid": shift.Applocationid,
|
||||||
|
"shiftdate": shift.Shiftdate,
|
||||||
|
"starttime": shift.Starttime,
|
||||||
|
"endtime": shift.Endtime,
|
||||||
|
"shifthours": shift.Shifthours,
|
||||||
|
"basefare": shift.Basefare,
|
||||||
|
"additionalkm": shift.Additionalkm,
|
||||||
|
"additionalcharges": shift.Additionalcharges,
|
||||||
|
"orders": shift.Orders,
|
||||||
|
"fuelcharge": shift.Fuelcharge,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.Table("ridershifts").
|
||||||
|
Clauses(clause.Returning{Columns: []clause.Column{{Name: "shiftid"}}}).
|
||||||
|
Create(&row).Error; err != nil {
|
||||||
|
return models.Ridershifts{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := row["shiftid"]
|
||||||
|
if !ok || toInt(id) == 0 {
|
||||||
|
// The rider form selects the new shift by id the moment this returns. A
|
||||||
|
// shift written without one would leave the drawer selecting nothing and
|
||||||
|
// reading as a failed save.
|
||||||
|
return models.Ridershifts{}, errors.New("the shift was written without an id")
|
||||||
|
}
|
||||||
|
shift.Shiftid = toInt(id)
|
||||||
|
|
||||||
|
// The label the dropdown shows, built the same way GetRiderShifts builds it
|
||||||
|
// so a shift reads identically the moment it is created and after a reload.
|
||||||
|
shift.Shiftname = shift.Starttime + "-" + shift.Endtime
|
||||||
|
return shift, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,21 +36,42 @@ func normaliseShiftTime(raw string) (string, error) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListStaffShifts returns an outlet's shifts, newest last so a picker reads in
|
// ListStaffShifts returns the shifts a tenant's staff can be put on, newest
|
||||||
// the order they were created rather than alphabetically by name.
|
// last so a picker reads in the order they were created rather than
|
||||||
|
// alphabetically by name.
|
||||||
|
//
|
||||||
|
// ── Why the outlet is optional ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A shift is a fact about how a BUSINESS runs, not about one shop: a tenant
|
||||||
|
// that works 07:00–15:00 and 15:00–23:00 works those hours at every branch it
|
||||||
|
// owns, and making somebody re-enter them per outlet guarantees the third
|
||||||
|
// branch gets 07:00–15:30 and nobody notices. `locationid = 0` is a shift that
|
||||||
|
// belongs to the whole tenant.
|
||||||
|
//
|
||||||
|
// Branch-specific rows are still honoured, because they already exist and a
|
||||||
|
// tenant may genuinely run one outlet differently. Asking for an outlet returns
|
||||||
|
// the tenant's shifts AND that outlet's own; asking for none returns everything
|
||||||
|
// the tenant has.
|
||||||
func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
|
func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
|
||||||
if tenantID <= 0 || locationID <= 0 {
|
if tenantID <= 0 {
|
||||||
return nil, fmt.Errorf("tenantid and locationid are required")
|
return nil, fmt.Errorf("tenantid is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
shifts := make([]models.StaffShifts, 0)
|
shifts := make([]models.StaffShifts, 0)
|
||||||
query := `SELECT * FROM staffshifts WHERE tenantid = ? AND locationid = ?`
|
args := []any{tenantID}
|
||||||
|
|
||||||
|
query := `SELECT * FROM staffshifts WHERE tenantid = ?`
|
||||||
|
if locationID > 0 {
|
||||||
|
// The tenant-wide ones and this outlet's, never another outlet's.
|
||||||
|
query += ` AND (COALESCE(locationid, 0) = 0 OR locationid = ?)`
|
||||||
|
args = append(args, locationID)
|
||||||
|
}
|
||||||
if !includeInactive {
|
if !includeInactive {
|
||||||
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||||
}
|
}
|
||||||
query += ` ORDER BY staffshiftid`
|
query += ` ORDER BY staffshiftid`
|
||||||
|
|
||||||
if err := r.db.Raw(query, tenantID, locationID).Scan(&shifts).Error; err != nil {
|
if err := r.db.Raw(query, args...).Scan(&shifts).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return shifts, nil
|
return shifts, nil
|
||||||
@@ -58,8 +79,14 @@ func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactiv
|
|||||||
|
|
||||||
// CreateStaffShift adds a window at one outlet.
|
// CreateStaffShift adds a window at one outlet.
|
||||||
func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
|
func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
|
||||||
if tenantID <= 0 || locationID <= 0 {
|
if tenantID <= 0 {
|
||||||
return nil, fmt.Errorf("tenantid and locationid are required")
|
return nil, fmt.Errorf("tenantid is required")
|
||||||
|
}
|
||||||
|
// `locationid = 0` is deliberate and is now the ordinary case: the shift
|
||||||
|
// belongs to the tenant and every branch it owns can use it. An outlet is
|
||||||
|
// only named when one shop really does run different hours.
|
||||||
|
if locationID < 0 {
|
||||||
|
locationID = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimSpace(req.Name)
|
name := strings.TrimSpace(req.Name)
|
||||||
|
|||||||
54
repositories/posShift_test.go
Normal file
54
repositories/posShift_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
A shift belongs to a business, not to one of its shops.
|
||||||
|
|
||||||
|
Both halves of this used to demand an outlet, so the same two windows had to be
|
||||||
|
re-entered at every branch a tenant owns — which is how the third branch quietly
|
||||||
|
gets 07:00–15:30 and nobody notices until a cashier is filed under hours that do
|
||||||
|
not exist. A tenant that works 07:00–15:00 works those hours everywhere.
|
||||||
|
|
||||||
|
`locationid = 0` is now the ordinary case. A named outlet still works, because
|
||||||
|
one shop may genuinely run differently and those rows already exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestATimeIsAcceptedInBothFormsTheClientsSend(t *testing.T) {
|
||||||
|
// `<input type="time">` gives "07:00"; some browsers and every hand-typed
|
||||||
|
// value give "07:00:00"; `ridershifts` stores the seconds form already.
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"07:00", "07:00"},
|
||||||
|
{"07:00:00", "07:00"},
|
||||||
|
{" 23:59 ", "23:59"},
|
||||||
|
{"00:00", "00:00"},
|
||||||
|
} {
|
||||||
|
got, err := normaliseShiftTime(tc.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", tc.in, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("%q became %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSomethingThatIsNotATimeOfDayIsRefused(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", " ", "7:00", "24:00", "07:60", "morning", "07", "7pm"} {
|
||||||
|
if _, err := normaliseShiftTime(bad); err == nil {
|
||||||
|
t.Fatalf("%q was accepted as a time of day", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheTimeErrorSaysWhatShapeIsWanted(t *testing.T) {
|
||||||
|
// "invalid" tells somebody nothing. The message names the format, because
|
||||||
|
// the most common wrong answer is a valid time in the wrong notation.
|
||||||
|
_, err := normaliseShiftTime("7pm")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "HH:MM") {
|
||||||
|
t.Fatalf("the refusal does not say what to type: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,10 @@ type ProductRepository interface {
|
|||||||
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
|
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
|
||||||
CreateProductStock(stocks []models.Productstock) error
|
CreateProductStock(stocks []models.Productstock) error
|
||||||
UpdateProductStatus(productIDs []int, status string) error
|
UpdateProductStatus(productIDs []int, status string) error
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
SetShowHealthScore(tenantID, productID int, show bool) error
|
||||||
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
||||||
EnsureProductLocation(refs []models.ProductLocationRef) error
|
EnsureProductLocation(refs []models.ProductLocationRef) error
|
||||||
CreateProduct(product models.Products) error
|
|
||||||
UpdateProduct(product models.Products) error
|
UpdateProduct(product models.Products) error
|
||||||
DeleteProduct(productID int) error
|
DeleteProduct(productID int) error
|
||||||
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
||||||
@@ -393,19 +394,32 @@ func (r *productRepository) UpdateProductStatus(productIDs []int, status string)
|
|||||||
Update("productstatus", status).Error
|
Update("productstatus", status).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *productRepository) CreateProduct(product models.Products) error {
|
// normaliseProductJSON makes a product safe to INSERT.
|
||||||
tx := r.db.Begin()
|
//
|
||||||
|
// `products.productimages` is jsonb and `models.Products.Productimages` is a
|
||||||
if err := tx.Create(&product).Error; err != nil {
|
// plain string, so a caller that never set it hands GORM the zero value — and
|
||||||
tx.Rollback()
|
// GORM puts that empty string in the INSERT rather than omitting the column.
|
||||||
return err
|
// Postgres answers "invalid input syntax for type json (SQLSTATE 22P02)" and
|
||||||
|
// the whole row is rejected, over a field nobody asked for.
|
||||||
|
//
|
||||||
|
// That was not a corner case: the console's sheet importer sends no
|
||||||
|
// productimages at all, so EVERY product it created failed with a 500, and
|
||||||
|
// ImportCatalogueProduct leaves the field empty for any catalogue product that
|
||||||
|
// has no photos. An empty ARRAY is the honest value — there are no extra
|
||||||
|
// images — and it is what `catalogueUploadService` already does for its own
|
||||||
|
// jsonb column, for the same reason.
|
||||||
|
//
|
||||||
|
// Applied at the one create path, which is the last point before the SQL, and
|
||||||
|
// the constraint being satisfied is the database's.
|
||||||
|
func normaliseProductJSON(product *models.Products) {
|
||||||
|
if strings.TrimSpace(product.Productimages) == "" {
|
||||||
|
product.Productimages = "[]"
|
||||||
}
|
}
|
||||||
|
// An OBJECT, not an array: this one holds named catalogue fields, and `{}`
|
||||||
if err := tx.Commit().Error; err != nil {
|
// is what a reader parsing it expects to find when there are none.
|
||||||
return err
|
if strings.TrimSpace(product.Cataloguefacts) == "" {
|
||||||
|
product.Cataloguefacts = "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *productRepository) UpdateProduct(product models.Products) error {
|
func (r *productRepository) UpdateProduct(product models.Products) error {
|
||||||
@@ -1316,10 +1330,22 @@ func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand
|
|||||||
return &product, nil
|
return &product, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProductReturningID inserts a new product snapshot and returns its
|
// CreateProductReturningID inserts a product and returns its generated
|
||||||
// generated productid. Kept separate from CreateProduct so existing callers
|
// productid.
|
||||||
// of CreateProduct are unaffected.
|
//
|
||||||
|
// This is now the only way to create one. There used to be a second method,
|
||||||
|
// `CreateProduct`, that did the same INSERT and threw the id away — it took
|
||||||
|
// the struct by value, so GORM wrote the generated id onto a copy that went
|
||||||
|
// out of scope, and `POST /products/create` answered `productid: 0` for every
|
||||||
|
// product it had just created. The console worked around it by creating, then
|
||||||
|
// re-reading the whole tenant catalogue, then matching back by SKU.
|
||||||
|
//
|
||||||
|
// The two were kept apart so that "existing callers are unaffected", but the
|
||||||
|
// only caller of the id-less one was the endpoint that needed the id most.
|
||||||
|
// One create path also means the jsonb guard above has one place to live.
|
||||||
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
||||||
|
normaliseProductJSON(&product)
|
||||||
|
|
||||||
if err := r.db.Create(&product).Error; err != nil {
|
if err := r.db.Create(&product).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -1701,3 +1727,30 @@ func (r *productRepository) UpdateProductPricing(productid int, retailprice, pro
|
|||||||
"taxpercent": taxpercent,
|
"taxpercent": taxpercent,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
//
|
||||||
|
// `Update` with a single column, deliberately, and not `Updates` with a struct.
|
||||||
|
// GORM's struct update SKIPS zero values, so `showhealthscore: false` would be
|
||||||
|
// silently dropped — the flag could be switched on and never off again, which is
|
||||||
|
// the exact failure a merchant would report as "it does not save".
|
||||||
|
//
|
||||||
|
// Scoped by tenant as well as product. `middleware.WebAuth` already refuses a
|
||||||
|
// request naming a tenant the session does not own, so this is the second lock
|
||||||
|
// rather than the first — but a write that changes what a shopper sees should
|
||||||
|
// not rest on one check being correctly mounted.
|
||||||
|
func (r *productRepository) SetShowHealthScore(tenantID, productID int, show bool) error {
|
||||||
|
result := r.db.Table("products").
|
||||||
|
Where("productid = ? AND tenantid = ?", productID, tenantID).
|
||||||
|
Update("showhealthscore", show)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
// Either no such product, or one belonging to another business. Both
|
||||||
|
// are the same answer to the caller, and neither should look like it
|
||||||
|
// worked.
|
||||||
|
return fmt.Errorf("product %d was not found for this business", productID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
126
repositories/riderShift_test.go
Normal file
126
repositories/riderShift_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Shift windows, which riders cannot be hired without.
|
||||||
|
|
||||||
|
`CreateRider` refuses a rider with no shift — correctly, because `getriders`
|
||||||
|
joins `ridershifts` and a rider on a shift that does not exist is a rider nobody
|
||||||
|
can see. But the table could only be READ: no endpoint, no service method, and
|
||||||
|
no `applocationid` field on the model to write one with. A region that shipped
|
||||||
|
without shift rows was a region no rider could ever be added to, and the console
|
||||||
|
said "No shifts set up for this region" with nothing anybody could do about it.
|
||||||
|
|
||||||
|
These cover the rules that do not need a database. The two that do — the region
|
||||||
|
must be configured, and the window must not already exist — are enforced in
|
||||||
|
CreateRiderShift against real tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestATimeIsNormalisedSoOneWindowHasOneLabel(t *testing.T) {
|
||||||
|
// The dropdown labels a shift by concatenating its two columns raw, so
|
||||||
|
// `9:00-17:00` and `09:00-17:00` are two different labels for one window.
|
||||||
|
// Rows inserted by hand over the years use every spelling.
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"9:00", "09:00"},
|
||||||
|
{"09:00", "09:00"},
|
||||||
|
{"09:00:00", "09:00"},
|
||||||
|
{" 9:5 ", "09:05"},
|
||||||
|
{"23:59", "23:59"},
|
||||||
|
{"0:00", "00:00"},
|
||||||
|
} {
|
||||||
|
got, err := riderShiftClock(tc.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", tc.in, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("%q became %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSomethingThatIsNotATimeIsRefused(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", " ", "morning", "25:00", "09:60", "9", "9:00:00:00", "-1:00"} {
|
||||||
|
if _, err := riderShiftClock(bad); err == nil {
|
||||||
|
t.Fatalf("%q was accepted as a time", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHoursAreWorkedOutRatherThanAskedFor(t *testing.T) {
|
||||||
|
// `shifthours` feeds rider pay. It is the one field a person gets wrong and
|
||||||
|
// nothing downstream checks, so it is computed and the request's own number
|
||||||
|
// is discarded.
|
||||||
|
shift, err := validateRiderShift(models.Ridershifts{
|
||||||
|
Applocationid: 2, Starttime: "09:00", Endtime: "17:00",
|
||||||
|
Shifthours: 4, // wrong, and sent anyway
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a good shift was refused: %v", err)
|
||||||
|
}
|
||||||
|
if shift.Shifthours != 8 {
|
||||||
|
t.Fatalf("09:00-17:00 came out as %v hours, want 8", shift.Shifthours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANightShiftCrossesMidnightRatherThanGoingNegative(t *testing.T) {
|
||||||
|
// 22:00-06:00 is eight hours. Subtracting the clocks gives minus sixteen,
|
||||||
|
// which would pay a night rider for a negative shift.
|
||||||
|
if got := riderShiftHours("22:00", "06:00"); got != 8 {
|
||||||
|
t.Fatalf("22:00-06:00 came out as %v hours, want 8", got)
|
||||||
|
}
|
||||||
|
if got := riderShiftHours("09:30", "17:00"); got != 7.5 {
|
||||||
|
t.Fatalf("09:30-17:00 came out as %v hours, want 7.5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShiftNeedsARegion(t *testing.T) {
|
||||||
|
// Region 0 arrives from a form field nobody filled in. A shift there would
|
||||||
|
// be offered to nobody and joined to nothing.
|
||||||
|
_, err := validateRiderShift(models.Ridershifts{Starttime: "09:00", Endtime: "17:00"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "region") {
|
||||||
|
t.Fatalf("a shift with no region was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShiftCannotStartAndEndAtTheSameTime(t *testing.T) {
|
||||||
|
// Would compute as a 24-hour window under the midnight rule, which is not
|
||||||
|
// what anybody who typed the same time twice meant.
|
||||||
|
_, err := validateRiderShift(models.Ridershifts{
|
||||||
|
Applocationid: 2, Starttime: "09:00", Endtime: "9:00",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a zero-length window was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayCannotBeNegative(t *testing.T) {
|
||||||
|
for _, shift := range []models.Ridershifts{
|
||||||
|
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Basefare: -1},
|
||||||
|
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Additionalcharges: -5},
|
||||||
|
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Fuelcharge: -0.5},
|
||||||
|
} {
|
||||||
|
if _, err := validateRiderShift(shift); err == nil {
|
||||||
|
t.Fatalf("negative pay was accepted: %+v", shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheNormalisedTimesComeBackOnTheShift(t *testing.T) {
|
||||||
|
// The caller inserts what validation returned, not what it was handed —
|
||||||
|
// otherwise the normalising is computed and then thrown away.
|
||||||
|
shift, err := validateRiderShift(models.Ridershifts{
|
||||||
|
Applocationid: 2, Starttime: "9:0", Endtime: "17:00:00",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refused: %v", err)
|
||||||
|
}
|
||||||
|
if shift.Starttime != "09:00" || shift.Endtime != "17:00" {
|
||||||
|
t.Fatalf("times were not normalised on the way out: %q-%q", shift.Starttime, shift.Endtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
737
repositories/scanRepository.go
Normal file
737
repositories/scanRepository.go
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"nearle/db"
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/utils"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Scan-to-order reads from three places and this file is the only one that
|
||||||
|
knows which is which:
|
||||||
|
|
||||||
|
- the catalogue database (pgvector, one table per brand) — to turn a label
|
||||||
|
into a catalogue product;
|
||||||
|
- nearledb — who the customer is, which outlets they scanned into, and what
|
||||||
|
those outlets have on the shelf right now;
|
||||||
|
- Redis — a cache for the expensive and stable half (the label's vector and
|
||||||
|
its catalogue hits). Live stock is never cached.
|
||||||
|
|
||||||
|
Two connections are held rather than one because the catalogue must never be
|
||||||
|
reachable through the nearledb handle: the comment on db.CatalogueDB is
|
||||||
|
explicit about that and every catalogue reader in this package honours it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// CatalogueKey is how a tenant's product row points back at the catalogue.
|
||||||
|
// Imageid is the stable one; brand+catalogueid is kept for rows imported
|
||||||
|
// before imageid existed (see models.Products.Imageid).
|
||||||
|
type CatalogueKey struct {
|
||||||
|
Brand string
|
||||||
|
Catalogueid int64
|
||||||
|
Imageid string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogueHit is one catalogue row the search considered.
|
||||||
|
type CatalogueHit struct {
|
||||||
|
Brand string
|
||||||
|
ID int64
|
||||||
|
ProductName string
|
||||||
|
Title string
|
||||||
|
Category string
|
||||||
|
Size string
|
||||||
|
VariantKey string
|
||||||
|
ImageID string
|
||||||
|
ImageURL string
|
||||||
|
// Cosine distance from pgvector (0 = identical); -1 for a text-only hit.
|
||||||
|
Distance float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreOptionRow is one sellable product at one outlet, with its live stock.
|
||||||
|
type StoreOptionRow struct {
|
||||||
|
Tenantid int
|
||||||
|
Locationid int
|
||||||
|
Productid int
|
||||||
|
Productname string
|
||||||
|
Productbrand string
|
||||||
|
Catalogueid int64
|
||||||
|
Imageid string
|
||||||
|
Productimage string
|
||||||
|
Productunit string
|
||||||
|
Unitvalue string
|
||||||
|
Price float64
|
||||||
|
// The shop's own cost, distinct from Price. Selected because the product
|
||||||
|
// screens already return it and the app asked for it by name.
|
||||||
|
Productcost float64
|
||||||
|
Categoryid int
|
||||||
|
Subcategoryid int
|
||||||
|
Stock int
|
||||||
|
// For a size row: the product it hangs under and the label given to it.
|
||||||
|
Parentid int
|
||||||
|
Variantname string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanRepository interface {
|
||||||
|
// nearledb
|
||||||
|
CustomerExists(ctx context.Context, customerid int) (bool, error)
|
||||||
|
CustomerHome(ctx context.Context, customerid int) (lat, lng float64, ok bool, err error)
|
||||||
|
RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error)
|
||||||
|
// StoreOptions finds, at the given outlets, every published product tied
|
||||||
|
// to one of the catalogue keys (or, for hand-made products, one of the
|
||||||
|
// names) — and every size hanging under those products.
|
||||||
|
StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error)
|
||||||
|
// ProductAt is one product at one outlet with its live stock, or nil.
|
||||||
|
ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error)
|
||||||
|
|
||||||
|
// catalogue
|
||||||
|
VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error)
|
||||||
|
TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error)
|
||||||
|
VectorSearchAvailable() bool
|
||||||
|
// CatalogueRef is one product named by its catalogue key, with its other
|
||||||
|
// pack sizes after it. Nothing is recognised or scored.
|
||||||
|
CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error)
|
||||||
|
|
||||||
|
// cache
|
||||||
|
CachedVector(ctx context.Context, model, label string) ([]float32, bool)
|
||||||
|
CacheVector(ctx context.Context, model, label string, v []float32)
|
||||||
|
CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool)
|
||||||
|
CacheHits(ctx context.Context, method, label string, hits []CatalogueHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
catalogue *gorm.DB
|
||||||
|
|
||||||
|
// Catalogue tables and their columns, discovered once and refreshed on a
|
||||||
|
// timer — the catalogue pipeline adds brands without telling anyone.
|
||||||
|
tablesMu sync.Mutex
|
||||||
|
tables map[string]map[string]bool // table -> column set
|
||||||
|
tablesAt time.Time
|
||||||
|
embeddingDim int
|
||||||
|
|
||||||
|
// Process-local cache in front of Redis, bounded, so a hot label costs
|
||||||
|
// nothing even when Redis is not configured.
|
||||||
|
memMu sync.Mutex
|
||||||
|
memVecs map[string][]float32
|
||||||
|
memHits map[string][]CatalogueHit
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
scanTablesTTL = 10 * time.Minute
|
||||||
|
scanVectorTTL = 7 * 24 * time.Hour // a label's vector never changes for a given model
|
||||||
|
scanHitsTTL = 30 * time.Minute // the catalogue is rebuilt by scrape; not for long
|
||||||
|
scanMemCacheMax = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewScanRepository(nearle, catalogue *gorm.DB) ScanRepository {
|
||||||
|
return &scanRepository{
|
||||||
|
db: nearle,
|
||||||
|
catalogue: catalogue,
|
||||||
|
memVecs: make(map[string][]float32),
|
||||||
|
memHits: make(map[string][]CatalogueHit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── nearledb ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (r *scanRepository) CustomerExists(ctx context.Context, customerid int) (bool, error) {
|
||||||
|
var n int64
|
||||||
|
err := r.db.WithContext(ctx).Raw(
|
||||||
|
`SELECT COUNT(1) FROM customers WHERE customerid = ?`, customerid).Scan(&n).Error
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerHome is the saved primary address, falling back to the customers
|
||||||
|
// row itself. Either may be blank or unparsable — a customer created from a
|
||||||
|
// phone number alone has neither — and that is reported as ok=false rather
|
||||||
|
// than as (0, 0), which is a real place in the Gulf of Guinea.
|
||||||
|
func (r *scanRepository) CustomerHome(ctx context.Context, customerid int) (float64, float64, bool, error) {
|
||||||
|
var row struct {
|
||||||
|
Lat string
|
||||||
|
Lng string
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT COALESCE(NULLIF(l.latitude, ''), c.latitude, '') AS lat,
|
||||||
|
COALESCE(NULLIF(l.longitude, ''), c.longitude, '') AS lng
|
||||||
|
FROM customers c
|
||||||
|
LEFT JOIN customerlocations l ON l.customerid = c.customerid AND l.primaryaddress = 1
|
||||||
|
WHERE c.customerid = ?
|
||||||
|
LIMIT 1`, customerid).Scan(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
lat, lng, ok := utils.ParseLatLng(row.Lat, row.Lng)
|
||||||
|
return lat, lng, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredStores is every active outlet of every tenant the customer has
|
||||||
|
// scanned into. A tenantcustomers row with locationid 0 means "the tenant",
|
||||||
|
// i.e. all of its outlets; a non-zero one pins a single outlet.
|
||||||
|
func (r *scanRepository) RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error) {
|
||||||
|
var rows []struct {
|
||||||
|
Tenantid int
|
||||||
|
Tenantname string
|
||||||
|
Locationid int
|
||||||
|
Locationname string
|
||||||
|
Address string
|
||||||
|
Latitude string
|
||||||
|
Longitude string
|
||||||
|
Deliveryradius int
|
||||||
|
Deliverymins int
|
||||||
|
Opentime string
|
||||||
|
Closetime string
|
||||||
|
}
|
||||||
|
err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT DISTINCT
|
||||||
|
tl.tenantid, COALESCE(t.tenantname, '') AS tenantname,
|
||||||
|
tl.locationid, COALESCE(tl.locationname, '') AS locationname,
|
||||||
|
COALESCE(tl.address, '') AS address,
|
||||||
|
COALESCE(tl.latitude, '') AS latitude, COALESCE(tl.longitude, '') AS longitude,
|
||||||
|
COALESCE(tl.deliveryradius, 0) AS deliveryradius, COALESCE(tl.deliverymins, 0) AS deliverymins,
|
||||||
|
COALESCE(tl.opentime, '') AS opentime, COALESCE(tl.closetime, '') AS closetime
|
||||||
|
FROM tenantcustomers tc
|
||||||
|
INNER JOIN tenantlocations tl
|
||||||
|
ON tl.tenantid = tc.tenantid
|
||||||
|
AND (COALESCE(tc.locationid, 0) = 0 OR tc.locationid = tl.locationid)
|
||||||
|
LEFT JOIN tenants t ON t.tenantid = tl.tenantid
|
||||||
|
WHERE tc.customerid = ?
|
||||||
|
AND LOWER(COALESCE(tl.status, 'active')) <> 'inactive'
|
||||||
|
ORDER BY tl.tenantid, tl.locationid`, customerid).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
stores := make([]models.ScanStore, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
lat, lng, _ := utils.ParseLatLng(row.Latitude, row.Longitude)
|
||||||
|
stores = append(stores, models.ScanStore{
|
||||||
|
Tenantid: row.Tenantid,
|
||||||
|
Tenantname: row.Tenantname,
|
||||||
|
Locationid: row.Locationid,
|
||||||
|
Locationname: row.Locationname,
|
||||||
|
Address: row.Address,
|
||||||
|
Latitude: lat,
|
||||||
|
Longitude: lng,
|
||||||
|
DistanceKm: -1,
|
||||||
|
Deliveryradius: row.Deliveryradius,
|
||||||
|
Deliverymins: row.Deliverymins,
|
||||||
|
Open: utils.OpenNow(row.Opentime, row.Closetime, now),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stores, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeOptionSelect is the projection every outlet read shares, so the
|
||||||
|
// price and stock rules cannot differ between the lookup and the confirm.
|
||||||
|
//
|
||||||
|
// Price: the outlet's own price when it set one, else the tenant's retail
|
||||||
|
// price — the same rule GetProducts applies. Stock: the live IN−OUT balance
|
||||||
|
// of the ledger at that outlet, the same expression the app displays, so a
|
||||||
|
// product can never be offered here and show 0 on the next screen.
|
||||||
|
const storeOptionSelect = `
|
||||||
|
SELECT a.tenantid, b.locationid, a.productid,
|
||||||
|
COALESCE(a.productname, '') AS productname,
|
||||||
|
LOWER(COALESCE(a.productbrand, '')) AS productbrand,
|
||||||
|
COALESCE(a.catalogueid, 0) AS catalogueid,
|
||||||
|
COALESCE(a.imageid, '') AS imageid,
|
||||||
|
COALESCE(a.productimage, '') AS productimage,
|
||||||
|
COALESCE(a.productunit, '') AS productunit,
|
||||||
|
COALESCE(a.unitvalue, '') AS unitvalue,
|
||||||
|
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
|
||||||
|
COALESCE(a.productcost, 0) AS productcost,
|
||||||
|
COALESCE(a.categoryid, 0) AS categoryid,
|
||||||
|
COALESCE(a.subcategoryid, 0) AS subcategoryid,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity
|
||||||
|
WHEN LOWER(c.stocktype) = 'out' THEN -c.quantity
|
||||||
|
ELSE 0 END)
|
||||||
|
FROM productstocks c
|
||||||
|
WHERE c.productid = a.productid AND c.locationid = b.locationid AND c.tenantid = a.tenantid
|
||||||
|
), 0) AS stock,
|
||||||
|
COALESCE(v.productid, 0) AS parentid,
|
||||||
|
COALESCE(v.variantname, '') AS variantname
|
||||||
|
FROM products a
|
||||||
|
INNER JOIN productlocations b ON b.productid = a.productid AND b.tenantid = a.tenantid
|
||||||
|
LEFT JOIN productvariants v ON v.variantproductid = a.productid AND v.tenantid = a.tenantid
|
||||||
|
AND LOWER(COALESCE(v.status, 'active')) <> 'inactive'`
|
||||||
|
|
||||||
|
func (r *scanRepository) StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error) {
|
||||||
|
if len(locationids) == 0 || (len(keys) == 0 && len(names) == 0) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The products that ARE the catalogue match, at these outlets.
|
||||||
|
var matchConds []string
|
||||||
|
var args []interface{}
|
||||||
|
args = append(args, locationids)
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.Imageid != "" {
|
||||||
|
matchConds = append(matchConds, "a.imageid = ?")
|
||||||
|
args = append(args, k.Imageid)
|
||||||
|
}
|
||||||
|
if k.Brand != "" && k.Catalogueid > 0 {
|
||||||
|
matchConds = append(matchConds, "(LOWER(a.productbrand) = ? AND a.catalogueid = ?)")
|
||||||
|
args = append(args, strings.ToLower(k.Brand), k.Catalogueid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if n = strings.ToLower(strings.TrimSpace(n)); n != "" {
|
||||||
|
matchConds = append(matchConds, "LOWER(a.productname) = ?")
|
||||||
|
args = append(args, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matchConds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two reads rather than one recursive query: the second is keyed on the
|
||||||
|
// first's product ids, and a variant of a variant is not a thing here.
|
||||||
|
query := storeOptionSelect + `
|
||||||
|
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
|
||||||
|
AND b.locationid IN (?)
|
||||||
|
AND (` + strings.Join(matchConds, " OR ") + `)`
|
||||||
|
|
||||||
|
var parents []StoreOptionRow
|
||||||
|
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&parents).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(parents) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parentIDs := make([]int, 0, len(parents))
|
||||||
|
for _, p := range parents {
|
||||||
|
parentIDs = append(parentIDs, p.Productid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sizes hanging under those products, at the same outlets. Only the
|
||||||
|
// rows whose parent is one of ours — the LEFT JOIN in the select can
|
||||||
|
// attach any parent, so it is pinned here.
|
||||||
|
var sizes []StoreOptionRow
|
||||||
|
err := r.db.WithContext(ctx).Raw(storeOptionSelect+`
|
||||||
|
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
|
||||||
|
AND b.locationid IN (?)
|
||||||
|
AND v.productid IN (?)`, locationids, parentIDs).Scan(&sizes).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(parents, sizes...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *scanRepository) ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error) {
|
||||||
|
var rows []StoreOptionRow
|
||||||
|
err := r.db.WithContext(ctx).Raw(storeOptionSelect+`
|
||||||
|
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
|
||||||
|
AND a.tenantid = ? AND b.locationid = ? AND a.productid = ?
|
||||||
|
LIMIT 1`, tenantid, locationid, productid).Scan(&rows).Error
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── catalogue ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// brandTables is every `brand_*` table and its columns, cached briefly.
|
||||||
|
func (r *scanRepository) brandTables(ctx context.Context) (map[string]map[string]bool, error) {
|
||||||
|
if r.catalogue == nil {
|
||||||
|
return nil, ErrCatalogueDBUnavailable
|
||||||
|
}
|
||||||
|
r.tablesMu.Lock()
|
||||||
|
defer r.tablesMu.Unlock()
|
||||||
|
if r.tables != nil && time.Since(r.tablesAt) < scanTablesTTL {
|
||||||
|
return r.tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
TableName string
|
||||||
|
ColumnName string
|
||||||
|
}
|
||||||
|
err := r.catalogue.WithContext(ctx).Raw(`
|
||||||
|
SELECT c.table_name, c.column_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema = 'public' AND c.table_name LIKE 'brand\_%'`).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tables := make(map[string]map[string]bool)
|
||||||
|
for _, row := range rows {
|
||||||
|
if tables[row.TableName] == nil {
|
||||||
|
tables[row.TableName] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
tables[row.TableName][row.ColumnName] = true
|
||||||
|
}
|
||||||
|
for name, cols := range tables {
|
||||||
|
if !cols["id"] || !cols["product_name"] {
|
||||||
|
delete(tables, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The vector width, read from the first embedding column found. pgvector
|
||||||
|
// stores it as the type modifier, so a mismatch with the model can be
|
||||||
|
// named in the error instead of surfacing as a bare "different vector
|
||||||
|
// dimensions" from the driver.
|
||||||
|
if r.embeddingDim == 0 {
|
||||||
|
for name, cols := range tables {
|
||||||
|
if !cols["embedding"] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var dim int
|
||||||
|
r.catalogue.WithContext(ctx).Raw(`
|
||||||
|
SELECT a.atttypmod FROM pg_attribute a
|
||||||
|
JOIN pg_class c ON c.oid = a.attrelid
|
||||||
|
WHERE c.relname = ? AND a.attname = 'embedding'`, name).Scan(&dim)
|
||||||
|
if dim > 0 {
|
||||||
|
r.embeddingDim = dim
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.tables, r.tablesAt = tables, time.Now()
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VectorSearchAvailable is whether any catalogue table carries a vector.
|
||||||
|
func (r *scanRepository) VectorSearchAvailable() bool {
|
||||||
|
tables, err := r.brandTables(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, cols := range tables {
|
||||||
|
if cols["embedding"] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hitColumns is the projection each search returns, with NULL stand-ins for
|
||||||
|
// columns a particular brand table lacks — the same tolerance
|
||||||
|
// catalogueRepository applies, for the same reason: a newer table missing
|
||||||
|
// one enrichment column is still a perfectly good catalogue of products.
|
||||||
|
func hitColumns(brand string, cols map[string]bool) string {
|
||||||
|
opt := func(name string) string {
|
||||||
|
if cols[name] {
|
||||||
|
return "COALESCE(" + name + ", '') AS " + name
|
||||||
|
}
|
||||||
|
return "'' AS " + name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`'%s' AS brand, id, COALESCE(product_name, '') AS product_name, %s, %s, %s, %s, %s, %s`,
|
||||||
|
brand, opt("title"), opt("category"), opt("size"), opt("variant_key"), opt("image_id"), opt("image_url"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VectorSearch ranks every brand table by cosine distance to the label's
|
||||||
|
// vector and merges the top of each.
|
||||||
|
//
|
||||||
|
// One branch per table, each with its own ORDER BY and LIMIT inside
|
||||||
|
// parentheses, so Postgres can use the per-table vector index instead of
|
||||||
|
// scanning the union. The literal is bound as a parameter and cast — never
|
||||||
|
// concatenated — and table names come from information_schema, never from
|
||||||
|
// the request.
|
||||||
|
func (r *scanRepository) VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error) {
|
||||||
|
tables, err := r.brandTables(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.embeddingDim > 0 && len(vector) != r.embeddingDim {
|
||||||
|
return nil, fmt.Errorf("embedding is %d wide but the catalogue's embedding column is %d: EMBEDDING_MODEL/EMBEDDING_DIMENSIONS do not match the model that indexed the catalogue", len(vector), r.embeddingDim)
|
||||||
|
}
|
||||||
|
|
||||||
|
literal := utils.VectorLiteral(vector)
|
||||||
|
var branches []string
|
||||||
|
var args []interface{}
|
||||||
|
for _, table := range sortedKeys(tables) {
|
||||||
|
cols := tables[table]
|
||||||
|
if !cols["embedding"] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
brand := strings.TrimPrefix(table, "brand_")
|
||||||
|
branches = append(branches, fmt.Sprintf(
|
||||||
|
`(SELECT %s, (embedding <=> ?::vector) AS distance FROM %s WHERE embedding IS NOT NULL ORDER BY embedding <=> ?::vector LIMIT %d)`,
|
||||||
|
hitColumns(brand, cols), table, limit))
|
||||||
|
args = append(args, literal, literal)
|
||||||
|
}
|
||||||
|
if len(branches) == 0 {
|
||||||
|
return nil, errors.New("no catalogue table has an embedding column")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.Join(branches, " UNION ALL ") + fmt.Sprintf(" ORDER BY distance LIMIT %d", limit)
|
||||||
|
var hits []CatalogueHit
|
||||||
|
if err := r.catalogue.WithContext(ctx).Raw(query, args...).Scan(&hits).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// minTokenHits is how many of the label's words a row must carry to be worth
|
||||||
|
// looking at. Every word was once required, which meant a single word the
|
||||||
|
// catalogue does not use — "Parle G biscuit pack", "Milk Bikis pack" — kept
|
||||||
|
// the right product out of the result entirely, leaving the vector search to
|
||||||
|
// answer alone and confidently wrong. Most of them is enough; scoring sorts
|
||||||
|
// out the rest.
|
||||||
|
func minTokenHits(n int) int {
|
||||||
|
if n <= 2 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return (n*2 + 2) / 3 // two thirds, rounded up; never below 2 for n >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextSearch is the fallback when there is no embedder, and the tie-breaker
|
||||||
|
// beside it when there is: rows whose name or title contains the label, or
|
||||||
|
// carry most of its words.
|
||||||
|
func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error) {
|
||||||
|
tables, err := r.brandTables(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
label = strings.ToLower(strings.TrimSpace(label))
|
||||||
|
tokens := utils.SearchTokens(label)
|
||||||
|
if label == "" || len(tokens) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var branches []string
|
||||||
|
var args []interface{}
|
||||||
|
for _, table := range sortedKeys(tables) {
|
||||||
|
cols := tables[table]
|
||||||
|
brand := strings.TrimPrefix(table, "brand_")
|
||||||
|
|
||||||
|
hay := "LOWER(COALESCE(product_name, ''))"
|
||||||
|
if cols["title"] {
|
||||||
|
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, ''))"
|
||||||
|
}
|
||||||
|
if cols["search_query"] {
|
||||||
|
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, '') || ' ' || COALESCE(search_query, ''))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// How well a row matches, as a number: the whole label as a substring
|
||||||
|
// outweighs any number of loose words, then one point per word found.
|
||||||
|
hits := make([]string, 0, len(tokens)+1)
|
||||||
|
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 100 ELSE 0 END)")
|
||||||
|
for range tokens {
|
||||||
|
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 1 ELSE 0 END)")
|
||||||
|
}
|
||||||
|
rank := strings.Join(hits, " + ")
|
||||||
|
|
||||||
|
// The expression appears twice in the SQL — once to filter, once to
|
||||||
|
// order — so its arguments are bound twice, in that order.
|
||||||
|
bind := func() {
|
||||||
|
args = append(args, "%"+label+"%")
|
||||||
|
for _, tok := range tokens {
|
||||||
|
args = append(args, "%"+tok+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bind()
|
||||||
|
bind()
|
||||||
|
|
||||||
|
// Ordering matters as much as the threshold: a looser WHERE lets more
|
||||||
|
// rows qualify, and an unordered LIMIT would then be free to return
|
||||||
|
// the wrong ones. Best match per brand first, id to keep it stable.
|
||||||
|
branches = append(branches, fmt.Sprintf(
|
||||||
|
`(SELECT %s, -1::float8 AS distance FROM %s WHERE (%s) >= %d ORDER BY (%s) DESC, id LIMIT %d)`,
|
||||||
|
hitColumns(brand, cols), table, rank, minTokenHits(len(tokens)), rank, limit))
|
||||||
|
}
|
||||||
|
if len(branches) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hits []CatalogueHit
|
||||||
|
if err := r.catalogue.WithContext(ctx).Raw(strings.Join(branches, " UNION ALL "), args...).Scan(&hits).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableFor resolves a brand the caller named to a real catalogue table.
|
||||||
|
//
|
||||||
|
// The lookup is against the tables discovered from information_schema, never
|
||||||
|
// a string built from the request: table names cannot be parameterised in
|
||||||
|
// SQL, so the discovered map is what keeps this from being an injection
|
||||||
|
// point. Both the table suffix ("britannia") and a display name ("24 Mantra"
|
||||||
|
// → brand_24_mantra) resolve.
|
||||||
|
func (r *scanRepository) tableFor(ctx context.Context, brand string) (string, map[string]bool, error) {
|
||||||
|
tables, err := r.brandTables(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{
|
||||||
|
"brand_" + strings.ToLower(strings.TrimSpace(brand)),
|
||||||
|
"brand_" + normaliseBrandKey(brand),
|
||||||
|
} {
|
||||||
|
if cols, ok := tables[candidate]; ok {
|
||||||
|
return candidate, cols, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil, ErrUnknownBrand
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogueRef reads one product by (brand, id) and appends its other pack
|
||||||
|
// sizes — same variant_key where the catalogue assigned one, same name
|
||||||
|
// otherwise, matching how the search groups a family.
|
||||||
|
//
|
||||||
|
// Distance is 0 on every row: nothing here was ranked, the caller said which
|
||||||
|
// product they meant.
|
||||||
|
func (r *scanRepository) CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error) {
|
||||||
|
table, cols, err := r.tableFor(ctx, brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(table, "brand_")
|
||||||
|
columns := hitColumns(suffix, cols)
|
||||||
|
|
||||||
|
var self []CatalogueHit
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE id = ?`, columns, table), id).Scan(&self).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(self) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var siblings []CatalogueHit
|
||||||
|
if cols["variant_key"] && strings.TrimSpace(self[0].VariantKey) != "" {
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE variant_key = ? AND id <> ? ORDER BY id`,
|
||||||
|
columns, table), self[0].VariantKey, id).Scan(&siblings).Error
|
||||||
|
} else {
|
||||||
|
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||||
|
`SELECT %s, 0::float8 AS distance FROM %s WHERE LOWER(product_name) = LOWER(?) AND id <> ? ORDER BY id`,
|
||||||
|
columns, table), self[0].ProductName, id).Scan(&siblings).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// The product itself was found; losing its other sizes is the smaller
|
||||||
|
// failure and the caller asked for this one.
|
||||||
|
log.Printf("scan: could not read pack sizes of %s#%d: %v", brand, id, err)
|
||||||
|
return self, nil
|
||||||
|
}
|
||||||
|
return append(self, siblings...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]map[string]bool) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cache ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Two tiers. Redis is shared across replicas and survives a restart; the
|
||||||
|
// in-process map is there so the request after a cache hit costs no network
|
||||||
|
// round trip at all, and so a deployment without Redis still gets the
|
||||||
|
// benefit within one process. Neither tier ever holds stock.
|
||||||
|
|
||||||
|
func scanCacheKey(kind, scope, label string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(label))))
|
||||||
|
return "scan:" + kind + ":v1:" + scope + ":" + hex.EncodeToString(sum[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *scanRepository) CachedVector(ctx context.Context, model, label string) ([]float32, bool) {
|
||||||
|
key := scanCacheKey("emb", model, label)
|
||||||
|
|
||||||
|
r.memMu.Lock()
|
||||||
|
v, ok := r.memVecs[key]
|
||||||
|
r.memMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
raw, err := db.Rdb.Get(ctx, key).Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &v) != nil || len(v) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
r.remember(key, v, nil)
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *scanRepository) CacheVector(ctx context.Context, model, label string, v []float32) {
|
||||||
|
key := scanCacheKey("emb", model, label)
|
||||||
|
r.remember(key, v, nil)
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw, err := json.Marshal(v); err == nil {
|
||||||
|
if err := db.Rdb.Set(ctx, key, raw, scanVectorTTL).Err(); err != nil {
|
||||||
|
log.Printf("scan: could not cache vector: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *scanRepository) CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool) {
|
||||||
|
key := scanCacheKey("hits", method, label)
|
||||||
|
|
||||||
|
r.memMu.Lock()
|
||||||
|
h, ok := r.memHits[key]
|
||||||
|
r.memMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
return h, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
raw, err := db.Rdb.Get(ctx, key).Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &h) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
r.remember(key, nil, h)
|
||||||
|
return h, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *scanRepository) CacheHits(ctx context.Context, method, label string, hits []CatalogueHit) {
|
||||||
|
key := scanCacheKey("hits", method, label)
|
||||||
|
r.remember(key, nil, hits)
|
||||||
|
if db.Rdb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw, err := json.Marshal(hits); err == nil {
|
||||||
|
if err := db.Rdb.Set(ctx, key, raw, scanHitsTTL).Err(); err != nil {
|
||||||
|
log.Printf("scan: could not cache hits: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remember writes one entry into the process-local tier. Eviction is the
|
||||||
|
// simplest thing that bounds memory: when full, drop everything. Labels are
|
||||||
|
// short-lived popularity, not a working set worth an LRU.
|
||||||
|
func (r *scanRepository) remember(key string, v []float32, h []CatalogueHit) {
|
||||||
|
r.memMu.Lock()
|
||||||
|
defer r.memMu.Unlock()
|
||||||
|
if len(r.memVecs)+len(r.memHits) >= scanMemCacheMax {
|
||||||
|
r.memVecs = make(map[string][]float32)
|
||||||
|
r.memHits = make(map[string][]CatalogueHit)
|
||||||
|
}
|
||||||
|
if v != nil {
|
||||||
|
r.memVecs[key] = v
|
||||||
|
}
|
||||||
|
if h != nil {
|
||||||
|
r.memHits[key] = h
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,14 +25,20 @@ type TenantRepository interface {
|
|||||||
UpdateTenantProfile(tenantID int, fields map[string]any) error
|
UpdateTenantProfile(tenantID int, fields map[string]any) error
|
||||||
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
|
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
|
||||||
GetStaffs(tid int) ([]models.StaffInfo, error)
|
GetStaffs(tid int) ([]models.StaffInfo, error)
|
||||||
CreateStaff(user models.User) error
|
// Returns the new userid: the account has no password and has to be invited.
|
||||||
|
CreateStaff(user models.User) (int, error)
|
||||||
AssignStaffToBranch(tenantID, userID, locationID int) error
|
AssignStaffToBranch(tenantID, userID, locationID int) error
|
||||||
UpdateStaff(user models.User) error
|
UpdateStaff(user models.User) error
|
||||||
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error)
|
// Second return is the userid of the login this spawned for the branch, or 0
|
||||||
|
// when an existing person was named and no account was created.
|
||||||
|
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error)
|
||||||
UpdateTenantLocation(data models.Tenantlocations) error
|
UpdateTenantLocation(data models.Tenantlocations) error
|
||||||
CheckTenantByNo(cno string) int
|
CheckTenantByNo(cno string) int
|
||||||
CreateTenantUser(data models.Tenants) (bool, error)
|
CreateTenantUser(data models.Tenants) (bool, error)
|
||||||
GetUserByNo(cno string) models.UserInfo
|
GetUserByNo(cno string) models.UserInfo
|
||||||
|
PrimaryAdminForTenant(tenantID int) (InviteTarget, error)
|
||||||
|
InviteTargetForUser(userID int) (InviteTarget, error)
|
||||||
|
TenantNameByID(tenantID int) (string, error)
|
||||||
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
|
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
|
||||||
AssignPartner(tenantID, partnerID int) error
|
AssignPartner(tenantID, partnerID int) error
|
||||||
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
|
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
|
||||||
@@ -85,7 +91,22 @@ func (r *tenantRepository) GetAllTenants(pageno, pagesize, aid int, status, tena
|
|||||||
|
|
||||||
var data []models.Tenantinfo
|
var data []models.Tenantinfo
|
||||||
|
|
||||||
base := `SELECT * FROM tenants a WHERE 1 = 1`
|
// `branchcount` is selected here because there is nowhere else to get it.
|
||||||
|
//
|
||||||
|
// This returns one row per TENANT — there is no join to tenantlocations at
|
||||||
|
// all — but the console's store list read it as one row per
|
||||||
|
// tenant-location pair and counted the duplicates, so every merchant on the
|
||||||
|
// platform showed exactly one branch, and the "Branches" and "Avg branches"
|
||||||
|
// tiles above the list were the tenant count wearing another name. The
|
||||||
|
// tenant's own detail page, which reads gettenantlocations, disagreed with
|
||||||
|
// the list it was opened from.
|
||||||
|
//
|
||||||
|
// A correlated subquery rather than a LEFT JOIN + GROUP BY: the row shape
|
||||||
|
// stays exactly as it was, so nothing else that reads this endpoint has to
|
||||||
|
// change, and every filter below still applies to `a` alone.
|
||||||
|
base := `SELECT a.*,
|
||||||
|
(SELECT COUNT(*) FROM tenantlocations tl WHERE tl.tenantid = a.tenantid) AS branchcount
|
||||||
|
FROM tenants a WHERE 1 = 1`
|
||||||
|
|
||||||
var (
|
var (
|
||||||
conds []string
|
conds []string
|
||||||
@@ -337,7 +358,25 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
|||||||
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
||||||
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
||||||
b.locationname,
|
b.locationname,
|
||||||
COALESCE(c.rolename,'') AS rolename
|
COALESCE(c.rolename,'') AS rolename,
|
||||||
|
-- Whether the account still works. Absent from this SELECT
|
||||||
|
-- until now, so Users & access had nothing to read and showed
|
||||||
|
-- every person on the platform as "Unknown" — an admin could not
|
||||||
|
-- tell a working login from one that had been switched off.
|
||||||
|
COALESCE(a.status,'') AS status,
|
||||||
|
-- Whether they have ever signed in — or can.
|
||||||
|
--
|
||||||
|
-- Every back-office account is created with an empty password and
|
||||||
|
-- is emailed a link to choose one. Until they use it they are in
|
||||||
|
-- this list, in every branch picker, and cannot sign in at all.
|
||||||
|
-- Without this column the directory cannot tell that person from
|
||||||
|
-- anybody else, so a lost invitation is invisible until they say
|
||||||
|
-- so — and the screen has no way to offer them a new one.
|
||||||
|
--
|
||||||
|
-- Computed here rather than by returning the password: there is no
|
||||||
|
-- reason for a cleartext password to travel up through a service
|
||||||
|
-- and a controller to answer a yes/no question.
|
||||||
|
(COALESCE(TRIM(a.password), '') <> '') AS issetup
|
||||||
FROM app_users a
|
FROM app_users a
|
||||||
LEFT JOIN tenantlocations b ON a.locationid = b.locationid
|
LEFT JOIN tenantlocations b ON a.locationid = b.locationid
|
||||||
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
||||||
@@ -363,27 +402,32 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
|||||||
// `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY`
|
// `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY`
|
||||||
// column and Postgres allocates it. Computing one here would leave the sequence
|
// column and Postgres allocates it. Computing one here would leave the sequence
|
||||||
// unadvanced and two allocators racing each other.
|
// unadvanced and two allocators racing each other.
|
||||||
func (r *tenantRepository) CreateStaff(user models.User) error {
|
// The userid is returned because the account is created with NO password and the
|
||||||
|
// caller has to invite it. Postgres allocates the id and GORM writes it back
|
||||||
|
// onto `user`, so this costs nothing — and without it the service would have to
|
||||||
|
// look the row up again by authname, which is the one field a concurrent create
|
||||||
|
// could collide on.
|
||||||
|
func (r *tenantRepository) CreateStaff(user models.User) (int, error) {
|
||||||
pin, err := ValidateStaffUser(&user)
|
pin, err := ValidateStaffUser(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
user.Pin = int(pin)
|
user.Pin = int(pin)
|
||||||
|
|
||||||
if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 {
|
if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 {
|
||||||
taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid)
|
taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
if taken {
|
if taken {
|
||||||
return fmt.Errorf("another person at this outlet already uses that PIN")
|
return 0, fmt.Errorf("another person at this outlet already uses that PIN")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.db.Table("app_users").Create(&user).Error; err != nil {
|
if err := r.db.Table("app_users").Create(&user).Error; err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
return nil
|
return user.Userid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) UpdateStaff(user models.User) error {
|
func (r *tenantRepository) UpdateStaff(user models.User) error {
|
||||||
@@ -393,7 +437,7 @@ func (r *tenantRepository) UpdateStaff(user models.User) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error) {
|
func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) {
|
||||||
var user models.Tenantuser
|
var user models.Tenantuser
|
||||||
tx := r.db.Begin()
|
tx := r.db.Begin()
|
||||||
|
|
||||||
@@ -418,7 +462,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
|||||||
// authenticate.
|
// authenticate.
|
||||||
if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" {
|
if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.Tenantlocations{}, errors.New(
|
return models.Tenantlocations{}, 0, errors.New(
|
||||||
"a branch needs somebody to run it: name an existing user in operatorid, or give an email to create a login from")
|
"a branch needs somebody to run it: name an existing user in operatorid, or give an email to create a login from")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +471,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
|||||||
// QR code (payload is just {tenantid, locationid}) right after onboarding.
|
// QR code (payload is just {tenantid, locationid}) right after onboarding.
|
||||||
if err := tx.Create(&data).Error; err != nil {
|
if err := tx.Create(&data).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.Tenantlocations{}, err
|
return models.Tenantlocations{}, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2a: bind an existing person, when one was named.
|
// Step 2a: bind an existing person, when one was named.
|
||||||
@@ -444,21 +488,25 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
|||||||
Updates(map[string]any{"locationid": data.Locationid})
|
Updates(map[string]any{"locationid": data.Locationid})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.Tenantlocations{}, res.Error
|
return models.Tenantlocations{}, 0, res.Error
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
if res.RowsAffected == 0 {
|
||||||
// Either the person does not exist, belongs to another merchant, or
|
// Either the person does not exist, belongs to another merchant, or
|
||||||
// is a till account. All three are the same answer to the caller,
|
// is a till account. All three are the same answer to the caller,
|
||||||
// and none of them should leave a branch standing.
|
// and none of them should leave a branch standing.
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.Tenantlocations{}, fmt.Errorf(
|
return models.Tenantlocations{}, 0, fmt.Errorf(
|
||||||
"user %d cannot run this branch — they belong to another business, do not exist, or are a till account",
|
"user %d cannot run this branch — they belong to another business, do not exist, or are a till account",
|
||||||
data.Operatorid)
|
data.Operatorid)
|
||||||
}
|
}
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return models.Tenantlocations{}, err
|
return models.Tenantlocations{}, 0, err
|
||||||
}
|
}
|
||||||
return data, nil
|
// No userid: nothing was created. The named person already had an account
|
||||||
|
// before this branch existed, so there is nothing here to invite — if
|
||||||
|
// THEY have never set a password, it is their own creation that owes them
|
||||||
|
// an invitation, not this one.
|
||||||
|
return data, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2b: no person named — spawn a login, as before.
|
// Step 2b: no person named — spawn a login, as before.
|
||||||
@@ -488,15 +536,19 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
|||||||
|
|
||||||
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return models.Tenantlocations{}, err
|
return models.Tenantlocations{}, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit
|
// Commit
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return models.Tenantlocations{}, err
|
return models.Tenantlocations{}, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
// The spawned login's userid, so the service can invite it. This account is
|
||||||
|
// created with `Password = ""` a few lines above, and the invitation is now
|
||||||
|
// the only way to fill that in — the sign-in screen no longer offers a form.
|
||||||
|
// Without this the branch would be commissioned with a login nobody can use.
|
||||||
|
return data, user.Userid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) UpdateTenantLocation(input models.Tenantlocations) error {
|
func (r *tenantRepository) UpdateTenantLocation(input models.Tenantlocations) error {
|
||||||
@@ -625,6 +677,51 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
|
|||||||
var custloc models.Customerlocations
|
var custloc models.Customerlocations
|
||||||
var tcust models.Tenantcustomers
|
var tcust models.Tenantcustomers
|
||||||
|
|
||||||
|
// A tenant with configid 0 is unreachable, and it takes its customer row
|
||||||
|
// with it.
|
||||||
|
//
|
||||||
|
// Step 3 below already forces `user.Configid = 1`, with a comment
|
||||||
|
// explaining that AppLogin only ever queries configid 1 and a zero makes
|
||||||
|
// the account permanently unfindable. The same zero was left to flow into
|
||||||
|
// `tenants` itself and into the `customers` row copied from it at step 4,
|
||||||
|
// where nothing corrected it — so a caller that omits configid (the console
|
||||||
|
// sends it; the mobile route and anything else need not) created a business
|
||||||
|
// and a customer that no scoped read can see.
|
||||||
|
//
|
||||||
|
// Defaulted rather than rejected: 1 is the only value any caller has ever
|
||||||
|
// meant here, and refusing the create would break callers that work today.
|
||||||
|
if data.Configid == 0 {
|
||||||
|
data.Configid = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the primary outlet the scaffolding the tenant already has.
|
||||||
|
//
|
||||||
|
// The outlet itself is created by GORM, as the `Tenantlocations`
|
||||||
|
// association on the struct below — the console nests a full object in the
|
||||||
|
// request and step 1 saves it with the tenant. What it does NOT do is fill
|
||||||
|
// anything the caller left out, and two of those columns matter:
|
||||||
|
//
|
||||||
|
// applocationid — `orderRepository.go` calls it "authoritative" and has
|
||||||
|
// no fallback anywhere for a 0.
|
||||||
|
// moduleid — same file: "tenantlocations carries 0 for
|
||||||
|
// moduleid/partnerid at outlets whose live orders
|
||||||
|
// nonetheless use non-zero values", worked around there
|
||||||
|
// by copying scaffolding off the most recent real order.
|
||||||
|
// A shop commissioned a minute ago has no such order.
|
||||||
|
//
|
||||||
|
// Neither column has a database default, and no onboarding form asks for
|
||||||
|
// them — they describe the platform, not the shop. The tenant's own values
|
||||||
|
// are the right answer and are already right here.
|
||||||
|
//
|
||||||
|
// Filled before the insert rather than corrected after it, so there is one
|
||||||
|
// write and no window where the row exists with a zero in it.
|
||||||
|
if data.Tenantlocations.Applocationid == 0 {
|
||||||
|
data.Tenantlocations.Applocationid = data.Applocationid
|
||||||
|
}
|
||||||
|
if data.Tenantlocations.Moduleid == 0 {
|
||||||
|
data.Tenantlocations.Moduleid = data.Moduleid
|
||||||
|
}
|
||||||
|
|
||||||
tx := r.db.Begin()
|
tx := r.db.Begin()
|
||||||
|
|
||||||
// Step 1: Insert into tenants
|
// Step 1: Insert into tenants
|
||||||
@@ -997,3 +1094,119 @@ func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InviteTarget is the account a tenant's invitation is addressed to.
|
||||||
|
type InviteTarget struct {
|
||||||
|
Userid int
|
||||||
|
Email string
|
||||||
|
Tenantname string
|
||||||
|
// True when the account already has a password, which means the merchant is
|
||||||
|
// set up and there is nothing to invite them to.
|
||||||
|
IsSetUp bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrimaryAdminForTenant finds the account an invitation should go to.
|
||||||
|
//
|
||||||
|
// ── Which of a tenant's users is "the" admin ────────────────────────────────
|
||||||
|
//
|
||||||
|
// A business can have several roleid-3 accounts — staff added later are the
|
||||||
|
// same role. The one onboarding created is identified by its authname matching
|
||||||
|
// the tenant's own `primaryemail`, which is how `CreateTenantUser` writes it,
|
||||||
|
// and that is the account the invitation belongs to. Picking any roleid-3 row
|
||||||
|
// would email whichever staff member happened to sort first.
|
||||||
|
//
|
||||||
|
// `IsSetUp` is computed in the query rather than by returning the password.
|
||||||
|
// There is no reason for a hash — or on this backend, a cleartext password — to
|
||||||
|
// travel up through a service and a controller to answer a yes/no question.
|
||||||
|
func (r *tenantRepository) PrimaryAdminForTenant(tenantID int) (InviteTarget, error) {
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return InviteTarget{}, errors.New("tenantid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row InviteTarget
|
||||||
|
query := `
|
||||||
|
SELECT u.userid AS userid,
|
||||||
|
COALESCE(NULLIF(TRIM(u.email), ''), t.primaryemail) AS email,
|
||||||
|
t.tenantname AS tenantname,
|
||||||
|
(COALESCE(TRIM(u.password), '') <> '') AS issetup
|
||||||
|
FROM tenants t
|
||||||
|
JOIN app_users u
|
||||||
|
ON u.tenantid = t.tenantid
|
||||||
|
AND LOWER(TRIM(u.authname)) = LOWER(TRIM(t.primaryemail))
|
||||||
|
WHERE t.tenantid = ?
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, tenantID).Scan(&row).Error; err != nil {
|
||||||
|
return InviteTarget{}, err
|
||||||
|
}
|
||||||
|
if row.Userid == 0 {
|
||||||
|
// Either no such tenant, or one whose primary email matches no account.
|
||||||
|
// The second happens when the address was changed on the tenant after
|
||||||
|
// onboarding without the login being changed with it — worth saying,
|
||||||
|
// because the fix is to correct one of the two rather than to resend.
|
||||||
|
return InviteTarget{}, fmt.Errorf(
|
||||||
|
"tenant %d has no account matching its primary email address", tenantID)
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteTargetForUser finds one account by its userid.
|
||||||
|
//
|
||||||
|
// The other half of resend. `PrimaryAdminForTenant` answers "the owner of this
|
||||||
|
// business", which is the only account a tenant HAS at onboarding — but staff
|
||||||
|
// added later and the login every branch spawns are created with no password
|
||||||
|
// too, and there is exactly one of the owner, so they cannot be reached that
|
||||||
|
// way. An operator chasing a branch manager who never got their mail needs to
|
||||||
|
// name the person.
|
||||||
|
//
|
||||||
|
// The tenant is joined for its name only, and joined LEFT: a back-office account
|
||||||
|
// with no tenant is a Nearle staff row, and one exists — the platform agent's.
|
||||||
|
// Failing the lookup on that would be refusing to answer a question that has a
|
||||||
|
// perfectly good answer.
|
||||||
|
func (r *tenantRepository) InviteTargetForUser(userID int) (InviteTarget, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return InviteTarget{}, errors.New("userid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row InviteTarget
|
||||||
|
query := `
|
||||||
|
SELECT u.userid AS userid,
|
||||||
|
COALESCE(NULLIF(TRIM(u.email), ''), TRIM(u.authname)) AS email,
|
||||||
|
COALESCE(t.tenantname, '') AS tenantname,
|
||||||
|
(COALESCE(TRIM(u.password), '') <> '') AS issetup
|
||||||
|
FROM app_users u
|
||||||
|
LEFT JOIN tenants t ON t.tenantid = u.tenantid
|
||||||
|
WHERE u.userid = ?
|
||||||
|
AND COALESCE(u.roleid, 0) NOT IN (7, 8)
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
if err := r.db.Raw(query, userID).Scan(&row).Error; err != nil {
|
||||||
|
return InviteTarget{}, err
|
||||||
|
}
|
||||||
|
if row.Userid == 0 {
|
||||||
|
// No such account, or a till one. Roles 7 and 8 are excluded because a
|
||||||
|
// cashier does not sign in to the console at all — they authenticate at
|
||||||
|
// the terminal with a PIN, and an invitation would send them to a screen
|
||||||
|
// that cannot help them.
|
||||||
|
return InviteTarget{}, fmt.Errorf(
|
||||||
|
"user %d is not a back-office account on this platform", userID)
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantNameByID is the business's name, for an invitation's first line.
|
||||||
|
//
|
||||||
|
// Its own tiny read rather than a field threaded through the create paths: a
|
||||||
|
// staff account arrives carrying a tenantid and nothing else about the business,
|
||||||
|
// and the alternative was every caller passing a name it would have had to look
|
||||||
|
// up anyway. An empty name is not an error — `inviteMessage` says "your
|
||||||
|
// business" instead, which is worse copy and a working email.
|
||||||
|
func (r *tenantRepository) TenantNameByID(tenantID int) (string, error) {
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
err := r.db.Raw(`SELECT COALESCE(tenantname, '') FROM tenants WHERE tenantid = ? LIMIT 1`,
|
||||||
|
tenantID).Scan(&name).Error
|
||||||
|
return name, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -12,16 +14,15 @@ import (
|
|||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error)
|
GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error)
|
||||||
GetUserByID(uid int) (models.UserInfo, error)
|
GetUserByID(uid int) (models.UserInfo, error)
|
||||||
|
SetInitialPassword(userid int, password string) error
|
||||||
Login(user models.User) (models.UserInfo, error)
|
Login(user models.User) (models.UserInfo, error)
|
||||||
FindUserID(authname, contactno string, configid int) (int, error)
|
FindUserID(authname, contactno string, configid int) (int, error)
|
||||||
UpdateStaff(user models.User) error
|
UpdateStaff(user models.User) error
|
||||||
GetUserByAuthname(authname string, configid int) (int, string, string)
|
|
||||||
GetUserByContactNo(contactno string, configid int) (int, string, string)
|
|
||||||
UpdateFCMToken(userid int, token string) error
|
UpdateFCMToken(userid int, token string) error
|
||||||
GetTenantUserById(userid int) models.TenantUserInfo
|
GetTenantUserById(userid int) models.TenantUserInfo
|
||||||
CreateUser(user models.User) (int, error)
|
CreateUser(user models.User) (int, error)
|
||||||
GetUserById(uid int) (models.UserInfo, error)
|
GetUserById(uid int) (models.UserInfo, error)
|
||||||
GetUserLogin(field, value string, configid int) (int, string, string, int)
|
GetUserLogin(field, value string, configid int) (int, string, string, int, error)
|
||||||
UpdateUserFcmToken(uid int, token string) error
|
UpdateUserFcmToken(uid int, token string) error
|
||||||
GetLocationStatus(locationid int) string
|
GetLocationStatus(locationid int) string
|
||||||
DeleteUser(userid int) error
|
DeleteUser(userid int) error
|
||||||
@@ -164,7 +165,6 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
|
|||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) {
|
func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) {
|
||||||
var uid int
|
var uid int
|
||||||
var query string
|
var query string
|
||||||
@@ -187,39 +187,10 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
|
|||||||
return uid, nil
|
return uid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func (r *userRepository) UpdateStaff(user models.User) error {
|
func (r *userRepository) UpdateStaff(user models.User) error {
|
||||||
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
|
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// A till account is not a Nearle Daily user. The two products share this table
|
|
||||||
// and nothing else, so every way into the application excludes roles 7 and 8 in
|
|
||||||
// the lookup itself: a cashier is not "refused", they are simply not found.
|
|
||||||
//
|
|
||||||
// Doing it in the query rather than after it is deliberate. A check bolted on
|
|
||||||
// afterwards has to be repeated at each of these call sites and is one edit away
|
|
||||||
// from being forgotten at one of them, and that one would be the hole.
|
|
||||||
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
|
|
||||||
var uid int
|
|
||||||
var password, status string
|
|
||||||
query := `SELECT userid, password, status FROM app_users
|
|
||||||
WHERE authname = ? AND configid = ?
|
|
||||||
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
|
||||||
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
|
|
||||||
return uid, password, status
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
|
|
||||||
var uid int
|
|
||||||
var password, status string
|
|
||||||
query := `SELECT userid, password, status FROM app_users
|
|
||||||
WHERE contactno = ? AND configid = ?
|
|
||||||
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
|
||||||
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
|
|
||||||
return uid, password, status
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) UpdateFCMToken(userid int, token string) error {
|
func (r *userRepository) UpdateFCMToken(userid int, token string) error {
|
||||||
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
|
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
|
||||||
return r.db.Exec(query, token, userid).Error
|
return r.db.Exec(query, token, userid).Error
|
||||||
@@ -285,6 +256,30 @@ func (r *userRepository) GetTenantUserById(userid int) models.TenantUserInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) CreateUser(user models.User) (int, error) {
|
func (r *userRepository) CreateUser(user models.User) (int, error) {
|
||||||
|
// Inherit the delivery region from the tenant when the caller did not name
|
||||||
|
// one.
|
||||||
|
//
|
||||||
|
// `app_users.applocationid` has no column default, and no console form
|
||||||
|
// collects it — it is a platform region, not something a merchant picks
|
||||||
|
// per person. So every back-office account created through this path landed
|
||||||
|
// with 0, which is not a region: `orderRepository.go` calls the equivalent
|
||||||
|
// column on tenantlocations "authoritative" and has no fallback for a zero,
|
||||||
|
// and 43 of 75 live branches are already in that state.
|
||||||
|
//
|
||||||
|
// A lookup rather than a default value, because the right answer is
|
||||||
|
// whichever region the business trades in. Failure is not fatal: the
|
||||||
|
// account is still worth creating, and a 0 here is exactly what would have
|
||||||
|
// been written anyway.
|
||||||
|
if user.Applocationid == 0 && user.Tenantid > 0 {
|
||||||
|
var inherited int
|
||||||
|
if err := r.db.Raw(
|
||||||
|
`SELECT COALESCE(applocationid, 0) FROM tenants WHERE tenantid = ?`,
|
||||||
|
user.Tenantid,
|
||||||
|
).Scan(&inherited).Error; err == nil && inherited > 0 {
|
||||||
|
user.Applocationid = inherited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx := r.db.Begin()
|
tx := r.db.Begin()
|
||||||
|
|
||||||
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
||||||
@@ -329,9 +324,40 @@ func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int) {
|
// GetUserLogin is the one sign-in lookup, for the app and the console alike.
|
||||||
var uid, roleid int
|
//
|
||||||
var password, status string
|
// `field` is the column matched — "authname" or "contactno", nothing else is
|
||||||
|
// accepted — and it is interpolated, so the whitelist is what keeps this from
|
||||||
|
// being an injection point.
|
||||||
|
//
|
||||||
|
// A till account is not a Nearle Daily user. The two products share this table
|
||||||
|
// and nothing else, so the lookup itself excludes roles 7 and 8: a cashier is
|
||||||
|
// not "refused", they are simply not found. Doing it in the query rather than
|
||||||
|
// after it is deliberate — a check bolted on afterwards has to be repeated at
|
||||||
|
// every call site and is one edit away from being forgotten at one of them.
|
||||||
|
//
|
||||||
|
// Three outcomes, and the caller must tell them apart:
|
||||||
|
//
|
||||||
|
// - found: uid > 0, err == nil
|
||||||
|
// - not found: uid == 0, err == nil
|
||||||
|
// - failed: err != nil — the database could not answer at all
|
||||||
|
//
|
||||||
|
// The third used to be invisible. `Row().Scan`'s error was discarded, so a
|
||||||
|
// database that was down, a connection pool that was exhausted or a
|
||||||
|
// misconfigured `configid` all came back as uid 0 — which the service then
|
||||||
|
// reported as "Invalid Email". On 2026-07-20 the deployment lost its
|
||||||
|
// ConfigMaps/Secrets and every user on the platform was told their email was
|
||||||
|
// wrong, and nothing in the logs said otherwise.
|
||||||
|
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int, error) {
|
||||||
|
switch field {
|
||||||
|
case "authname", "contactno":
|
||||||
|
default:
|
||||||
|
return 0, "", "", 0, fmt.Errorf("login: %q is not a sign-in field", field)
|
||||||
|
}
|
||||||
|
|
||||||
|
var uid int
|
||||||
|
var password, status sql.NullString
|
||||||
|
var roleid sql.NullInt64
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT userid, password, status, roleid
|
SELECT userid, password, status, roleid
|
||||||
@@ -339,9 +365,16 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s
|
|||||||
WHERE %s = ? AND configid = ?
|
WHERE %s = ? AND configid = ?
|
||||||
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
|
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
|
||||||
|
|
||||||
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
err := r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return uid, password, status, roleid
|
return 0, "", "", 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", "", 0, err
|
||||||
|
}
|
||||||
|
// Nullable columns scanned through sql.Null* so that a NULL password or
|
||||||
|
// role — both exist on real rows — does not itself read as a failed query.
|
||||||
|
return uid, password.String, status.String, int(roleid.Int64), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error {
|
func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error {
|
||||||
@@ -360,4 +393,45 @@ func (r *userRepository) DeleteUser(userid int) error {
|
|||||||
return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error
|
return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInitialPassword writes the first password on an account that has none.
|
||||||
|
//
|
||||||
|
// ── Why this is a separate call and not `UpdateStaff` ───────────────────────
|
||||||
|
//
|
||||||
|
// It is the one write that MUST work without a session, and that is the whole
|
||||||
|
// difficulty. A brand-new account — `createtenantlocation` spawns branch logins
|
||||||
|
// with an empty password — signs in, is told to set one, and at that moment has
|
||||||
|
// no token and no way to get one. The console was doing this through
|
||||||
|
// `PUT /users/update`, which sits behind the session guard, so the call came
|
||||||
|
// back "a session token is required; sign in again" and the account could never
|
||||||
|
// be used. Sign-in needs a password; setting the password needed a sign-in.
|
||||||
|
//
|
||||||
|
// `/users/update` could not simply be opened up: it writes whatever struct it
|
||||||
|
// is handed, so an unauthenticated caller could edit any field of any user.
|
||||||
|
// This can do exactly one thing, to exactly one kind of account.
|
||||||
|
//
|
||||||
|
// ── What makes it safe to expose ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The empty-password check IS the authorisation. An account with a password set
|
||||||
|
// is refused, so this can never overwrite a credential — it is a setup call,
|
||||||
|
// never a reset. There is no "forgot password" flow on this backend and this
|
||||||
|
// must not become one by accident: a reset needs proof of identity, and nothing
|
||||||
|
// here has any.
|
||||||
|
//
|
||||||
|
// The check and the write are one statement, so two callers racing cannot both
|
||||||
|
// see an empty password and both set one. Postgres decides, not this process.
|
||||||
|
func (r *userRepository) SetInitialPassword(userid int, password string) error {
|
||||||
|
result := r.db.Table("app_users").
|
||||||
|
Where("userid = ? AND (password IS NULL OR TRIM(password) = '')", userid).
|
||||||
|
Update("password", password)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
// One message for "no such user" and "already has a password". They
|
||||||
|
// must not be distinguishable, or this becomes a way to ask whether a
|
||||||
|
// given userid exists and whether it has been set up.
|
||||||
|
return errors.New("that account cannot have its password set here — it may already have one")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
25
routes/assistantroutes.go
Normal file
25
routes/assistantroutes.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nearle/facade"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nearle Buddy. See controllers/assistantController.go for the two calls and
|
||||||
|
// services/assistantService.go for the loop behind them.
|
||||||
|
//
|
||||||
|
// Under `/v1/web`, so it inherits `middleware.WebAuth` along with every other
|
||||||
|
// console route — which is the point. The assistant reads the same data the
|
||||||
|
// console does, and it must read it as the same person.
|
||||||
|
func RegisterAssistantRoutes(api fiber.Router, f *facade.Facade) {
|
||||||
|
assistant := api.Group("/v1/web/assistant")
|
||||||
|
|
||||||
|
assistant.Get("/status", f.AssistantController.Status)
|
||||||
|
assistant.Post("/ask", f.AssistantController.Ask)
|
||||||
|
assistant.Post("/approve", f.AssistantController.Approve)
|
||||||
|
|
||||||
|
// The MCP door, under the same group so it inherits the same session guard.
|
||||||
|
// One endpoint: JSON-RPC carries the method in the body.
|
||||||
|
assistant.Post("/mcp", f.MCPController.Handle)
|
||||||
|
}
|
||||||
33
routes/deliveryslotroutes.go
Normal file
33
routes/deliveryslotroutes.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"nearle/facade"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Delivery windows.
|
||||||
|
|
||||||
|
── The web half is guarded, the app half is read-only ──────────────────────
|
||||||
|
|
||||||
|
Setting a branch's hours is a tenant-wide decision with money behind it, so it
|
||||||
|
sits on /v1/web where WebAuth checks the session and the tenant scope. Reading
|
||||||
|
what is on offer is what a shopper's app does before it has any identity at all,
|
||||||
|
so that one endpoint — and only that one — lives on the unauthenticated /v1/mob
|
||||||
|
group.
|
||||||
|
|
||||||
|
There is deliberately NO write route on the mob group. The /v1/mob/* group has
|
||||||
|
no session at all, and a shop's trading hours are not something an anonymous
|
||||||
|
caller gets to set.
|
||||||
|
*/
|
||||||
|
func RegisterDeliverySlotRoutes(api fiber.Router, f *facade.Facade) {
|
||||||
|
// ── Console: read and edit a branch's windows ───────────────────────────
|
||||||
|
web := api.Group("/v1/web/deliveryslots")
|
||||||
|
web.Get("/", f.DeliverySlotController.ListDeliverySlots)
|
||||||
|
web.Put("/", f.DeliverySlotController.SaveDeliverySlots)
|
||||||
|
|
||||||
|
// ── App: what a shopper may pick, already filtered ──────────────────────
|
||||||
|
mob := api.Group("/v1/mob/deliveryslots")
|
||||||
|
mob.Get("/available", f.DeliverySlotController.AvailableDeliverySlots)
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
partner.Get("/getriders", f.PartnerController.GetActiveRiders)
|
partner.Get("/getriders", f.PartnerController.GetActiveRiders)
|
||||||
partner.Get("/getpartners", f.PartnerController.GetPartners)
|
partner.Get("/getpartners", f.PartnerController.GetPartners)
|
||||||
partner.Get("/getridershifts", f.PartnerController.GetRiderShifts)
|
partner.Get("/getridershifts", f.PartnerController.GetRiderShifts)
|
||||||
|
// Opening a shift window. Riders cannot be hired without one, and this table
|
||||||
|
// was read-only until now — see partnerController.CreateRiderShift.
|
||||||
|
partner.Post("/createridershift", f.PartnerController.CreateRiderShift)
|
||||||
partner.Get("/getlocations", f.PartnerController.GetLocationConfig)
|
partner.Get("/getlocations", f.PartnerController.GetLocationConfig)
|
||||||
partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs)
|
partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs)
|
||||||
partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary)
|
partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary)
|
||||||
|
|||||||
@@ -77,6 +77,28 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
registerPosStaffConsoleRoutes(api, f)
|
registerPosStaffConsoleRoutes(api, f)
|
||||||
registerPosReadConsoleRoutes(api, f)
|
registerPosReadConsoleRoutes(api, f)
|
||||||
registerLiveRoutes(api, f)
|
registerLiveRoutes(api, f)
|
||||||
|
registerPosAdoptionRoute(api, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// How much of the till fleet has adopted the session token.
|
||||||
|
//
|
||||||
|
// The number that decides when `POS_AUTH_REQUIRED` can be switched on. Nothing
|
||||||
|
// was recording it — an untokened request was waved through in silence — so the
|
||||||
|
// only way to judge the risk of flipping the flag was to flip it and watch.
|
||||||
|
//
|
||||||
|
// ── Why it is on /v1/web and only /v1/web ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// It names the outlets still calling without a token, which is a list of the
|
||||||
|
// shops that would stop trading if enforcement went on today. That is exactly
|
||||||
|
// the list an attacker would want, so it sits behind `middleware.WebAuth` and
|
||||||
|
// NOT on the unauthenticated health endpoint, where the rest of "is this
|
||||||
|
// deployment wired up" lives.
|
||||||
|
//
|
||||||
|
// Registered on its own rather than inside registerPosReadConsoleRoutes,
|
||||||
|
// because that function deliberately mirrors every route onto `/v1/mob/pos`
|
||||||
|
// as well — which has no guard at all.
|
||||||
|
func registerPosAdoptionRoute(api fiber.Router, f *facade.Facade) {
|
||||||
|
api.Group("/v1/web/pos").Get("/authadoption", f.PosController.AuthAdoption)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same counter-sales reads, for callers that are not a terminal.
|
// The same counter-sales reads, for callers that are not a terminal.
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
||||||
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
||||||
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
||||||
|
|
||||||
|
// Whether this shop shows a product's health score.
|
||||||
|
//
|
||||||
|
// On `/v1/web` only. The merchant decides for their own shelf, so it needs
|
||||||
|
// the session that says which shelf is theirs — and the mobile group has no
|
||||||
|
// guard at all, which would make this "anyone can turn any shop's health
|
||||||
|
// scores off".
|
||||||
|
products.Put("/showhealthscore", f.ProductController.SetShowHealthScore)
|
||||||
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
||||||
|
|
||||||
// Repairing catalogue links. A dry run unless `apply=true` — see the handler,
|
// Repairing catalogue links. A dry run unless `apply=true` — see the handler,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"nearle/facade"
|
"nearle/facade"
|
||||||
|
"nearle/middleware"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,22 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
|||||||
|
|
||||||
api := app.Group("/live/api")
|
api := app.Group("/live/api")
|
||||||
|
|
||||||
|
// Console sessions.
|
||||||
|
//
|
||||||
|
// Mounted by PATH rather than on a group object, because the `/v1/web`
|
||||||
|
// routes are not one group — a dozen files each create their own
|
||||||
|
// (`/v1/web/users`, `/v1/web/orders`, `/v1/web/products`, …). Registered
|
||||||
|
// here, ahead of all of them, so a route added later is guarded by default
|
||||||
|
// rather than by somebody remembering to.
|
||||||
|
//
|
||||||
|
// `/v1/pos` is deliberately NOT covered: that is the terminal surface, it
|
||||||
|
// carries a different kind of token, and it has its own guard. But
|
||||||
|
// `/v1/web/pos` and `/v1/web/tenants` ARE, despite their names — both are
|
||||||
|
// console callers, and `createposuser` on the second mints till credentials,
|
||||||
|
// which until now it did on the strength of an unauthenticated request. The
|
||||||
|
// note above registerPosStaffConsoleRoutes asked for exactly this.
|
||||||
|
api.Use("/v1/web", middleware.WebAuth(f.PosService()))
|
||||||
|
|
||||||
RegisterUserRoutes(api, f)
|
RegisterUserRoutes(api, f)
|
||||||
RegisterProductRoutes(api, f)
|
RegisterProductRoutes(api, f)
|
||||||
RegisterOrderRoutes(api, f)
|
RegisterOrderRoutes(api, f)
|
||||||
@@ -21,4 +38,15 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
|||||||
RegisterCatalogueRoutes(api, f)
|
RegisterCatalogueRoutes(api, f)
|
||||||
RegisterPosRoutes(api, f)
|
RegisterPosRoutes(api, f)
|
||||||
RegisterUploadRoutes(api, f)
|
RegisterUploadRoutes(api, f)
|
||||||
|
RegisterScanRoutes(api, f)
|
||||||
|
RegisterAssistantRoutes(api, f)
|
||||||
|
RegisterDeliverySlotRoutes(api, f)
|
||||||
|
|
||||||
|
// What is running here.
|
||||||
|
//
|
||||||
|
// Registered on `api` and NOT under `/v1/web`, so it answers without a
|
||||||
|
// session — which is the whole point. The question it exists for is "why
|
||||||
|
// does nothing work", and a health check that needs a working credential
|
||||||
|
// cannot answer that. It returns booleans and a build id, never values.
|
||||||
|
api.Get("/v1/health", f.HealthController.Health)
|
||||||
}
|
}
|
||||||
|
|||||||
17
routes/scanroutes.go
Normal file
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)
|
||||||
|
}
|
||||||
135
routes/startup_test.go
Normal file
135
routes/startup_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nearle/config"
|
||||||
|
"nearle/facade"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Can this server be built and can its routes be reached?
|
||||||
|
//
|
||||||
|
// Everything else in this repository tests a function. This tests the thing
|
||||||
|
// that actually happens on deploy: the whole object graph is constructed and
|
||||||
|
// every route is registered. Nothing here needs a database — the repositories
|
||||||
|
// hold their handle without touching it — so it runs in CI beside the unit
|
||||||
|
// tests rather than in an environment somebody has to provision.
|
||||||
|
//
|
||||||
|
// ── Why it is worth its own file ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Three things in `NewFacade` PANIC rather than return an error: a tool that
|
||||||
|
// fails to register, a help corpus that will not load, and an agent naming a
|
||||||
|
// tool that does not exist. Each is a programming mistake that should stop a
|
||||||
|
// deploy, and each was previously reachable only by starting the server against
|
||||||
|
// a real database — which meant, in practice, by deploying.
|
||||||
|
//
|
||||||
|
// The agent one is not hypothetical: a typo in `agents/orders.yaml` is a file
|
||||||
|
// edit away, and it takes a working assistant down at boot.
|
||||||
|
|
||||||
|
func testFacade(t *testing.T) *facade.Facade {
|
||||||
|
t.Helper()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
// Rendered as a failure rather than a panicking test, because the
|
||||||
|
// message IS the point — "agent orders lists a tool that does not
|
||||||
|
// exist" is the whole diagnosis.
|
||||||
|
t.Fatalf("the server cannot start: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// No database, no catalogue, no embedder, no model. A deployment with none
|
||||||
|
// of those must still boot and say what it is missing, rather than failing
|
||||||
|
// somewhere the operator cannot see.
|
||||||
|
return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests", nil, config.MailConfig{}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheServerCanBeBuilt(t *testing.T) {
|
||||||
|
f := testFacade(t)
|
||||||
|
if f == nil {
|
||||||
|
t.Fatal("no facade")
|
||||||
|
}
|
||||||
|
// The two doors onto the assistant. Absent means a route registered below
|
||||||
|
// would nil-panic on its first request rather than at boot.
|
||||||
|
if f.AssistantController == nil {
|
||||||
|
t.Fatal("no assistant controller")
|
||||||
|
}
|
||||||
|
if f.MCPController == nil {
|
||||||
|
t.Fatal("no MCP controller")
|
||||||
|
}
|
||||||
|
if f.Tools == nil {
|
||||||
|
t.Fatal("no tool registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryAssistantRouteIsReachable(t *testing.T) {
|
||||||
|
// Registered, not merely written down. A route added to a file that nothing
|
||||||
|
// calls is invisible until somebody reports the feature missing.
|
||||||
|
app := fiber.New()
|
||||||
|
RegisterRoutes(app, testFacade(t))
|
||||||
|
|
||||||
|
for _, route := range []struct {
|
||||||
|
method, path string
|
||||||
|
}{
|
||||||
|
{"GET", "/live/api/v1/web/assistant/status"},
|
||||||
|
{"POST", "/live/api/v1/web/assistant/ask"},
|
||||||
|
{"POST", "/live/api/v1/web/assistant/approve"},
|
||||||
|
{"POST", "/live/api/v1/web/assistant/mcp"},
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(route.method, route.path, strings.NewReader("{}"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", route.method, route.path, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == fiber.StatusNotFound {
|
||||||
|
t.Fatalf("%s %s is not registered", route.method, route.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthAnswersThroughTheRealRouteTableWithoutASession(t *testing.T) {
|
||||||
|
// Registered on `api` rather than under `/v1/web`, which is what keeps it
|
||||||
|
// outside the session guard. Asserted here rather than trusted, because the
|
||||||
|
// difference is one path segment and getting it wrong makes the endpoint
|
||||||
|
// useless for the only situation it exists for: nothing else works.
|
||||||
|
//
|
||||||
|
// It also has to survive a facade built with no database, no model and no
|
||||||
|
// embedder — the state somebody is most likely to be asking from.
|
||||||
|
app := fiber.New()
|
||||||
|
RegisterRoutes(app, testFacade(t))
|
||||||
|
|
||||||
|
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling health: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != fiber.StatusOK {
|
||||||
|
t.Fatalf("health needs a session or is unregistered: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAssistantSurfaceSitsBehindTheSessionGuard(t *testing.T) {
|
||||||
|
// The assistant reads the same data the console does and must read it as
|
||||||
|
// the same person. Being under `/v1/web` is what puts it behind WebAuth —
|
||||||
|
// a route registered one path segment to the left would answer anybody.
|
||||||
|
app := fiber.New()
|
||||||
|
RegisterRoutes(app, testFacade(t))
|
||||||
|
|
||||||
|
// WEB_AUTH_REQUIRED is off by default, so an untokened request reaches the
|
||||||
|
// handler; the assistant's own controller then refuses it. Either way it
|
||||||
|
// must not answer with data.
|
||||||
|
req := httptest.NewRequest("POST", "/live/api/v1/web/assistant/ask",
|
||||||
|
strings.NewReader(`{"question":"what is stuck?"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("calling: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == fiber.StatusOK {
|
||||||
|
t.Fatal("an untokened question was answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,14 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
|
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
|
||||||
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
|
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
|
||||||
|
|
||||||
|
// Re-issuing a merchant's first-password link, for the one who never got
|
||||||
|
// the mail or whose invitation expired.
|
||||||
|
//
|
||||||
|
// Web group only, and the handler additionally requires a platform account:
|
||||||
|
// this mints a credential, and the merchant it would invite is by
|
||||||
|
// definition somebody who cannot sign in to ask for it themselves.
|
||||||
|
tenant.Post("/resendinvite", f.TenantController.ResendInvite)
|
||||||
|
|
||||||
// One business, by id.
|
// One business, by id.
|
||||||
//
|
//
|
||||||
// Also /mob-only until now, so the console's only way to read its own
|
// Also /mob-only until now, so the console's only way to read its own
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ func RegisterUserRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
users.Post("/applogin", f.UserController.AppLogin)
|
users.Post("/applogin", f.UserController.AppLogin)
|
||||||
users.Post("/create", f.UserController.CreateUser)
|
users.Post("/create", f.UserController.CreateUser)
|
||||||
users.Post("/tenant/weblogin", f.UserController.TenantWebLogin)
|
users.Post("/tenant/weblogin", f.UserController.TenantWebLogin)
|
||||||
|
|
||||||
|
// First password, before a session can exist. Public by necessity and safe
|
||||||
|
// because of what it refuses — see userController.SetPassword.
|
||||||
|
users.Post("/setpassword", f.UserController.SetPassword)
|
||||||
users.Put("/update", f.UserController.UpdateStaff)
|
users.Put("/update", f.UserController.UpdateStaff)
|
||||||
users.Delete("/delete", f.UserController.DeleteUser)
|
users.Delete("/delete", f.UserController.DeleteUser)
|
||||||
|
|
||||||
|
|||||||
49
scratch/buddyconfig/main.go
Normal file
49
scratch/buddyconfig/main.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Would this server switch Nearle Buddy on?
|
||||||
|
//
|
||||||
|
// APP_ENV=production go run ./scratch/buddyconfig
|
||||||
|
//
|
||||||
|
// Reads the configuration exactly as `main.go` does — same files, same order,
|
||||||
|
// same defaults — and reports whether the assistant would be built. Prints a
|
||||||
|
// verdict and, when the answer is no, the name of the variable that is missing.
|
||||||
|
// Never a value: this exists to be run against production configuration.
|
||||||
|
//
|
||||||
|
// Connects to nothing. `config.Load` reads and validates; the database, the
|
||||||
|
// model and the queue are all somebody else's job.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"nearle/config"
|
||||||
|
"nearle/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("configuration is not valid:")
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("APP_ENV %s\n", cfg.AppEnv)
|
||||||
|
fmt.Printf("sessions can issue %t\n", utils.WebTokenConfigured())
|
||||||
|
|
||||||
|
if !cfg.Assistant.Enabled() {
|
||||||
|
fmt.Printf("assistant OFF — %s\n", cfg.Assistant.Why())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gateway itself, built the way the facade builds it. Enabled() passing
|
||||||
|
// and NewChat returning nil would be a disagreement worth catching here
|
||||||
|
// rather than at the first question somebody asks.
|
||||||
|
chat, err := utils.NewChat(cfg.Assistant)
|
||||||
|
if err != nil || chat == nil {
|
||||||
|
fmt.Printf("assistant OFF — gateway not built: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("assistant ON — provider %s, balanced tier %s\n",
|
||||||
|
cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
|
||||||
|
}
|
||||||
81
scratch/buddystatus/main.go
Normal file
81
scratch/buddystatus/main.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Asks the deployed server whether Nearle Buddy has a model.
|
||||||
|
//
|
||||||
|
// go run ./scratch/buddystatus # production
|
||||||
|
// go run ./scratch/buddystatus http://localhost:1122
|
||||||
|
//
|
||||||
|
// `/assistant/status` sits behind the session guard, so this mints one. That it
|
||||||
|
// CAN mint one, from a secret sitting in a tracked file, is itself the finding
|
||||||
|
// recorded in middleware/webauth.go: anybody with repository access can issue a
|
||||||
|
// session for any tenant. Rotating POS_TOKEN_SECRET out of `.env.local` is the
|
||||||
|
// fix, and this tool stops working the day that happens — which is correct.
|
||||||
|
//
|
||||||
|
// Read-only. It asks one question and prints the answer.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// The secret lives in the env files, not in this program.
|
||||||
|
_ = godotenv.Load(".env.local")
|
||||||
|
_ = godotenv.Load(".env")
|
||||||
|
|
||||||
|
host := "https://fiesta.nearle.app"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
host = strings.TrimRight(os.Args[1], "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _, err := utils.MintWebToken(utils.WebClaims{Userid: 904, Tenantid: 1147}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("cannot mint a session:", err)
|
||||||
|
fmt.Println("POS_TOKEN_SECRET is not set here, or is shorter than 16 characters.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := host + "/live/api/v1/web/assistant/status"
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("could not reach", url, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("%s\nHTTP %d\n%s\n\n", url, resp.StatusCode, body)
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Details struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
} `json:"details"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &envelope) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusUnauthorized:
|
||||||
|
fmt.Println("The session was refused — this deployment signs with a different secret.")
|
||||||
|
case envelope.Details.Available:
|
||||||
|
fmt.Println("Buddy has a model. The composer should accept a question on Console, Sales and Inventory.")
|
||||||
|
case envelope.Details.Reason != "":
|
||||||
|
fmt.Println("Buddy is off:", envelope.Details.Reason)
|
||||||
|
default:
|
||||||
|
fmt.Println("Buddy is off. This build does not say why — ASSISTANT_BASE_URL, ASSISTANT_MODEL")
|
||||||
|
fmt.Println("and ASSISTANT_API_KEY are what it needs, set on the platform and redeployed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
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, ",")
|
||||||
|
}
|
||||||
76
scratch/nutritionlive/main.go
Normal file
76
scratch/nutritionlive/main.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Shows the exact `getproductbyvariant` response once NUTRITION_BASE is set.
|
||||||
|
//
|
||||||
|
// Takes the LIVE Fiesta response for a product, runs the same decoration the
|
||||||
|
// endpoint runs, and prints the result. Nothing here is hand-assembled: the
|
||||||
|
// product row is production's, the panel and score are the live catalogue-
|
||||||
|
// intelligence service's, and the code between them is what is deployed.
|
||||||
|
//
|
||||||
|
// go run ./scratch/nutritionlive # product 7101
|
||||||
|
// go run ./scratch/nutritionlive <tenantid> <productid>
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fiesta = "https://fiesta.nearle.app/live/api/v1/mob/products/getproductbyvariant"
|
||||||
|
|
||||||
|
type envelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Details []models.Products `json:"details"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status bool `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
tenant, product := "1147", "7101"
|
||||||
|
if len(os.Args) == 3 {
|
||||||
|
tenant, product = os.Args[1], os.Args[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
base := os.Getenv("NUTRITION_BASE")
|
||||||
|
if base == "" {
|
||||||
|
base = "https://mcp.nearle.ai.in/api"
|
||||||
|
}
|
||||||
|
nutrition := services.NewNutritionService(base)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s?tenantid=%s&productid=%s&variantid=0", fiesta, tenant, product)
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
response, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("fetching the live product:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("reading the live product:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var out envelope
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
fmt.Println("parsing the live product:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same call the endpoint makes, on the same rows.
|
||||||
|
for i := range out.Details {
|
||||||
|
found := nutrition.ForProduct(out.Details[i].Productbrand, out.Details[i].Imageid)
|
||||||
|
out.Details[i].Nutrition = found.Panel
|
||||||
|
out.Details[i].Healthscore = found.Health
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, _ := json.MarshalIndent(out, "", " ")
|
||||||
|
fmt.Println(string(encoded))
|
||||||
|
}
|
||||||
59
scratch/nutritionproof/main.go
Normal file
59
scratch/nutritionproof/main.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Proves the nutrition panel and health score end to end against the LIVE
|
||||||
|
// catalogue-intelligence service — no database, no Fiesta, just the piece that
|
||||||
|
// was built.
|
||||||
|
//
|
||||||
|
// go run ./scratch/nutritionproof # known products
|
||||||
|
// go run ./scratch/nutritionproof <brand> <image_id> # any product
|
||||||
|
//
|
||||||
|
// Prints what `getproductbyvariant` will put on the product once deployed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
base := os.Getenv("NUTRITION_BASE")
|
||||||
|
if base == "" {
|
||||||
|
base = "https://mcp.nearle.ai.in/api"
|
||||||
|
}
|
||||||
|
service := services.NewNutritionService(base)
|
||||||
|
|
||||||
|
// Real products from tenant 1147, checked against the live service on
|
||||||
|
// 29 Sep 2026: one the service has scored, one it has nothing for, and one
|
||||||
|
// that is not food — which must come back with no score whatever the
|
||||||
|
// service says, because that endpoint is not gated for edibility.
|
||||||
|
products := []models.Products{
|
||||||
|
{Productid: 7101, Productbrand: "balaji", Imageid: "balaji_balaji_wafers_simply_salted_135g",
|
||||||
|
Productname: "Balaji Wafers Simply Salted 135g (scored)"},
|
||||||
|
{Productid: 7093, Productbrand: "patanjali", Imageid: "patanjali_patanjali_cow_ghee_500ml",
|
||||||
|
Productname: "Patanjali Cow Ghee 500ml (no data)"},
|
||||||
|
{Productid: 7121, Productbrand: "godrej", Imageid: "godrej_godrej_no1_sandal_and_turmeric_soap_100g",
|
||||||
|
Productname: "Godrej No.1 Soap 100g (not food)"},
|
||||||
|
}
|
||||||
|
if len(os.Args) == 3 {
|
||||||
|
products = []models.Products{{
|
||||||
|
Productbrand: os.Args[1],
|
||||||
|
Imageid: os.Args[2],
|
||||||
|
Productname: os.Args[1] + "/" + os.Args[2],
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range products {
|
||||||
|
found := service.ForProduct(products[i].Productbrand, products[i].Imageid)
|
||||||
|
products[i].Nutrition = found.Panel
|
||||||
|
products[i].Healthscore = found.Health
|
||||||
|
|
||||||
|
fmt.Printf("\n---- %s ----\n", products[i].Productname)
|
||||||
|
encoded, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"nutrition": products[i].Nutrition,
|
||||||
|
"healthscore": products[i].Healthscore,
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Println(string(encoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
267
scratch/poslivecheck/main.go
Normal file
267
scratch/poslivecheck/main.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// Does the mobile-number-and-PIN sign-in actually work against the live data?
|
||||||
|
//
|
||||||
|
// Picks a supervisor and a cashier that already have both halves of the
|
||||||
|
// credential, prints them so they can be typed into a terminal, then runs the
|
||||||
|
// real repository and service — the same code path the HTTP handler calls — and
|
||||||
|
// reports what came back.
|
||||||
|
//
|
||||||
|
// Read-only. PosLogin issues SELECTs and mints a token in memory; nothing here
|
||||||
|
// writes, and the token is not persisted anywhere by design.
|
||||||
|
//
|
||||||
|
// Numbers and PINs are masked unless -show is passed. They belong to real
|
||||||
|
// people at real shops, and the default should not be to print them into
|
||||||
|
// whatever is capturing this program's output.
|
||||||
|
//
|
||||||
|
// go run ./scratch/poslivecheck # picks a ready pair, masked
|
||||||
|
// go run ./scratch/poslivecheck -show # prints the credentials
|
||||||
|
// go run ./scratch/poslivecheck -show 1087 1137 # ...at a named outlet
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
|
"nearle/services"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type account struct {
|
||||||
|
Userid int
|
||||||
|
Tenantid int
|
||||||
|
Locationid int
|
||||||
|
Roleid int
|
||||||
|
Fullname string
|
||||||
|
Contactno string
|
||||||
|
Pin int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
|
||||||
|
log.Fatal("POS_TOKEN_SECRET is not set; sign-in mints a token and will fail without it")
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||||
|
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accounts holding both halves are candidates. An account missing
|
||||||
|
// either cannot sign in at all, and picking one would prove nothing except
|
||||||
|
// that the rejection works.
|
||||||
|
where := `COALESCE(roleid,0) = ?
|
||||||
|
AND COALESCE(pin,0) BETWEEN 1000 AND 9999
|
||||||
|
AND TRIM(COALESCE(contactno,'')) <> ''
|
||||||
|
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||||
|
args := []interface{}{}
|
||||||
|
|
||||||
|
// -show opts into printing the credentials themselves.
|
||||||
|
show := false
|
||||||
|
rest := []string{}
|
||||||
|
for _, arg := range os.Args[1:] {
|
||||||
|
if arg == "-show" || arg == "--show" {
|
||||||
|
show = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rest) > 1 {
|
||||||
|
tenantID, _ := strconv.Atoi(rest[0])
|
||||||
|
locationID, _ := strconv.Atoi(rest[1])
|
||||||
|
where += ` AND tenantid = ? AND locationid = ?`
|
||||||
|
args = append(args, tenantID, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pick := func(roleID int) *account {
|
||||||
|
var a account
|
||||||
|
params := append([]interface{}{roleID}, args...)
|
||||||
|
err := db.Raw(`
|
||||||
|
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
||||||
|
COALESCE(roleid,0) AS roleid,
|
||||||
|
TRIM(CONCAT(COALESCE(firstname,''),' ',COALESCE(lastname,''))) AS fullname,
|
||||||
|
COALESCE(contactno,'') AS contactno, COALESCE(pin,0) AS pin
|
||||||
|
FROM app_users WHERE `+where+` ORDER BY userid LIMIT 1`, params...).Scan(&a).Error
|
||||||
|
if err != nil || a.Userid == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
supervisor := pick(models.PosRoleSupervisor)
|
||||||
|
cashier := pick(models.PosRoleCashier)
|
||||||
|
|
||||||
|
fmt.Println("Accounts that can sign in today")
|
||||||
|
fmt.Println(strings.Repeat("-", 78))
|
||||||
|
for _, pair := range []struct {
|
||||||
|
label string
|
||||||
|
a *account
|
||||||
|
}{{"supervisor", supervisor}, {"cashier", cashier}} {
|
||||||
|
a := pair.a
|
||||||
|
if a == nil {
|
||||||
|
fmt.Printf(" %-11s none — no account of this role has both a number and a PIN\n", pair.label)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-11s userid %-6d tenant %-6d outlet %-6d %s\n",
|
||||||
|
pair.label, a.Userid, a.Tenantid, a.Locationid, a.Fullname)
|
||||||
|
fmt.Printf(" %-11s mobile %s PIN %s\n\n", "",
|
||||||
|
mask(a.Contactno, show), maskPin(a.Pin, show))
|
||||||
|
}
|
||||||
|
if !show {
|
||||||
|
fmt.Println(" (masked — re-run with -show to print them)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if supervisor == nil && cashier == nil {
|
||||||
|
log.Fatal("nothing to test with")
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := repositories.NewPosRepository(db)
|
||||||
|
svc := services.NewPosService(repo, nil)
|
||||||
|
|
||||||
|
fmt.Println("\nSigning in (real service, live data)")
|
||||||
|
fmt.Println(strings.Repeat("-", 78))
|
||||||
|
|
||||||
|
pass, fail := 0, 0
|
||||||
|
check := func(name string, ok bool, detail string) {
|
||||||
|
if ok {
|
||||||
|
pass++
|
||||||
|
fmt.Printf(" PASS %-46s %s\n", name, detail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail++
|
||||||
|
fmt.Printf(" FAIL %-46s %s\n", name, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
signIn := func(label string, a *account) *models.PosSession {
|
||||||
|
if a == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
session, err := svc.Login(models.PosLoginRequest{
|
||||||
|
Contactno: a.Contactno,
|
||||||
|
Pin: strconv.FormatInt(a.Pin, 10),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
check(label+" signs in", false, err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
check(label+" signs in", true, fmt.Sprintf(
|
||||||
|
"%s at %s (outlet %d), can_manage_staff=%v",
|
||||||
|
session.Role, session.Locationname, session.Locationid, session.Canmanagestaff))
|
||||||
|
check(label+" gets a token", session.Token != "",
|
||||||
|
fmt.Sprintf("%d chars, expires %s", len(session.Token), session.Expiresat))
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
supSession := signIn("supervisor", supervisor)
|
||||||
|
cashSession := signIn("cashier", cashier)
|
||||||
|
|
||||||
|
if supSession != nil {
|
||||||
|
check("supervisor can manage staff", supSession.Canmanagestaff, "role 7 grants it")
|
||||||
|
}
|
||||||
|
if cashSession != nil {
|
||||||
|
check("cashier cannot manage staff", !cashSession.Canmanagestaff, "role 8 does not")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The response must not carry anyone's PIN — the whole point of the change
|
||||||
|
// that removed staff[].pin.
|
||||||
|
//
|
||||||
|
// Checked against the serialised JSON, not the Go struct. PosStaffMember.Pin
|
||||||
|
// is still *populated* — the query needs it to drop two people sharing a PIN
|
||||||
|
// — and is kept off the wire by `json:"-"`. Asserting on the struct field
|
||||||
|
// tests the wrong layer and fails a correct implementation.
|
||||||
|
if supSession != nil {
|
||||||
|
encoded, merr := json.Marshal(supSession)
|
||||||
|
check("session serialises", merr == nil, errText(merr))
|
||||||
|
if merr == nil {
|
||||||
|
check("no PIN travels in the session", !strings.Contains(string(encoded), `"pin"`),
|
||||||
|
fmt.Sprintf("%d staff listed, %d bytes of JSON", len(supSession.Staff), len(encoded)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A number typed the way a person actually types it.
|
||||||
|
if supervisor != nil && len(supervisor.Contactno) == 10 {
|
||||||
|
for label, typed := range map[string]string{
|
||||||
|
"+91 with spaces": "+91 " + supervisor.Contactno[:5] + " " + supervisor.Contactno[5:],
|
||||||
|
"leading zero": "0" + supervisor.Contactno,
|
||||||
|
"bare ten digits": supervisor.Contactno,
|
||||||
|
} {
|
||||||
|
_, err := svc.Login(models.PosLoginRequest{
|
||||||
|
Contactno: typed, Pin: strconv.FormatInt(supervisor.Pin, 10),
|
||||||
|
})
|
||||||
|
check("number accepted as typed", err == nil, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the refusals.
|
||||||
|
if supervisor != nil {
|
||||||
|
wrong := supervisor.Pin + 1
|
||||||
|
if wrong > 9999 {
|
||||||
|
wrong = 1000
|
||||||
|
}
|
||||||
|
_, err := svc.Login(models.PosLoginRequest{
|
||||||
|
Contactno: supervisor.Contactno, Pin: strconv.FormatInt(wrong, 10),
|
||||||
|
})
|
||||||
|
check("a wrong PIN is refused", err != nil, errText(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"})
|
||||||
|
check("an unknown number is refused", err != nil, errText(err))
|
||||||
|
|
||||||
|
// Switching operator at an open terminal, which is what the till does when
|
||||||
|
// a colleague takes over.
|
||||||
|
if supSession != nil && cashier != nil && cashier.Locationid == supSession.Locationid {
|
||||||
|
switched, err := repo.PosLoginByPin(supSession.Tenantid, supSession.Locationid,
|
||||||
|
strconv.FormatInt(cashier.Pin, 10))
|
||||||
|
if err != nil {
|
||||||
|
check("operator switch by PIN", false, err.Error())
|
||||||
|
} else {
|
||||||
|
check("operator switch by PIN", true,
|
||||||
|
fmt.Sprintf("now %s, can_manage_staff=%v", switched.Role, switched.Canmanagestaff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%d passed, %d failed\n", pass, fail)
|
||||||
|
if fail > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mask shows enough of a number to recognise the account, not enough to sign in
|
||||||
|
// as it.
|
||||||
|
func mask(number string, show bool) string {
|
||||||
|
if show {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
if len(number) != 10 {
|
||||||
|
return strings.Repeat("*", len(number))
|
||||||
|
}
|
||||||
|
return number[:2] + "******" + number[8:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskPin(pin int64, show bool) string {
|
||||||
|
if show {
|
||||||
|
return strconv.FormatInt(pin, 10)
|
||||||
|
}
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
func errText(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return "no error"
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
166
scratch/posloginready/main.go
Normal file
166
scratch/posloginready/main.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Can the accounts that may open a till actually complete the new sign-in?
|
||||||
|
//
|
||||||
|
// Read-only, and deliberately prints no numbers and no PINs — only whether each
|
||||||
|
// account has one and whether the login would find it.
|
||||||
|
//
|
||||||
|
// The question this answers is narrower than "does it have a number". The login
|
||||||
|
// matches `LOWER(TRIM(contactno)) = LOWER(TRIM($1))` where $1 has already been
|
||||||
|
// reduced to ten digits, so the comparison is exact: a row holding
|
||||||
|
// "+91 98765 43210" is invisible to somebody typing the same number, because
|
||||||
|
// only one side of the comparison gets normalised.
|
||||||
|
//
|
||||||
|
// go run ./scratch/posloginready
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
Userid int
|
||||||
|
Tenantid int
|
||||||
|
Locationid int
|
||||||
|
Roleid int
|
||||||
|
Contactno string
|
||||||
|
Pin int64
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalise mirrors repositories.normalisePosPhone, which is unexported.
|
||||||
|
func normalise(raw string) string {
|
||||||
|
digits := strings.Map(func(r rune) rune {
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, raw)
|
||||||
|
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
|
||||||
|
digits = digits[2:]
|
||||||
|
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
|
||||||
|
digits = digits[1:]
|
||||||
|
}
|
||||||
|
if len(digits) != 10 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||||
|
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||||
|
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
if err := db.Raw(`
|
||||||
|
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
||||||
|
COALESCE(roleid,0) AS roleid, COALESCE(contactno,'') AS contactno,
|
||||||
|
COALESCE(pin,0) AS pin, COALESCE(status,'') AS status
|
||||||
|
FROM app_users
|
||||||
|
WHERE COALESCE(roleid,0) IN (?, ?)
|
||||||
|
ORDER BY tenantid, locationid, userid`,
|
||||||
|
models.PosRoleSupervisor, models.PosRoleCashier).Scan(&rows).Error; err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the login would see. Only active till accounts are candidates, and a
|
||||||
|
// number matching more than one of them is refused outright.
|
||||||
|
byPhone := map[string][]int{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(r.Status), "inactive") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stored := strings.TrimSpace(r.Contactno); stored != "" {
|
||||||
|
byPhone[strings.ToLower(stored)] = append(byPhone[strings.ToLower(stored)], r.Userid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("till accounts (roleid %d/%d): %d\n\n", models.PosRoleSupervisor, models.PosRoleCashier, len(rows))
|
||||||
|
fmt.Printf("%-8s %-8s %-9s %-5s %-9s %-8s %-7s %s\n",
|
||||||
|
"userid", "tenant", "location", "role", "status", "number", "pin", "can sign in?")
|
||||||
|
fmt.Println(strings.Repeat("-", 86))
|
||||||
|
|
||||||
|
var ready, noPhone, unnormalised, noPin, ambiguous, inactive int
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
stored := strings.TrimSpace(r.Contactno)
|
||||||
|
norm := normalise(stored)
|
||||||
|
|
||||||
|
phoneState := "missing"
|
||||||
|
switch {
|
||||||
|
case stored == "":
|
||||||
|
phoneState = "missing"
|
||||||
|
case norm == "":
|
||||||
|
phoneState = "unusable"
|
||||||
|
case norm != stored:
|
||||||
|
phoneState = "STORED RAW"
|
||||||
|
default:
|
||||||
|
phoneState = "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
pinState := "missing"
|
||||||
|
if r.Pin >= 1000 && r.Pin <= 9999 {
|
||||||
|
pinState = "ok"
|
||||||
|
} else if r.Pin != 0 {
|
||||||
|
pinState = "unusable"
|
||||||
|
}
|
||||||
|
|
||||||
|
verdict := "yes"
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(r.Status, "inactive"):
|
||||||
|
verdict, inactive = "no — inactive", inactive+1
|
||||||
|
case stored == "":
|
||||||
|
verdict, noPhone = "no — no number", noPhone+1
|
||||||
|
case norm == "":
|
||||||
|
verdict, noPhone = "no — number unusable", noPhone+1
|
||||||
|
case norm != stored:
|
||||||
|
// The one that looks fine in the console and fails at the counter.
|
||||||
|
verdict, unnormalised = "NO — number stored unnormalised", unnormalised+1
|
||||||
|
case pinState != "ok":
|
||||||
|
verdict, noPin = "no — no usable PIN", noPin+1
|
||||||
|
case len(byPhone[strings.ToLower(stored)]) > 1:
|
||||||
|
verdict, ambiguous = "NO — number shared with another till account", ambiguous+1
|
||||||
|
default:
|
||||||
|
ready++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-8d %-8d %-9d %-5d %-9s %-8s %-7s %s\n",
|
||||||
|
r.Userid, r.Tenantid, r.Locationid, r.Roleid, r.Status, phoneState, pinState, verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nready to sign in with number + PIN : %d of %d\n", ready, len(rows))
|
||||||
|
fmt.Printf(" blocked, no/unusable number : %d\n", noPhone)
|
||||||
|
fmt.Printf(" blocked, number stored raw : %d\n", unnormalised)
|
||||||
|
fmt.Printf(" blocked, no usable PIN : %d\n", noPin)
|
||||||
|
fmt.Printf(" blocked, number not unique : %d\n", ambiguous)
|
||||||
|
fmt.Printf(" inactive : %d\n", inactive)
|
||||||
|
|
||||||
|
// Cross-tenant collisions are the failure creation cannot prevent:
|
||||||
|
// posPhoneTaken scopes uniqueness to one tenant, the login does not scope at
|
||||||
|
// all, so two tenants may each hold a number that neither can then use.
|
||||||
|
fmt.Println("\nnumbers shared by more than one active till account:")
|
||||||
|
found := false
|
||||||
|
for _, users := range byPhone {
|
||||||
|
if len(users) > 1 {
|
||||||
|
found = true
|
||||||
|
fmt.Printf(" userids %v — all refused with \"more than one account uses these sign-in details\"\n", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
fmt.Println(" none")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,15 +137,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
|
fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
|
||||||
uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid)
|
// Both the app and the console sign-in now go through GetUserLogin.
|
||||||
check("applogin lookup does not find the supervisor",
|
uid2, _, _, _, lookupErr := users.GetUserLogin("authname", sup.Authname, sup.Configid)
|
||||||
uid == 0,
|
check("app and tenant web login do not find the supervisor",
|
||||||
fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid))
|
uid2 == 0 && lookupErr == nil,
|
||||||
|
fmt.Sprintf("GetUserLogin(%s) -> userid %d err=%v", sup.Authname, uid2, lookupErr))
|
||||||
uid2, _, _, _ := users.GetUserLogin("authname", sup.Authname, sup.Configid)
|
|
||||||
check("tenant web login does not find the supervisor",
|
|
||||||
uid2 == 0,
|
|
||||||
fmt.Sprintf("GetUserLogin(%s) -> userid %d", sup.Authname, uid2))
|
|
||||||
|
|
||||||
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
|
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
|
||||||
check("password-setup lookup does not find the supervisor",
|
check("password-setup lookup does not find the supervisor",
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"nearle/config"
|
||||||
"nearle/db"
|
"nearle/db"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type row struct {
|
type row struct {
|
||||||
@@ -44,8 +43,7 @@ func main() {
|
|||||||
tenant := flag.Int("tenant", 0, "restrict to one tenant")
|
tenant := flag.Int("tenant", 0, "restrict to one tenant")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
_ = godotenv.Load()
|
db.Connect(config.MustLoad())
|
||||||
db.Connect()
|
|
||||||
|
|
||||||
if db.ImageStore == nil {
|
if db.ImageStore == nil {
|
||||||
log.Fatal("the image store is not configured (USE_S3 / S3_*), so there is nothing to list images from")
|
log.Fatal("the image store is not configured (USE_S3 / S3_*), so there is nothing to list images from")
|
||||||
|
|||||||
229
services/agents.go
Normal file
229
services/agents.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agents, as configuration.
|
||||||
|
//
|
||||||
|
// An agent is a document, not a class: a name, a tier, some words about its job,
|
||||||
|
// and a list of tools it may use. Adding one is a file. Changing what an agent
|
||||||
|
// can reach is an edit, not a deploy of new code — and crucially, nothing about
|
||||||
|
// the loop changes when either happens, so five agents cannot drift into five
|
||||||
|
// slightly different behaviours.
|
||||||
|
//
|
||||||
|
// ── Embedded, with a disk override ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The defaults are compiled in, so a binary always has working agents and a
|
||||||
|
// deployment cannot be broken by a missing directory. `ASSISTANT_AGENTS_DIR`
|
||||||
|
// replaces them wholesale — not merges, which would leave a deployment guessing
|
||||||
|
// which half of an agent it was running.
|
||||||
|
//
|
||||||
|
// ── Why the tool names are checked at startup ───────────────────────────────
|
||||||
|
//
|
||||||
|
// A typo in a tool name is invisible at runtime: the agent simply never calls
|
||||||
|
// that tool, the model explains it cannot look something up, and everything
|
||||||
|
// reports healthy. Refusing to start is the loud version of the same fact.
|
||||||
|
|
||||||
|
//go:embed agents/*.yaml
|
||||||
|
var embeddedAgents embed.FS
|
||||||
|
|
||||||
|
// basePrompt is the part every agent shares.
|
||||||
|
//
|
||||||
|
// In Go rather than repeated in each file, because it is about how this system
|
||||||
|
// works rather than about any one domain — and three copies of "say when a
|
||||||
|
// result was truncated" is three chances for one of them to lose it.
|
||||||
|
//
|
||||||
|
// Each agent's own `system` is appended, and says what that agent is for.
|
||||||
|
const basePrompt = `You are Nearle Buddy, helping a shopkeeper run their business from the Nearle console.
|
||||||
|
|
||||||
|
Answer from tool results and nothing else. Every number you state must have come
|
||||||
|
from a tool in this conversation. If no tool can answer, say so plainly and say
|
||||||
|
what you would need — never estimate, and never fill a gap from general knowledge
|
||||||
|
about retail.
|
||||||
|
|
||||||
|
When a tool returns no rows, that is an answer: say there are none. Do not
|
||||||
|
describe an empty result as a problem with the system.
|
||||||
|
|
||||||
|
When a result says it was truncated, say so in your reply. Do not describe a
|
||||||
|
capped list as the full picture.
|
||||||
|
|
||||||
|
When a tool refuses, tell the person what it said. A refusal usually names
|
||||||
|
something they can do — pick a branch, for instance.
|
||||||
|
|
||||||
|
Say what your answer covers — one branch or all of them — using the scope the
|
||||||
|
tool reports.
|
||||||
|
|
||||||
|
Be brief. A shopkeeper reading this is mid-shift: lead with the answer, then the
|
||||||
|
detail that makes it actionable. No preamble, no restating the question.`
|
||||||
|
|
||||||
|
// agentFile is one agent on disk.
|
||||||
|
type agentFile struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Tier string `yaml:"tier"`
|
||||||
|
System string `yaml:"system"`
|
||||||
|
Tools []string `yaml:"tools"`
|
||||||
|
MaxSteps int `yaml:"max_steps"`
|
||||||
|
MaxToolCalls int `yaml:"max_tool_calls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensible bounds for an agent that does not name its own.
|
||||||
|
const (
|
||||||
|
defaultMaxSteps = 4
|
||||||
|
defaultMaxToolCalls = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadAgents reads the agent definitions.
|
||||||
|
//
|
||||||
|
// `dir` empty uses the embedded defaults. `known` reports whether a tool name
|
||||||
|
// exists — passed in rather than imported, so this does not depend on the
|
||||||
|
// registry and can be tested without one.
|
||||||
|
func LoadAgents(dir string, known func(string) bool) (map[string]Agent, error) {
|
||||||
|
files, err := readAgentFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, fmt.Errorf("no agent definitions found in %q", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
agents := make(map[string]Agent, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
agent, err := file.build(known)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, taken := agents[agent.Name]; taken {
|
||||||
|
// Two files claiming one name means one of them is being ignored,
|
||||||
|
// and which one depends on directory order.
|
||||||
|
return nil, fmt.Errorf("two agents are called %q", agent.Name)
|
||||||
|
}
|
||||||
|
agents[agent.Name] = agent
|
||||||
|
}
|
||||||
|
return agents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAgentFiles(dir string) ([]agentFile, error) {
|
||||||
|
var (
|
||||||
|
entries []fs.DirEntry
|
||||||
|
read func(string) ([]byte, error)
|
||||||
|
err error
|
||||||
|
where string
|
||||||
|
)
|
||||||
|
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
where = "agents"
|
||||||
|
entries, err = embeddedAgents.ReadDir(where)
|
||||||
|
read = func(name string) ([]byte, error) { return embeddedAgents.ReadFile(path.Join(where, name)) }
|
||||||
|
} else {
|
||||||
|
where = dir
|
||||||
|
entries, err = os.ReadDir(where)
|
||||||
|
read = func(name string) ([]byte, error) { return os.ReadFile(path.Join(where, name)) }
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading agent definitions from %q: %w", where, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted, so a duplicate name is reported against the same file every time
|
||||||
|
// rather than whichever the filesystem happened to hand back first.
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
files := make([]agentFile, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
raw, err := read(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading %s: %w", name, err)
|
||||||
|
}
|
||||||
|
var file agentFile
|
||||||
|
if err := yaml.Unmarshal(raw, &file); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is not valid YAML: %w", name, err)
|
||||||
|
}
|
||||||
|
if file.Name == "" {
|
||||||
|
// Named by its contents, not its filename: a file renamed on deploy
|
||||||
|
// would otherwise silently become a different agent.
|
||||||
|
return nil, fmt.Errorf("%s does not name its agent", name)
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// build turns a file into an agent, refusing anything that would fail quietly.
|
||||||
|
func (f agentFile) build(known func(string) bool) (Agent, error) {
|
||||||
|
if len(f.Tools) == 0 {
|
||||||
|
// An agent with no tools can only answer from the prompt, which is the
|
||||||
|
// one thing this assistant is built not to do.
|
||||||
|
return Agent{}, fmt.Errorf("agent %q has no tools", f.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range f.Tools {
|
||||||
|
if known != nil && !known(tool) {
|
||||||
|
return Agent{}, fmt.Errorf("agent %q lists a tool that does not exist: %q", f.Name, tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tier := strings.ToLower(strings.TrimSpace(f.Tier))
|
||||||
|
switch tier {
|
||||||
|
case utils.TierFast, utils.TierBalanced, utils.TierDeep:
|
||||||
|
case "":
|
||||||
|
tier = utils.TierBalanced
|
||||||
|
default:
|
||||||
|
// A tier nobody recognises would silently resolve to the balanced model
|
||||||
|
// via the gateway's fallback, so a deep agent could quietly run on the
|
||||||
|
// cheap one for months.
|
||||||
|
return Agent{}, fmt.Errorf("agent %q names an unknown tier %q", f.Name, f.Tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := f.MaxSteps
|
||||||
|
if steps <= 0 {
|
||||||
|
steps = defaultMaxSteps
|
||||||
|
}
|
||||||
|
calls := f.MaxToolCalls
|
||||||
|
if calls <= 0 {
|
||||||
|
calls = defaultMaxToolCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
system := basePrompt
|
||||||
|
if extra := strings.TrimSpace(f.System); extra != "" {
|
||||||
|
system += "\n\n" + extra
|
||||||
|
}
|
||||||
|
|
||||||
|
return Agent{
|
||||||
|
Name: f.Name,
|
||||||
|
Tier: tier,
|
||||||
|
System: system,
|
||||||
|
Tools: f.Tools,
|
||||||
|
MaxSteps: steps,
|
||||||
|
MaxToolCalls: calls,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentNames lists what is loaded, for a startup log.
|
||||||
|
//
|
||||||
|
// Sorted, so two deployments running the same config log the same line and a
|
||||||
|
// diff between them means something.
|
||||||
|
func AgentNames(agents map[string]Agent) []string {
|
||||||
|
names := make([]string, 0, len(agents))
|
||||||
|
for name := range agents {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
32
services/agents/console.yaml
Normal file
32
services/agents/console.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# The Console overview.
|
||||||
|
#
|
||||||
|
# Deliberately the widest allow-list here, because the page it serves is a
|
||||||
|
# dashboard: "what needs attention across my business today?" legitimately
|
||||||
|
# spans orders, stock and tills, and an agent narrower than the page would
|
||||||
|
# leave chips on screen that answer "I cannot look that up".
|
||||||
|
#
|
||||||
|
# It does NOT get every tool. `stuck_orders` and `sales_by_channel` belong
|
||||||
|
# to the Sales page, where somebody is already looking at orders — a
|
||||||
|
# dashboard question does not need the individual jobs, it needs the count.
|
||||||
|
name: console
|
||||||
|
tier: balanced
|
||||||
|
|
||||||
|
system: |
|
||||||
|
You answer overview questions about a whole business — every branch, the
|
||||||
|
shelves, and the tills.
|
||||||
|
|
||||||
|
Lead with what needs a person today and leave out what is running normally.
|
||||||
|
This is the first thing somebody reads in the morning, so an answer listing
|
||||||
|
four healthy things and one problem has buried the only part that matters.
|
||||||
|
|
||||||
|
When several things need attention, order them by what costs money soonest:
|
||||||
|
a till that cannot sell, then stock that has run out, then approvals waiting,
|
||||||
|
then deliveries that have stalled.
|
||||||
|
|
||||||
|
tools:
|
||||||
|
- branch_performance
|
||||||
|
- delivery_progress
|
||||||
|
- low_stock
|
||||||
|
- pending_approvals
|
||||||
|
- till_status
|
||||||
|
- help
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user