Compare commits
101 Commits
cd2459dbb6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f6d5c7c87b | |||
| f0361d8343 | |||
| 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 | |||
| be47435547 | |||
| 1b2cc77063 | |||
| 8cc567c89b | |||
| e7577fe0cf | |||
| 62a441d798 | |||
| 3853b84c9f | |||
| d9cad2bbe4 | |||
| 7d9e628fd0 | |||
| e87f09c4f2 | |||
| c699a400c3 | |||
| f1b3a5eba4 | |||
| 593b11f1b8 | |||
| e51ad615ed | |||
| fb5d7da81f | |||
| 18a0f9b34d | |||
| 0d17f46fcf | |||
| 7ed821e8b0 | |||
| da0e9d987e | |||
| 2401158190 | |||
| e45da9f7df | |||
| 09efc5403f | |||
| 68871cb043 | |||
| 9f9b05ed1c | |||
| c0d39e550d | |||
| 1e3fe88e87 | |||
| 6b27448ff9 | |||
| ab74e70ba5 | |||
| 485f31239d | |||
| ae6162a632 | |||
| d8cfe2e87b | |||
| bc10b589cd | |||
| d55f101834 | |||
| 4bce5ac854 | |||
| ffc66462fc | |||
| 76554d26e5 | |||
| c5109bf216 | |||
| fb4fcaee66 | |||
| 7c5be9b5cf | |||
| 40500f936a | |||
| 6c1ad472f2 | |||
| c31f3aca27 | |||
| aef105979a | |||
| 46da26c452 | |||
| edb9c2803f | |||
| dc9c049bb9 | |||
|
|
ca846f9cd6 | ||
| d566ca5591 | |||
| ff72af9a8a | |||
| fba42259ea | |||
| c179e4d642 | |||
| d35caf34d3 | |||
| 3531c656d4 | |||
|
|
5c04d40f0e | ||
|
|
f7895d3ccf | ||
|
|
9e9401215d | ||
|
|
c0a7fbc1b1 | ||
|
|
f5e16b54cc |
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
|
||||||
94
.env
Normal file
94
.env
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Fiesta configuration — the shared base file.
|
||||||
|
#
|
||||||
|
# Loaded AFTER `.env.<APP_ENV>` (see config/config.go), and godotenv never
|
||||||
|
# overwrites a value that is already set, so anything here is a fallback for
|
||||||
|
# what the environment file and the real environment leave unset. Keep it
|
||||||
|
# local: with APP_ENV unset this is loaded straight after `.env.local`, and a
|
||||||
|
# production value here would silently reach a local run.
|
||||||
|
#
|
||||||
|
# go run . → .env.local, then this file
|
||||||
|
# APP_ENV=production go run . → .env.production, then this file
|
||||||
|
#
|
||||||
|
# The full list of settings, with what each one does, is in `.env.example`.
|
||||||
|
|
||||||
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
|
# 1122 locally; production serves on 1009. Change it to run a second copy
|
||||||
|
# beside something else; the console then points at the same number.
|
||||||
|
APP_PORT=1122
|
||||||
|
|
||||||
|
ENV=development
|
||||||
|
|
||||||
|
# ── The main database (nearledb) ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
|
||||||
|
#
|
||||||
|
# There is no "local mode" that protects you: `go run .` against the live host
|
||||||
|
# creates real tenants, real logins and real stock movements, and main.go runs
|
||||||
|
# schema migrations on boot. If the point of running locally is to try a change
|
||||||
|
# before it is deployed, a local Postgres with a dump restored into it is the
|
||||||
|
# only version that actually does that.
|
||||||
|
# These match docker-compose.local.yml, so `docker compose -f
|
||||||
|
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_NAME=nearledb
|
||||||
|
DB_USER=nearle
|
||||||
|
DB_PASSWORD=localdev
|
||||||
|
|
||||||
|
|
||||||
|
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# A separate connection on purpose, so catalogue work never touches nearledb.
|
||||||
|
# Leave blank to start without it: catalogue endpoints then fail at query time
|
||||||
|
# rather than at boot, which is fine for testing anything else.
|
||||||
|
# 5434, not 5432: a developer machine usually has something on 5432 already,
|
||||||
|
# and a silent connection to the wrong database is worse than a refused one.
|
||||||
|
CATALOGUE_DB_HOST=localhost
|
||||||
|
CATALOGUE_DB_PORT=5434
|
||||||
|
CATALOGUE_DB_NAME=cataloguedb
|
||||||
|
CATALOGUE_DB_USER=nearle
|
||||||
|
CATALOGUE_DB_PASSWORD=localdev
|
||||||
|
|
||||||
|
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
|
||||||
|
#
|
||||||
|
# Optional. Losing the health board is an inconvenience; losing a sale is not,
|
||||||
|
# so the API runs without it.
|
||||||
|
# Set to enable POS terminal presence. The local compose publishes redis on
|
||||||
|
# 6379, so localhost is all it needs; leave blank to run without it.
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_USER=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
|
||||||
|
# sign in against the local stack; production sets its own in the platform.
|
||||||
|
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
USER_CONTEXT_KEY=
|
||||||
|
|
||||||
|
# ── Email ───────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The first-password invitation. See docs/MAIL_SETUP.md.
|
||||||
|
#
|
||||||
|
# MAIL_HOST IS DELIBERATELY BLANK HERE. This file is tracked and shared, and a
|
||||||
|
# host set here would mean any local run could email a real merchant a real
|
||||||
|
# password link. Blank is the documented off state: the server boots, onboarding
|
||||||
|
# works, and every create answers `invited: false` with the reason.
|
||||||
|
#
|
||||||
|
# Turn it on by putting the Google Workspace host and App Password in
|
||||||
|
# `.env.secrets`, which is read first and is the only one of these git ignores.
|
||||||
|
MAIL_HOST=
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
# On nearledaily.com because the link points at app.nearledaily.com — a password
|
||||||
|
# mail whose sender and destination are different domains reads as phishing.
|
||||||
|
MAIL_FROM=care@nearledaily.com
|
||||||
|
MAIL_FROM_NAME=Nearle
|
||||||
|
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||||
|
|
||||||
|
# ── Nutrition ───────────────────────────────────────────────────────────────
|
||||||
|
# The catalogue-intelligence host behind the health score card. The customer
|
||||||
|
# app product screen reads its nutrition panel from here. Unset means no panel.
|
||||||
|
NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||||
215
.env.example
Normal file
215
.env.example
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Fiesta configuration — the complete list of settings, with local values.
|
||||||
|
#
|
||||||
|
# How the files are picked (config/config.go):
|
||||||
|
#
|
||||||
|
# APP_ENV unset / local → .env.local then .env
|
||||||
|
# APP_ENV=production → .env.production then .env
|
||||||
|
#
|
||||||
|
# A real environment variable always wins over a file, and no file has to
|
||||||
|
# exist: on the deployed host the values come from the platform's environment
|
||||||
|
# settings (Dokploy / Kubernetes), not from a file. Anything added here must be
|
||||||
|
# added there too — a variable in this file and not in the platform is a
|
||||||
|
# variable that is unset in production.
|
||||||
|
#
|
||||||
|
# Startup checks every required setting and prints everything that is missing
|
||||||
|
# in one go, before any connection is attempted.
|
||||||
|
#
|
||||||
|
# The credentials in the committed .env.production have to be treated as
|
||||||
|
# public: rotate them, and keep new values out of git.
|
||||||
|
|
||||||
|
# ── Environment ─────────────────────────────────────────────────────────────
|
||||||
|
# local | production. Production requires POS_TOKEN_SECRET and never falls
|
||||||
|
# back to localhost defaults. Set in the real environment, not in a file: a
|
||||||
|
# file cannot decide which file gets loaded.
|
||||||
|
#APP_ENV=local
|
||||||
|
|
||||||
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
|
# 1122 locally; production serves on 1009 (matching the Dockerfile's EXPOSE).
|
||||||
|
APP_PORT=1122
|
||||||
|
|
||||||
|
# ── The main database (nearledb) ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
|
||||||
|
#
|
||||||
|
# There is no "local mode" that protects you: `go run .` against the live host
|
||||||
|
# creates real tenants, real logins and real stock movements, and main.go runs
|
||||||
|
# schema migrations on boot. Startup warns when APP_ENV=local and DB_HOST is
|
||||||
|
# not a local address, but it does not stop you.
|
||||||
|
#
|
||||||
|
# These match docker-compose.local.yml, so `docker compose -f
|
||||||
|
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_NAME=nearledb
|
||||||
|
DB_USER=nearle
|
||||||
|
DB_PASSWORD=localdev
|
||||||
|
|
||||||
|
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# A separate connection on purpose, so catalogue work never touches nearledb.
|
||||||
|
# Leave CATALOGUE_DB_HOST blank to start without it: catalogue endpoints then
|
||||||
|
# fail at query time rather than at boot. With a host set, the other four are
|
||||||
|
# required. 5434, not 5432: a developer machine usually has something on 5432.
|
||||||
|
CATALOGUE_DB_HOST=localhost
|
||||||
|
CATALOGUE_DB_PORT=5434
|
||||||
|
CATALOGUE_DB_NAME=cataloguedb
|
||||||
|
CATALOGUE_DB_USER=nearle
|
||||||
|
CATALOGUE_DB_PASSWORD=localdev
|
||||||
|
|
||||||
|
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
|
||||||
|
#
|
||||||
|
# Optional: leave REDIS_HOST blank to run without it. Losing the health board
|
||||||
|
# is an inconvenience; losing a sale is not, so the API runs without it.
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_USER=default
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# ── DigitalOcean Spaces (S3-compatible) — catalogue product images ──────────
|
||||||
|
#
|
||||||
|
# Optional locally. With USE_S3=true every S3_* value below is required.
|
||||||
|
USE_S3=false
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_REGION=
|
||||||
|
|
||||||
|
# ── POS terminals — the MQTT broker the in-store tills publish to ───────────
|
||||||
|
#
|
||||||
|
# Optional locally: with MQTT_URL blank the ingest and the console live stream
|
||||||
|
# stay quiet and the HTTP endpoints still work. In production a blank MQTT_URL
|
||||||
|
# means every till queues its bills silently — on startup you should see
|
||||||
|
# three lines reading "pos: subscribed to nearle/pos/+/+/...".
|
||||||
|
MQTT_URL=
|
||||||
|
MQTT_USER=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
# Unique per replica: a second connection with the same id evicts the first.
|
||||||
|
# Defaults to HOSTNAME (the pod name) when unset.
|
||||||
|
MQTT_CLIENT_ID=
|
||||||
|
# always | never — otherwise a StatefulSet pod ending in "-0" is elected and
|
||||||
|
# anything else consumes. See messaging/posmqtt.go.
|
||||||
|
#POS_MQTT_CONSUMER=
|
||||||
|
|
||||||
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
# Signs POS terminal sessions; at least 16 characters. Falls back to
|
||||||
|
# JWT_SECRET_KEY when unset. Required in production.
|
||||||
|
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
USER_CONTEXT_KEY=nearle
|
||||||
|
# true to make the POS routes require a terminal session.
|
||||||
|
#POS_AUTH_REQUIRED=false
|
||||||
|
|
||||||
|
# ── Scan-to-order — the embedding model behind product recognition ─────────
|
||||||
|
#
|
||||||
|
# MUST be the model that filled the catalogue's `embedding` column: vectors
|
||||||
|
# from two models are not comparable and pgvector will rank garbage without
|
||||||
|
# complaint. The first search reads the column's width and refuses a mismatch
|
||||||
|
# with an error that names both numbers.
|
||||||
|
# Optional: with no provider the search matches on words alone (works, ranks
|
||||||
|
# worse). openai = any OpenAI-compatible /embeddings endpoint (set
|
||||||
|
# EMBEDDING_BASE_URL for Azure, Ollama, vLLM...); gemini = Google AI Studio.
|
||||||
|
EMBEDDING_PROVIDER=
|
||||||
|
EMBEDDING_MODEL=
|
||||||
|
EMBEDDING_API_KEY=
|
||||||
|
EMBEDDING_BASE_URL=
|
||||||
|
# 0 = the model's default width.
|
||||||
|
EMBEDDING_DIMENSIONS=0
|
||||||
|
|
||||||
|
# ── Geocoding ───────────────────────────────────────────────────────────────
|
||||||
|
# Google Geocoding when set; OpenStreetMap's Nominatim otherwise.
|
||||||
|
GEOCODER_API_KEY=
|
||||||
|
# ── Nutrition ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The catalogue-intelligence service — the same host the console reads its
|
||||||
|
# health score card from. The customer app product screen gets its nutrition
|
||||||
|
# panel from here, through `getproductbyvariant`.
|
||||||
|
#
|
||||||
|
# Read server-side rather than by the app: the brand-spelling resolution below
|
||||||
|
# would otherwise have to be reimplemented in the app, and a wrong spelling
|
||||||
|
# returns a well-formed record with every figure null — indistinguishable from
|
||||||
|
# a product nobody has scored.
|
||||||
|
#
|
||||||
|
# Unset means product screens carry no nutrition panel and nothing else changes.
|
||||||
|
NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||||
|
|
||||||
|
# ── Email ───────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Sending the first-password invitation a newly onboarded merchant receives.
|
||||||
|
# Without MAIL_HOST the server still boots and still onboards tenants — the
|
||||||
|
# create response comes back `invited: false` with the reason — but nobody is
|
||||||
|
# emailed, and the only way into a new account is a Nearle staff member using
|
||||||
|
# Resend invite.
|
||||||
|
#
|
||||||
|
# SMTP, because every provider speaks it. Any transactional service is the same
|
||||||
|
# five variables: its host, 587, the API key as MAIL_PASSWORD, and whatever
|
||||||
|
# username it documents.
|
||||||
|
#
|
||||||
|
# WE USE GOOGLE WORKSPACE SMTP, authenticating as care@nearledaily.com with a
|
||||||
|
# 16-character App Password — never the account's login password, because an App
|
||||||
|
# Password can be revoked on its own. See docs/MAIL_SETUP.md for the setup and
|
||||||
|
# for the DNS records, which are what actually decide whether the invitation
|
||||||
|
# reaches an inbox rather than a spam folder.
|
||||||
|
#
|
||||||
|
# Self-hosting (Postal) was the earlier plan and is the better answer at volume.
|
||||||
|
# At a few dozen invitations a month the work is not the software, it is IP
|
||||||
|
# reputation, rDNS and blocklists — so this buys the reputation instead.
|
||||||
|
#
|
||||||
|
# Google Workspace: smtp.gmail.com 587 an App Password
|
||||||
|
# via an SMTP relay: smtp-relay.gmail.com 587 if an admin sets one up
|
||||||
|
# Amazon SES: email-smtp.<region>.amazonaws.com 587
|
||||||
|
# SendGrid: smtp.sendgrid.net 587 username literally "apikey"
|
||||||
|
# Resend: smtp.resend.com 587 username literally "resend"
|
||||||
|
#
|
||||||
|
# Credentials belong in .env.secrets (git-ignored, read first), or in the
|
||||||
|
# deployment platform's own environment — NOT in this file and not in .env.
|
||||||
|
MAIL_HOST=
|
||||||
|
MAIL_PORT=587
|
||||||
|
# Optional. Leave both empty for a relay that authenticates by network rather
|
||||||
|
# than by credentials.
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
# Who the invitation appears to come from. Separate from MAIL_USERNAME because
|
||||||
|
# most providers authenticate as one identity and send as another, and using
|
||||||
|
# the login as the From address is how mail lands in spam.
|
||||||
|
#
|
||||||
|
# ON NEARLEDAILY.COM, DELIBERATELY. The link in the mail points at
|
||||||
|
# app.nearledaily.com, and a password link arriving from a DIFFERENT domain than
|
||||||
|
# the one it sends you to is the exact shape of a phishing mail — to a filter
|
||||||
|
# and to the merchant reading it. Sender and link stay on one domain.
|
||||||
|
#
|
||||||
|
# `care@` rather than `no-reply@`, also deliberately: somebody who replies "I
|
||||||
|
# never got this" is the single most useful reply this system can receive, and
|
||||||
|
# it should reach a person.
|
||||||
|
MAIL_FROM=care@nearledaily.com
|
||||||
|
MAIL_FROM_NAME=Nearle
|
||||||
|
# Where the invitation link points — the MERCHANT console, always. A merchant
|
||||||
|
# sets their password there and nowhere else, so this is never the platform
|
||||||
|
# console's address.
|
||||||
|
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nearle Buddy ────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ONE variable. The provider, endpoint and model are defaults in config.go
|
||||||
|
# (openai / api.groq.com / openai/gpt-oss-120b) because each has one right
|
||||||
|
# answer for this product — and three variables that must be typed correctly
|
||||||
|
# into a hosting platform are three ways for the assistant to sit silently off,
|
||||||
|
# which is how it spent its first week.
|
||||||
|
#
|
||||||
|
# The key is the only one that differs per deployment and the only one that
|
||||||
|
# cannot live in this repository. Locally it goes in `.env.secrets`, which git
|
||||||
|
# ignores; in production it is set on the platform.
|
||||||
|
ASSISTANT_API_KEY=
|
||||||
|
|
||||||
|
# Overrides, none of them needed for the shipped setup.
|
||||||
|
# Ollama on a laptop: ASSISTANT_BASE_URL=http://localhost:11434/v1 and
|
||||||
|
# ASSISTANT_MODEL=llama3 — a local endpoint needs no key.
|
||||||
|
ASSISTANT_PROVIDER=
|
||||||
|
ASSISTANT_BASE_URL=
|
||||||
|
ASSISTANT_MODEL=
|
||||||
|
# Per-tier overrides. ASSISTANT_MODEL alone sets all three.
|
||||||
|
ASSISTANT_MODEL_FAST=
|
||||||
|
ASSISTANT_MODEL_BALANCED=
|
||||||
|
ASSISTANT_MODEL_DEEP=
|
||||||
85
.env.local
Normal file
85
.env.local
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Fiesta — LOCAL configuration (docker-compose.local.yml).
|
||||||
|
#
|
||||||
|
# This is the file you get by default: APP_ENV unset means `.env.local`.
|
||||||
|
# Production values live in `.env.production` and are only loaded by asking:
|
||||||
|
# APP_ENV=production ./nearle
|
||||||
|
#
|
||||||
|
# Keep every host here pointing at localhost. The whole point of the split is
|
||||||
|
# that running the server locally cannot reach live data by accident.
|
||||||
|
#
|
||||||
|
# `config.Load()` reads `.env.$APP_ENV` and then `.env` from the working
|
||||||
|
# directory, so `go run .` from this folder picks this file up with no flags.
|
||||||
|
# A real environment variable always wins over either file. The full list of
|
||||||
|
# settings is in `.env.example`.
|
||||||
|
|
||||||
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
|
# Production serves on 1009 (see .env.production). Change this locally to run
|
||||||
|
# beside something else; the console then points at the same number.
|
||||||
|
APP_PORT=1122
|
||||||
|
|
||||||
|
ENV=development
|
||||||
|
|
||||||
|
# ── The main database (nearledb) ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
|
||||||
|
#
|
||||||
|
# There is no "local mode" that protects you: `go run .` against the live host
|
||||||
|
# creates real tenants, real logins and real stock movements, and main.go runs
|
||||||
|
# schema migrations on boot. If the point of running locally is to try a change
|
||||||
|
# before it is deployed, a local Postgres with a dump restored into it is the
|
||||||
|
# only version that actually does that.
|
||||||
|
# These match docker-compose.local.yml, so `docker compose -f
|
||||||
|
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_NAME=nearledb
|
||||||
|
DB_USER=nearle
|
||||||
|
DB_PASSWORD=localdev
|
||||||
|
|
||||||
|
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# A separate connection on purpose, so catalogue work never touches nearledb.
|
||||||
|
# Leave blank to start without it: catalogue endpoints then fail at query time
|
||||||
|
# rather than at boot, which is fine for testing anything else.
|
||||||
|
# 5434, not 5432: a developer machine usually has something on 5432 already,
|
||||||
|
# and a silent connection to the wrong database is worse than a refused one.
|
||||||
|
CATALOGUE_DB_HOST=localhost
|
||||||
|
CATALOGUE_DB_PORT=5434
|
||||||
|
CATALOGUE_DB_NAME=cataloguedb
|
||||||
|
CATALOGUE_DB_USER=nearle
|
||||||
|
CATALOGUE_DB_PASSWORD=localdev
|
||||||
|
|
||||||
|
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
|
||||||
|
#
|
||||||
|
# Optional. Losing the health board is an inconvenience; losing a sale is not,
|
||||||
|
# so the API runs without it.
|
||||||
|
# Set to enable POS terminal presence. The local compose publishes redis on
|
||||||
|
# 6379, so localhost is all it needs; leave blank to run without it.
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_USER=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
# Signs POS terminal sessions (16+ characters). A throwaway value so a till can
|
||||||
|
# sign in against the local stack; production sets its own in the platform.
|
||||||
|
POS_TOKEN_SECRET=local-dev-signing-secret-not-real
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
USER_CONTEXT_KEY=
|
||||||
|
|
||||||
|
# ── Nearle Buddy ────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The model behind the assistant. Any OpenAI-compatible endpoint: Groq here,
|
||||||
|
# Ollama at http://localhost:11434/v1 with no key, or api.openai.com/v1.
|
||||||
|
#
|
||||||
|
# ASSISTANT_API_KEY is DELIBERATELY ABSENT. This file is tracked by git, so a
|
||||||
|
# key written here is a key pushed to the remote. Supply it from the real
|
||||||
|
# environment, which wins over both env files:
|
||||||
|
#
|
||||||
|
# ASSISTANT_API_KEY=gsk_... go run .
|
||||||
|
#
|
||||||
|
# On the deployed host there is no env file at all — every value comes from the
|
||||||
|
# platform's environment settings, which is where the key belongs.
|
||||||
|
ASSISTANT_PROVIDER=openai
|
||||||
|
ASSISTANT_BASE_URL=https://api.groq.com/openai/v1
|
||||||
|
ASSISTANT_MODEL=openai/gpt-oss-120b
|
||||||
84
.env.production
Normal file
84
.env.production
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Fiesta — PRODUCTION configuration.
|
||||||
|
#
|
||||||
|
# NOT loaded by default, on purpose. `go run .` and `./nearle` with no APP_ENV
|
||||||
|
# read `.env.local`; this file is reached only by asking for it:
|
||||||
|
#
|
||||||
|
# APP_ENV=production ./nearle
|
||||||
|
#
|
||||||
|
# ⚠️ Everything here is live. `db.Connect()` runs schema migrations on boot and
|
||||||
|
# every handler writes for real, so a process started with APP_ENV=production
|
||||||
|
# creates real tenants, real logins and real stock movements. There is no dry
|
||||||
|
# run. If the point is to try a change before it ships, use `.env.local` with a
|
||||||
|
# dump restored into the local Postgres — that is the only version that does.
|
||||||
|
#
|
||||||
|
# On the deployed host these values come from the platform's environment
|
||||||
|
# settings (Dokploy / Kubernetes), never from this file: the image is built
|
||||||
|
# without any `.env.*` (see .dockerignore). Keep the two in step — a variable
|
||||||
|
# added here and not there is a variable that is unset in production, and
|
||||||
|
# startup will refuse to boot on a missing required one.
|
||||||
|
#
|
||||||
|
# This file is currently committed to git, which means every credential in it
|
||||||
|
# has to be treated as public: rotate them, and keep the new values out of
|
||||||
|
# the repository.
|
||||||
|
|
||||||
|
# ── Where it listens ────────────────────────────────────────────────────────
|
||||||
|
APP_PORT=1009
|
||||||
|
ENV=production
|
||||||
|
|
||||||
|
# ── The main database (nearledb) ────────────────────────────────────────────
|
||||||
|
DB_HOST=66.116.207.225
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_NAME=nearledb
|
||||||
|
DB_USER=admin
|
||||||
|
DB_PASSWORD="Package@123#"
|
||||||
|
|
||||||
|
# ── The catalogue database (pgvector) ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Read-only integration, on its own connection so catalogue work never touches
|
||||||
|
# nearledb. Note the database is named `pgvector`, not `cataloguedb` as it is
|
||||||
|
# locally — `CATALOGUE_DB_NAME` is what reconciles the two.
|
||||||
|
CATALOGUE_DB_HOST=31.97.228.132
|
||||||
|
CATALOGUE_DB_PORT=6054
|
||||||
|
CATALOGUE_DB_NAME=pgvector
|
||||||
|
CATALOGUE_DB_USER=admin
|
||||||
|
CATALOGUE_DB_PASSWORD="'Package@321#'"
|
||||||
|
# ^ the single quotes are PART OF THE PASSWORD, not quoting. Verified against
|
||||||
|
# the live host: stripping them gives "password authentication failed".
|
||||||
|
|
||||||
|
# ── DigitalOcean Spaces (S3-compatible) — catalogue product images ──────────
|
||||||
|
USE_S3=true
|
||||||
|
S3_ACCESS_KEY=DO801G8Q8JAZKF49U3WJ
|
||||||
|
S3_SECRET_KEY=lBQExYfkVqH+ybmGVmQH5MkThBbrIohA/VQLgcPUvug
|
||||||
|
S3_ENDPOINT=https://nearle.sgp1.digitaloceanspaces.com
|
||||||
|
S3_BUCKET=nearle
|
||||||
|
S3_REGION=sgp1
|
||||||
|
|
||||||
|
# ── POS terminals — the MQTT broker the in-store tills publish to ───────────
|
||||||
|
#
|
||||||
|
# A BLANK MQTT_URL MEANS THE INGEST DOES NOT START. The service comes up
|
||||||
|
# looking healthy and every till queues its bills silently. On startup you
|
||||||
|
# should see three lines reading "pos: subscribed to nearle/pos/+/+/...".
|
||||||
|
MQTT_URL=tcp://66.116.225.226:1883
|
||||||
|
MQTT_USER=pos_ingest
|
||||||
|
MQTT_PASSWORD=AXbEPrNDWnMLdp7T1tFETwyU
|
||||||
|
# Unique per replica: a second connection with the same id evicts the first.
|
||||||
|
MQTT_CLIENT_ID=nearle-pos-ingest
|
||||||
|
|
||||||
|
# ── POS presence — terminal heartbeats under a 90-second TTL ────────────────
|
||||||
|
#
|
||||||
|
# Shared with the express backend; POS keys are namespaced pos:* so they cannot
|
||||||
|
# collide with delivery:*, city:* or rider_*. Optional: without it the health
|
||||||
|
# board goes dark, but bills still arrive and commit. Losing presence is an
|
||||||
|
# inconvenience; losing a sale is not.
|
||||||
|
REDIS_HOST=66.116.226.255
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_USER=default
|
||||||
|
REDIS_PASSWORD=Package@324969#
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
POS_TOKEN_SECRET=XCYrH7J6pi0wGzufaYfIXialqRVzlLRslaTlDbhfqQQl
|
||||||
|
|
||||||
|
# ── Nearle Buddy ────────────────────────────────────────────────────────────
|
||||||
|
# One variable. Provider, endpoint and model are constants in config.go.
|
||||||
|
ASSISTANT_API_KEY=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
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -53,6 +53,10 @@ Thumbs.db
|
|||||||
# credentials in this repository's history — removing it from the index stops
|
# credentials in this repository's history — removing it from the index stops
|
||||||
# that getting worse, but the existing history still has them and the password
|
# that getting worse, but the existing history still has them and the password
|
||||||
# should be rotated.
|
# should be rotated.
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
# Secrets, for local runs only. Never committed — the rule below is what makes
|
||||||
|
# that true, and it is why this file exists separately from .env.local, which
|
||||||
|
# IS tracked and therefore cannot hold a key.
|
||||||
|
|
||||||
|
.env.secrets
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
534
config/config.go
534
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".
|
||||||
|
AppEnv string
|
||||||
|
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
|
||||||
Port string
|
Port string
|
||||||
DBName string
|
|
||||||
DBUser string
|
DB DBConfig
|
||||||
DBPassword string
|
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
|
||||||
DBPort string
|
Redis RedisConfig
|
||||||
DBHost string
|
S3 S3Config
|
||||||
UserContextKey string
|
MQTT MQTTConfig
|
||||||
|
Embedding EmbeddingConfig
|
||||||
|
// Assistant is the model behind Nearle Buddy. Empty provider = no typed
|
||||||
|
// questions; the tools still work.
|
||||||
|
Assistant AssistantConfig
|
||||||
|
// Mail. Optional: a deployment without it still onboards tenants and reports
|
||||||
|
// the invitation as unsent.
|
||||||
|
Mail MailConfig
|
||||||
|
|
||||||
|
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
|
||||||
|
// unset, matching utils/postoken.go.
|
||||||
|
POSTokenSecret string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
UserContextKey string
|
||||||
|
GeocoderAPIKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
// DBConfig is one Postgres connection.
|
||||||
|
type DBConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Name string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether a host was configured at all. Only meaningful for
|
||||||
|
// the optional catalogue connection; the main database is required.
|
||||||
|
func (d DBConfig) Enabled() bool { return d.Host != "" }
|
||||||
|
|
||||||
|
// RedisConfig is the shared Redis used for POS terminal presence. Optional:
|
||||||
|
// Host empty means presence is disabled and bills still commit.
|
||||||
|
type RedisConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
DB int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RedisConfig) Enabled() bool { return r.Host != "" }
|
||||||
|
|
||||||
|
// S3Config is the DigitalOcean Spaces bucket holding catalogue product images.
|
||||||
|
type S3Config struct {
|
||||||
|
Enabled bool // USE_S3=true
|
||||||
|
Endpoint string
|
||||||
|
Bucket string
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
Region string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty
|
||||||
|
// means the MQTT ingest and the console live stream stay quiet.
|
||||||
|
type MQTTConfig struct {
|
||||||
|
URL string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
ClientID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MQTTConfig) Enabled() bool { return m.URL != "" }
|
||||||
|
|
||||||
|
// EmbeddingConfig is the text-embedding model behind the scan-to-product
|
||||||
|
// search (services/scanService.go). It MUST be the model that filled the
|
||||||
|
// catalogue's `embedding` column — vectors from two different models are not
|
||||||
|
// comparable, and pgvector will happily rank garbage. Optional: with no
|
||||||
|
// provider the search falls back to plain text matching.
|
||||||
|
type EmbeddingConfig struct {
|
||||||
|
Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini"
|
||||||
|
Model string
|
||||||
|
APIKey string
|
||||||
|
BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1
|
||||||
|
Dimensions int // 0 = the model's default
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" }
|
||||||
|
|
||||||
|
// AssistantConfig is the model behind Nearle Buddy.
|
||||||
|
//
|
||||||
|
// Optional, like the embedder. With no provider the assistant refuses typed
|
||||||
|
// questions and says so — the tools still work and still answer correctly,
|
||||||
|
// because they are ordinary Go functions; only the part that turns a sentence
|
||||||
|
// into a tool call is missing.
|
||||||
|
//
|
||||||
|
// ── Why three models and not one ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// An agent names a TIER, never a model. "Which branch is underperforming?"
|
||||||
|
// and "why is the cancel rate high?" want different amounts of thinking, and
|
||||||
|
// wiring a model name into an agent means changing every agent to change
|
||||||
|
// provider. The tiers are the stable vocabulary; this map is the only place a
|
||||||
|
// model name appears.
|
||||||
|
//
|
||||||
|
// `ASSISTANT_MODEL` alone sets all three, which is the sane default for a
|
||||||
|
// deployment that has not thought about it yet.
|
||||||
|
type AssistantConfig struct {
|
||||||
|
Provider string // "openai" — any OpenAI-compatible endpoint
|
||||||
|
BaseURL string // default https://api.openai.com/v1
|
||||||
|
APIKey string
|
||||||
|
// Tier → model name. Empty falls back to Balanced, which falls back to
|
||||||
|
// ASSISTANT_MODEL.
|
||||||
|
Fast string
|
||||||
|
Balanced string
|
||||||
|
Deep string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AssistantConfig) Enabled() bool { return a.Why() == "" }
|
||||||
|
|
||||||
|
// Why says what is missing, or "" when the assistant can run.
|
||||||
|
//
|
||||||
|
// A sentence rather than a bool, because "off" is the same answer for four
|
||||||
|
// different mistakes: no provider, no model, no key, a provider nobody
|
||||||
|
// recognises. Without this the only symptom is a disabled composer, and the
|
||||||
|
// difference between "we have not switched it on" and "somebody misspelled a
|
||||||
|
// variable" is invisible from the outside — which is exactly where this was
|
||||||
|
// stuck.
|
||||||
|
// The model is reported before the provider, and that order matters. Since
|
||||||
|
// `assistantProvider` derives the provider from the model, an empty provider
|
||||||
|
// means the model is empty too — and naming ASSISTANT_PROVIDER first would send
|
||||||
|
// an operator to set a variable they no longer need, while the one they
|
||||||
|
// actually missed went unmentioned.
|
||||||
|
func (a AssistantConfig) Why() string {
|
||||||
|
if a.Balanced == "" {
|
||||||
|
return "ASSISTANT_MODEL is not set; give it the provider's model name, " +
|
||||||
|
"for example openai/gpt-oss-120b"
|
||||||
|
}
|
||||||
|
switch a.Provider {
|
||||||
|
case "openai", "groq", "ollama", "together", "compatible":
|
||||||
|
case "":
|
||||||
|
// Not reachable through Load, which derives it. Reachable when
|
||||||
|
// something builds this struct by hand, and silence would be worse.
|
||||||
|
return "ASSISTANT_PROVIDER is not set and could not be derived"
|
||||||
|
default:
|
||||||
|
return "ASSISTANT_PROVIDER is " + a.Provider + ", which is not one this server speaks"
|
||||||
|
}
|
||||||
|
// A local provider needs no credential; a hosted one always does, and a
|
||||||
|
// missing key otherwise surfaces as a 401 from the provider on the first
|
||||||
|
// question rather than as a configuration problem.
|
||||||
|
if a.APIKey == "" && !isLocalEndpoint(a.BaseURL) {
|
||||||
|
where := a.BaseURL
|
||||||
|
if where == "" {
|
||||||
|
// Empty means the OpenAI default, which is emphatically not local.
|
||||||
|
// "and is not a local endpoint" is how that read before.
|
||||||
|
where = "the default https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
return "ASSISTANT_API_KEY is not set, and " + where + " is not a local endpoint"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalEndpoint reports whether a base URL is something running beside us.
|
||||||
|
//
|
||||||
|
// Ollama and LM Studio need no key, and demanding one would refuse the setup a
|
||||||
|
// developer is most likely to have on their own machine.
|
||||||
|
func isLocalEndpoint(baseURL string) bool {
|
||||||
|
url := strings.ToLower(baseURL)
|
||||||
|
return strings.Contains(url, "localhost") ||
|
||||||
|
strings.Contains(url, "127.0.0.1") ||
|
||||||
|
strings.Contains(url, "host.docker.internal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelFor resolves a tier to a model name, falling back rather than failing.
|
||||||
|
//
|
||||||
|
// A missing `fast` model should answer a cheap question with the balanced one,
|
||||||
|
// not refuse it. A deployment that sets one model gets one model everywhere.
|
||||||
|
func (a AssistantConfig) ModelFor(tier string) string {
|
||||||
|
switch tier {
|
||||||
|
case "fast":
|
||||||
|
if a.Fast != "" {
|
||||||
|
return a.Fast
|
||||||
|
}
|
||||||
|
case "deep":
|
||||||
|
if a.Deep != "" {
|
||||||
|
return a.Deep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.Balanced
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Nearle Buddy runs on unless a deployment says otherwise.
|
||||||
|
//
|
||||||
|
// These are in the code rather than in the environment because they are not
|
||||||
|
// secrets and not deployment-specific — they are what this product uses. Every
|
||||||
|
// variable that has one right answer is a variable somebody has to remember,
|
||||||
|
// get past a platform's UI, and then re-enter on the next environment; three of
|
||||||
|
// the four were exactly that, and the assistant sat switched off for days
|
||||||
|
// because one of them had not been typed.
|
||||||
|
//
|
||||||
|
// The API key is the one that genuinely varies and genuinely cannot live here.
|
||||||
|
const (
|
||||||
|
defaultAssistantProvider = "openai"
|
||||||
|
defaultAssistantBaseURL = "https://api.groq.com/openai/v1"
|
||||||
|
defaultAssistantModel = "openai/gpt-oss-120b"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assistantProvider reads the provider, defaulting to the one shape this
|
||||||
|
// server speaks.
|
||||||
|
//
|
||||||
|
// Every endpoint here is OpenAI-compatible — Groq, Ollama, Together and OpenAI
|
||||||
|
// itself — so the base URL is what actually distinguishes them. Naming a
|
||||||
|
// protocol you have no choice about is a variable that exists only to be
|
||||||
|
// forgotten.
|
||||||
|
func assistantProvider() string {
|
||||||
|
if named := strings.ToLower(strings.TrimSpace(env("ASSISTANT_PROVIDER", ""))); named != "" {
|
||||||
|
return named
|
||||||
|
}
|
||||||
|
return defaultAssistantProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistantFromEnv reads the assistant's settings, defaults and all.
|
||||||
|
//
|
||||||
|
// Exported and used by `Load` rather than written inline there, because the
|
||||||
|
// live tests need the SAME reading. They used to build this struct by hand from
|
||||||
|
// `os.Getenv`, which meant they skipped silently the moment a default was
|
||||||
|
// introduced — they were testing a configuration production no longer uses,
|
||||||
|
// and the one time that mattered was the day the provider stopped being
|
||||||
|
// required and nothing noticed.
|
||||||
|
//
|
||||||
|
// Three of the four fields have one right answer and come from the constants
|
||||||
|
// above. The key varies between deployments and is the only one that cannot
|
||||||
|
// live in this repository.
|
||||||
|
func AssistantFromEnv() AssistantConfig {
|
||||||
|
return AssistantConfig{
|
||||||
|
Provider: assistantProvider(),
|
||||||
|
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
|
||||||
|
APIKey: env("ASSISTANT_API_KEY", ""),
|
||||||
|
Fast: env("ASSISTANT_MODEL_FAST", ""),
|
||||||
|
// ASSISTANT_MODEL alone still sets every tier, for a deployment that
|
||||||
|
// wants one model everywhere but not this one.
|
||||||
|
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
|
||||||
|
Deep: env("ASSISTANT_MODEL_DEEP", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProduction is true under APP_ENV=production.
|
||||||
|
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
|
||||||
|
|
||||||
|
// Load picks and loads the environment files, reads every setting and
|
||||||
|
// validates them. The returned error lists every problem at once.
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
loadEnvFiles()
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Env: getEnv("ENV", "production"),
|
AppEnv: env("APP_ENV", EnvLocal),
|
||||||
Port: getEnv("APP_PORT", "1009"),
|
Port: env("APP_PORT", "1122"),
|
||||||
|
|
||||||
// ✅ STANDARDIZED DB ENV KEYS
|
DB: DBConfig{
|
||||||
DBName: getEnv("DB_NAME", ""),
|
Host: env("DB_HOST", ""),
|
||||||
DBUser: getEnv("DB_USER", ""),
|
Port: env("DB_PORT", "5433"),
|
||||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
Name: env("DB_NAME", ""),
|
||||||
DBPort: getEnv("DB_PORT", "5432"),
|
User: env("DB_USER", ""),
|
||||||
DBHost: getEnv("DB_HOST", "localhost"),
|
Password: env("DB_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
Catalogue: DBConfig{
|
||||||
|
Host: env("CATALOGUE_DB_HOST", ""),
|
||||||
|
Port: env("CATALOGUE_DB_PORT", "5432"),
|
||||||
|
Name: env("CATALOGUE_DB_NAME", ""),
|
||||||
|
User: env("CATALOGUE_DB_USER", ""),
|
||||||
|
Password: env("CATALOGUE_DB_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
Redis: RedisConfig{
|
||||||
|
Host: env("REDIS_HOST", ""),
|
||||||
|
Port: env("REDIS_PORT", "6379"),
|
||||||
|
User: env("REDIS_USER", "default"),
|
||||||
|
Password: env("REDIS_PASSWORD", ""),
|
||||||
|
},
|
||||||
|
S3: S3Config{
|
||||||
|
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
|
||||||
|
Endpoint: env("S3_ENDPOINT", ""),
|
||||||
|
Bucket: env("S3_BUCKET", ""),
|
||||||
|
AccessKey: env("S3_ACCESS_KEY", ""),
|
||||||
|
SecretKey: env("S3_SECRET_KEY", ""),
|
||||||
|
Region: env("S3_REGION", ""),
|
||||||
|
},
|
||||||
|
MQTT: MQTTConfig{
|
||||||
|
URL: env("MQTT_URL", ""),
|
||||||
|
// MQTT_USERNAME is accepted because livehub.go read that name for
|
||||||
|
// a while, so an existing deployment may still set it.
|
||||||
|
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
|
||||||
|
Password: env("MQTT_PASSWORD", ""),
|
||||||
|
ClientID: env("MQTT_CLIENT_ID", ""),
|
||||||
|
},
|
||||||
|
|
||||||
UserContextKey: getEnv("USER_CONTEXT_KEY", "nearle"),
|
Embedding: EmbeddingConfig{
|
||||||
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
|
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
|
||||||
|
Model: env("EMBEDDING_MODEL", ""),
|
||||||
|
APIKey: env("EMBEDDING_API_KEY", ""),
|
||||||
|
BaseURL: env("EMBEDDING_BASE_URL", ""),
|
||||||
|
},
|
||||||
|
|
||||||
|
Assistant: AssistantFromEnv(),
|
||||||
|
Mail: MailFromEnv(),
|
||||||
|
|
||||||
|
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
|
||||||
|
JWTSecret: env("JWT_SECRET_KEY", ""),
|
||||||
|
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
|
||||||
|
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Correct validation
|
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
|
||||||
if cfg.DBPassword == "" {
|
cfg.Redis.DB = db
|
||||||
log.Println("Warning: DB_PASSWORD is not set")
|
} else {
|
||||||
|
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
|
||||||
|
n, err := strconv.Atoi(dims)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
|
||||||
|
}
|
||||||
|
cfg.Embedding.Dimensions = n
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustLoad is Load for main(): every problem is printed and the process exits.
|
||||||
|
func MustLoad() *Config {
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
|
||||||
|
}
|
||||||
|
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
|
||||||
|
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
// validate collects every problem rather than stopping at the first, so one
|
||||||
if v := os.Getenv(key); v != "" {
|
// restart is enough to learn everything that is wrong.
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
var problems []string
|
||||||
|
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
|
||||||
|
|
||||||
|
if c.DB.Host == "" {
|
||||||
|
missing("DB_HOST")
|
||||||
|
}
|
||||||
|
if c.DB.User == "" {
|
||||||
|
missing("DB_USER")
|
||||||
|
}
|
||||||
|
if c.DB.Password == "" {
|
||||||
|
missing("DB_PASSWORD")
|
||||||
|
}
|
||||||
|
if c.DB.Name == "" {
|
||||||
|
missing("DB_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional subsystems are either fully configured or absent. Half a
|
||||||
|
// configuration used to be skipped with a warning, which reads as "fine"
|
||||||
|
// in a log and turns into "why are there no images" a week later.
|
||||||
|
if c.Catalogue.Enabled() {
|
||||||
|
if c.Catalogue.User == "" {
|
||||||
|
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
|
||||||
|
}
|
||||||
|
if c.Catalogue.Password == "" {
|
||||||
|
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
|
||||||
|
}
|
||||||
|
if c.Catalogue.Name == "" {
|
||||||
|
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.S3.Enabled {
|
||||||
|
if c.S3.Endpoint == "" {
|
||||||
|
missing("S3_ENDPOINT (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.Bucket == "" {
|
||||||
|
missing("S3_BUCKET (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.AccessKey == "" {
|
||||||
|
missing("S3_ACCESS_KEY (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.SecretKey == "" {
|
||||||
|
missing("S3_SECRET_KEY (USE_S3=true)")
|
||||||
|
}
|
||||||
|
if c.S3.Region == "" {
|
||||||
|
missing("S3_REGION (USE_S3=true)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Embedding.Enabled() {
|
||||||
|
switch c.Embedding.Provider {
|
||||||
|
case "openai", "gemini":
|
||||||
|
default:
|
||||||
|
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
|
||||||
|
}
|
||||||
|
if c.Embedding.Model == "" {
|
||||||
|
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
|
||||||
|
}
|
||||||
|
if c.Embedding.APIKey == "" {
|
||||||
|
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.IsProduction() {
|
||||||
|
// utils/postoken.go refuses to sign with a short secret at request
|
||||||
|
// time; catching it here means the first till login is not the first
|
||||||
|
// anyone hears of it.
|
||||||
|
secret := c.POSTokenSecret
|
||||||
|
if secret == "" {
|
||||||
|
secret = c.JWTSecret
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(secret) == "" {
|
||||||
|
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
|
||||||
|
} else if len(strings.TrimSpace(secret)) < 16 {
|
||||||
|
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
|
||||||
|
}
|
||||||
|
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
|
||||||
|
// Not fatal: a dump restored on another machine on the LAN is a valid
|
||||||
|
// local setup. But `.env.local` pointing at the live host is the
|
||||||
|
// mistake every comment in that file warns about, so say it out loud.
|
||||||
|
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
|
||||||
|
c.AppEnv, c.DB.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(problems) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New(strings.Join(problems, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
|
||||||
|
//
|
||||||
|
// APP_ENV is read from the real environment before any file, so a file cannot
|
||||||
|
// change which environment it is loaded for.
|
||||||
|
// `.env.secrets` is read FIRST and is the only one of these git does not track.
|
||||||
|
// godotenv never overwrites a value already set, so first read wins — which is
|
||||||
|
// what makes this file the place a key belongs. Every other file here is in the
|
||||||
|
// repository, so a secret written to one is a secret published; there was
|
||||||
|
// previously nowhere to put a key at all, and the answer was "export it in your
|
||||||
|
// shell every time", which is the kind of instruction people route around.
|
||||||
|
// envFileOrder is the read order, and the order is the rule: godotenv never
|
||||||
|
// overwrites a value already set, so whichever file names a variable first is
|
||||||
|
// the one that decides it.
|
||||||
|
func envFileOrder(appEnv string) []string {
|
||||||
|
return []string{".env.secrets", ".env." + appEnv, ".env"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnvFiles() {
|
||||||
|
for _, name := range envFileOrder(env("APP_ENV", EnvLocal)) {
|
||||||
|
if _, err := os.Stat(name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := godotenv.Load(name); err != nil {
|
||||||
|
log.Printf("config: could not read %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("config: loaded %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isLocalHost(host string) bool {
|
||||||
|
switch strings.ToLower(host) {
|
||||||
|
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(host, "127.")
|
||||||
|
}
|
||||||
|
|||||||
251
config/config_test.go
Normal file
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
controllers/appRequest.go
Normal file
26
controllers/appRequest.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isAppRequest reports whether a request arrived on the customer app's base.
|
||||||
|
//
|
||||||
|
// Several handlers are registered on both `/v1/web/...` and `/v1/mob/...`, and
|
||||||
|
// a few of them owe the two callers different answers. The clearest case is
|
||||||
|
// stock: the console lists a product with an empty shelf so a merchant can
|
||||||
|
// refill it, and the app must not list the same product at all, because a
|
||||||
|
// shopper can only choose it and be refused at checkout.
|
||||||
|
//
|
||||||
|
// The base is read from the path rather than passed down through the service,
|
||||||
|
// so the difference stays where it belongs — at the edge, in the one place that
|
||||||
|
// knows which audience asked. Services keep answering the same question the
|
||||||
|
// same way for everyone.
|
||||||
|
//
|
||||||
|
// Matched with the surrounding slashes so a tenant, product or keyword
|
||||||
|
// containing "mob" cannot make a console request look like an app one.
|
||||||
|
func isAppRequest(c *fiber.Ctx) bool {
|
||||||
|
return strings.Contains(c.Path(), "/v1/mob/")
|
||||||
|
}
|
||||||
50
controllers/appRequest_test.go
Normal file
50
controllers/appRequest_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
The console and the customer app share handlers, and a few of those handlers owe
|
||||||
|
the two callers different answers. This is the switch that tells them apart, so
|
||||||
|
it is worth a test of its own: get it wrong in the permissive direction and
|
||||||
|
shoppers are offered empty shelves again; get it wrong in the strict direction
|
||||||
|
and a merchant's restocking screen goes blank, hiding the work they came to do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func pathSays(t *testing.T, path string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
app := fiber.New()
|
||||||
|
got := false
|
||||||
|
app.Get("/*", func(c *fiber.Ctx) error {
|
||||||
|
got = isAppRequest(c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if _, err := app.Test(httptest.NewRequest("GET", path, nil)); err != nil {
|
||||||
|
t.Fatalf("Test(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("isAppRequest(%q) = %v, want %v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAppBaseIsRecognised(t *testing.T) {
|
||||||
|
pathSays(t, "/live/api/v1/mob/products/getallproducts", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheConsoleBaseIsNotTreatedAsTheApp(t *testing.T) {
|
||||||
|
// The console must keep seeing empty shelves — restocking them is the whole
|
||||||
|
// point of that screen.
|
||||||
|
pathSays(t, "/live/api/v1/web/products/getallproducts", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKeywordContainingMobDoesNotImpersonateTheApp(t *testing.T) {
|
||||||
|
// Matched with its slashes, so a search for "mobil" or a shop called
|
||||||
|
// "Mobius" cannot flip a console request into an app one and empty the
|
||||||
|
// merchant's screen.
|
||||||
|
pathSays(t, "/live/api/v1/web/products/getallproducts?keyword=mobile", false)
|
||||||
|
pathSays(t, "/live/api/v1/web/tenants/search?keyword=mob", false)
|
||||||
|
}
|
||||||
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,3 +146,48 @@ func (ctl *CatalogueController) GetProductBySKU(c *fiber.Ctx) error {
|
|||||||
"details": product,
|
"details": product,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProductByImageID resolves a catalogue row by the id the ingest pipeline
|
||||||
|
// treats as canonical, so a caller holding an ingest manifest can find the
|
||||||
|
// catalogueid it needs to import the product into a shop.
|
||||||
|
func (ctl *CatalogueController) GetProductByImageID(c *fiber.Ctx) error {
|
||||||
|
brand := c.Query("brand")
|
||||||
|
imageID := c.Query("image_id")
|
||||||
|
if brand == "" || imageID == "" {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 400,
|
||||||
|
"message": "brand and image_id are required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
product, err := ctl.catalogueService.GetProductByImageID(brand, imageID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repositories.ErrUnknownBrand) {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 400,
|
||||||
|
"message": "Unknown brand: " + brand,
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 500,
|
||||||
|
"message": "Failed to fetch catalogue product",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if product == nil {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 404,
|
||||||
|
"message": "Product not found",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": product,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
159
controllers/catalogueUploadController.go
Normal file
159
controllers/catalogueUploadController.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CatalogueUploadController struct {
|
||||||
|
service services.CatalogueUploadService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCatalogueUploadController(service services.CatalogueUploadService) *CatalogueUploadController {
|
||||||
|
return &CatalogueUploadController{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordUpload stores the receipt for a spreadsheet just accepted by the ingest
|
||||||
|
// service.
|
||||||
|
//
|
||||||
|
// Called the moment the drop is accepted, before any polling — that is the one
|
||||||
|
// instant the batch id is guaranteed to exist and guaranteed not to have been
|
||||||
|
// lost. Everything after it (the review wait, the run, the shelving) can be
|
||||||
|
// recovered from the id; the id itself cannot be recovered from anything.
|
||||||
|
func (ctl *CatalogueUploadController) RecordUpload(c *fiber.Ctx) error {
|
||||||
|
var input models.CatalogueUpload
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(input.Batchid) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "batchid is required — it is the only credential for reading the result back",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if input.Tenantid <= 0 {
|
||||||
|
// A receipt with no tenant belongs to nobody and would be visible to
|
||||||
|
// every store login that asks for "all". Refused rather than stored
|
||||||
|
// unscoped.
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required — a receipt has to belong to a merchant",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.service.Record(&input); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "message": "Upload recorded", "status": true, "details": input})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploads lists receipts for whoever is asking.
|
||||||
|
//
|
||||||
|
// `tenantid=0` means every tenant and is how a Nearle Admin sees the platform.
|
||||||
|
// The console decides which to send from the session — the same rule the import
|
||||||
|
// itself follows — because a store login reading another merchant's receipts
|
||||||
|
// would see what that merchant stocks.
|
||||||
|
func (ctl *CatalogueUploadController) GetUploads(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid", "0"))
|
||||||
|
locationID, _ := strconv.Atoi(c.Query("locationid", "0"))
|
||||||
|
pageNo, _ := strconv.Atoi(c.Query("pageno", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.Query("pagesize", "50"))
|
||||||
|
|
||||||
|
data, err := ctl.service.List(tenantID, locationID, pageNo, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
||||||
|
}
|
||||||
|
// Never null. An empty list is a legitimate answer — a shop that has never
|
||||||
|
// uploaded — and a JSON null here makes every caller guard a case that is
|
||||||
|
// really just "none yet".
|
||||||
|
if data == nil {
|
||||||
|
data = []models.CatalogueUpload{}
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUpload caches what the ingest service last reported.
|
||||||
|
//
|
||||||
|
// Written by the browser that is polling, because that is where the reading
|
||||||
|
// happens: the batch id is the credential and the read is anonymous, so the
|
||||||
|
// person waiting is already holding the answer. This endpoint only writes it
|
||||||
|
// down so the next person does not have to wait for it again.
|
||||||
|
func (ctl *CatalogueUploadController) UpdateUpload(c *fiber.Ctx) error {
|
||||||
|
var input models.CatalogueUpload
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Batchid) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "batchid is required", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := ctl.service.UpdateStatus(&input); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "message": "Upload updated", "status": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkShelved records that the products reached a branch's shelf with a price
|
||||||
|
// and their opening stock.
|
||||||
|
//
|
||||||
|
// Separate from UpdateUpload on purpose. The ingest service confirms the global
|
||||||
|
// catalogue, which every merchant shares and which therefore carries no price
|
||||||
|
// and no stock — so "added" and "on sale here" are two different claims, made by
|
||||||
|
// two different systems, and a receipt that ran them together would report
|
||||||
|
// products as sellable that no customer can buy.
|
||||||
|
func (ctl *CatalogueUploadController) MarkShelved(c *fiber.Ctx) error {
|
||||||
|
var input struct {
|
||||||
|
Batchid string `json:"batchid"`
|
||||||
|
Shelved int `json:"shelved"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Batchid) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "batchid is required", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := ctl.service.MarkShelved(input.Batchid, input.Shelved, input.Skipped); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "message": "Shelving recorded", "status": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachSheet supplies the prices and opening stock for a receipt that has
|
||||||
|
// none, so its products can still be put on a shelf.
|
||||||
|
//
|
||||||
|
// Needed because the receipt and the sheet used to part company. The ingest
|
||||||
|
// service holds neither price nor stock — its catalogue is shared by every
|
||||||
|
// merchant — so both live only in the spreadsheet, and a receipt written before
|
||||||
|
// those rows were stored has no way to finish. Handing the file over again is
|
||||||
|
// the only way back, and this is where it lands.
|
||||||
|
func (ctl *CatalogueUploadController) AttachSheet(c *fiber.Ctx) error {
|
||||||
|
var input struct {
|
||||||
|
Batchid string `json:"batchid"`
|
||||||
|
Sheetrows string `json:"sheetrows"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Batchid) == "" || strings.TrimSpace(input.Sheetrows) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "batchid and sheetrows are both required", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := ctl.service.AttachSheet(input.Batchid, input.Sheetrows); err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "message": "Sheet attached", "status": true})
|
||||||
|
}
|
||||||
175
controllers/deliverySlotController.go
Normal file
175
controllers/deliverySlotController.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeliverySlotController struct {
|
||||||
|
service services.DeliverySlotService
|
||||||
|
// Asked whether the shop is trading at all. Held here rather than inside
|
||||||
|
// the slot service, which answers one question — is this window still
|
||||||
|
// running — and should not also have to know about shop closures.
|
||||||
|
tenants services.TenantService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeliverySlotController(
|
||||||
|
service services.DeliverySlotService,
|
||||||
|
tenants services.TenantService,
|
||||||
|
) *DeliverySlotController {
|
||||||
|
return &DeliverySlotController{service: service, tenants: tenants}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
GET /v1/web/deliveryslots?tenantid&locationid
|
||||||
|
|
||||||
|
Everything a branch has configured, active or not, for the console's editor.
|
||||||
|
A branch that has set nothing returns an empty list — see the note on Available
|
||||||
|
about why that is never an error.
|
||||||
|
*/
|
||||||
|
func (ctl *DeliverySlotController) ListDeliverySlots(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
|
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slots, err := ctl.service.ListForBranch(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": slots,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
PUT /v1/web/deliveryslots
|
||||||
|
|
||||||
|
The branch's windows, all three together rather than one at a time: they are
|
||||||
|
edited as a set on one screen, and sending them together is what lets the
|
||||||
|
service reject the whole edit when one row is wrong instead of applying part of
|
||||||
|
it.
|
||||||
|
|
||||||
|
A business objection — an unreadable time, a window ending before it starts,
|
||||||
|
the same key twice — is 409 and not 500. It is an answer about the request, and
|
||||||
|
the message is written to be shown to the person who typed it.
|
||||||
|
*/
|
||||||
|
func (ctl *DeliverySlotController) SaveDeliverySlots(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Slots []models.DeliverySlots `json:"slots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "Invalid request body",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tenantid <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.service.Save(req.Tenantid, req.Locationid, req.Slots); err != nil {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Successfully Updated",
|
||||||
|
"status": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
GET /v1/mob/deliveryslots/available?tenantid&locationid
|
||||||
|
|
||||||
|
What the shopper may pick, already filtered and dated. The app renders this list
|
||||||
|
and does no time arithmetic of its own — see the service for why one clock has
|
||||||
|
to be authoritative.
|
||||||
|
|
||||||
|
An empty list is 200 with `details: []`, NOT an error. It means this branch has
|
||||||
|
set no windows, which is the state every shop is in today, and the app is
|
||||||
|
required to fall back to ordering without one. Returning 404 here would turn an
|
||||||
|
ordinary shop into a broken one.
|
||||||
|
*/
|
||||||
|
func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
|
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A shut shop offers nothing.
|
||||||
|
|
||||||
|
Without this, a branch closed for the day still hands the app tomorrow
|
||||||
|
morning — and the shopper picks it, reaches checkout, and is refused by
|
||||||
|
the closed-shop guard in order creation with no warning anything was
|
||||||
|
wrong. The empty list is already the app's "no windows here" path, so
|
||||||
|
this needs nothing new on their side.
|
||||||
|
|
||||||
|
The reason rides along so the app can say WHY rather than just showing
|
||||||
|
nothing, on an endpoint it is already calling.
|
||||||
|
*/
|
||||||
|
if reason := ctl.tenants.StoreClosedReason(tenantID, locationID); reason != "" {
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": []models.AvailableDeliverySlot{},
|
||||||
|
"closedreason": reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slots, err := ctl.service.Available(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": slots,
|
||||||
|
})
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
165
controllers/liveController.go
Normal file
165
controllers/liveController.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/messaging"
|
||||||
|
"nearle/services"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The console's live event stream.
|
||||||
|
//
|
||||||
|
// Server-Sent Events rather than a WebSocket, deliberately. The traffic is one
|
||||||
|
// way — the console never sends anything up this pipe — and SSE is plain HTTP,
|
||||||
|
// so it inherits the reverse proxy, the TLS termination and the load balancer
|
||||||
|
// already in front of this service with no upgrade handshake to configure. The
|
||||||
|
// browser's own `EventSource` also reconnects on its own, which is a reconnect
|
||||||
|
// loop nobody has to write or get wrong.
|
||||||
|
//
|
||||||
|
// What goes down it is a nudge, never a figure: "outlet 1185 sold something,
|
||||||
|
// products 42 and 77". The console re-reads through the normal endpoints. See
|
||||||
|
// `messaging/livehub.go` for why.
|
||||||
|
|
||||||
|
type LiveController struct {
|
||||||
|
posService services.PosService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLiveController(posService services.PosService) *LiveController {
|
||||||
|
return &LiveController{posService: posService}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Below every idle timeout worth worrying about: nginx and most cloud load
|
||||||
|
// balancers cut an idle connection at 60s, and a quiet shop produces no
|
||||||
|
// events for hours.
|
||||||
|
liveHeartbeat = 25 * time.Second
|
||||||
|
|
||||||
|
// A connection is recycled rather than held forever, so a replica being
|
||||||
|
// drained empties out on its own and a client that has silently gone away
|
||||||
|
// stops being written to. The browser reconnects immediately; the console
|
||||||
|
// refetches on reconnect anyway, so the seam is invisible.
|
||||||
|
liveMaxAge = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stream opens the event stream for one outlet.
|
||||||
|
//
|
||||||
|
// GET /live/api/v1/web/live/events?tenantid=1147&locationid=1185
|
||||||
|
//
|
||||||
|
// Scoped exactly like every other console POS read: the tenant must own the
|
||||||
|
// outlet. Without that check any caller could name any locationid and watch a
|
||||||
|
// competitor's tills report in.
|
||||||
|
func (ctl *LiveController) Stream(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||||
|
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
|
||||||
|
if tenantID <= 0 || locationID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid and locationid are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusInternalServerError,
|
||||||
|
"message": "could not verify the outlet",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusForbidden,
|
||||||
|
"message": fmt.Sprintf("outlet %d does not belong to tenant %d", locationID, tenantID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
events, release := messaging.Hub.Subscribe(locationID)
|
||||||
|
|
||||||
|
c.Set("Content-Type", "text/event-stream")
|
||||||
|
c.Set("Cache-Control", "no-cache")
|
||||||
|
c.Set("Connection", "keep-alive")
|
||||||
|
// Nginx buffers proxied responses by default, which holds each event until
|
||||||
|
// the buffer fills — for a stream of 80-byte messages, indefinitely.
|
||||||
|
c.Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
c.Context().SetBodyStreamWriter(fasthttp.StreamWriter(func(w *bufio.Writer) {
|
||||||
|
// The writer owns the subscription from here: this closure outlives the
|
||||||
|
// handler, so releasing in a defer above would unsubscribe instantly.
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
// Tells EventSource how long to wait before reconnecting, and gives the
|
||||||
|
// client something to prove the stream is open.
|
||||||
|
if _, err := fmt.Fprintf(w, "retry: 3000\nevent: open\ndata: {\"locationid\":%d}\n\n", locationID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat := time.NewTicker(liveHeartbeat)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
deadline := time.NewTimer(liveMaxAge)
|
||||||
|
defer deadline.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, open := <-events:
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, payload); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A failed flush is how a disconnected client is discovered:
|
||||||
|
// fasthttp surfaces the broken pipe here and nowhere else.
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-heartbeat.C:
|
||||||
|
// An SSE comment. EventSource ignores it; every proxy in the
|
||||||
|
// path sees traffic and keeps the connection open.
|
||||||
|
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-deadline.C:
|
||||||
|
fmt.Fprint(w, "event: bye\ndata: {}\n\n")
|
||||||
|
_ = w.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health reports what the stream is doing. Useful for answering "is anyone
|
||||||
|
// actually connected" without reading logs.
|
||||||
|
func (ctl *LiveController) Health(c *fiber.Ctx) error {
|
||||||
|
outlets, connections, dropped := messaging.Hub.Stats()
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": true, "code": http.StatusOK,
|
||||||
|
"details": fiber.Map{
|
||||||
|
"outlets_watched": outlets,
|
||||||
|
"connections": connections,
|
||||||
|
"dropped": dropped,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
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,10 +16,26 @@ import (
|
|||||||
|
|
||||||
type OrderController struct {
|
type OrderController struct {
|
||||||
orderService services.OrderService
|
orderService services.OrderService
|
||||||
|
// Asked whether a chosen delivery window is still open. Held here rather
|
||||||
|
// than reimplemented, so the app's list and this check can never disagree
|
||||||
|
// about where a window ends.
|
||||||
|
deliverySlotService services.DeliverySlotService
|
||||||
|
// Asked whether the shop is trading. Same reasoning: the customer-facing
|
||||||
|
// store list and this check run the same rule, so a shop shown as open is
|
||||||
|
// never refused here.
|
||||||
|
tenantService services.TenantService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOrderController(orderService services.OrderService) *OrderController {
|
func NewOrderController(
|
||||||
return &OrderController{orderService: orderService}
|
orderService services.OrderService,
|
||||||
|
deliverySlotService services.DeliverySlotService,
|
||||||
|
tenantService services.TenantService,
|
||||||
|
) *OrderController {
|
||||||
|
return &OrderController{
|
||||||
|
orderService: orderService,
|
||||||
|
deliverySlotService: deliverySlotService,
|
||||||
|
tenantService: tenantService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
||||||
@@ -126,7 +142,6 @@ func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
||||||
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||||
@@ -160,7 +175,6 @@ func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
|
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
|
||||||
tenantIDStr := c.Query("tenantid")
|
tenantIDStr := c.Query("tenantid")
|
||||||
tenantID, _ := strconv.Atoi(tenantIDStr)
|
tenantID, _ := strconv.Atoi(tenantIDStr)
|
||||||
@@ -350,12 +364,82 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
|||||||
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Is the shop even trading?
|
||||||
|
|
||||||
|
Checked HERE and not only in the app, because /v1/mob/* carries no
|
||||||
|
session: anything arriving is a claim. The ordinary case is innocent and
|
||||||
|
still has to be caught — a shopper with the app open when the shopkeeper
|
||||||
|
closes, or a stale screen from this morning.
|
||||||
|
|
||||||
|
409 with the shop's own reason, written to be shown as-is.
|
||||||
|
*/
|
||||||
|
if reason := ctl.tenantService.StoreClosedReason(data.Tenantid, data.Locationid); reason != "" {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict,
|
||||||
|
"message": reason,
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chosen delivery window, re-decided here.
|
||||||
|
//
|
||||||
|
// The app sends back what /v1/mob/deliveryslots/available handed it, but
|
||||||
|
// that group carries NO SESSION — anything arriving is a claim, not a fact.
|
||||||
|
// The common case is innocent and still has to be caught: a shopper leaves
|
||||||
|
// the checkout screen open while the window closes, then taps pay.
|
||||||
|
//
|
||||||
|
// 409 rather than 400: the request was well formed and was true when it was
|
||||||
|
// built. The message is written to be shown to the shopper as-is.
|
||||||
|
//
|
||||||
|
// An order naming NO window passes straight through. That is every order
|
||||||
|
// placed before this shipped and every order from a branch that has set no
|
||||||
|
// windows, and it must stay ordinary.
|
||||||
|
if err := ctl.deliverySlotService.ValidateForOrder(
|
||||||
|
data.Tenantid, data.Locationid, data.Deliveryslotid, data.Deliveryslotdate,
|
||||||
|
); err != nil {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// An order that does not state its config is an APP order, because that is
|
||||||
|
// the only kind this endpoint takes.
|
||||||
|
//
|
||||||
|
// Every revenue figure in the product filters on `configid = 1` — the
|
||||||
|
// per-branch summary, the tenant revenue summary, the sales summary and the
|
||||||
|
// top-locations list all carry it. An order created with configid 0 is
|
||||||
|
// therefore accepted, deducts stock, appears in the order LIST, and counts
|
||||||
|
// for nothing in any total: the branch reads zero orders and zero revenue
|
||||||
|
// while the stock has genuinely moved.
|
||||||
|
//
|
||||||
|
// Confirmed against live data before defaulting it: every real order across
|
||||||
|
// every tenant carries configid 1. Nothing legitimately arrives here as 0,
|
||||||
|
// so this only ever rescues a caller that forgot the field rather than
|
||||||
|
// relabelling an order that meant something else.
|
||||||
|
if data.Configid == 0 {
|
||||||
|
data.Configid = 1
|
||||||
|
}
|
||||||
|
|
||||||
order, err := ctl.orderService.CreateOrder(data)
|
order, err := ctl.orderService.CreateOrder(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("CreateOrder service error:", err)
|
log.Println("CreateOrder service error:", err)
|
||||||
|
// A rejected order is not a server fault, and the status code has to say
|
||||||
|
// so: a client that retries on 5xx will hammer a request that can never
|
||||||
|
// succeed, while a 4xx tells it to fix the request and stop trying.
|
||||||
|
//
|
||||||
|
// "names no outlet" is the caller having omitted locationid, which is
|
||||||
|
// squarely a bad request. It was falling through to the 500 default —
|
||||||
|
// right message, wrong class — because only the stock case was mapped.
|
||||||
statusCode := http.StatusInternalServerError
|
statusCode := http.StatusInternalServerError
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "insufficient stock") {
|
lowered := strings.ToLower(err.Error())
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lowered, "insufficient stock"):
|
||||||
statusCode = http.StatusConflict
|
statusCode = http.StatusConflict
|
||||||
|
case strings.Contains(lowered, "names no outlet"):
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
}
|
}
|
||||||
return c.Status(statusCode).JSON(fiber.Map{
|
return c.Status(statusCode).JSON(fiber.Map{
|
||||||
"code": statusCode,
|
"code": statusCode,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"nearle/models"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -71,6 +73,40 @@ func (ctl *PartnerController) GetPartners(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRiderShift opens a working window in a delivery region.
|
||||||
|
//
|
||||||
|
// Riders cannot be hired without one, and until this existed the table could
|
||||||
|
// only be read — a region that shipped with no shift rows was a region no rider
|
||||||
|
// could ever be added to, with nothing in the product able to change that.
|
||||||
|
//
|
||||||
|
// The region comes from the body rather than the query because this is a write
|
||||||
|
// and the whole shift is one object; `getridershifts` beside it reads the same
|
||||||
|
// id from a param, which is the existing convention for reads here.
|
||||||
|
func (ctl *PartnerController) CreateRiderShift(c *fiber.Ctx) error {
|
||||||
|
|
||||||
|
var shift models.Ridershifts
|
||||||
|
if err := c.BodyParser(&shift); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusBadRequest, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.partnerService.CreateRiderShift(shift)
|
||||||
|
if err != nil {
|
||||||
|
// 400, not 500. Every failure here is something the person typed — a
|
||||||
|
// region that is not configured, a window that already exists, a time
|
||||||
|
// that is not a time — and each message says which.
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"status": false, "code": http.StatusBadRequest, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"status": true, "code": http.StatusCreated,
|
||||||
|
"message": "Shift created", "details": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error {
|
func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error {
|
||||||
|
|
||||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||||
@@ -193,3 +229,237 @@ func (ctl *PartnerController) GetRiderInfo(c *fiber.Ctx) error {
|
|||||||
"details": result,
|
"details": result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRider hires a rider — three tables in one transaction.
|
||||||
|
//
|
||||||
|
// `tenantid` comes from the query string, which is where this console's other
|
||||||
|
// scoped writes take it from. It is required: a rider with no tenant belongs to
|
||||||
|
// the region and to no merchant, which is the state the 84 existing riders are
|
||||||
|
// already in and not one worth creating more of.
|
||||||
|
func (ctl *PartnerController) CreateRider(c *fiber.Ctx) error {
|
||||||
|
var rider models.NewRider
|
||||||
|
|
||||||
|
if err := c.BodyParser(&rider); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken from the scope rather than trusted from the body. A store admin
|
||||||
|
// must not be able to put a rider on another merchant's books by editing a
|
||||||
|
// payload, and the body is the caller's to edit.
|
||||||
|
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
|
||||||
|
rider.Tenantid = tid
|
||||||
|
}
|
||||||
|
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
|
||||||
|
rider.Partnerid = pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rider belongs to a merchant OR to a delivery partner — never to
|
||||||
|
// neither, and never to both.
|
||||||
|
//
|
||||||
|
// This used to demand a tenantid outright, which made a PARTNER's rider
|
||||||
|
// impossible to create: a partner supplies riders to many merchants and
|
||||||
|
// their riders sit under no single one. It is the same endpoint because it
|
||||||
|
// is the same rider — the difference is only who they ride for, and that is
|
||||||
|
// what the assign screen later branches on.
|
||||||
|
//
|
||||||
|
// Both at once is refused rather than silently preferred. A rider carrying
|
||||||
|
// a tenantid AND a partnerid appears in two directories and two assign
|
||||||
|
// pickers, and nothing downstream says which one owns them.
|
||||||
|
switch {
|
||||||
|
case rider.Tenantid == 0 && rider.Partnerid == 0:
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": "a rider needs either a tenantid (the shop's own rider) " +
|
||||||
|
"or a partnerid (a delivery partner's rider)",
|
||||||
|
})
|
||||||
|
case rider.Tenantid != 0 && rider.Partnerid != 0:
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": "a rider belongs to a shop or to a partner, not to both",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
userid, err := ctl.partnerService.CreateRider(rider)
|
||||||
|
if err != nil {
|
||||||
|
// 400, not 500: every failure this can produce is something the caller
|
||||||
|
// sent — a shift that does not exist, an unconfigured region, a phone
|
||||||
|
// number already in use. Answering 500 would send them to look at the
|
||||||
|
// server for a problem in their own form.
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"code": http.StatusCreated,
|
||||||
|
"status": true,
|
||||||
|
// Said plainly because it is the single most confusing thing about this
|
||||||
|
// flow: the rider is created and will NOT show in the on-duty fleet
|
||||||
|
// until they open the app and start a shift.
|
||||||
|
"message": "Rider created. They appear in the fleet once they sign in and start a shift.",
|
||||||
|
"details": fiber.Map{"userid": userid},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *PartnerController) UpdateRider(c *fiber.Ctx) error {
|
||||||
|
var rider models.NewRider
|
||||||
|
|
||||||
|
if err := c.BodyParser(&rider); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.partnerService.UpdateRider(rider); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": "Success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRiderRoster lists riders whether or not they are working today.
|
||||||
|
//
|
||||||
|
// The scope is required. Unscoped this returns every rider on the platform,
|
||||||
|
// which is not a merchant's business — the same guard the other tenant-scoped
|
||||||
|
// reads carry.
|
||||||
|
func (ctl *PartnerController) GetRiderRoster(c *fiber.Ctx) error {
|
||||||
|
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||||
|
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||||
|
|
||||||
|
if tid == 0 && aid == 0 && pid == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": "One of tenantid, applocationid or partnerid is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.partnerService.GetRiderRoster(tid, aid, pid)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"status": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": "Successful",
|
||||||
|
"details": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Onboarding a delivery partner ────────────────────────────────────────────
|
||||||
|
|
||||||
|
Platform-side only. A merchant does not create the company that supplies their
|
||||||
|
riders — they are assigned one, which is `AssignPartner` on the tenant. Both of
|
||||||
|
these live in the web group for the same reason `createrider` does: a partner is
|
||||||
|
onboarded from a console, not from a phone. */
|
||||||
|
|
||||||
|
// CreatePartner onboards a delivery partner and the regions they cover.
|
||||||
|
func (ctl *PartnerController) CreatePartner(c *fiber.Ctx) error {
|
||||||
|
var input models.NewPartner
|
||||||
|
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
partnerid, err := ctl.partnerService.CreatePartner(input)
|
||||||
|
if err != nil {
|
||||||
|
// 400, not 500: every failure this produces is something the caller
|
||||||
|
// sent — a missing name, an unconfigured region, a number already in
|
||||||
|
// use. A 500 sends them to look at the server for their own form.
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"code": http.StatusCreated, "status": true, "message": "Successfully Created",
|
||||||
|
"details": fiber.Map{"partnerid": partnerid},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePartner edits a partner and, when regions are sent, re-states them.
|
||||||
|
func (ctl *PartnerController) UpdatePartner(c *fiber.Ctx) error {
|
||||||
|
var input models.NewPartner
|
||||||
|
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if pid, _ := strconv.Atoi(c.Query("partnerid")); pid != 0 {
|
||||||
|
input.Partnerid = pid
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.partnerService.UpdatePartner(input); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPartnerLocations lists the regions one partner covers.
|
||||||
|
func (ctl *PartnerController) GetPartnerLocations(c *fiber.Ctx) error {
|
||||||
|
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||||
|
if pid == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "partnerid is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := ctl.partnerService.GetPartnerLocations(pid)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Success", "details": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// riderOwner enforces that a rider belongs to a shop or to a partner.
|
||||||
|
//
|
||||||
|
// Its own function so the rule can be tested without a request, and so both
|
||||||
|
// call sites — create today, anything that moves a rider tomorrow — cannot
|
||||||
|
// drift apart on it.
|
||||||
|
func riderOwner(tenantid, partnerid int) error {
|
||||||
|
switch {
|
||||||
|
case tenantid == 0 && partnerid == 0:
|
||||||
|
return errors.New("a rider needs either a tenantid (the shop's own rider) " +
|
||||||
|
"or a partnerid (a delivery partner's rider)")
|
||||||
|
case tenantid != 0 && partnerid != 0:
|
||||||
|
return errors.New("a rider belongs to a shop or to a partner, not to both")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// TerminalHealth returns one till's live state, for a support call that starts
|
// TerminalHealth returns one till's live state, for a support call that starts
|
||||||
// with a terminal code.
|
// with a terminal code.
|
||||||
|
//
|
||||||
|
// The outlet check cannot live in the middleware like every other POS route's
|
||||||
|
// does. The middleware scopes a request by the location it *names*, and this
|
||||||
|
// request names none — only a terminal code, which is free text minted at the
|
||||||
|
// till and belongs to whichever shop is holding that device. So the outlet is
|
||||||
|
// not known until after the lookup, and the check has to happen here.
|
||||||
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
||||||
terminalID := strings.TrimSpace(c.Query("terminal_id"))
|
terminalID := strings.TrimSpace(c.Query("terminal_id"))
|
||||||
if terminalID == "" {
|
if terminalID == "" {
|
||||||
@@ -200,6 +206,11 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
|||||||
// Not an error. The till has simply not reported inside its TTL, which
|
// Not an error. The till has simply not reported inside its TTL, which
|
||||||
// is the answer the caller wanted — said plainly rather than as a 404
|
// is the answer the caller wanted — said plainly rather than as a 404
|
||||||
// that reads like the terminal does not exist.
|
// that reads like the terminal does not exist.
|
||||||
|
//
|
||||||
|
// Answered without an outlet check, and safely so: there is nothing to
|
||||||
|
// check against and nothing to leak. "Offline" is the same answer for a
|
||||||
|
// terminal code that was never issued, so guessing codes reveals only
|
||||||
|
// that guessing does not work.
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"code": http.StatusOK,
|
"code": http.StatusOK,
|
||||||
"status": true,
|
"status": true,
|
||||||
@@ -211,9 +222,54 @@ func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ctl.posTerminalInScope(c, fields); err != nil {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// posTerminalInScope refuses a heartbeat belonging to somebody else's shop.
|
||||||
|
//
|
||||||
|
// Reads the outlet off the heartbeat itself, because that — not the request —
|
||||||
|
// is the authority on which shop a terminal code belongs to. A caller who
|
||||||
|
// guesses "T4A9" gets a 403 rather than another shop's pending-bill count,
|
||||||
|
// takings so far today, and app version.
|
||||||
|
//
|
||||||
|
// Silent when the request carries no token, matching middleware.PosAuth: while
|
||||||
|
// POS_AUTH_REQUIRED is off, tills in the field are still calling these routes
|
||||||
|
// unauthenticated, and refusing them here would take the fleet board down for
|
||||||
|
// exactly the terminals it exists to watch. Once the flag is on, an untokened
|
||||||
|
// request never reaches this handler.
|
||||||
|
func (ctl *PosController) posTerminalInScope(c *fiber.Ctx, fields map[string]string) error {
|
||||||
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
locationID, err := strconv.Atoi(strings.TrimSpace(fields["location_id"]))
|
||||||
|
if err != nil || locationID <= 0 {
|
||||||
|
// A heartbeat that cannot say where it came from cannot be shown to a
|
||||||
|
// caller who must be scoped. Refusing beats guessing.
|
||||||
|
return fmt.Errorf("this terminal's outlet could not be determined")
|
||||||
|
}
|
||||||
|
|
||||||
|
if locationID == claims.Locationid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := ctl.posService.LocationAllowed(claims.Tenantid, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not verify outlet access")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return fmt.Errorf("this terminal belongs to an outlet this session cannot reach")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LocationHealth returns every till at a shop — the "which counters are dark"
|
// LocationHealth returns every till at a shop — the "which counters are dark"
|
||||||
// board. Tills that have stopped reporting come back marked offline rather than
|
// board. Tills that have stopped reporting come back marked offline rather than
|
||||||
// being omitted, because a missing till is exactly what somebody is looking for.
|
// being omitted, because a missing till is exactly what somebody is looking for.
|
||||||
@@ -387,6 +443,11 @@ func posIngestError(c *fiber.Ctx, op string, err error) error {
|
|||||||
// The one POS route that is deliberately left unauthenticated — it is where a
|
// The one POS route that is deliberately left unauthenticated — it is where a
|
||||||
// token comes from. Everything else on the group sits behind the session this
|
// token comes from. Everything else on the group sits behind the session this
|
||||||
// issues.
|
// issues.
|
||||||
|
//
|
||||||
|
// A mobile number and a PIN. Because it is unauthenticated and the PIN is four
|
||||||
|
// digits, this is the one route on the group that needs a rate limit in front
|
||||||
|
// of it — the pair is only strong while an attacker cannot try ten thousand
|
||||||
|
// times. That belongs at the edge, not here.
|
||||||
func (ctl *PosController) Login(c *fiber.Ctx) error {
|
func (ctl *PosController) Login(c *fiber.Ctx) error {
|
||||||
var req models.PosLoginRequest
|
var req models.PosLoginRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
@@ -396,10 +457,23 @@ func (ctl *PosController) Login(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" {
|
// Which account, and which of the two credentials was offered. Both are
|
||||||
|
// checked here so an empty field is answered as the malformed request it is,
|
||||||
|
// rather than spending a database round trip to say the same thing.
|
||||||
|
identity := strings.TrimSpace(req.Contactno)
|
||||||
|
if identity == "" {
|
||||||
|
identity = strings.TrimSpace(req.Authname)
|
||||||
|
}
|
||||||
|
if identity == "" {
|
||||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
"code": http.StatusBadRequest, "status": false,
|
"code": http.StatusBadRequest, "status": false,
|
||||||
"message": "an email or mobile number is required",
|
"message": "a mobile number is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Pin) == "" && strings.TrimSpace(req.Password) == "" {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "a PIN is required",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +489,7 @@ func (ctl *PosController) Login(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("pos login (%s): %v", req.Authname, err)
|
log.Printf("pos login (%s): %v", identity, err)
|
||||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
||||||
})
|
})
|
||||||
@@ -459,9 +533,14 @@ func (ctl *PosController) Session(c *fiber.Ctx) error {
|
|||||||
// Staff lists who may ring a bill at this terminal's outlet.
|
// Staff lists who may ring a bill at this terminal's outlet.
|
||||||
//
|
//
|
||||||
// Scoped by the caller's own session rather than by a query parameter. A till
|
// Scoped by the caller's own session rather than by a query parameter. A till
|
||||||
// asking "who works here" must not be able to ask on behalf of another shop,
|
// asking "who works here" must not be able to ask on behalf of another shop, so
|
||||||
// and the answer carries PINs — so the outlet comes from the token, and a
|
// the outlet comes from the token, and a request without one is refused
|
||||||
// request without one is refused whatever POS_AUTH_REQUIRED says.
|
// whatever POS_AUTH_REQUIRED says.
|
||||||
|
//
|
||||||
|
// The answer no longer carries PINs — that stopped when the PIN became half of
|
||||||
|
// the sign-in, see models.PosStaffMember. The scoping outlives the reason: an
|
||||||
|
// outlet's roster is still its own business, and a list of who is on shift
|
||||||
|
// where is worth something to somebody casing a chain.
|
||||||
func (ctl *PosController) Staff(c *fiber.Ctx) error {
|
func (ctl *PosController) Staff(c *fiber.Ctx) error {
|
||||||
claims, ok := middleware.PosClaimsFrom(c)
|
claims, ok := middleware.PosClaimsFrom(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -637,7 +716,7 @@ func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||||
"code": http.StatusUnauthorized, "status": false,
|
"code": http.StatusUnauthorized, "status": false,
|
||||||
"message": "sign the terminal in with an email and password before using PIN sign-in",
|
"message": "sign the terminal in with a mobile number and PIN before switching operator",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +771,20 @@ func posClaimError(c *fiber.Ctx, err error) error {
|
|||||||
// once the console can hold a session.
|
// once the console can hold a session.
|
||||||
|
|
||||||
// posWebScope reads and checks the tenant and outlet a console request names.
|
// posWebScope reads and checks the tenant and outlet a console request names.
|
||||||
|
// posTenantScope is the guard for things that belong to a whole business
|
||||||
|
// rather than to one of its shops — shift windows, so far.
|
||||||
|
//
|
||||||
|
// No ownership query, because there is nothing to own: `middleware.WebAuth`
|
||||||
|
// pins the tenant from the signed session and refuses a request naming another
|
||||||
|
// one, so reaching here with a tenant id at all means it is this caller's.
|
||||||
|
// Naming an outlet is what needs checking, and that is `posWebScope` below.
|
||||||
|
func (ctl *PosController) posTenantScope(tenantID int) error {
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return fmt.Errorf("tenantid is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
|
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
|
||||||
if tenantID <= 0 {
|
if tenantID <= 0 {
|
||||||
return fmt.Errorf("tenantid is required")
|
return fmt.Errorf("tenantid is required")
|
||||||
@@ -817,13 +910,125 @@ func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
|
|||||||
{
|
{
|
||||||
"role_id": models.PosRoleSupervisor, "role": "supervisor",
|
"role_id": models.PosRoleSupervisor, "role": "supervisor",
|
||||||
"label": models.PosRoleName(models.PosRoleSupervisor),
|
"label": models.PosRoleName(models.PosRoleSupervisor),
|
||||||
"description": "Runs the terminal and creates counter staff. Also signs into the app.",
|
"description": "Runs the terminal: imports, settings, voids, and " +
|
||||||
|
"creating counter staff. Signs in at a till only — a till " +
|
||||||
|
"account has no Nearle Daily login.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role_id": models.PosRoleCashier, "role": "cashier",
|
"role_id": models.PosRoleCashier, "role": "cashier",
|
||||||
"label": models.PosRoleName(models.PosRoleCashier),
|
"label": models.PosRoleName(models.PosRoleCashier),
|
||||||
"description": "Billing only.",
|
"description": "Billing only. Signs in at a till with their own username " +
|
||||||
|
"and password, so a shop can open without a supervisor present.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────── Staff shifts
|
||||||
|
//
|
||||||
|
// Working windows for till staff, managed from the console. Same scoping rule
|
||||||
|
// as the till-user routes above: the outlet is asserted by the caller and
|
||||||
|
// checked against the tenant before anything is written.
|
||||||
|
|
||||||
|
// WebListStaffShifts returns an outlet's shifts, for a picker.
|
||||||
|
func (ctl *PosController) WebListStaffShifts(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||||
|
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
|
||||||
|
// Tenant-scoped, because a shift belongs to the business rather than to one
|
||||||
|
// of its shops. An outlet may still be named to narrow the list, and is
|
||||||
|
// checked for ownership when it is — omitting it is not a way to read
|
||||||
|
// somebody else's, because the tenant comes from the signed session.
|
||||||
|
if err := ctl.posTenantScope(tenantID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
if locationID > 0 {
|
||||||
|
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shifts, err := ctl.posService.ListStaffShifts(tenantID, locationID,
|
||||||
|
strings.EqualFold(c.Query("include_inactive"), "true"))
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "WebListStaffShifts", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": fiber.Map{"location_id": locationID, "shifts": shifts},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebCreateStaffShift adds a working window at one outlet.
|
||||||
|
func (ctl *PosController) WebCreateStaffShift(c *fiber.Ctx) error {
|
||||||
|
var req models.StaffShifts
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A shift with no outlet belongs to the tenant and every branch it owns,
|
||||||
|
// which is the ordinary case — a business that works 07:00–15:00 works
|
||||||
|
// those hours at every shop, and entering them per outlet is how the third
|
||||||
|
// branch quietly ends up on 07:00–15:30. An outlet is named only when one
|
||||||
|
// shop really does differ, and is checked for ownership then.
|
||||||
|
if err := ctl.posTenantScope(req.Tenantid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
if req.Locationid > 0 {
|
||||||
|
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shift, err := ctl.posService.CreateStaffShift(req.Tenantid, req.Locationid, req)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"code": http.StatusCreated, "status": true,
|
||||||
|
"message": "Shift created", "details": shift,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebUpdateStaffShift edits a window. Deactivate by sending status "Inactive" —
|
||||||
|
// shifts are not deleted, because a person may still be assigned to one and an
|
||||||
|
// orphaned shiftid reads as a shift that never existed.
|
||||||
|
func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
|
||||||
|
var req models.StaffShifts
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shift, err := ctl.posService.UpdateStaffShift(req.Tenantid, req.Locationid, req)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": "Shift updated", "details": shift,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthAdoption reports how much of the till fleet is carrying a session token.
|
||||||
|
//
|
||||||
|
// The answer to "is it safe to set POS_AUTH_REQUIRED=true yet". Every outlet it
|
||||||
|
// lists is a till that would stop being able to ring a bill the moment
|
||||||
|
// enforcement goes on.
|
||||||
|
//
|
||||||
|
// Behind the web session guard on purpose: that list is also a map of which
|
||||||
|
// shops are reachable without a credential today.
|
||||||
|
func (ctl *PosController) AuthAdoption(c *fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": "Success",
|
||||||
|
"details": middleware.PosAdoptionReport(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
@@ -189,7 +191,14 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctl.productService.CreateProduct(product); err != nil {
|
// The created row, not the parsed body.
|
||||||
|
//
|
||||||
|
// This returned the struct it had just parsed off the request, which by
|
||||||
|
// definition carried `productid: 0` — the id is assigned by the database a
|
||||||
|
// moment later and was never read back. Every caller that needed the id
|
||||||
|
// went and looked the product up again by SKU.
|
||||||
|
created, err := ctl.productService.CreateProduct(product)
|
||||||
|
if err != nil {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"code": http.StatusInternalServerError,
|
"code": http.StatusInternalServerError,
|
||||||
"message": "Failed to create product",
|
"message": "Failed to create product",
|
||||||
@@ -201,7 +210,7 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
|||||||
"code": http.StatusCreated,
|
"code": http.StatusCreated,
|
||||||
"message": "Product created successfully",
|
"message": "Product created successfully",
|
||||||
"status": true,
|
"status": true,
|
||||||
"data": product,
|
"data": created,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +392,41 @@ func (ctl *ProductController) GetAllProducts(c *fiber.Ctx) error {
|
|||||||
categoryID, subcategoryID, productID, applocationID, tenantID,
|
categoryID, subcategoryID, productID, applocationID, tenantID,
|
||||||
locationID, keyword, productStatus, approve, pageno, pagesize,
|
locationID, keyword, productStatus, approve, pageno, pagesize,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The customer app is not shown what the shop cannot sell.
|
||||||
|
//
|
||||||
|
// Scoped to the /v1/mob base rather than applied in the service, because
|
||||||
|
// this one handler answers on BOTH bases and the two callers want opposite
|
||||||
|
// things. The console reads it to restock — an empty line is exactly what a
|
||||||
|
// merchant needs to see and act on, so filtering there would hide the work.
|
||||||
|
// A shopper reading the same list can only be misled by it: measured
|
||||||
|
// 2026-09-02 on R mart, four products were on offer in the app with a zero
|
||||||
|
// balance, including Cadbury Bournvita 500g and two Amul packs. Ordering one
|
||||||
|
// gets a 409 at checkout, after the shopper has chosen it.
|
||||||
|
//
|
||||||
|
// It filters on Productstock, the same number the response carries, so the
|
||||||
|
// list and the figure beside it cannot disagree.
|
||||||
|
if err == nil && isAppRequest(c) {
|
||||||
|
details = services.InStockOnlyGrouped(details)
|
||||||
|
|
||||||
|
// And nothing a shopper cannot be charged for. An unpriced product is
|
||||||
|
// unfinished, not free, and ₹0 on a phone reads as the latter.
|
||||||
|
details = services.PricedOnly(details)
|
||||||
|
|
||||||
|
// One product, three sizes — not three products.
|
||||||
|
//
|
||||||
|
// A shop stocking Aachi Baby Fryums in 100g, 500g and 1kg has three
|
||||||
|
// product rows, each with its own price and its own shelf. The shopper
|
||||||
|
// should meet ONE listing and pick the size; the sizes travel with the
|
||||||
|
// parent in `variantoptions`, so nothing becomes unreachable.
|
||||||
|
//
|
||||||
|
// Best-effort, like the stock filter above: if the lookup fails the
|
||||||
|
// shopper sees the three rows they see today, which is worse than the
|
||||||
|
// grouping and much better than an error.
|
||||||
|
if isChild, cErr := ctl.productService.VariantChildIDs(tenantID); cErr == nil {
|
||||||
|
details = services.WithoutVariantChildren(details, isChild)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": false,
|
"status": false,
|
||||||
@@ -404,8 +448,33 @@ func (ctl *ProductController) GetProductByVariant(c *fiber.Ctx) error {
|
|||||||
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
variantid, _ := strconv.Atoi(c.Query("variantid"))
|
variantid, _ := strconv.Atoi(c.Query("variantid"))
|
||||||
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||||
|
// `productid` is the parameter the ordering screen should send.
|
||||||
|
//
|
||||||
|
// With it the caller no longer has to know whether the thing it tapped has
|
||||||
|
// sizes: a product in a variant group comes back with the whole group to
|
||||||
|
// choose from, a product in none comes back on its own. Previously only
|
||||||
|
// `variantid` was accepted, so an ungrouped product — `variants = 0`, which
|
||||||
|
// is most of them — could not be requested at all and the order screen had
|
||||||
|
// nothing to work with.
|
||||||
|
productID, _ := strconv.Atoi(c.Query("productid"))
|
||||||
|
|
||||||
result, err := ctl.productService.GetProductByVariant(tenantID, variantid, locationID)
|
// One of the two has to name something, and `variantid=0` names nothing.
|
||||||
|
//
|
||||||
|
// 0 is not a variant group — it is the value every UNGROUPED product
|
||||||
|
// carries, so a query for it used to match the tenant's whole ungrouped
|
||||||
|
// catalogue and return six unrelated products as each other's variants. The
|
||||||
|
// ordering screen then had a list it could not choose from, and no order
|
||||||
|
// could be placed. Refusing here turns that into one sentence naming the
|
||||||
|
// parameter to send instead.
|
||||||
|
if variantid <= 0 && productID <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": "send productid (preferred) or a variantid above 0 — variantid 0 is not a variant group, it is what every ungrouped product carries",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctl.productService.GetProductByVariant(tenantID, variantid, locationID, productID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
@@ -524,6 +593,33 @@ func (ctl *ProductController) CreateProductLocation(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateProductVariant puts a product into a variant group, or takes it out.
|
||||||
|
//
|
||||||
|
// variantid 0 means ungrouped and is allowed — it is a real thing to want. The
|
||||||
|
// read path is what refuses to treat 0 as a group to search for.
|
||||||
|
func (ctl *ProductController) UpdateProductVariant(c *fiber.Ctx) error {
|
||||||
|
var body struct {
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
Variantid int `json:"variantid"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if body.Productid <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "productid is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := ctl.productService.UpdateProductVariant(body.Productid, body.Variantid); err != nil {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "message": "Success"})
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *ProductController) CreateProductVariant(c *fiber.Ctx) error {
|
func (ctl *ProductController) CreateProductVariant(c *fiber.Ctx) error {
|
||||||
var input models.Productvariant
|
var input models.Productvariant
|
||||||
|
|
||||||
@@ -572,10 +668,54 @@ func (ctl *ProductController) ImportCatalogueProduct(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, req := range data {
|
for _, req := range data {
|
||||||
if req.Tenantid == 0 || req.Locationid == 0 || req.Brand == "" || req.Catalogueid == 0 || req.Categoryid == 0 {
|
// `categoryid` IS required, and the comment that used to sit here
|
||||||
|
// argued the opposite. It said an unclassified product is "visibly
|
||||||
|
// unfinished" and therefore harmless, and that importing "reaches no
|
||||||
|
// shop and cannot be sold".
|
||||||
|
//
|
||||||
|
// Both halves were wrong, and a sweep of all 262 tenants measured it:
|
||||||
|
// 7 products across 3 tenants sat with categoryid 0, and 6 outlets were
|
||||||
|
// serving shoppers an EMPTY shop while their consoles listed stock.
|
||||||
|
// Nothing was visibly unfinished — `getlocationproducts` does not filter
|
||||||
|
// on category, so the product looked entirely normal to the merchant.
|
||||||
|
// And import DOES reach a shop: it writes productlocations.
|
||||||
|
//
|
||||||
|
// The state was also unrecoverable. GetProductsBySubcategory rejects
|
||||||
|
// categoryid 0 outright, `UpdateProduct` writes only
|
||||||
|
// productlocations.status, and re-import corrected pricing alone — so
|
||||||
|
// no request could put it right. Re-import now repairs the category
|
||||||
|
// (productService.ImportCatalogueProduct), and this refuses to create
|
||||||
|
// the state in the first place.
|
||||||
|
//
|
||||||
|
// A wrongly filed product remains the better failure: it is findable,
|
||||||
|
// and it is fixable by re-importing. An unfiled one was neither.
|
||||||
|
// Named individually rather than as one list of five.
|
||||||
|
//
|
||||||
|
// The old message recited every required field whichever one was
|
||||||
|
// actually missing, so a request rejected for `categoryid: 0` read as
|
||||||
|
// "catalogueid is required" and sent somebody looking at the wrong
|
||||||
|
// field entirely. An error that does not say what is wrong costs more
|
||||||
|
// than the branch it saves.
|
||||||
|
var missing []string
|
||||||
|
if req.Tenantid == 0 {
|
||||||
|
missing = append(missing, "tenantid")
|
||||||
|
}
|
||||||
|
if req.Locationid == 0 {
|
||||||
|
missing = append(missing, "locationid")
|
||||||
|
}
|
||||||
|
if req.Brand == "" {
|
||||||
|
missing = append(missing, "brand")
|
||||||
|
}
|
||||||
|
if req.Catalogueid == 0 {
|
||||||
|
missing = append(missing, "catalogueid")
|
||||||
|
}
|
||||||
|
if req.Categoryid == 0 {
|
||||||
|
missing = append(missing, "categoryid")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
"code": http.StatusBadRequest,
|
"code": http.StatusBadRequest,
|
||||||
"message": "tenantid, locationid, brand, catalogueid, and categoryid are required",
|
"message": fmt.Sprintf("missing required field(s): %s", strings.Join(missing, ", ")),
|
||||||
"status": false,
|
"status": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -654,6 +794,93 @@ func (ctl *ProductController) GetTenantCategories(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recategorise re-files a tenant's products in bulk.
|
||||||
|
//
|
||||||
|
// PUT and not POST: it changes rows that already exist and creates none. The
|
||||||
|
// response carries how many rows actually moved, which is the only way a caller
|
||||||
|
// can tell "all done" from "every id I sent was wrong".
|
||||||
|
func (ctl *ProductController) Recategorise(c *fiber.Ctx) error {
|
||||||
|
var body struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Updates []models.ProductCategoryUpdate `json:"updates"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "invalid body",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if body.Tenantid == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
moved, err := ctl.productService.RecategoriseProducts(body.Tenantid, body.Updates)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": fiber.Map{"moved": moved},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCategories exchanges category names for this tenant's category ids.
|
||||||
|
//
|
||||||
|
// POST, not GET, because it CREATES the categories it cannot find — a sheet
|
||||||
|
// naming an aisle this shop has never stocked should not fail, it should open
|
||||||
|
// the aisle. The response maps the name a caller sent to the id it must store.
|
||||||
|
//
|
||||||
|
// Keyed on the lowercased, trimmed name, so a caller can look up whatever
|
||||||
|
// casing its own sheet used without having to guess how it was filed.
|
||||||
|
func (ctl *ProductController) ResolveCategories(c *fiber.Ctx) error {
|
||||||
|
var body struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "invalid body",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if body.Tenantid == 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := ctl.productService.EnsureTenantCategories(body.Tenantid, body.Names)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"message": err.Error(),
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"message": "Success",
|
||||||
|
"status": true,
|
||||||
|
"details": resolved,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
||||||
var input struct {
|
var input struct {
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
@@ -691,3 +918,134 @@ func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
|
|||||||
"status": true,
|
"status": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishProduct releases a product from the admin catalogue to every outlet.
|
||||||
|
//
|
||||||
|
// The two rules this enforces — a price is required, and publishing covers the
|
||||||
|
// whole tenant — live in the repository, so they hold for any caller rather
|
||||||
|
// than only for the console form that happens to ask nicely.
|
||||||
|
func (ctl *ProductController) PublishProduct(c *fiber.Ctx) error {
|
||||||
|
var input struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
// Optional. Omitted leaves the product at 0%, which is a real rate for
|
||||||
|
// staples rather than a missing value — so it is written as given
|
||||||
|
// rather than skipped when zero.
|
||||||
|
Taxpercent float64 `json:"taxpercent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outlets, err := ctl.productService.PublishProduct(input.Tenantid, input.Productid, input.Price, input.Taxpercent)
|
||||||
|
if err != nil {
|
||||||
|
// 400 rather than 500: every failure here is something the caller can
|
||||||
|
// act on — no price, no outlets, or a product that is not theirs.
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": fmt.Sprintf("Published to %d outlet(s)", outlets),
|
||||||
|
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnpublishProduct withdraws a product from every shop, keeping its rows.
|
||||||
|
func (ctl *ProductController) UnpublishProduct(c *fiber.Ctx) error {
|
||||||
|
var input struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outlets, err := ctl.productService.UnpublishProduct(input.Tenantid, input.Productid)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": fmt.Sprintf("Withdrawn from %d outlet(s)", outlets),
|
||||||
|
"details": fiber.Map{"productid": input.Productid, "outlets": outlets},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelinkCatalogue repairs a tenant's pointers back into the global catalogue.
|
||||||
|
//
|
||||||
|
// Defaults to a DRY RUN. The pass rewrites the column that decides what a
|
||||||
|
// browse screen claims a shop already holds, and clearing a link is not
|
||||||
|
// reversible from the outside — so the whole plan should be readable before any
|
||||||
|
// of it happens. Pass `apply=true` to write.
|
||||||
|
func (ctl *ProductController) RelinkCatalogue(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(c.Query("tenantid", "0"))
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"message": "tenantid is required — this repairs one merchant's links, not the platform's",
|
||||||
|
"status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Anything but an explicit `apply=true` is a dry run, including a typo.
|
||||||
|
apply := c.Query("apply", "") == "true"
|
||||||
|
|
||||||
|
report, err := ctl.productService.RelinkCatalogue(tenantID, !apply)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
//
|
||||||
|
// Scoped twice over: `middleware.WebAuth` refuses a request naming a tenant the
|
||||||
|
// session does not own — it reads `tenantid` from the body as well as the query
|
||||||
|
// — and the repository's UPDATE carries the tenant in its WHERE clause. A write
|
||||||
|
// that changes what a shopper sees should not rest on one guard being mounted
|
||||||
|
// correctly.
|
||||||
|
func (ctl *ProductController) SetShowHealthScore(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
// A POINTER so a body that forgot the field is refused rather than read
|
||||||
|
// as "turn it off". The whole point of this endpoint is the difference
|
||||||
|
// between the two.
|
||||||
|
Showhealthscore *bool `json:"showhealthscore"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.Showhealthscore == nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "showhealthscore is required: send true or false.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.productService.SetShowHealthScore(req.Tenantid, req.Productid, *req.Showhealthscore); err != nil {
|
||||||
|
// 409, not 500. "No such product for this business" is a fact the
|
||||||
|
// caller can act on, not a fault in the server.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
96
controllers/productVariantLinkController.go
Normal file
96
controllers/productVariantLinkController.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Attaching sizes to a product, and taking them off again.
|
||||||
|
|
||||||
|
The pair that was missing. `createproductvariant` wrote a name into a table
|
||||||
|
without a parent, so there was no way to say "500ml is a size of Coke" — and
|
||||||
|
without that the ordering screen had nothing but `products.variants` to work
|
||||||
|
from, a bare group number that is 0 on every product.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// AddProductVariant — POST /products/addproductvariant
|
||||||
|
//
|
||||||
|
// Body: {tenantid, productid, variantproductid, variantname, varianttype?, price?}
|
||||||
|
func (ctl *ProductController) AddProductVariant(c *fiber.Ctx) error {
|
||||||
|
var body models.Productvariant
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusBadRequest, "status": false,
|
||||||
|
"message": "could not read the request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Tenantid <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusBadRequest, "status": false,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, err := ctl.productService.AddProductVariant(body)
|
||||||
|
if err != nil {
|
||||||
|
// The refusals are all about the CALLER's request, so they answer 400
|
||||||
|
// with the reason. Anything else is ours and answers 500.
|
||||||
|
for _, known := range []error{
|
||||||
|
repositories.ErrVariantParentMissing,
|
||||||
|
repositories.ErrVariantProductMissing,
|
||||||
|
repositories.ErrVariantSelfReference,
|
||||||
|
repositories.ErrVariantNotYours,
|
||||||
|
repositories.ErrVariantDuplicate,
|
||||||
|
} {
|
||||||
|
if errors.Is(err, known) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusBadRequest, "status": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusInternalServerError, "status": false,
|
||||||
|
"message": "could not add the variant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusCreated, "status": true,
|
||||||
|
"message": "Success", "details": saved,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveProductVariant — DELETE /products/removeproductvariant?tenantid=&variantid=
|
||||||
|
//
|
||||||
|
// Detaches the size. Neither product is deleted: a variant is a relationship,
|
||||||
|
// and removing it leaves two ordinary products behind.
|
||||||
|
func (ctl *ProductController) RemoveProductVariant(c *fiber.Ctx) error {
|
||||||
|
tenantid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||||
|
variantid, _ := strconv.Atoi(c.Query("variantid"))
|
||||||
|
|
||||||
|
if tenantid <= 0 || variantid <= 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusBadRequest, "status": false,
|
||||||
|
"message": "tenantid and variantid are both required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.productService.RemoveProductVariant(tenantid, variantid); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusInternalServerError, "status": false,
|
||||||
|
"message": "could not remove the variant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": fiber.StatusOK, "status": true, "message": "Success",
|
||||||
|
})
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
controllers/riderOwnership_test.go
Normal file
41
controllers/riderOwnership_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
/*
|
||||||
|
A rider belongs to a shop or to a delivery partner — never to neither, never to
|
||||||
|
both.
|
||||||
|
|
||||||
|
`CreateRider` used to demand a tenantid outright, which made a partner's rider
|
||||||
|
impossible to create: a partner supplies riders to many merchants and their
|
||||||
|
riders sit under no single one. The two ids are now exclusive, and both mistakes
|
||||||
|
are refused rather than one being silently preferred — a rider carrying both
|
||||||
|
appears in two directories and two assign pickers, with nothing downstream
|
||||||
|
saying which owns them.
|
||||||
|
|
||||||
|
`riderOwner` is the rule on its own so it can be checked without a request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestRiderOwnerAcceptsAShopsOwnRider(t *testing.T) {
|
||||||
|
if err := riderOwner(1147, 0); err != nil {
|
||||||
|
t.Fatalf("a tenant's own rider is valid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRiderOwnerAcceptsAPartnersRider(t *testing.T) {
|
||||||
|
if err := riderOwner(0, 44); err != nil {
|
||||||
|
t.Fatalf("a partner's rider is valid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRiderOwnerRefusesNeither(t *testing.T) {
|
||||||
|
if err := riderOwner(0, 0); err == nil {
|
||||||
|
t.Fatal("a rider owned by nobody appears in no directory and must be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRiderOwnerRefusesBoth(t *testing.T) {
|
||||||
|
if err := riderOwner(1147, 44); err == nil {
|
||||||
|
t.Fatal("a rider owned by both appears in two pickers and must be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
101
controllers/scanController.go
Normal file
101
controllers/scanController.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"nearle/models"
|
||||||
|
"nearle/services"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanController is the scan-to-order surface for the customer app:
|
||||||
|
//
|
||||||
|
// POST /v1/mob/scan/lookup a label → the product, and which of my stores has it
|
||||||
|
// POST /v1/mob/scan/confirm I picked a store and a size → still there? else where?
|
||||||
|
// GET /v1/mob/scan/stores my stores, nearest first
|
||||||
|
//
|
||||||
|
// Business outcomes ("out of stock", "not registered with that store") are
|
||||||
|
// 200s with a reason in the body: the app renders them, it does not retry
|
||||||
|
// them. HTTP errors are reserved for a request that cannot be served at all.
|
||||||
|
type ScanController struct {
|
||||||
|
scanService services.ScanService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScanController(scanService services.ScanService) *ScanController {
|
||||||
|
return &ScanController{scanService: scanService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *ScanController) Lookup(c *fiber.Ctx) error {
|
||||||
|
var req models.ScanLookupRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return scanBadRequest(c, "Invalid request body")
|
||||||
|
}
|
||||||
|
resp, err := ctl.scanService.Lookup(c.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return scanError(c, err, "Could not look up that product")
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": resp.Message,
|
||||||
|
"details": resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *ScanController) Confirm(c *fiber.Ctx) error {
|
||||||
|
var req models.ScanConfirmRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return scanBadRequest(c, "Invalid request body")
|
||||||
|
}
|
||||||
|
resp, err := ctl.scanService.Confirm(c.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return scanError(c, err, "Could not check that store")
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": resp.Message,
|
||||||
|
"details": resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctl *ScanController) Stores(c *fiber.Ctx) error {
|
||||||
|
customerid, _ := c.QueryInt("customerid"), 0
|
||||||
|
stores, err := ctl.scanService.Stores(c.Context(), customerid,
|
||||||
|
models.FlexibleString(c.Query("latitude")), models.FlexibleString(c.Query("longitude")))
|
||||||
|
if err != nil {
|
||||||
|
return scanError(c, err, "Could not list your stores")
|
||||||
|
}
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"status": true,
|
||||||
|
"message": "Success",
|
||||||
|
"details": stores,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanBadRequest(c *fiber.Ctx, msg string) error {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"status": false,
|
||||||
|
"message": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanError(c *fiber.Ctx, err error, fallback string) error {
|
||||||
|
code, msg := http.StatusInternalServerError, fallback
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrScanBadRequest):
|
||||||
|
code, msg = http.StatusBadRequest, err.Error()
|
||||||
|
case errors.Is(err, services.ErrScanCustomerNotFound):
|
||||||
|
code, msg = http.StatusNotFound, "Customer not found"
|
||||||
|
case errors.Is(err, services.ErrScanCatalogueDown):
|
||||||
|
code, msg = http.StatusServiceUnavailable, "Product search is temporarily unavailable"
|
||||||
|
}
|
||||||
|
return c.Status(code).JSON(fiber.Map{
|
||||||
|
"code": code,
|
||||||
|
"status": false,
|
||||||
|
"message": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
controllers/stockRequestBatch.go
Normal file
68
controllers/stockRequestBatch.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseStockRequests reads either one request or a list of them.
|
||||||
|
//
|
||||||
|
// Sniffing the first byte rather than trying one shape and falling back to the
|
||||||
|
// other: BodyParser consumes what it reads, so a failed first attempt can leave
|
||||||
|
// nothing for the second. The body is JSON here and `[` is unambiguous.
|
||||||
|
func parseStockRequests(c *fiber.Ctx) ([]models.StockRequest, error) {
|
||||||
|
body := c.Body()
|
||||||
|
|
||||||
|
for _, b := range body {
|
||||||
|
switch b {
|
||||||
|
case ' ', '\t', '\r', '\n':
|
||||||
|
continue
|
||||||
|
case '[':
|
||||||
|
var many []models.StockRequest
|
||||||
|
if err := json.Unmarshal(body, &many); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return many, nil
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var one models.StockRequest
|
||||||
|
if err := c.BodyParser(&one); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []models.StockRequest{one}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeIDs drops repeats and anything non-positive, preserving order.
|
||||||
|
//
|
||||||
|
// Repeats matter here rather than being tidiness: approving adds stock, so the
|
||||||
|
// same id twice in one batch would try to receive the same delivery twice. The
|
||||||
|
// service has its own guard, but a batch should not be relying on it.
|
||||||
|
func dedupeIDs(ids []int) []int {
|
||||||
|
seen := make(map[int]bool, len(ids))
|
||||||
|
out := make([]int, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id <= 0 || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stockBatchMessage says what happened in words a merchant can act on.
|
||||||
|
//
|
||||||
|
// "8 approved, 2 could not be" beats "Success" when two of ten did not land —
|
||||||
|
// the whole point of reporting per row is that the person can go and look.
|
||||||
|
func stockBatchMessage(ok, failed int, verb string) string {
|
||||||
|
if failed == 0 {
|
||||||
|
return fmt.Sprintf("%d %s", ok, verb)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d %s, %d could not be", ok, verb, failed)
|
||||||
|
}
|
||||||
102
controllers/stockRequestBatch_test.go
Normal file
102
controllers/stockRequestBatch_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Batch decisions on stock requests.
|
||||||
|
|
||||||
|
Approving is not a status write — the service adds the requested quantity to the
|
||||||
|
branch's stock — so a batch has to behave like a list of separate actions that
|
||||||
|
each really happened, not like one all-or-nothing write. These tests pin the
|
||||||
|
parts that would quietly move stock twice or lose a row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func parseVia(t *testing.T, body string) ([]int, error) {
|
||||||
|
t.Helper()
|
||||||
|
app := fiber.New()
|
||||||
|
var got []int
|
||||||
|
var perr error
|
||||||
|
app.Post("/", func(c *fiber.Ctx) error {
|
||||||
|
reqs, err := parseStockRequests(c)
|
||||||
|
perr = err
|
||||||
|
for _, r := range reqs {
|
||||||
|
got = append(got, r.Productid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if _, err := app.Test(req); err != nil {
|
||||||
|
t.Fatalf("Test: %v", err)
|
||||||
|
}
|
||||||
|
return got, perr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneRequestStillWorksUnwrapped(t *testing.T) {
|
||||||
|
// The existing caller sends an object, and must keep working untouched.
|
||||||
|
got, err := parseVia(t, `{"productid":42,"qty":5}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("single: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != 42 {
|
||||||
|
t.Errorf("got %v, want [42]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAListOfRequestsIsRead(t *testing.T) {
|
||||||
|
got, err := parseVia(t, `[{"productid":1,"qty":2},{"productid":2,"qty":3}]`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Errorf("got %v, want two requests", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeadingWhitespaceDoesNotHideAList(t *testing.T) {
|
||||||
|
// A client that pretty-prints its body still sends a list.
|
||||||
|
got, err := parseVia(t, " \n\t[{\"productid\":7,\"qty\":1}]")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("padded list: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != 7 {
|
||||||
|
t.Errorf("got %v, want [7]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSameRequestCannotBeApprovedTwiceInOneBatch(t *testing.T) {
|
||||||
|
// Approving adds stock. The same id twice would receive one delivery twice.
|
||||||
|
got := dedupeIDs([]int{5, 5, 6, 5})
|
||||||
|
if len(got) != 2 || got[0] != 5 || got[1] != 6 {
|
||||||
|
t.Errorf("got %v, want [5 6]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonPositiveIdsAreDropped(t *testing.T) {
|
||||||
|
// 0 is what an unparsed or missing field becomes; it is not a request.
|
||||||
|
if got := dedupeIDs([]int{0, -3, 9}); len(got) != 1 || got[0] != 9 {
|
||||||
|
t.Errorf("got %v, want [9]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderIsPreservedSoTheReportMatchesTheScreen(t *testing.T) {
|
||||||
|
got := dedupeIDs([]int{3, 1, 2})
|
||||||
|
if got[0] != 3 || got[1] != 1 || got[2] != 2 {
|
||||||
|
t.Errorf("got %v, want the order sent", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThePartialOutcomeIsStatedNotHidden(t *testing.T) {
|
||||||
|
if msg := stockBatchMessage(8, 2, "approved"); msg != "8 approved, 2 could not be" {
|
||||||
|
t.Errorf("got %q", msg)
|
||||||
|
}
|
||||||
|
if msg := stockBatchMessage(5, 0, "approved"); msg != "5 approved" {
|
||||||
|
t.Errorf("got %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,22 +18,59 @@ func NewStockRequestController(stockRequestService services.StockRequestService)
|
|||||||
return &StockRequestController{stockRequestService: stockRequestService}
|
return &StockRequestController{stockRequestService: stockRequestService}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateStockRequest accepts one request or a list of them.
|
||||||
|
//
|
||||||
|
// A shop restocking after a delivery is asking for twenty things at once, and
|
||||||
|
// sending twenty HTTP requests to say so is slow, gives no single answer, and
|
||||||
|
// leaves a half-sent batch behind when the connection drops. The single-object
|
||||||
|
// form is unchanged, so every existing caller keeps working.
|
||||||
|
//
|
||||||
|
// Each row is reported individually rather than the batch failing whole: a
|
||||||
|
// request for a product that no longer exists should not discard the other
|
||||||
|
// nineteen, and the requester needs to know WHICH one it was.
|
||||||
func (ctl *StockRequestController) CreateStockRequest(c *fiber.Ctx) error {
|
func (ctl *StockRequestController) CreateStockRequest(c *fiber.Ctx) error {
|
||||||
var input models.StockRequest
|
inputs, err := parseStockRequests(c)
|
||||||
if err := c.BodyParser(&input); err != nil {
|
if err != nil {
|
||||||
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
||||||
}
|
}
|
||||||
|
if len(inputs) == 0 {
|
||||||
if input.Status == "" {
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "send at least one request", "status": false})
|
||||||
input.Status = "Pending"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := ctl.stockRequestService.CreateStockRequest(&input)
|
created := make([]models.StockRequest, 0, len(inputs))
|
||||||
if err != nil {
|
failed := make([]fiber.Map, 0)
|
||||||
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
|
||||||
|
for i := range inputs {
|
||||||
|
if inputs[i].Status == "" {
|
||||||
|
inputs[i].Status = "Pending"
|
||||||
|
}
|
||||||
|
if err := ctl.stockRequestService.CreateStockRequest(&inputs[i]); err != nil {
|
||||||
|
failed = append(failed, fiber.Map{
|
||||||
|
"productid": inputs[i].Productid,
|
||||||
|
"reason": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created = append(created, inputs[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"code": 200, "message": "Stock request created", "status": true, "details": input})
|
// A single-object caller gets the object back, exactly as before.
|
||||||
|
if len(inputs) == 1 && len(failed) == 0 {
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "message": "Stock request created", "status": true, "details": created[0]})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(created) == 0 {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusInternalServerError, "status": false,
|
||||||
|
"message": "no requests could be created", "details": fiber.Map{"failed": failed},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 200, "status": true,
|
||||||
|
"message": stockBatchMessage(len(created), len(failed), "created"),
|
||||||
|
"details": fiber.Map{"created": created, "failed": failed},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctl *StockRequestController) GetStockRequests(c *fiber.Ctx) error {
|
func (ctl *StockRequestController) GetStockRequests(c *fiber.Ctx) error {
|
||||||
@@ -52,19 +89,70 @@ func (ctl *StockRequestController) GetStockRequests(c *fiber.Ctx) error {
|
|||||||
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data})
|
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateStockRequest decides one request or a batch of them.
|
||||||
|
//
|
||||||
|
// `requestid` for one, `requestids` for many, one status for the batch. That
|
||||||
|
// shape rather than a list of {id,status} pairs because the action a merchant
|
||||||
|
// takes is "approve these" or "reject these" — a mixed batch is two actions,
|
||||||
|
// and letting one call do both makes an accidental mass-approve possible.
|
||||||
|
//
|
||||||
|
// Approving is not a status write: the service adds the requested quantity to
|
||||||
|
// the branch’s stock. So each id is applied on its own and reported on its own.
|
||||||
|
// If the fourth of ten fails, the first three have really been received and the
|
||||||
|
// merchant has to know that, rather than being told the batch failed and
|
||||||
|
// approving it a second time.
|
||||||
func (ctl *StockRequestController) UpdateStockRequest(c *fiber.Ctx) error {
|
func (ctl *StockRequestController) UpdateStockRequest(c *fiber.Ctx) error {
|
||||||
var input struct {
|
var input struct {
|
||||||
RequestID int `json:"requestid"`
|
RequestID int `json:"requestid"`
|
||||||
|
RequestIDs []int `json:"requestids"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&input); err != nil {
|
if err := c.BodyParser(&input); err != nil {
|
||||||
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
|
||||||
}
|
}
|
||||||
|
|
||||||
err := ctl.stockRequestService.UpdateStockRequest(input.RequestID, input.Status)
|
if input.Status == "" {
|
||||||
if err != nil {
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "status": false,
|
||||||
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
|
"message": "status is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ids := input.RequestIDs
|
||||||
|
if input.RequestID != 0 {
|
||||||
|
ids = append([]int{input.RequestID}, ids...)
|
||||||
|
}
|
||||||
|
ids = dedupeIDs(ids)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "send requestid, or requestids for several"})
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := make([]int, 0, len(ids))
|
||||||
|
failed := make([]fiber.Map, 0)
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := ctl.stockRequestService.UpdateStockRequest(id, input.Status); err != nil {
|
||||||
|
failed = append(failed, fiber.Map{"requestid": id, "reason": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated = append(updated, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single-id caller keeps the answer it has always had.
|
||||||
|
if len(ids) == 1 {
|
||||||
|
if len(failed) > 0 {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "status": false,
|
||||||
|
"message": failed[0]["reason"]})
|
||||||
|
}
|
||||||
return c.JSON(fiber.Map{"code": 200, "message": "Stock request updated", "status": true})
|
return c.JSON(fiber.Map{"code": 200, "message": "Stock request updated", "status": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(updated) == 0 {
|
||||||
|
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "status": false,
|
||||||
|
"message": "no requests could be updated", "details": fiber.Map{"failed": failed}})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": 200, "status": true,
|
||||||
|
"message": stockBatchMessage(len(updated), len(failed), "updated"),
|
||||||
|
"details": fiber.Map{"updated": updated, "failed": failed},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"nearle/middleware"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -44,6 +45,25 @@ func (ctl *TenantController) SearchTenant(c *fiber.Ctx) error {
|
|||||||
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
|
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
|
||||||
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||||
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||||
|
|
||||||
|
// Paging is defaulted, not required.
|
||||||
|
//
|
||||||
|
// The repository builds LIMIT/OFFSET from these directly, so a caller that
|
||||||
|
// omitted either — or sent pageno=0 — got an empty result reported as
|
||||||
|
// `code 200, status true, message "Success"`. "There are no tenants on the
|
||||||
|
// platform" and "you forgot a query parameter" are very different answers
|
||||||
|
// and this endpoint gave the first for the second.
|
||||||
|
//
|
||||||
|
// Defaulted rather than rejected with a 400: every existing caller that
|
||||||
|
// works today keeps working, and a platform list with no paging asked for
|
||||||
|
// has an obvious right answer — the first page.
|
||||||
|
if pageno < 1 {
|
||||||
|
pageno = 1
|
||||||
|
}
|
||||||
|
if pagesize < 1 {
|
||||||
|
pagesize = 50
|
||||||
|
}
|
||||||
|
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||||
tenanttype := c.Query("tenanttype")
|
tenanttype := c.Query("tenanttype")
|
||||||
@@ -327,7 +347,8 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctl.tenantService.CreateStaff(data); err != nil {
|
invite, err := ctl.tenantService.CreateStaff(data)
|
||||||
|
if err != nil {
|
||||||
// A rejected PIN, a missing name, a role nobody set — these are things
|
// A rejected PIN, a missing name, a role nobody set — these are things
|
||||||
// the person filling in the form can fix, so they come back as 400 with
|
// the person filling in the form can fix, so they come back as 400 with
|
||||||
// the reason. This answered 500 with a body claiming 409, which told a
|
// the reason. This answered 500 with a body claiming 409, which told a
|
||||||
@@ -339,10 +360,17 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The person was hired either way. Whether they were emailed their
|
||||||
|
// first-password link is reported beside that rather than folded into
|
||||||
|
// `status`: this account is created with no password and the link is the only
|
||||||
|
// way in, so an operator who is not told cannot know they have added somebody
|
||||||
|
// who cannot sign in.
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"code": http.StatusCreated,
|
"code": http.StatusCreated,
|
||||||
"message": "Staff created successfully",
|
"message": "Staff created successfully",
|
||||||
"status": true,
|
"status": true,
|
||||||
|
"invited": invite.Sent,
|
||||||
|
"invitereason": invite.Reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +445,7 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := ctl.tenantService.CreateTenantUser(data)
|
result, invite, err := ctl.tenantService.CreateTenantUser(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "Tenant Already Exists" {
|
if err.Error() == "Tenant Already Exists" {
|
||||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
@@ -434,11 +462,21 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tenant was created either way. The invitation is reported beside it
|
||||||
|
// rather than folded into `status`, because a merchant who exists and has
|
||||||
|
// not been emailed is a task for the operator — resend, or correct the
|
||||||
|
// address — and not a failed onboarding to be retried.
|
||||||
|
//
|
||||||
|
// `invited: false` with a reason is the state the platform console shows on
|
||||||
|
// the tenant, so it never has to guess whether the email went.
|
||||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
"code": 201,
|
"code": 201,
|
||||||
"status": true,
|
"status": true,
|
||||||
"message": "Successfully Created",
|
"message": "Successfully Created",
|
||||||
"details": result,
|
"details": result,
|
||||||
|
"invited": invite.Sent,
|
||||||
|
// Omitted when it sent, so a successful onboarding carries no apology.
|
||||||
|
"invitereason": invite.Reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,3 +618,306 @@ func (ctl *TenantController) GetTenantByKeyword(c *fiber.Ctx) error {
|
|||||||
"details": data,
|
"details": data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssignStaff moves one of a merchant's people to a branch, or takes them off.
|
||||||
|
//
|
||||||
|
// `locationid` 0 unassigns, and is a real instruction rather than a missing
|
||||||
|
// value — somebody can leave a shop before the next one opens, and the console
|
||||||
|
// needs a way to say that which is not "delete the account".
|
||||||
|
//
|
||||||
|
// The tenant comes from the request and every check is scoped to it in the
|
||||||
|
// query, so a userid belonging to another business matches nothing and the call
|
||||||
|
// fails rather than moving a stranger's staff.
|
||||||
|
func (ctl *TenantController) AssignStaff(c *fiber.Ctx) error {
|
||||||
|
var input struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Unassign bool `json:"unassign"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid input",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if input.Tenantid <= 0 || input.Userid <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "tenantid and userid are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule lives in services.ResolveAssignment so it can be tested without
|
||||||
|
// a request: a zero locationid must never be read as "unassign", because a
|
||||||
|
// dropped field looks exactly like one.
|
||||||
|
location, err := services.ResolveAssignment(input.Locationid, input.Unassign)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.tenantService.AssignStaffToBranch(input.Tenantid, input.Userid, location); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTenantProfile lets a merchant change their own business record.
|
||||||
|
//
|
||||||
|
// The body is read as a free-form map rather than into `models.Tenants`,
|
||||||
|
// deliberately. Binding to the struct would make every column on the table a
|
||||||
|
// candidate for writing and leave "which of these may a merchant set?" answered
|
||||||
|
// by whichever fields a form happened to send. The allowlist in
|
||||||
|
// services.TenantProfileUpdate answers it in one place instead, and everything
|
||||||
|
// absent from a request is left alone rather than blanked.
|
||||||
|
func (ctl *TenantController) UpdateTenantProfile(c *fiber.Ctx) error {
|
||||||
|
fields := map[string]any{}
|
||||||
|
if err := c.BodyParser(&fields); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid input",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row to write is named by `tenantid`, and it is the one value in the
|
||||||
|
// body that is never a value to write.
|
||||||
|
tenantID := 0
|
||||||
|
switch id := fields["tenantid"].(type) {
|
||||||
|
case float64:
|
||||||
|
tenantID = int(id)
|
||||||
|
case int:
|
||||||
|
tenantID = id
|
||||||
|
}
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "tenantid is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.tenantService.UpdateTenantProfile(tenantID, fields); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOwnProfile lets somebody change their own name, mobile or email.
|
||||||
|
//
|
||||||
|
// Read as a map rather than into `models.User` for the same reason as the shop
|
||||||
|
// profile: `app_users` keeps identity next to authorisation, so binding to the
|
||||||
|
// struct would make `roleid`, `locationid`, `status`, `password` and `pin`
|
||||||
|
// candidates for writing. The allowlist in services.OwnProfileUpdate answers
|
||||||
|
// "what may a person change about themselves?" in one place.
|
||||||
|
//
|
||||||
|
// Scoped by userid AND tenantid — the existing `users/update` checks only the
|
||||||
|
// userid, which is why the store user's account page has been read-only rather
|
||||||
|
// than editable.
|
||||||
|
func (ctl *TenantController) UpdateOwnProfile(c *fiber.Ctx) error {
|
||||||
|
fields := map[string]any{}
|
||||||
|
if err := c.BodyParser(&fields); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid input",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
readID := func(key string) int {
|
||||||
|
switch id := fields[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(id)
|
||||||
|
case int:
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
userID, tenantID := readID("userid"), readID("tenantid")
|
||||||
|
if userID <= 0 || tenantID <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "userid and tenantid are both required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.tenantService.UpdateOwnProfile(userID, tenantID, fields); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"code": 200, "status": true, "message": "Success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignPartner puts a merchant under a delivery partner, or takes them out of
|
||||||
|
// one.
|
||||||
|
//
|
||||||
|
// `partnerid` 0 is a real instruction here — it means "this merchant uses their
|
||||||
|
// own riders" — so it is read as sent rather than treated as absent. Everywhere
|
||||||
|
// else in the tenant API a zero means "not supplied"; this is the exception and
|
||||||
|
// it is the reason the route exists separately.
|
||||||
|
func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
|
||||||
|
var body struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Partnerid *int `json:"partnerid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if tid, _ := strconv.Atoi(c.Query("tenantid")); tid != 0 {
|
||||||
|
body.Tenantid = tid
|
||||||
|
}
|
||||||
|
if body.Tenantid <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "tenantid is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// A pointer, so "no partner" and "field omitted" are different requests.
|
||||||
|
// Sent as a plain int, an omitted field would read as 0 and quietly unassign
|
||||||
|
// a merchant's partner.
|
||||||
|
if body.Partnerid == nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "partnerid is required — send 0 to take the partner away",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.tenantService.AssignPartner(body.Tenantid, *body.Partnerid); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendInvite re-issues a merchant's first-password link.
|
||||||
|
//
|
||||||
|
// ── Why this is platform staff only ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It mints a credential. `middleware.WebAuth` already pins a merchant's session
|
||||||
|
// to their own tenant, so a shop could at most re-invite itself — but the
|
||||||
|
// account it would be inviting is the one signing in to ask, which can only
|
||||||
|
// happen if that account already has a password, and the service refuses that
|
||||||
|
// case outright.
|
||||||
|
//
|
||||||
|
// So the only caller this is for is Nearle's own staff, chasing a merchant who
|
||||||
|
// never received the mail. Saying so explicitly is better than relying on two
|
||||||
|
// other checks to make the wrong case impossible.
|
||||||
|
func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
|
||||||
|
claims, ok := middleware.WebClaimsFrom(c)
|
||||||
|
if !ok || !claims.IsPlatformAccount() {
|
||||||
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||||
|
"code": http.StatusForbidden, "status": false,
|
||||||
|
"message": "Only Nearle staff can resend an invitation.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either a tenant — meaning its owner, the one account onboarding created —
|
||||||
|
// or one named person. Staff added later and the login every branch spawns
|
||||||
|
// are created with no password too, and a business has many of them, so
|
||||||
|
// "the tenant's invitation" cannot reach them.
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.Tenantid <= 0 && req.Userid <= 0 {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "Send a tenantid to re-invite the owner, or a userid to re-invite one person.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `userid` wins when both arrive. It is the more specific of the two, and a
|
||||||
|
// caller that sent a person's id meant that person — silently emailing the
|
||||||
|
// owner instead would be the wrong mailbox with no sign anything was off.
|
||||||
|
var (
|
||||||
|
outcome services.InviteOutcome
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if req.Userid > 0 {
|
||||||
|
outcome, err = ctl.tenantService.ResendInviteToUser(req.Userid)
|
||||||
|
} else {
|
||||||
|
outcome, err = ctl.tenantService.ResendInvite(req.Tenantid)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// 409, not 500. Every failure here is a business fact the operator can
|
||||||
|
// act on — no such tenant, an address that matches no login, a merchant
|
||||||
|
// already set up — rather than a fault in the server.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !outcome.Sent {
|
||||||
|
// The tenant is fine and the mail did not go. Reported as a failure
|
||||||
|
// because the operator pressed a button expecting an email to leave,
|
||||||
|
// and the reason names what to fix.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": outcome.Reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
PUT /v1/web/tenants/storeopen
|
||||||
|
|
||||||
|
Open or close a branch for the day.
|
||||||
|
|
||||||
|
── Why this is on the guarded group ────────────────────────────────────────
|
||||||
|
|
||||||
|
Closing a shop stops it taking money. On /v1/mob/* — which carries no session
|
||||||
|
at all — anyone who could guess a locationid could shut any shop on the
|
||||||
|
platform. WebAuth checks the tenant scope, so a business can only reach its
|
||||||
|
own branches.
|
||||||
|
|
||||||
|
A business objection — a date in the past, a branch that is not yours — is 409
|
||||||
|
and not 500: the request was well formed, and the message is written to be
|
||||||
|
shown to the person who typed it.
|
||||||
|
*/
|
||||||
|
func (ctl *TenantController) SetStoreOpen(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
// A POINTER, so "not sent" and "sent as false" stay different answers.
|
||||||
|
// A plain bool would read a missing field as "close this shop".
|
||||||
|
Isopen *bool `json:"isopen"`
|
||||||
|
Closeduntil string `json:"closeduntil"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.Isopen == nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "message": "isopen is required", "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.tenantService.SetStoreOpen(
|
||||||
|
req.Tenantid, req.Locationid, *req.Isopen, req.Closeduntil,
|
||||||
|
); err != nil {
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "message": err.Error(), "status": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "message": "Successfully Updated", "status": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
114
docker-compose.local.yml
Normal file
114
docker-compose.local.yml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# A local stack to develop Fiesta against, so a change can be tried before it is
|
||||||
|
# deployed.
|
||||||
|
#
|
||||||
|
# Named `.local` because it is NOT the deployment compose. Nothing here should
|
||||||
|
# ever run on a server: the passwords are literals, the ports are published to
|
||||||
|
# the host, and the whole point is that the data is disposable.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.local.yml up -d
|
||||||
|
#
|
||||||
|
# ── READ THIS FIRST ─────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# An EMPTY database is not enough to boot Fiesta. `main.go` runs migrations on
|
||||||
|
# startup, and most of them assume tables that nothing in this repository
|
||||||
|
# creates — `ALTER TABLE products`, `ALTER TABLE productlocations`. AutoMigrate
|
||||||
|
# covers only stockrequests, the POS order tables and staffshifts. Against a
|
||||||
|
# blank database the first ALTER fails and `log.Fatal` stops the process.
|
||||||
|
#
|
||||||
|
# So load the schema before the first run:
|
||||||
|
#
|
||||||
|
# pg_dump --schema-only --no-owner --no-privileges \
|
||||||
|
# -h <live-host> -p 5433 -U <user> -d nearledb \
|
||||||
|
# > init/nearledb/01-schema.sql
|
||||||
|
#
|
||||||
|
# Anything in ./init/nearledb is applied, in filename order, the first time the
|
||||||
|
# volume is created. To reload after changing it, drop the volume:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.local.yml down -v
|
||||||
|
#
|
||||||
|
# ── Why bother ──────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Because pointing `DB_HOST` at production is not local testing — it is
|
||||||
|
# production with a local UI. `go run .` there creates real tenants and real
|
||||||
|
# logins, and runs those same schema migrations against live data.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# The main database. Port 5433 on the host, matching production's DB_PORT, so
|
||||||
|
# the only line that changes between the two is DB_HOST.
|
||||||
|
nearledb:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: nearle-db-local
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: nearledb
|
||||||
|
POSTGRES_USER: nearle
|
||||||
|
POSTGRES_PASSWORD: localdev
|
||||||
|
# Asia/Kolkata to match the DSN Fiesta builds. Timestamps written here
|
||||||
|
# otherwise differ from production by five and a half hours, which is the
|
||||||
|
# sort of thing that looks like a bug in the code being tested.
|
||||||
|
TZ: Asia/Kolkata
|
||||||
|
PGTZ: Asia/Kolkata
|
||||||
|
ports:
|
||||||
|
- '5433:5432'
|
||||||
|
volumes:
|
||||||
|
- nearledb-data:/var/lib/postgresql/data
|
||||||
|
- ./init/nearledb:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
# `go run .` fails hard if the database is not up yet, so the compose
|
||||||
|
# waits for a real connection rather than for the container to exist.
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U nearle -d nearledb']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
# The catalogue database, kept separate exactly as it is in production — the
|
||||||
|
# comment on `db.CatalogueDB` is explicit that catalogue work must never touch
|
||||||
|
# nearledb, and one container per database is the cheapest way to keep that
|
||||||
|
# true locally too.
|
||||||
|
#
|
||||||
|
# The pgvector image rather than plain postgres: the live catalogue is a
|
||||||
|
# pgvector database. Nothing in Fiesta's Go code uses a vector column today —
|
||||||
|
# it reads the per-brand tables — but a schema dump from the real one will
|
||||||
|
# carry `CREATE EXTENSION vector`, and that fails on a stock image.
|
||||||
|
#
|
||||||
|
# 5434 on the host, not 5432: a developer machine usually already has
|
||||||
|
# something on 5432, and a silent connection to the wrong database is worse
|
||||||
|
# than a refused one.
|
||||||
|
cataloguedb:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
container_name: nearle-catalogue-local
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: cataloguedb
|
||||||
|
POSTGRES_USER: nearle
|
||||||
|
POSTGRES_PASSWORD: localdev
|
||||||
|
TZ: Asia/Kolkata
|
||||||
|
PGTZ: Asia/Kolkata
|
||||||
|
ports:
|
||||||
|
- '5434:5432'
|
||||||
|
volumes:
|
||||||
|
- cataloguedb-data:/var/lib/postgresql/data
|
||||||
|
- ./init/cataloguedb:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U nearle -d cataloguedb']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
# POS terminal presence, under a TTL.
|
||||||
|
#
|
||||||
|
# Optional in the same way it is optional in production: `db.InitRedis()`
|
||||||
|
# failing costs the till health board and nothing else, because losing a sale
|
||||||
|
# matters and losing a dashboard does not. Included because it is one line.
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: nearle-redis-local
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nearledb-data:
|
||||||
|
cataloguedb-data:
|
||||||
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).
|
||||||
@@ -29,10 +29,10 @@ told.**
|
|||||||
```bash
|
```bash
|
||||||
BASE=https://fiesta.nearle.app/live/api/v1/pos
|
BASE=https://fiesta.nearle.app/live/api/v1/pos
|
||||||
|
|
||||||
# 1. Sign in
|
# 1. Sign in — a mobile number and a 4-digit PIN
|
||||||
curl -s -X POST $BASE/login \
|
curl -s -X POST $BASE/login \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"authname":"rsselvapuram@gmail.com","password":"…","terminal_id":"T5EDD"}'
|
-d '{"contactno":"9876543210","pin":"4821","terminal_id":"T5EDD"}'
|
||||||
|
|
||||||
# 2. Use the token on everything else
|
# 2. Use the token on everything else
|
||||||
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
|
curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
|
||||||
@@ -44,9 +44,9 @@ curl -s $BASE/session -H "Authorization: Bearer $TOKEN"
|
|||||||
|
|
||||||
These steps are in order, and the order matters.
|
These steps are in order, and the order matters.
|
||||||
|
|
||||||
**1. Sign in.** `POST /login` with the operator's own credentials — the same
|
**1. Sign in.** `POST /login` with the operator's **mobile number and 4-digit
|
||||||
`app_users` account they use for the web console. There is no separate POS
|
PIN** — the pair the back office issued them. Both are held on their own
|
||||||
password.
|
`app_users` row; there is no separate POS credential store.
|
||||||
|
|
||||||
**2. Read `store_id` out of the response.** Do not ask anyone to type it. It is
|
**2. Read `store_id` out of the response.** Do not ask anyone to type it. It is
|
||||||
whatever the back office says that account's outlet is.
|
whatever the back office says that account's outlet is.
|
||||||
@@ -74,8 +74,8 @@ The only unauthenticated route. It is where a token comes from.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"authname": "rsselvapuram@gmail.com",
|
"contactno": "9876543210",
|
||||||
"password": "…",
|
"pin": "4821",
|
||||||
"terminal_id": "T5EDD",
|
"terminal_id": "T5EDD",
|
||||||
"device_id": "a5f3…",
|
"device_id": "a5f3…",
|
||||||
"location_id": 1135,
|
"location_id": 1135,
|
||||||
@@ -85,15 +85,15 @@ The only unauthenticated route. It is where a token comes from.
|
|||||||
|
|
||||||
| Field | Required | Notes |
|
| Field | Required | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `authname` | yes* | Email. **Or** send `contactno` instead. |
|
| `contactno` | yes | Mobile number. Send it as typed — `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account. |
|
||||||
| `contactno` | yes* | Mobile number, as an alternative to `authname`. |
|
| `pin` | yes | Exactly 4 digits, never starting with `0`. |
|
||||||
| `password` | yes | |
|
|
||||||
| `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. |
|
| `terminal_id` | no | This till's short code, e.g. `T5EDD`. Recorded on the session. |
|
||||||
| `device_id` | no | The device's stable UUID. |
|
| `device_id` | no | The device's stable UUID. |
|
||||||
| `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. |
|
| `location_id` | no | **Only** meaningful for a multi-outlet account. A request, not an assertion — it is checked against what the account may reach. |
|
||||||
| `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. |
|
| `configid` | no | Inferred when absent. Send it only if you get the ambiguity error below. |
|
||||||
|
| `authname` + `password` | no | The previous way in. Still accepted, so a shop whose numbers have not been backfilled is not stranded — see [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md). |
|
||||||
|
|
||||||
\* one of `authname` or `contactno`.
|
`pin` wins if a password is sent as well; `authname` wins over `contactno`.
|
||||||
|
|
||||||
### Response — `200`
|
### Response — `200`
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ The only unauthenticated route. It is where a token comes from.
|
|||||||
|
|
||||||
"staff": [
|
"staff": [
|
||||||
{ "user_id": 1148, "full_name": "Ragul Kannan",
|
{ "user_id": 1148, "full_name": "Ragul Kannan",
|
||||||
"role": "Super admin", "pin": "1111", "status": "Active" }
|
"role": "Super admin", "status": "Active" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,10 @@ sign-in.
|
|||||||
**`locations`** — every outlet this account may open a till at. Length 1 is the
|
**`locations`** — every outlet this account may open a till at. Length 1 is the
|
||||||
normal case.
|
normal case.
|
||||||
|
|
||||||
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty.**
|
**`staff`** — see [Staff and PINs](#staff-and-pins). **Often empty**, and it
|
||||||
|
**no longer carries `pin`**: a PIN is now half of the sign-in, so a list of them
|
||||||
|
is a list of working credentials for the outlet. Switch operator through
|
||||||
|
`POST /login/pin` instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,9 +194,13 @@ Requires the token. Returns `401` when there isn't one.
|
|||||||
Who may ring a bill at this terminal's outlet. For pulling down somebody hired
|
Who may ring a bill at this terminal's outlet. For pulling down somebody hired
|
||||||
mid-shift without signing the terminal out.
|
mid-shift without signing the terminal out.
|
||||||
|
|
||||||
**Takes no parameters.** The answer carries PINs, so the outlet comes from the
|
**Takes no parameters.** The outlet comes from the caller's own token — a till
|
||||||
caller's own token — a till must not be able to ask who works at the shop next
|
must not be able to ask who works at the shop next door. A request without a
|
||||||
door. A request without a token is refused whatever the enforcement setting is.
|
token is refused whatever the enforcement setting is.
|
||||||
|
|
||||||
|
Names and roles only; **`pin` is not returned here either**, for the same reason
|
||||||
|
it left the login session. A supervisor who needs to see or change one uses
|
||||||
|
`GET /pos/users`, which is role-gated.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -228,10 +235,84 @@ four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most
|
|||||||
accounts carry an id that is not in the table at all — any mapping written on
|
accounts carry an id that is not in the table at all — any mapping written on
|
||||||
the terminal would be wrong.
|
the terminal would be wrong.
|
||||||
|
|
||||||
Back-office roles 1–6 also count as supervisors: somebody who already
|
### The till and Nearle Daily do not share accounts
|
||||||
administers the shop from a browser is not made less privileged by standing at
|
|
||||||
the counter. **`role_id` 0 is not a role** — it is what an account carries when
|
`app_users` is the only thing the two products have in common. An account
|
||||||
nobody set one, and it grants nothing.
|
belongs to one or the other, never to both:
|
||||||
|
|
||||||
|
| | Nearle Daily app + console | POS terminal |
|
||||||
|
|---|---|---|
|
||||||
|
| roles | `1`–`6` — Super admin, Operations, Admin, Manager | `7` Supervisor, `8` Cashier |
|
||||||
|
| `/applogin`, `/tenant/weblogin`, `/tenant/login` | yes | **not found** |
|
||||||
|
| `POST /v1/pos/login` | **403** | yes |
|
||||||
|
| listed by `/getallusers`, `/getstaffs` | yes | **hidden** |
|
||||||
|
|
||||||
|
A Nearle Daily **Super admin is not the administrator of anybody's POS.** The
|
||||||
|
back office reaches a till by *provisioning* a Supervisor from the console; it
|
||||||
|
never becomes one by signing in.
|
||||||
|
|
||||||
|
This was the other way round until it was measured. Roles 1–6 counted as
|
||||||
|
supervisors, on the reasoning that somebody who already administers a shop from
|
||||||
|
a browser is not made less privileged by standing at the counter. That handed
|
||||||
|
till-supervisor powers to **68 live accounts, 59 of them platform Super
|
||||||
|
admins**, while the actual shop accounts carry `roleid 0` and were refused.
|
||||||
|
|
||||||
|
Both directions are now closed in the queries themselves rather than in a check
|
||||||
|
each call site has to remember — a till account is not *rejected* by the app
|
||||||
|
login, it is simply not found.
|
||||||
|
|
||||||
|
**`role_id` 0 is not a role.** It is what an account carries when nobody set
|
||||||
|
one, 22 live accounts have it including a delivery rider, and it grants nothing
|
||||||
|
on either side.
|
||||||
|
|
||||||
|
### Every till account gets its own number and PIN
|
||||||
|
|
||||||
|
Both roles. A PIN now opens a *closed* terminal too, paired with the account's
|
||||||
|
mobile number — which is what removed the old deadlock: `/pos/login/pin` needs a
|
||||||
|
session that already exists, so before this a PIN-only account could not unlock
|
||||||
|
a till at all. For a Supervisor that was an outright deadlock; for a Cashier it
|
||||||
|
meant a shop that could not open until two people had arrived, and whoever gets
|
||||||
|
in at seven is as often the cashier as the supervisor.
|
||||||
|
|
||||||
|
**So a till account needs both `contactno` and `pin` set.** One without the
|
||||||
|
other cannot sign in. A username and password still work, and every account
|
||||||
|
created before this still has them.
|
||||||
|
|
||||||
|
Creation enforces half of that: `POST /pos/users` and `createposuser` refuse a
|
||||||
|
request with no mobile number, or one that holds no ten-digit number. The PIN
|
||||||
|
stays optional at creation — an account can be provisioned before somebody has
|
||||||
|
chosen one — so it is the half still worth checking before a shop goes live.
|
||||||
|
|
||||||
|
So a Cashier signs in exactly like a Supervisor does, and the *role* decides
|
||||||
|
what they get — not which credential they used:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/pos/login supervisor.1185@pos.nearle.in -> full shell
|
||||||
|
POST /v1/pos/login cashier.1185@pos.nearle.in -> billing only
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /pos/users` generates both when the request omits them, and returns the
|
||||||
|
password **once**, in the creation response only:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "user_id": 1452, "role": "Cashier",
|
||||||
|
"authname": "cashier.1185@pos.nearle.in",
|
||||||
|
"password": "<14 generated characters>", "pin": "4513", "has_password": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /pos/users` never returns a password, only `has_password`. An admin who
|
||||||
|
loses it reissues rather than looks it up.
|
||||||
|
|
||||||
|
Send `authname` and `password` explicitly if the shop wants its people signing
|
||||||
|
in as themselves. A generated name that collides — a second cashier at one
|
||||||
|
outlet — becomes `cashier2.1185@pos.nearle.in`; a name **you** supplied is never
|
||||||
|
adjusted, it is refused, because silently signing somebody in as another
|
||||||
|
person's address is worse than an error.
|
||||||
|
|
||||||
|
**The PIN is no longer optional in practice.** The field still is — creation
|
||||||
|
accepts an account without one — but an account with no PIN cannot sign a
|
||||||
|
terminal in, and is told so by name: *"this account has no PIN set; ask your
|
||||||
|
supervisor to set one in the web console first."*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -239,11 +320,12 @@ nobody set one, and it grants nothing.
|
|||||||
|
|
||||||
For a cashier taking over a counter a supervisor has already opened.
|
For a cashier taking over a counter a supervisor has already opened.
|
||||||
|
|
||||||
**Requires an existing valid token.** That is the security model, not an
|
**Requires an existing valid token.** A PIN alone is four digits — ten thousand
|
||||||
oversight: four digits is ten thousand guesses, which is no barrier at all to an
|
guesses, and no barrier to an anonymous caller. Tying it to a session confines
|
||||||
anonymous caller. Tying it to a session means a supervisor has opened the
|
the guesses to one outlet's staff, at a terminal somebody has already opened.
|
||||||
terminal with a real password first, and the guesses are confined to that one
|
That is why this route takes a bare PIN and `/login` does not: there, the PIN is
|
||||||
outlet's staff.
|
checked against one mobile number, and the number is what makes the pair worth
|
||||||
|
anything.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST $BASE/login/pin \
|
curl -s -X POST $BASE/login/pin \
|
||||||
@@ -286,11 +368,16 @@ own store id.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `full_name` | required; split across `firstname`/`lastname` |
|
| `full_name` | required; split across `firstname`/`lastname` |
|
||||||
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
|
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
|
||||||
| `pin` | 4 digits. See the rules below |
|
| `pin` | optional, 4 digits. See the rules below |
|
||||||
| `password` + `authname` | optional; for someone who also signs the terminal in |
|
| `authname` | optional. **Generated if omitted** — `cashier.1185@pos.nearle.in`, or `cashier2.…` if that is taken |
|
||||||
|
| `password` | optional. **Generated if omitted**, and returned once in this response |
|
||||||
|
|
||||||
**At least one of `pin` or `password` is required.** Creating a person who can
|
**Everyone gets a username and a password, cashiers included**, because a PIN
|
||||||
sign in by neither would look like it worked right up until somebody tried.
|
cannot open a closed terminal. Omit both fields and they are generated for you,
|
||||||
|
so provisioning a shop is one call per person.
|
||||||
|
|
||||||
|
The response is the only time the password is returned; `GET /pos/users` reports
|
||||||
|
`has_password` and nothing more.
|
||||||
|
|
||||||
:warning: **PIN rules, and why**
|
:warning: **PIN rules, and why**
|
||||||
|
|
||||||
@@ -417,22 +504,35 @@ GET /catalogue?store_id=1185 → 403 {"message":"this session cannot reach o
|
|||||||
|
|
||||||
| Code | Meaning | What the till should do |
|
| Code | Meaning | What the till should do |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `400` | Body unreadable, or neither `authname` nor `contactno` sent | Fix the request |
|
| `400` | Body unreadable, or `a mobile number is required` / `a PIN is required` — one of the two fields is empty | Fix the request; do not send it again unchanged |
|
||||||
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **Wrong email and wrong password give the same message** — deliberately, so the endpoint isn't a directory of who banks here |
|
| `401` | `those sign-in details were not recognised` | Ask them to re-type. **A wrong number and a wrong PIN give the same message** — deliberately, so the endpoint isn't a directory of who banks here. A number that cannot be ten digits, and a PIN that is not four, answer the same way |
|
||||||
| `403` | Real account, but it can't open this till | Show the message; re-typing won't help |
|
| `403` | Real account, but it can't open this till | Show the message; re-typing won't help |
|
||||||
|
|
||||||
The `403` messages, verbatim:
|
The `403` messages, verbatim:
|
||||||
|
|
||||||
|
- `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console`
|
||||||
- `this account is inactive; contact your administrator`
|
- `this account is inactive; contact your administrator`
|
||||||
|
- `this account has no PIN set; ask your supervisor to set one in the web console first`
|
||||||
- `this account has no password set; set one in the web console first`
|
- `this account has no password set; set one in the web console first`
|
||||||
- `this account is not attached to a tenant and cannot open a till`
|
- `this account is not attached to a tenant and cannot open a till`
|
||||||
- `no active outlet is registered for this account`
|
- `no active outlet is registered for this account`
|
||||||
- `this account cannot open a till at outlet 1185`
|
- `this account cannot open a till at outlet 1185`
|
||||||
- `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login`
|
- `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login`
|
||||||
|
|
||||||
That last one is real, not theoretical: `authname` is not unique in this schema.
|
That last one is real, not theoretical: neither `authname` nor `contactno` is
|
||||||
Live data has the same address twice. We refuse rather than pick one, because
|
unique in this schema. Live data has the same address twice, and 34 mobile
|
||||||
picking wrong means billing into another tenant's books.
|
numbers shared by 104 accounts. We refuse rather than pick one, because picking
|
||||||
|
wrong means billing into another tenant's books.
|
||||||
|
|
||||||
|
Sign-in only ever looks at **till accounts** (roleid 7 and 8), which is what
|
||||||
|
makes a number workable as a credential: it has to be unique among a tenant's
|
||||||
|
own till staff, not across all 608 users on the platform.
|
||||||
|
|
||||||
|
The **first** one is the common case now, and it is deliberately specific where a
|
||||||
|
bad password is deliberately vague. By the time it fires the caller has already
|
||||||
|
proved the credential, so naming the reason leaks nothing they did not just
|
||||||
|
demonstrate — and the vague answer would send a shop owner hunting for a
|
||||||
|
password that was never wrong.
|
||||||
|
|
||||||
### Authenticated routes
|
### Authenticated routes
|
||||||
|
|
||||||
221
docs/POS_PHONE_LOGIN_HANDOVER.md
Normal file
221
docs/POS_PHONE_LOGIN_HANDOVER.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# POS sign-in by mobile number, and shift assignment — handover to the terminal team
|
||||||
|
|
||||||
|
> ## ⚠️ Superseded by [POS_PHONE_PIN_LOGIN_HANDOVER.md](POS_PHONE_PIN_LOGIN_HANDOVER.md)
|
||||||
|
>
|
||||||
|
> `POST /pos/login` now takes a mobile number and a **PIN**, not a mobile number
|
||||||
|
> and a password, and the session no longer carries `staff[].pin`. Read the new
|
||||||
|
> handover instead — build against this one and the sign-in screen will be
|
||||||
|
> wrong.
|
||||||
|
>
|
||||||
|
> Still accurate here, and not repeated there: the `createposuser` /
|
||||||
|
> `updateposuser` / `getposusers` field changes (§2), the number format rules
|
||||||
|
> (§3), and shifts (§4). **Not** accurate here: §2's claim that `shift_*`
|
||||||
|
> appears on `staff[]` in the `/pos/login` session — it never did.
|
||||||
|
|
||||||
|
Backend is done and builds clean. **Nothing about this breaks the current app** —
|
||||||
|
username sign-in keeps working exactly as it does today. Read §5 before you ship
|
||||||
|
anything, because the switch has one ordering rule that will lock out every
|
||||||
|
cashier if it is done in the wrong order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What changed, in one line
|
||||||
|
|
||||||
|
`POST /pos/login` now accepts a **mobile number** as well as a username, only
|
||||||
|
till accounts are candidates, and a till account can carry a **shift**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Endpoints — what was edited and how
|
||||||
|
|
||||||
|
### `POST /live/api/v1/pos/login` — CHANGED (backwards compatible)
|
||||||
|
|
||||||
|
The request body already had both fields. **Nothing in the contract changed.**
|
||||||
|
What changed is behaviour behind it.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Sign in by mobile — the new way
|
||||||
|
{ "contactno": "9876543210", "password": "…" }
|
||||||
|
|
||||||
|
// Sign in by username — still works, unchanged
|
||||||
|
{ "authname": "cashier.1135@pos.nearle.in", "password": "…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`authname` wins if both are sent. The response is unchanged.
|
||||||
|
|
||||||
|
**Behavioural change:** the account lookup is now restricted to till roles
|
||||||
|
(Supervisor `7`, Cashier `8`).
|
||||||
|
|
||||||
|
*Why it matters to you:* previously a number shared with a back-office account
|
||||||
|
returned two candidates and the login was refused outright. On live data **34
|
||||||
|
numbers are shared by 104 accounts** — one by eleven — so without this,
|
||||||
|
sign-in by phone would simply have failed for a large number of people. Now a
|
||||||
|
number only has to be unique among till accounts.
|
||||||
|
|
||||||
|
A back-office user who types their own password at a till still gets the
|
||||||
|
specific `403 "this account is not set up for the till"` rather than a vague
|
||||||
|
rejection. That did not change.
|
||||||
|
|
||||||
|
### `POST /live/api/v1/web/tenants/createposuser` — CHANGED (two new fields)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"tenantid": 1087,
|
||||||
|
"locationid": 1135,
|
||||||
|
"full_name": "Priya Raman",
|
||||||
|
"role": "cashier",
|
||||||
|
"pin": "4731",
|
||||||
|
"contactno": "9876543210", // NEW — the sign-in number, and required
|
||||||
|
"shift_id": 3 // NEW — optional, 0/omitted = unassigned
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The response gains `shift_id`, and `contactno` now comes back **normalised to
|
||||||
|
ten digits**.
|
||||||
|
|
||||||
|
### `PUT /live/api/v1/web/tenants/updateposuser` — CHANGED (two new fields)
|
||||||
|
|
||||||
|
Accepts `contactno` and `shift_id`. Every field stays optional; only what is
|
||||||
|
sent is written.
|
||||||
|
|
||||||
|
### `GET /live/api/v1/web/tenants/getposusers` — CHANGED (four new fields)
|
||||||
|
|
||||||
|
Each user in `details.users[]` now also carries:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"contactno": "9876543210",
|
||||||
|
"shift_id": 3,
|
||||||
|
"shift_name": "Morning",
|
||||||
|
"shift_start": "07:00",
|
||||||
|
"shift_end": "15:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Blank on accounts created before shifts existed. **`shift_*` also appears on
|
||||||
|
`staff[]` in the `/pos/login` session**, so the terminal gets it for free.
|
||||||
|
|
||||||
|
### `GET/POST/PUT /live/api/v1/web/tenants/{getstaffshifts,createstaffshift,updatestaffshift}` — NEW
|
||||||
|
|
||||||
|
Console-only. The terminal does not need to call these; shifts arrive with the
|
||||||
|
session. Documented for completeness:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// POST createstaffshift
|
||||||
|
{ "tenantid": 1087, "locationid": 1135,
|
||||||
|
"name": "Morning", "start_time": "07:00", "end_time": "15:00",
|
||||||
|
"weekdays": "1111100" } // 7 chars from Monday; empty = every day
|
||||||
|
```
|
||||||
|
|
||||||
|
Also mirrored under `/v1/mob/tenants/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Number format — the one thing to get exactly right
|
||||||
|
|
||||||
|
The server reduces every number to **ten digits** before storing or matching:
|
||||||
|
non-digits are stripped, then a leading `91` or `0` is dropped once. Anything
|
||||||
|
that is not ten digits afterwards is **rejected**, not stored.
|
||||||
|
|
||||||
|
So all of these are the same account:
|
||||||
|
|
||||||
|
```
|
||||||
|
"+91 98765 43210" → 9876543210
|
||||||
|
"098765-43210" → 9876543210
|
||||||
|
"9876543210" → 9876543210
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you should send:** the ten digits, or anything in the list above — the
|
||||||
|
server normalises either way. **Do not** send a country code the user did not
|
||||||
|
type, and do not reject `+91` locally; let it through and let the server reduce
|
||||||
|
it. What matters is that you never send something that normalises to a
|
||||||
|
*different* number than what the console stored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Shift is informational
|
||||||
|
|
||||||
|
`shift_id` / `shift_name` / `shift_start` / `shift_end` are for **display**.
|
||||||
|
Nothing on the server refuses a bill rung outside a shift window, and you should
|
||||||
|
not add that check on the device either. A cashier locked out mid-queue by a
|
||||||
|
clock is a worse failure than a bill filed against the wrong window. Show whose
|
||||||
|
shift it is; do not gate on it.
|
||||||
|
|
||||||
|
Times are 24-hour `HH:MM`. A shift may legitimately wrap midnight (`22:00`
|
||||||
|
→ `06:00`) — do not assume `end > start`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 🔴 Sequencing — read this before shipping
|
||||||
|
|
||||||
|
**Every existing POS account has no mobile number.** All 12 live accounts have
|
||||||
|
`contactno = ""`:
|
||||||
|
|
||||||
|
```
|
||||||
|
supervisor.1135@pos.nearle.in phone=""
|
||||||
|
cashier.1135@pos.nearle.in phone=""
|
||||||
|
… 12 of 12
|
||||||
|
```
|
||||||
|
|
||||||
|
If the app ships sign-in-by-phone **only**, every cashier in every shop is
|
||||||
|
locked out on the next app update.
|
||||||
|
|
||||||
|
**Required order:**
|
||||||
|
|
||||||
|
1. Backend deploys. *(Nothing changes for the app — username login is untouched.)*
|
||||||
|
2. Back office adds a mobile number to every existing till account through the
|
||||||
|
console. New accounts already require one — `createposuser` refuses a
|
||||||
|
request without it.
|
||||||
|
3. **Only then** the app makes mobile the primary sign-in field.
|
||||||
|
|
||||||
|
**Recommendation for the app:** keep both. One field labelled *"Mobile number or
|
||||||
|
username"* — if the value is all digits send it as `contactno`, otherwise as
|
||||||
|
`authname`. That is a handful of lines, works before and after the backfill, and
|
||||||
|
means a shop with one un-backfilled account is not stranded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Errors you should handle
|
||||||
|
|
||||||
|
| Message | Meaning | What the app should do |
|
||||||
|
|---|---|---|
|
||||||
|
| generic 401 rejection | wrong number/username or wrong password | "Check your details" — do **not** say which was wrong |
|
||||||
|
| `this account is not set up for the till…` | a back-office login was used | Show it verbatim; it names the fix |
|
||||||
|
| `more than one account uses these sign-in details…` | ambiguous match | Show verbatim; it needs the back office |
|
||||||
|
| `this account has no password set…` | provisioned without a password | Show verbatim |
|
||||||
|
| `another till account in this business already signs in with 9876543210` | console-side only | Not seen by the app |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. What did **not** change
|
||||||
|
|
||||||
|
- The response shape of `/pos/login`, including `token`, `expires_at`,
|
||||||
|
`can_manage_staff`, `locations[]` and `staff[]`
|
||||||
|
- `POST /pos/login/pin`
|
||||||
|
- Token format, TTL (30 days) and the `PosAuth` guard
|
||||||
|
- `POST /pos/orders`, `/pos/customers`, `/pos/health`, `GET /pos/catalogue`
|
||||||
|
- `POS_AUTH_REQUIRED` still defaults to off
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verify after deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B=https://fiesta.nearle.app/live/api/v1
|
||||||
|
|
||||||
|
# username sign-in still works (regression check — run this first)
|
||||||
|
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"authname":"supervisor.1135@pos.nearle.in","password":"…"}' \
|
||||||
|
| grep -o '"can_manage_staff":[a-z]*'
|
||||||
|
# expect: "can_manage_staff":true
|
||||||
|
|
||||||
|
# after a number is set on that account, the same account by phone
|
||||||
|
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"contactno":"9876543210","password":"…"}' \
|
||||||
|
| grep -o '"can_manage_staff":[a-z]*'
|
||||||
|
|
||||||
|
# a back-office account is still refused with the specific message
|
||||||
|
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"authname":"<a back-office account>","password":"…"}'
|
||||||
|
# expect: 403 "this account is not set up for the till"
|
||||||
|
```
|
||||||
321
docs/POS_PHONE_PIN_LOGIN_HANDOVER.md
Normal file
321
docs/POS_PHONE_PIN_LOGIN_HANDOVER.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# POS sign-in by mobile number and PIN — handover to the terminal team
|
||||||
|
|
||||||
|
`POST /pos/login` now takes a **mobile number and a four-digit PIN**. Backend is
|
||||||
|
done, builds clean, and is proved end to end against a real database (§8).
|
||||||
|
|
||||||
|
Two things need reading before you ship: **§5**, because shipping the new screen
|
||||||
|
before the back office has filled in numbers and PINs locks out every cashier in
|
||||||
|
every shop; and **§4**, because the login response no longer carries staff PINs
|
||||||
|
and any terminal switching operator from that array will stop working.
|
||||||
|
|
||||||
|
Supersedes `POS_PHONE_LOGIN_HANDOVER.md`, which described the same endpoint
|
||||||
|
taking a number and a *password*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What changed, in one line
|
||||||
|
|
||||||
|
A till signs in with the two things a person standing at a counter can actually
|
||||||
|
type — their number and their PIN — and the response stops handing out everyone
|
||||||
|
else's PIN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The endpoint
|
||||||
|
|
||||||
|
`POST https://fiesta.nearle.app/live/api/v1/pos/login`
|
||||||
|
|
||||||
|
Unauthenticated. It is where a token comes from. Everything else on the POS
|
||||||
|
group sits behind the session it issues.
|
||||||
|
|
||||||
|
### Payload
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"contactno": "9876543210", // required
|
||||||
|
"pin": "4821", // required — exactly 4 digits, never starts with 0
|
||||||
|
"terminal_id": "T5EDD", // optional, recorded on the session
|
||||||
|
"device_id": "a5f3…", // optional
|
||||||
|
"location_id": 1135, // optional, multi-outlet accounts only
|
||||||
|
"configid": 1 // optional, only if you are told to send it
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `contactno` | **yes** | Send it as typed. `+91 98765 43210`, `098765-43210` and `9876543210` all reach the same account — the server reduces every number to ten digits before matching. |
|
||||||
|
| `pin` | **yes** | Exactly 4 digits. `0451` is not a PIN here and never was — see §3. |
|
||||||
|
| `terminal_id` | no | This till's short code. Recorded on the session so a stolen token can be told apart from the terminal it was issued to. |
|
||||||
|
| `device_id` | no | The device's stable UUID. |
|
||||||
|
| `location_id` | no | Means something **only** for an account entitled to more than one outlet. It is a request, not an assertion: checked against what the account may reach, and refused if it is not one of them. |
|
||||||
|
| `configid` | no | Inferred when absent. Send it only after the ambiguity error in §6. |
|
||||||
|
| `authname` + `password` | no | The previous way in. **Still works** — see §5. |
|
||||||
|
|
||||||
|
If both are sent, `pin` is used over `password` and `authname` over `contactno`.
|
||||||
|
|
||||||
|
### Response — `200`, unchanged in shape
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"status": true,
|
||||||
|
"message": "Login successful",
|
||||||
|
"details": {
|
||||||
|
"token": "eyJ1aWQiOjQwMDEsInRpZCI6MTA4Nywi….PKmoMn92BMs",
|
||||||
|
"expires_at": "2026-09-11T11:31:08Z",
|
||||||
|
|
||||||
|
"user_id": 4001,
|
||||||
|
"full_name": "Meena Sundaram",
|
||||||
|
"email": "meena@example.com",
|
||||||
|
"role_id": 7,
|
||||||
|
"role": "Supervisor",
|
||||||
|
"can_manage_staff": true,
|
||||||
|
|
||||||
|
"tenant_id": 1087,
|
||||||
|
"tenant_name": "R Mart",
|
||||||
|
|
||||||
|
"store_id": "1135",
|
||||||
|
"location_id": 1135,
|
||||||
|
"location_name": "Selvapuram",
|
||||||
|
"gstin": "33AABCU9603R1ZM",
|
||||||
|
"address": "4 Trichy Road",
|
||||||
|
"phone": "04422334455",
|
||||||
|
|
||||||
|
"locations": [
|
||||||
|
{ "location_id": 1135, "location_name": "Selvapuram",
|
||||||
|
"address": "4 Trichy Road", "city": "Coimbatore", "status": "Active" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"staff": [
|
||||||
|
{ "user_id": 4002, "full_name": "Priya Raman",
|
||||||
|
"role": "Cashier", "status": "Active" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The only change to the response is that `staff[].pin` is gone.** Everything
|
||||||
|
else — `token`, `expires_at`, `store_id`, `locations[]`, `can_manage_staff` —
|
||||||
|
is byte-for-byte what it was. §4 is why, and what to do instead.
|
||||||
|
|
||||||
|
Two corrections to what the previous handover promised about this response:
|
||||||
|
`staff[]` has **no** `shift_id` / `shift_name` / `shift_start` / `shift_end`
|
||||||
|
fields, and never did. The shift fields exist on `GET /web/tenants/getposusers`.
|
||||||
|
If the terminal needs the shift on the sign-in screen, say so and it can be
|
||||||
|
added — do not write code against it today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What counts as a PIN, and the trap in it
|
||||||
|
|
||||||
|
Four digits, and **never a leading zero**. `app_users.pin` is a `bigint`, so
|
||||||
|
`0451` is stored as `451` and read back as three digits — somebody would type
|
||||||
|
four and be refused for ever. Creation refuses those, so this only matters for
|
||||||
|
what you let a person type: accept four digits, send them as a string.
|
||||||
|
|
||||||
|
**Do not add a client-side guessable-PIN check.** The console refuses to *issue*
|
||||||
|
`1234`, `1111`, `9999` and a handful more, but live data already holds `1234` on
|
||||||
|
eleven accounts and `1111` on nine — issued before that rule existed. Sign-in
|
||||||
|
deliberately accepts them, because refusing them would lock twenty real people
|
||||||
|
out of terminals this platform signed them up to. A rule about what may be
|
||||||
|
created is not a rule about what may be typed.
|
||||||
|
|
||||||
|
**Number format.** Non-digits are stripped, then a leading `91` or `0` is
|
||||||
|
dropped once. Anything that is not ten digits afterwards is rejected. Do not
|
||||||
|
add a country code the user did not type, and do not reject `+91` locally — let
|
||||||
|
it through and let the server reduce it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🔴 `staff[].pin` is gone — read this if you switch operators offline
|
||||||
|
|
||||||
|
The login session used to carry every colleague's PIN so the till could switch
|
||||||
|
operator without a round trip. That was defensible while a PIN was *shift
|
||||||
|
attribution*: the token decided which books a terminal could reach, and the PIN
|
||||||
|
only decided whose name went on the bill.
|
||||||
|
|
||||||
|
That stopped being true the moment the PIN became half of the sign-in. The array
|
||||||
|
would now be a list of working credentials for the whole outlet — including the
|
||||||
|
supervisor's, which carries `can_manage_staff`. Any cashier could read it and
|
||||||
|
sign back in as their own manager.
|
||||||
|
|
||||||
|
So `pin` no longer appears in `staff[]`, and no longer appears in `GET
|
||||||
|
/pos/staff` either. `staff[]` still carries `user_id`, `full_name`, `role` and
|
||||||
|
`status`, so the operator picker still works.
|
||||||
|
|
||||||
|
**Switch operator through `POST /pos/login/pin`** — an existing session, a bare
|
||||||
|
PIN, and you get a fresh session with the new person's role:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$B/pos/login/pin" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"pin":"7391"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**The cost, stated plainly: this needs the network.** A till that switched
|
||||||
|
operators offline against the cached array can no longer do so. If that matters
|
||||||
|
for your shops, tell us — the options are a short-lived cache of hashed PINs on
|
||||||
|
the device, or a separate endpoint a supervisor calls once to warm one. Neither
|
||||||
|
is built, and neither should be built on a guess about how shops actually work.
|
||||||
|
|
||||||
|
One wart we did **not** change: `staff[]` can contain a back-office account that
|
||||||
|
has a PIN and sits at that location — the query filters on having a PIN, not on
|
||||||
|
role. Tapping them and typing their PIN answers *"this account is not set up for
|
||||||
|
the till"*. Filtering by role would look tidier and would empty the staff list
|
||||||
|
for a great many real shops, whose people carry role ids that are not POS roles
|
||||||
|
at all. Show the message; it names the fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 🔴 Sequencing — read this before shipping
|
||||||
|
|
||||||
|
**Every till account on the platform predates the number it now signs in with.**
|
||||||
|
When the previous handover was written, all 12 live POS accounts had
|
||||||
|
`contactno = ""`. Some also have no PIN. An account needs **both** to sign in.
|
||||||
|
|
||||||
|
If the app ships number-and-PIN **only**, every cashier in every shop is locked
|
||||||
|
out on the next update.
|
||||||
|
|
||||||
|
**Required order:**
|
||||||
|
|
||||||
|
1. **Backend deploys.** Nothing changes for the app — username and password
|
||||||
|
still work, unchanged.
|
||||||
|
2. **Back office fills in a mobile number and a PIN on every existing till
|
||||||
|
account** through the console (`PUT /web/tenants/updateposuser`). New
|
||||||
|
accounts cannot be created without a number — `createposuser` refuses one
|
||||||
|
with `400 "a mobile number is required…"`, so this is a finite backfill of
|
||||||
|
the accounts that predate the rule rather than a gap that keeps reopening.
|
||||||
|
|
||||||
|
**A PIN is still optional at creation**, so an account can be created that
|
||||||
|
cannot yet sign in. It is told so by name at the counter — see the `403` in
|
||||||
|
§6 — but the check in step 2 below is what catches it first.
|
||||||
|
|
||||||
|
Editing is unaffected: an update that does not mention `contactno` leaves the
|
||||||
|
stored number alone rather than clearing it, so a partial edit cannot strand
|
||||||
|
somebody mid-backfill.
|
||||||
|
3. **Only then** does the app make number-and-PIN the primary sign-in.
|
||||||
|
|
||||||
|
**Recommendation for the app:** ship the new screen, and keep a small
|
||||||
|
"sign in with a username instead" link behind it until step 2 is confirmed
|
||||||
|
finished for every shop. One un-backfilled account then means one awkward login,
|
||||||
|
not a shop that cannot open.
|
||||||
|
|
||||||
|
**Verify step 2 is actually done** before you flip anything — a shop is ready
|
||||||
|
only when every one of its till accounts has both fields:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "$B/web/tenants/getposusers?tenantid=1087&locationid=1135" \
|
||||||
|
-H "Authorization: Bearer $CONSOLE_TOKEN" | jq '.details.users[] | {full_name, contactno, pin}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Errors you should handle
|
||||||
|
|
||||||
|
| Code | Message | What it means | What the app should do |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `400` | `a mobile number is required` | The field was empty, or held no digits at all | Fix locally; do not resend unchanged |
|
||||||
|
| `400` | `a PIN is required` | Neither `pin` nor `password` was sent | Fix locally |
|
||||||
|
| `401` | `those sign-in details were not recognised` | Wrong number, wrong PIN, a number that cannot be ten digits, a PIN that is not four, or somebody who has left | *"Check your number and PIN"* — do **not** say which was wrong |
|
||||||
|
| `403` | `this account has no PIN set; ask your supervisor to set one in the web console first` | Provisioned without one | Show verbatim; it names the fix |
|
||||||
|
| `403` | `this account is not set up for the till; ask your store admin to add you as a Supervisor or Cashier in the web console` | A back-office login was used | Show verbatim |
|
||||||
|
| `403` | `more than one account uses these sign-in details; ask your administrator for the configid and send it with the login` | Ambiguous match | Show verbatim; it needs the back office |
|
||||||
|
| `403` | `this account cannot open a till at outlet 1185` | `location_id` named an outlet this account cannot reach | Drop `location_id` and retry, then show the picker from `locations[]` |
|
||||||
|
|
||||||
|
The `401` is deliberately one message for several causes. Distinguishing them
|
||||||
|
turns an unauthenticated endpoint into a directory of who banks here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 🔴 One thing this endpoint cannot fix: rate limiting
|
||||||
|
|
||||||
|
A four-digit PIN is ten thousand guesses. On `/pos/login/pin` that is contained,
|
||||||
|
because the route needs a valid session and can only reach one outlet's staff.
|
||||||
|
On `/pos/login` it is **not** — the route is unauthenticated by necessity, and
|
||||||
|
the pair is only strong while an attacker cannot sit and try every PIN against a
|
||||||
|
number they know.
|
||||||
|
|
||||||
|
Nothing in this change adds a limiter, and the endpoint is the wrong place for
|
||||||
|
one. **Before this becomes the only way into a till, `/pos/login` needs a rate
|
||||||
|
limit at the edge** — per source and per `contactno`, with a lockout after a
|
||||||
|
few failures. Please raise it with whoever owns the ingress; it is not the
|
||||||
|
terminal team's job, but shipping the screen without it is what would make it
|
||||||
|
somebody's incident.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. How this was verified
|
||||||
|
|
||||||
|
Not against production — there are no live credentials in this working copy, so
|
||||||
|
nothing here was run against the real database. Proved instead against a
|
||||||
|
throwaway Postgres seeded with a shop and six accounts, running the **real**
|
||||||
|
repository, service and SQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --rm --name nearle-posproof -e POSTGRES_PASSWORD=proof \
|
||||||
|
-e POSTGRES_DB=proof -p 55432:5432 postgres:16-alpine
|
||||||
|
|
||||||
|
POS_PROOF_DSN='postgres://postgres:proof@localhost:55432/proof?sslmode=disable' \
|
||||||
|
POS_TOKEN_SECRET=proof-secret-at-least-16 \
|
||||||
|
go run ./scratch/posphonepinproof # 18 passed, 0 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Covered: sign-in by number and PIN; the same account typed four different ways;
|
||||||
|
a cashier getting a cashier's session; a weak-but-issued PIN still admitted; a
|
||||||
|
wrong PIN, an unknown number, an account with no PIN, a back-office account, a
|
||||||
|
malformed number, a malformed PIN and a leaver all refused with the right
|
||||||
|
answer; username-and-password still working; `POST /login/pin` still switching
|
||||||
|
operator; no PIN anywhere in the response; a token minted.
|
||||||
|
|
||||||
|
Unit tests for the same rules: `go test ./repositories/` —
|
||||||
|
`repositories/posLogin_test.go`.
|
||||||
|
|
||||||
|
**Still to do against live data, by whoever has the credentials:** confirm the
|
||||||
|
regression check below, and count how many till accounts still lack a number or
|
||||||
|
a PIN (§5, step 2).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B=https://fiesta.nearle.app/live/api/v1
|
||||||
|
|
||||||
|
# regression: username sign-in must still work — run this first
|
||||||
|
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"authname":"supervisor.1135@pos.nearle.in","password":"…"}' \
|
||||||
|
| grep -o '"can_manage_staff":[a-z]*'
|
||||||
|
# expect: "can_manage_staff":true
|
||||||
|
|
||||||
|
# the new way in, once that account has a number and a PIN
|
||||||
|
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"contactno":"9876543210","pin":"4821"}' \
|
||||||
|
| grep -o '"can_manage_staff":[a-z]*'
|
||||||
|
|
||||||
|
# and no PIN in the response
|
||||||
|
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"contactno":"9876543210","pin":"4821"}' | grep -c '"pin"'
|
||||||
|
# expect: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What did **not** change
|
||||||
|
|
||||||
|
- The response shape, apart from `staff[].pin` leaving
|
||||||
|
- `token` format, TTL (30 days) and the `PosAuth` guard
|
||||||
|
- `POST /pos/login/pin` — same request, same answer, and it still needs a session
|
||||||
|
- `POST /pos/orders`, `/pos/customers`, `/pos/health`, `GET /pos/catalogue`
|
||||||
|
- `GET /pos/users`, which still shows PINs to a supervisor and blanks them for a
|
||||||
|
cashier — a supervisor sets those PINs, so seeing them tells them nothing they
|
||||||
|
could not already change
|
||||||
|
- `POS_AUTH_REQUIRED` still defaults to off
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Where this lives, if you need to read it
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| Which credential was offered, and how a row is checked against it | `repositories/posAuthRepository.go` — `posLoginSecret` |
|
||||||
|
| Which column names the account, and number normalisation | `repositories/posAuthRepository.go` — `posLoginIdentity` |
|
||||||
|
| PIN format at sign-in vs. the rule for issuing one | `repositories/posUserRepository.go` — `posLoginPin`, `validatePosPin` |
|
||||||
|
| The staff list, and why `Pin` is `json:"-"` | `models/pos.go` — `PosStaffMember` |
|
||||||
|
| Request/response shapes | `models/pos.go` — `PosLoginRequest`, `PosSession` |
|
||||||
|
| Full endpoint reference | `docs/POS_LOGIN.md` |
|
||||||
441
docs/SCAN_TO_ORDER.md
Normal file
441
docs/SCAN_TO_ORDER.md
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
# Scan-to-order — mobile integration
|
||||||
|
|
||||||
|
A customer photographs a product. Google Lens (on the phone) turns the photo
|
||||||
|
into a label — `"Milk Bikis"`, `"Dabur Honey 500g"`. The app sends that label
|
||||||
|
here and gets back: what the product is, which of the customer's stores sell
|
||||||
|
it, in which sizes, with live stock, nearest first, and which store we
|
||||||
|
recommend. When the customer taps a store and a size, a second call confirms
|
||||||
|
the shelf still has it — and if it does not, names the next-nearest store
|
||||||
|
that does.
|
||||||
|
|
||||||
|
When the label fits several products — `"britannia"` names 258 of them — it
|
||||||
|
answers with a short "did you mean?" list instead of picking one, because a
|
||||||
|
confident price on the wrong biscuit is worse than one extra tap.
|
||||||
|
|
||||||
|
Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
||||||
|
`{ code, status, message, details }`; the shapes below are `details`.
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
```
|
||||||
|
photo ──Lens──▶ label
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /lookup ───▶ ambiguous:true + candidates[] "did you mean?"
|
||||||
|
│ │
|
||||||
|
│ customer taps one candidate
|
||||||
|
│ │
|
||||||
|
│ POST /lookup { brand, catalogueid }
|
||||||
|
│ │
|
||||||
|
└───▶ match + stores[] (recommended first) ◀──┘
|
||||||
|
│
|
||||||
|
customer taps a store + a size
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /confirm ───▶ ok:true → add to basket with existing order APIs
|
||||||
|
ok:false + alternative → offer the other store
|
||||||
|
```
|
||||||
|
|
||||||
|
**`/lookup` has two possible answers and the app must handle both.** A label
|
||||||
|
that names one product comes back with `match` + `stores`. A label that fits
|
||||||
|
several — a bare brand name like `"britannia"`, a generic word like
|
||||||
|
`"biscuits"` — comes back with `ambiguous: true` and `candidates`, and the
|
||||||
|
app asks the customer which one before any price is shown. Lens returns a
|
||||||
|
bare wordmark often, because it is usually the biggest thing printed on a
|
||||||
|
packet, so this is a normal path and not an error case.
|
||||||
|
|
||||||
|
`GET /stores` is for the "choose another shop" sheet: the customer's
|
||||||
|
registered stores, nearest first, independent of any product.
|
||||||
|
|
||||||
|
## `POST /lookup`
|
||||||
|
|
||||||
|
Note the `//` notes below are annotations, not JSON — strip them.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerid": 5123,
|
||||||
|
"label": "Milk Bikis",
|
||||||
|
"latitude": 11.0290, // phone fix; optional — saved address is used without it
|
||||||
|
"longitude": 77.0290,
|
||||||
|
"tenantids": [1135, 1140], // optional: what the app THINKS the customer joined
|
||||||
|
"limit": 0 // optional: max stores, 0 = all
|
||||||
|
|
||||||
|
// Instead of a label: name the product outright. This is how you resolve
|
||||||
|
// a candidate the customer tapped, and how a deep link or a "buy again"
|
||||||
|
// skips recognition. With both set, `label` is ignored.
|
||||||
|
// "brand": "britannia", "catalogueid": 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`label` is required **unless** `brand` and `catalogueid` are both given.
|
||||||
|
|
||||||
|
`tenantids` is verified, never trusted: the server intersects it with the
|
||||||
|
`tenantcustomers` table. Ids the customer is not actually registered with
|
||||||
|
come back in `unregistered_tenantids` — treat that as "refresh the local
|
||||||
|
list". A list that matches nothing at all is treated as stale and all
|
||||||
|
registered stores are used.
|
||||||
|
|
||||||
|
### Response A — one product identified
|
||||||
|
|
||||||
|
`ambiguous: false`, `match` set, `candidates` empty.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "Milk Bikis",
|
||||||
|
"match": {
|
||||||
|
"brand": "britannia", "catalogueid": 7, "imageid": "britannia_milk_bikis_100g",
|
||||||
|
"product_name": "Milk Bikis", "size": "100 g", "variant_key": "milk_bikis",
|
||||||
|
"image": "https://…", "score": 0.94, "method": "vector+text"
|
||||||
|
},
|
||||||
|
"catalogue_variants": [ { "…same shape…": "100 g" }, { "…": "200 g" } ],
|
||||||
|
"ambiguous": false,
|
||||||
|
"candidates": [],
|
||||||
|
"confidence": 0.94,
|
||||||
|
"available": true,
|
||||||
|
"recommended_locationid": 20,
|
||||||
|
"stores": [
|
||||||
|
{
|
||||||
|
"tenantid": 2, "tenantname": "R Mart", "locationid": 20, "locationname": "Hopes",
|
||||||
|
"latitude": 11.01, "longitude": 77.0, "distance_km": 3.8, "open": true,
|
||||||
|
"deliveryradius": 5, "deliverymins": 30,
|
||||||
|
"recommended": true, "available": true,
|
||||||
|
"options": [
|
||||||
|
{ "productid": 200, "productname": "Milk Bikis 100g", "size": "100 g", "price": 12, "stock": 6,
|
||||||
|
"available": true, "is_variant": false, "matched_by": "imageid", "image": "…" },
|
||||||
|
{ "productid": 201, "productname": "Milk Bikis 200g", "size": "200 g", "price": 22, "stock": 3,
|
||||||
|
"available": true, "is_variant": true, "variantname": "200 g", "matched_by": "variant-of:200" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "locationid": 10, "locationname": "Peelamedu", "distance_km": 0.9, "available": false, "recommended": false,
|
||||||
|
"options": [ { "productid": 100, "stock": 0, "available": false, "…": "…" } ] }
|
||||||
|
],
|
||||||
|
"unregistered_tenantids": [],
|
||||||
|
"message": "Available at 1 of your stores."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response B — several products fit, none clearly
|
||||||
|
|
||||||
|
`ambiguous: true`, `match: null`, `stores: []`. Show a "did you mean?" list.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "britannia",
|
||||||
|
"match": null,
|
||||||
|
"ambiguous": true,
|
||||||
|
"candidates": [
|
||||||
|
{ "brand": "britannia", "catalogueid": 23, "product_name": "Britannia Marie Gold",
|
||||||
|
"size": "250 g", "image": "https://…", "score": 0.95, "method": "text", "available": true },
|
||||||
|
{ "brand": "britannia", "catalogueid": 22, "product_name": "Britannia Good Day Butter Cookies",
|
||||||
|
"image": "https://…", "score": 0.95, "method": "text" },
|
||||||
|
{ "brand": "britannia", "catalogueid": 21, "product_name": "Britannia Good Day Cashew Cookies",
|
||||||
|
"image": "https://…", "score": 0.95, "method": "text" }
|
||||||
|
],
|
||||||
|
"confidence": 0.95,
|
||||||
|
"available": false,
|
||||||
|
"stores": [],
|
||||||
|
"catalogue_variants": [],
|
||||||
|
"message": "Which one is it? 1 of these 3 are in stock near you."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`confidence` is not low here, and that is not a bug.** "britannia" really
|
||||||
|
does appear in all three names, so relevance is high — what is missing is
|
||||||
|
*identification*. Gate on `ambiguous`, never on `confidence`: an app that
|
||||||
|
reads 0.95 as "sure enough to show a price" reintroduces the exact bug this
|
||||||
|
path exists to prevent.
|
||||||
|
- **`available` on a candidate** means at least one of the customer's
|
||||||
|
registered stores has it in stock right now. Candidates are ordered
|
||||||
|
available-first, so the list can show what is buyable before what is not
|
||||||
|
— and the field is absent (not `false`) when unavailable, so read it as
|
||||||
|
falsy, not as a required key.
|
||||||
|
- **To resolve a pick**, call `/lookup` again with that candidate's `brand`
|
||||||
|
and `catalogueid` and no label. You get Response A for that exact product,
|
||||||
|
with `method: "direct"` and `confidence: 1`.
|
||||||
|
- At most 10 candidates come back.
|
||||||
|
|
||||||
|
### How to read either response
|
||||||
|
|
||||||
|
- `match == null && !ambiguous` → nothing recognised; show `message` and let
|
||||||
|
them retry with a clearer photo.
|
||||||
|
- `ambiguous: true` → ask, do not guess. Never show a price on this path;
|
||||||
|
`stores` is deliberately empty.
|
||||||
|
- `confidence` below ~0.5 with a `match` → recognised but unsure; worth
|
||||||
|
confirming the name before showing prices. `method: "text"` means no
|
||||||
|
embedding model was involved (not configured, or it timed out) — be a
|
||||||
|
little more cautious. `method: "direct"` means the caller named the
|
||||||
|
product, so nothing was recognised at all.
|
||||||
|
- `stores` is ordered **in-stock first, then nearest**. Exactly one store has
|
||||||
|
`recommended: true` — the nearest with stock — and only when `available`
|
||||||
|
is true. Stores that sell it but have nothing on the shelf are still listed
|
||||||
|
(so the customer understands why they are not recommended); stores that do
|
||||||
|
not sell it are not.
|
||||||
|
- `options` are the things that can actually go in a basket at that store —
|
||||||
|
the matched product and each of its sizes — each a real product with its
|
||||||
|
own `productid`, price and live `stock`. Use `productid` in the existing
|
||||||
|
cart/order calls exactly as you would from the catalogue screen.
|
||||||
|
- `distance_km: -1` means the distance is unknown (no fix from the phone and
|
||||||
|
no saved address, or the store has no coordinates). Do not render it as 0.
|
||||||
|
Send `latitude`/`longitude` on `/confirm` too if you display distance from
|
||||||
|
its reply: the saved address is only consulted there when the shelf is
|
||||||
|
empty and alternatives have to be ranked, so without a fix the store you
|
||||||
|
tapped comes back `-1`.
|
||||||
|
|
||||||
|
## `POST /confirm`
|
||||||
|
|
||||||
|
Sent when the customer taps a store and an option. Re-reads live stock —
|
||||||
|
nothing is cached on this path.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "customerid": 5123, "tenantid": 1, "locationid": 10, "productid": 100, "quantity": 2,
|
||||||
|
"latitude": 11.029, "longitude": 77.029 }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"reason": "out_of_stock", // in_stock | insufficient_stock | out_of_stock | not_sold_here | store_not_registered
|
||||||
|
"store": { "…the store they tapped…" }, // distance_km filled from the fix you send
|
||||||
|
"option": { "productid": 100, "stock": 0, "…": "…" },
|
||||||
|
"requested": 2,
|
||||||
|
"alternative": { // absent when nobody has enough
|
||||||
|
"locationid": 20, "locationname": "Hopes", "distance_km": 3.8, "recommended": true, "available": true,
|
||||||
|
"options": [ { "productid": 200, "stock": 6, "price": 12, "…": "…" } ]
|
||||||
|
},
|
||||||
|
"message": "Out of stock at Peelamedu. Hopes has it (3.8 km away)."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ok: true` → proceed to the basket. `ok: false` → show `message`; if
|
||||||
|
`alternative` is present offer it as a one-tap switch (it is the **same
|
||||||
|
product**, not another size — the customer chose a size and we do not
|
||||||
|
substitute). These are HTTP 200s: they are answers, not errors.
|
||||||
|
|
||||||
|
## `GET /stores?customerid=5123&latitude=11.029&longitude=77.029`
|
||||||
|
|
||||||
|
The customer's registered stores, nearest first, `distance_km: -1` last.
|
||||||
|
Same `ScanStore` shape as inside `stores[]` above, without options.
|
||||||
|
|
||||||
|
## Errors (HTTP status ≠ 200)
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
|---|---|
|
||||||
|
| 400 | Missing `customerid`/`label`/ids, or a body that is not JSON. `message` says which. |
|
||||||
|
| 404 | `customerid` does not exist. |
|
||||||
|
| 503 | The catalogue database is not reachable. Retry later; the rest of the app is unaffected. |
|
||||||
|
| 500 | Anything else. Logged server-side. |
|
||||||
|
|
||||||
|
## Behind the curtain (for whoever operates it)
|
||||||
|
|
||||||
|
- **Recognition** = pgvector cosine search over every `brand_*` table in the
|
||||||
|
catalogue (each with its own index, merged), plus a word match on
|
||||||
|
`product_name`/`title`/`search_query` that settles near-ties and works on
|
||||||
|
its own when no embedding model is configured. The model is set by
|
||||||
|
`EMBEDDING_PROVIDER/MODEL/API_KEY` and **must** be the one that indexed
|
||||||
|
the catalogue — the first search checks the vector width and refuses a
|
||||||
|
mismatch by name.
|
||||||
|
- **The word match asks for most of the label, not all of it**
|
||||||
|
(`minTokenHits`: two thirds, rounded up, and both of a two-word label).
|
||||||
|
Requiring every word meant one word the catalogue does not use took the
|
||||||
|
right product out of the running entirely — "Dettol bottle pack" retrieved
|
||||||
|
no Dettol, "Parle G biscuit pack" retrieved no Parle-G — and the vector
|
||||||
|
search then answered alone, confidently and wrongly, at a score the floor
|
||||||
|
could not catch. Each brand's rows are ordered by how much of the label
|
||||||
|
they carry (the whole label as a substring outranks any number of loose
|
||||||
|
words) so that the per-brand `LIMIT` keeps the best rows and not merely the
|
||||||
|
first ones the planner reached. Packaging words — "pack", "bottle", "jar",
|
||||||
|
"sachet" and friends, see `utils.isPackaging` — are dropped before any of
|
||||||
|
this, like pack sizes, unless the label is nothing else.
|
||||||
|
- **The catalogue's model** (verified 2026-09-15 by cosine against a stored
|
||||||
|
row: 1.0000): `all-MiniLM-L6-v2`, 384-d, unit-normalised, embedding the
|
||||||
|
`search_query` column (brand + name + category + blurb + price range).
|
||||||
|
Ollama ships it as `all-minilm`; the cluster's `ollama.krow` service serves
|
||||||
|
it, so production is:
|
||||||
|
```
|
||||||
|
EMBEDDING_PROVIDER=openai
|
||||||
|
EMBEDDING_BASE_URL=http://ollama.krow.svc.cluster.local:11434/v1
|
||||||
|
EMBEDDING_MODEL=all-minilm
|
||||||
|
EMBEDDING_API_KEY=ollama # any non-empty value; Ollama ignores it
|
||||||
|
EMBEDDING_DIMENSIONS=384
|
||||||
|
```
|
||||||
|
A bare label ("Milk Bikis") scores ~0.92 against its product's stored
|
||||||
|
vector and ~0.23 against an unrelated one, which is what the 0.50 floor in
|
||||||
|
`scanService.go` is set against — the middle of that split, not the edge of
|
||||||
|
the noise. It was 0.30 until a near-miss got through in production
|
||||||
|
("Paracetamol" → "Paneer Makhni 500ml", 0.304). If the catalogue team ever
|
||||||
|
re-embeds with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here
|
||||||
|
and nothing else.
|
||||||
|
- **Speed**: the label's vector (7 days) and the ranked catalogue hits
|
||||||
|
(30 min) are cached in Redis and in-process, so a popular product costs
|
||||||
|
one model call platform-wide. Customer, stores and catalogue are read
|
||||||
|
concurrently; the whole lookup is capped at 5 s and a slow model degrades
|
||||||
|
to a text answer instead of a spinner. Live stock is one indexed query and
|
||||||
|
is never cached.
|
||||||
|
- **Availability** is the same rule the app's catalogue screen uses:
|
||||||
|
`products.approve = 1`, `productlocations.publishedat IS NOT NULL`, stock =
|
||||||
|
live `SUM(in) − SUM(out)` of `productstocks` at that outlet, price = the
|
||||||
|
outlet's own price else the tenant's retail price.
|
||||||
|
- **No reservation.** Confirm re-reads the ledger; a hold would give the
|
||||||
|
same answer with a timer to babysit. If contention becomes real, a
|
||||||
|
Redis-backed short hold slots in at `Confirm` without changing the API.
|
||||||
|
- **Identity** is the `customerid` in the body, like every other mobile
|
||||||
|
endpoint here — there is no auth layer yet (see `SECURITY_HANDOFF.md`).
|
||||||
|
|
||||||
|
## Two decisions, and why
|
||||||
|
|
||||||
|
Both come from a proposal (2026-09-23) to have the app send vectors it
|
||||||
|
computed on the phone. Recorded here because the next person will ask.
|
||||||
|
|
||||||
|
### The app does not send `textvector`
|
||||||
|
|
||||||
|
An on-device MiniLM vector is only comparable to the catalogue's if the app
|
||||||
|
ships the identical model *and* tokenizer *and* pooling *and* normalisation;
|
||||||
|
a quantised tflite build usually drifts, and the failure is silent — the
|
||||||
|
ranking just gets worse. There is also nothing to gain: the server-side
|
||||||
|
embed is ~30 ms warm and the result is cached in Redis by label, so one
|
||||||
|
model call serves every customer who scans that product. A client-supplied
|
||||||
|
vector *defeats* that cache (the key would have to be the vector, not the
|
||||||
|
label), and 384 floats is ~5 KB of upload against ~12 bytes for
|
||||||
|
`"Milk Bikis"`. If the field ever arrives it can be accepted and validated,
|
||||||
|
but the app should not be asked to compute it.
|
||||||
|
|
||||||
|
**Send the full OCR text instead** if you want to give the server more to
|
||||||
|
work with — ~100 bytes, no model coupling, strictly more information than a
|
||||||
|
single label.
|
||||||
|
|
||||||
|
### The app does not send `imagevector` — yet
|
||||||
|
|
||||||
|
The catalogue *does* carry image vectors: every `brand_*` table has
|
||||||
|
`img_vector vector(1024)`, filled on 1885 of 2124 rows (empty in
|
||||||
|
`brand_haldirams`, `brand_kaleesuwari`, `brand_mdh`, `brand_zzsmoketest`).
|
||||||
|
That matches the proposed MobileNetV3-Small embedder, so the idea is
|
||||||
|
coherent and half-built — this flow simply does not read that column.
|
||||||
|
|
||||||
|
It stays unread for now because **Google Lens is already the image
|
||||||
|
recogniser, and a far better one**: photo → Lens → label is Google's product
|
||||||
|
recognition, trained on billions of images. Putting a 137M-parameter
|
||||||
|
ImageNet backbone searching 1885 vectors *behind* that adds little where
|
||||||
|
Lens succeeds, and MobileNetV3-Small — which struggles to tell one blue
|
||||||
|
biscuit wrapper from another — is unlikely to rescue the cases where Lens
|
||||||
|
fails. There is also an unverified dependency: the preprocessing the app
|
||||||
|
would use (BGR → centre crop → 224×224 INTER_AREA → RGB → `/255.0`) has to
|
||||||
|
match whatever the catalogue pipeline actually ran, or the search returns
|
||||||
|
confidently-ranked noise.
|
||||||
|
|
||||||
|
**What would change this:** the field data. Once live, count how often
|
||||||
|
`/lookup` returns `ambiguous: true` or nothing recognised. If Lens labels are
|
||||||
|
reliable, image search is polish; if that number is high, it becomes the
|
||||||
|
priority — and the first task is the cosine check (embed a known catalogue
|
||||||
|
product's image through the app's exact pipeline, compare with its stored
|
||||||
|
`img_vector`; ≈0.99 means the contract holds), not writing the query.
|
||||||
|
|
||||||
|
There is one non-recognition argument for it worth remembering: on-device
|
||||||
|
inference is free and needs no Google dependency, which matters if Cloud
|
||||||
|
Vision costs start to bite at volume. That is a business reason, not a
|
||||||
|
quality one.
|
||||||
|
|
||||||
|
## For backend developers
|
||||||
|
|
||||||
|
### Where the code is
|
||||||
|
|
||||||
|
| File | Holds |
|
||||||
|
|---|---|
|
||||||
|
| `models/scan.go` | request/response shapes (`ScanLookupRequest`, `ScanStoreOffer`, `ScanOption`, …) |
|
||||||
|
| `repositories/scanRepository.go` | all SQL: registered stores, live options, vector + text search, the two-tier cache |
|
||||||
|
| `services/scanService.go` | the pipeline: parallel reads, scoring, family grouping, ranking, confirm fallback |
|
||||||
|
| `controllers/scanController.go` | the three handlers and the error → status mapping |
|
||||||
|
| `routes/scanroutes.go` | `/v1/mob/scan/*` |
|
||||||
|
| `utils/embedding.go` | `Embedder` interface, OpenAI-compatible and Gemini clients |
|
||||||
|
| `utils/geo.go` | coordinate parsing, haversine, opening hours, label tokenising |
|
||||||
|
| `config/config.go` | `EmbeddingConfig` and its validation |
|
||||||
|
| `scratch/cataloguedims` | read-only check of every catalogue vector column's width and fill |
|
||||||
|
|
||||||
|
### Try it locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . # with the local compose stack; EMBEDDING_* unset → text-only, still works
|
||||||
|
curl -s localhost:1122/live/api/v1/mob/scan/lookup -H 'Content-Type: application/json' \
|
||||||
|
-d '{"customerid":1,"label":"Milk Bikis","latitude":11.03,"longitude":77.03}' | jq .details
|
||||||
|
```
|
||||||
|
|
||||||
|
To exercise the vector path locally, run Ollama on your Mac
|
||||||
|
(`ollama pull all-minilm`) and set `EMBEDDING_PROVIDER=openai`,
|
||||||
|
`EMBEDDING_BASE_URL=http://localhost:11434/v1`, `EMBEDDING_MODEL=all-minilm`,
|
||||||
|
`EMBEDDING_API_KEY=ollama`, `EMBEDDING_DIMENSIONS=384` in `.env.local`. The
|
||||||
|
local catalogue must carry vectors from the same model for results to mean
|
||||||
|
anything; a schema-only dump does not.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`go test ./services -run 'Lookup|Confirm|Stores|Brand|Ambiguous|Specific|TextScore|Distinct|Naming'`
|
||||||
|
drives the whole pipeline through a fake repository
|
||||||
|
(`services/scan_test.go`); no database. `go test ./utils` covers both HTTP
|
||||||
|
clients against `httptest` servers, and the geo helpers. Add a case to
|
||||||
|
`scan_test.go`'s fixture when you change ranking — it is the spec, and
|
||||||
|
`newBrandLabelFixture` in particular is the regression guard for the
|
||||||
|
brand-name bug described under Scoring.
|
||||||
|
|
||||||
|
### Knobs (constants in `scanService.go`)
|
||||||
|
|
||||||
|
| Constant | Default | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `scanLookupTimeout` | 5 s | whole lookup, including the model call |
|
||||||
|
| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge |
|
||||||
|
| `scanMinScore` | 0.50 | below this the best hit is not shown as a match |
|
||||||
|
| `scanAmbiguityMargin` | 0.06 | how close the runner-up may be before the answer becomes a question |
|
||||||
|
| `scanMaxCandidates` | 10 | longest "did you mean?" list |
|
||||||
|
| `embedTimeout` (`utils/embedding.go`) | 4 s | one model call |
|
||||||
|
| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes |
|
||||||
|
|
||||||
|
Scores: vector = `1 − cosine distance`; text = 0.95 for the whole label
|
||||||
|
inside the name, else `0.8 × (label words found / label words)`; combined =
|
||||||
|
`max(vector, text) + 0.10` when both hit, capped at 1. Ties are broken by
|
||||||
|
cosine distance — nearest first, a text-only row last — and only then by
|
||||||
|
name.
|
||||||
|
|
||||||
|
The label and the product name are both separator-folded before that
|
||||||
|
substring test (`utils.FoldSeparators`), and compared again with separators
|
||||||
|
removed (`utils.TightenLabel`, labels of 4+ characters), so the brand's own
|
||||||
|
punctuation does not decide the match: "Parle G", "Parle-G" and "ParleG" all
|
||||||
|
reach *Parle-G Original Glucose Biscuits*. A single-character token survives
|
||||||
|
tokenising when it follows a word, because it is often the whole name — the
|
||||||
|
"G" of Parle-G, the "K" of Special K. It is still dropped when it stands
|
||||||
|
alone or is a pack multiplier.
|
||||||
|
|
||||||
|
All three mattered at once: before this, "Parle G" tied with *Parle Monaco
|
||||||
|
Classic* at 0.9 (the "G" was dropped, so only "parle" matched either row),
|
||||||
|
and the name tie-break handed it to Monaco because a space precedes a hyphen
|
||||||
|
in ASCII. A confident, wrong answer — the kind no score floor can catch.
|
||||||
|
|
||||||
|
**When the substring rule ties, that tie is the answer.** A bare brand name
|
||||||
|
is a substring of every one of that brand's names, so all of them score 0.95
|
||||||
|
— identically, at a high score no floor would ever catch. Rather than
|
||||||
|
scoring around it, `isAmbiguous` reads it: if the runner-up is within
|
||||||
|
`scanAmbiguityMargin` of the leader, the reply becomes `ambiguous: true`
|
||||||
|
with `candidates` instead of a match (see Response B). Erring towards asking
|
||||||
|
is deliberate — one tap on a picture against the wrong biscuit. A label that
|
||||||
|
names one product leaves the runner-up far behind, so the common case is
|
||||||
|
untouched, and `services/scan_test.go`'s
|
||||||
|
`TestABrandNameScoresItsProductsIdentically` guards the tie itself: a
|
||||||
|
formula that broke it on name length or word count would bring the bug
|
||||||
|
back.
|
||||||
|
|
||||||
|
### Changing the embedding model
|
||||||
|
|
||||||
|
1. The catalogue team re-embeds `search_query` with the new model.
|
||||||
|
2. Serve it (Ollama pull, or a hosted key).
|
||||||
|
3. Change `EMBEDDING_MODEL` / `EMBEDDING_DIMENSIONS` (and provider/URL if
|
||||||
|
needed) in the cluster; roll the pods.
|
||||||
|
4. Flush the hit cache if you cannot wait 30 min: keys are
|
||||||
|
`scan:hits:v1:*` and `scan:emb:v1:*` in Redis (they are also keyed by
|
||||||
|
model name, so old entries simply stop being read).
|
||||||
|
|
||||||
|
Nothing in Go changes. A width mismatch fails the first search with an error
|
||||||
|
naming both numbers.
|
||||||
|
|
||||||
|
### Adding a provider
|
||||||
|
|
||||||
|
Implement `utils.Embedder` (`Embed(ctx, text) ([]float32, error)` and
|
||||||
|
`Model() string`), add a case to `NewEmbedder`, and add the provider name to
|
||||||
|
the allow-list in `config.validate`. Keep the HTTP client timeout: the
|
||||||
|
customer is holding a phone.
|
||||||
172
docs/STORE_OPEN_APP.md
Normal file
172
docs/STORE_OPEN_APP.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Store open / closed — the app contract
|
||||||
|
|
||||||
|
A shop can now close itself for the day. When it does, customers must not be
|
||||||
|
able to order from it.
|
||||||
|
|
||||||
|
Two things to build: show the closed state, and handle the refusal if someone
|
||||||
|
orders anyway. Everything else is done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Four new fields on the store list
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://fiesta.nearle.app/live/api/v1/mob/tenants/getcustomertenants
|
||||||
|
?customerid=&tenant=0&latitude=&longitude=&categoryid=
|
||||||
|
```
|
||||||
|
|
||||||
|
Every store in `details[]` now carries:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isopen": true,
|
||||||
|
"closeduntil": "",
|
||||||
|
"isaccepting": true,
|
||||||
|
"closedreason": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read `isaccepting`, not `isopen`
|
||||||
|
|
||||||
|
This is the one thing to get right.
|
||||||
|
|
||||||
|
| field | what it is |
|
||||||
|
|---|---|
|
||||||
|
| **`isaccepting`** | **the answer.** Can this store take an order right now? |
|
||||||
|
| `isopen` | the raw switch — what the shopkeeper last pressed |
|
||||||
|
| `closeduntil` | the first day back, `"YYYY-MM-DD"`, or `""` |
|
||||||
|
| `closedreason` | why not, written to show the customer. `""` when open |
|
||||||
|
|
||||||
|
They differ. A shop that closed on Friday "until Monday" has `isopen: false`
|
||||||
|
every day including Monday — the flag is never flipped back. On Monday the
|
||||||
|
server works out that the date has passed and sets `isaccepting: true`.
|
||||||
|
|
||||||
|
Branch the UI on `isaccepting`. `isopen` and `closeduntil` are there if you
|
||||||
|
want to say more, not to decide with.
|
||||||
|
|
||||||
|
### `closedreason` is already written for the customer
|
||||||
|
|
||||||
|
```
|
||||||
|
"This store is closed today"
|
||||||
|
"This store is closed today and reopens on 12 Oct"
|
||||||
|
"This store is not currently available"
|
||||||
|
```
|
||||||
|
|
||||||
|
Show it as it comes. Don't build the sentence from `closeduntil` — the server
|
||||||
|
already handles the cases where there is no date, where the branch has been
|
||||||
|
switched off by Nearle rather than by the shop, and where the date is
|
||||||
|
unreadable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. A closed store is still returned
|
||||||
|
|
||||||
|
It is **not** filtered out of the list. You decide whether to grey it or hide
|
||||||
|
it.
|
||||||
|
|
||||||
|
**I would grey it.** A shop that vanishes reads to a regular customer as gone
|
||||||
|
for good; one marked "Closed today — reopens 12 Oct" brings them back
|
||||||
|
tomorrow. But it is your call, and the data supports either.
|
||||||
|
|
||||||
|
What the store must not do is look ordinary. If `isaccepting` is false,
|
||||||
|
whatever you render has to stop a customer getting as far as a basket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Delivery windows follow automatically
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/mob/deliveryslots/available?tenantid=&locationid=
|
||||||
|
```
|
||||||
|
|
||||||
|
A closed store now returns an **empty list**, plus the reason:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 200, "status": true, "details": [], "closedreason": "This store is closed today" }
|
||||||
|
```
|
||||||
|
|
||||||
|
You already handle an empty `details` as "no windows here" — see
|
||||||
|
`DELIVERY_SLOTS_APP.md` §2 — so this needs nothing new. It stops a customer
|
||||||
|
picking tomorrow morning from a shop that is shut and only finding out at
|
||||||
|
checkout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The refusal
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/mob/orders/createorder
|
||||||
|
```
|
||||||
|
|
||||||
|
If the store is closed, the order is refused:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 409,
|
||||||
|
"status": false,
|
||||||
|
"message": "This store is closed today and reopens on 12 Oct"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handle this properly rather than as a generic failure.** It is not a bug or
|
||||||
|
an edge case — it happens to real people in normal use: a customer with the
|
||||||
|
app open when the shopkeeper closes, or a screen left open since this morning.
|
||||||
|
|
||||||
|
On 409: show the `message`, and refresh the store list so the UI catches up.
|
||||||
|
|
||||||
|
The server checks on every order because `/v1/mob/*` carries no session —
|
||||||
|
anything arriving is a claim. Hiding the store in the app is presentation;
|
||||||
|
this is the enforcement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nothing changes for an open store
|
||||||
|
|
||||||
|
Every store on the platform has `isopen: true` and `isaccepting: true` right
|
||||||
|
now. There was no backfill and there will not be one. A shop that never
|
||||||
|
touches the switch behaves exactly as it does today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Done on our side
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `tenantlocations.isopen` + `closeduntil` | ✅ live, default open |
|
||||||
|
| Four fields on `getcustomertenants` | ✅ live, verified on all 9 stores |
|
||||||
|
| `PUT /v1/web/tenants/storeopen` (guarded) | ✅ live — 401 unauthenticated, absent from `/v1/mob` |
|
||||||
|
| `createorder` refuses a closed store | ✅ built, 409 with the shop's wording |
|
||||||
|
| Closed store returns no delivery windows | ✅ built |
|
||||||
|
| Merchant console: switch in the shop profile header | ✅ live |
|
||||||
|
| Nearle console: "Trading" column per branch | ✅ live |
|
||||||
|
| Auto-reopen on the date | ✅ computed on read — no job, nothing to miss |
|
||||||
|
|
||||||
|
**Verified against production:** the four fields, and that the write endpoint
|
||||||
|
is live and guarded.
|
||||||
|
|
||||||
|
**Not yet exercised against production:** the 409 and the empty window list.
|
||||||
|
Both are covered by unit tests and need a real store closed to confirm
|
||||||
|
end to end. If you want to test, ask for tenant `1141` / branch `1179` to be
|
||||||
|
closed — it is a test store with delivery windows already set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Two rules worth knowing
|
||||||
|
|
||||||
|
**Closed with no date stays closed.** A power cut has no end date. The shop
|
||||||
|
reopens when a person says so, not on a timer. `closeduntil: ""` with
|
||||||
|
`isaccepting: false` is a normal state, not missing data.
|
||||||
|
|
||||||
|
**"Closed until the 12th" means open ON the 12th.** The date names the first
|
||||||
|
day back, which is how the phrase reads in English. The console says
|
||||||
|
"Reopens on" so nobody has to work it out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
The rule lives in one function server-side (`models/storeopen.go`), and the
|
||||||
|
store list, order creation and the delivery-window endpoint all call it. So if
|
||||||
|
anything about open/closed looks inconsistent between those three, it is one
|
||||||
|
place to fix and not three to reconcile. Ask rather than working around it in
|
||||||
|
the app — a local override is how the two sides drift apart.
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package facade
|
package facade
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"nearle/config"
|
||||||
"nearle/controllers"
|
"nearle/controllers"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
"nearle/services"
|
"nearle/services"
|
||||||
|
"nearle/services/tools"
|
||||||
|
"nearle/utils"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -20,6 +25,20 @@ type Facade struct {
|
|||||||
StockRequestController *controllers.StockRequestController
|
StockRequestController *controllers.StockRequestController
|
||||||
CatalogueController *controllers.CatalogueController
|
CatalogueController *controllers.CatalogueController
|
||||||
PosController *controllers.PosController
|
PosController *controllers.PosController
|
||||||
|
LiveController *controllers.LiveController
|
||||||
|
CatalogueUploadController *controllers.CatalogueUploadController
|
||||||
|
ScanController *controllers.ScanController
|
||||||
|
DeliverySlotController *controllers.DeliverySlotController
|
||||||
|
AssistantController *controllers.AssistantController
|
||||||
|
HealthController *controllers.HealthController
|
||||||
|
MCPController *controllers.MCPController
|
||||||
|
|
||||||
|
// Tools is what Nearle Buddy is allowed to do.
|
||||||
|
//
|
||||||
|
// Held on the facade because the assistant is not a module with a
|
||||||
|
// repository of its own — it is a door onto the services already built
|
||||||
|
// here, and every tool handler calls one of them rather than the database.
|
||||||
|
Tools *tools.Registry
|
||||||
|
|
||||||
// Held so the NATS consumer can reach the ingest without going through
|
// Held so the NATS consumer can reach the ingest without going through
|
||||||
// HTTP. Unexported: everything else should use the controller.
|
// HTTP. Unexported: everything else should use the controller.
|
||||||
@@ -30,11 +49,30 @@ type Facade struct {
|
|||||||
// catalogueDB is a separate connection to the pgvector catalogue database;
|
// catalogueDB is a separate connection to the pgvector catalogue database;
|
||||||
// it may be nil if catalogue env vars are not configured, in which case
|
// it may be nil if catalogue env vars are not configured, in which case
|
||||||
// catalogue endpoints will error at query time rather than at startup.
|
// catalogue endpoints will error at query time rather than at startup.
|
||||||
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
// embedder may be nil too: scan-to-order then matches on words alone.
|
||||||
|
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig, nutritionBase string) *Facade {
|
||||||
|
|
||||||
|
// The invitation, built first because two modules need it.
|
||||||
|
//
|
||||||
|
// Every back-office account on this platform is created with NO password —
|
||||||
|
// the onboarded merchant, every person added to the directory, and the login
|
||||||
|
// each branch spawns — and since the sign-in screen stopped offering to set
|
||||||
|
// one, the emailed link is the only way in. So whichever module creates an
|
||||||
|
// account has to be able to send it.
|
||||||
|
//
|
||||||
|
// `mailer` may be nil: a deployment with no mail configured still creates
|
||||||
|
// everything, and each response says the invitation was not sent and names
|
||||||
|
// the variable, rather than failing the create.
|
||||||
|
//
|
||||||
|
// The tenant repository supplies the business name for the mail's first line
|
||||||
|
// (`services.TenantNamer`), which is why it is built here rather than in the
|
||||||
|
// tenant module below.
|
||||||
|
tenantRepo := repositories.NewTenantRepository(db)
|
||||||
|
inviteService := services.NewInviteService(mailer, mailCfg, tenantRepo)
|
||||||
|
|
||||||
// User Module
|
// User Module
|
||||||
userRepo := repositories.NewUserRepository(db)
|
userRepo := repositories.NewUserRepository(db)
|
||||||
userService := services.NewUserService(userRepo)
|
userService := services.NewUserService(userRepo, inviteService)
|
||||||
userController := controllers.NewUserController(userService)
|
userController := controllers.NewUserController(userService)
|
||||||
|
|
||||||
// Catalogue Module (separate pgvector DB — never the main `db`). Built
|
// Catalogue Module (separate pgvector DB — never the main `db`). Built
|
||||||
@@ -45,14 +83,41 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
catalogueController := controllers.NewCatalogueController(catalogueService)
|
catalogueController := controllers.NewCatalogueController(catalogueService)
|
||||||
|
|
||||||
// Product Module
|
// Product Module
|
||||||
|
//
|
||||||
|
// The nutrition service is the catalogue-intelligence host — the same one
|
||||||
|
// behind the health score card in the console — read by the product screen
|
||||||
|
// for its nutrition panel. Nil when NUTRITION_BASE is unset, which serves
|
||||||
|
// every product screen exactly as before, without a panel.
|
||||||
productRepo := repositories.NewProductRepository(db)
|
productRepo := repositories.NewProductRepository(db)
|
||||||
productService := services.NewProductService(productRepo, catalogueService)
|
productService := services.NewProductService(
|
||||||
|
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
|
||||||
productController := controllers.NewProductController(productService)
|
productController := controllers.NewProductController(productService)
|
||||||
|
|
||||||
|
//Tenant Module
|
||||||
|
//
|
||||||
|
// Onboarding, adding a person and commissioning a branch all create an
|
||||||
|
// account with no password, so all three send an invitation. `tenantRepo` and
|
||||||
|
// `inviteService` are built above, where the reasoning is.
|
||||||
|
//
|
||||||
|
// BEFORE the order controller, which takes it: order creation asks this
|
||||||
|
// service whether the shop is trading before accepting, using the same
|
||||||
|
// rule the customer-facing store list runs. No dependency the other way.
|
||||||
|
tenantService := services.NewTenantService(tenantRepo, inviteService)
|
||||||
|
tenantController := controllers.NewTenantController(tenantService)
|
||||||
|
|
||||||
|
// When each branch delivers.
|
||||||
|
//
|
||||||
|
// BEFORE the order controller, which takes it: order creation re-checks a
|
||||||
|
// chosen window against the same rule the app was shown, so the two cannot
|
||||||
|
// drift. No dependency the other way — this service knows nothing of orders.
|
||||||
|
deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
|
||||||
|
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
|
||||||
|
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService, tenantService)
|
||||||
|
|
||||||
// Order Module
|
// Order Module
|
||||||
orderRepo := repositories.NewOrderRepository(db)
|
orderRepo := repositories.NewOrderRepository(db)
|
||||||
orderService := services.NewOrderService(orderRepo)
|
orderService := services.NewOrderService(orderRepo)
|
||||||
orderController := controllers.NewOrderController(orderService)
|
orderController := controllers.NewOrderController(orderService, deliverySlotService, tenantService)
|
||||||
|
|
||||||
// Deliveries Module
|
// Deliveries Module
|
||||||
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
||||||
@@ -64,11 +129,6 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
utilsService := services.NewUtilsService(utilsRepo)
|
utilsService := services.NewUtilsService(utilsRepo)
|
||||||
utilsController := controllers.NewUtilsController(utilsService)
|
utilsController := controllers.NewUtilsController(utilsService)
|
||||||
|
|
||||||
//Tenant Module
|
|
||||||
tenantRepo := repositories.NewTenantRepository(db)
|
|
||||||
tenantService := services.NewTenantService(tenantRepo)
|
|
||||||
tenantController := controllers.NewTenantController(tenantService)
|
|
||||||
|
|
||||||
//Partner Module
|
//Partner Module
|
||||||
partnerRepo := repositories.NewPartnerRepository(db)
|
partnerRepo := repositories.NewPartnerRepository(db)
|
||||||
partnerService := services.NewPartnerService(partnerRepo)
|
partnerService := services.NewPartnerService(partnerRepo)
|
||||||
@@ -94,6 +154,92 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
posService := services.NewPosService(posRepo, posPresence)
|
posService := services.NewPosService(posRepo, posPresence)
|
||||||
posController := controllers.NewPosController(posService)
|
posController := controllers.NewPosController(posService)
|
||||||
|
|
||||||
|
// Shares the POS service purely for its outlet-ownership check — the
|
||||||
|
// stream itself reads no database and holds no state beyond its
|
||||||
|
// subscribers.
|
||||||
|
liveController := controllers.NewLiveController(posService)
|
||||||
|
|
||||||
|
// Catalogue Upload Module — our own receipt for every spreadsheet sent to
|
||||||
|
// the ingest service. Their host deletes an unreviewed drop after seven
|
||||||
|
// days and the batch id is the only credential for reading the result
|
||||||
|
// back, so the id has to be kept somewhere that outlives a browser tab.
|
||||||
|
catalogueUploadRepo := repositories.NewCatalogueUploadRepository(db)
|
||||||
|
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
|
||||||
|
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService)
|
||||||
|
|
||||||
|
// Scan Module — a label from the customer's camera to "buy it here".
|
||||||
|
// Reads both databases: the catalogue to recognise the product, nearledb
|
||||||
|
// for who the customer is and what their outlets have on the shelf.
|
||||||
|
scanRepo := repositories.NewScanRepository(db, catalogueDB)
|
||||||
|
scanService := services.NewScanService(scanRepo, embedder)
|
||||||
|
scanController := controllers.NewScanController(scanService)
|
||||||
|
|
||||||
|
// The assistant registry. Built last, because every tool it holds is a thin
|
||||||
|
// wrapper over a service constructed above.
|
||||||
|
//
|
||||||
|
// A registration error panics rather than being logged. A duplicate name or
|
||||||
|
// a tool with no description is a programming mistake, and a server that
|
||||||
|
// starts with a tool silently absent answers real questions with "I cannot
|
||||||
|
// do that" for a reason nobody can see from the outside.
|
||||||
|
// The help corpus, checked before it is registered. A passage carrying one
|
||||||
|
// shop's figures stops the server rather than reaching another shop's screen.
|
||||||
|
helpCorpus, err := tools.LoadHelp()
|
||||||
|
if err != nil {
|
||||||
|
panic("assistant help: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit trail goes to the database and to the log. See
|
||||||
|
// services/assistantAudit.go for why both.
|
||||||
|
auditRepo := repositories.NewAssistantAuditRepository(db)
|
||||||
|
toolRegistry := tools.New(services.NewDBAudit(auditRepo))
|
||||||
|
for _, tool := range []tools.Tool{
|
||||||
|
tools.StuckOrders(deliveriesService, nil),
|
||||||
|
tools.DeliveryProgress(deliveriesService),
|
||||||
|
tools.BranchPerformance(orderService),
|
||||||
|
tools.PendingApprovals(stockRequestService, nil),
|
||||||
|
tools.LowStock(productService),
|
||||||
|
tools.TillsNotSyncing(posService),
|
||||||
|
tools.SalesByChannel(orderService, posService, nil),
|
||||||
|
tools.Help(helpCorpus),
|
||||||
|
tools.ApproveStockRequest(stockRequestService, stockRequestService),
|
||||||
|
} {
|
||||||
|
if err := toolRegistry.Register(tool); err != nil {
|
||||||
|
panic("assistant tools: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearle Buddy. `chat` may be nil — a deployment with no model configured
|
||||||
|
// still gets the registry and the endpoint, and the endpoint answers "not
|
||||||
|
// switched on here" rather than a 500. The tools themselves are ordinary
|
||||||
|
// Go functions and work either way; only turning a sentence into a tool
|
||||||
|
// call needs a model.
|
||||||
|
// Agent definitions, validated against the registry above. A typo in a tool
|
||||||
|
// name stops the server rather than producing an agent that quietly cannot
|
||||||
|
// do one of the things it claims — which is invisible at runtime, because the
|
||||||
|
// model simply reports it could not look something up.
|
||||||
|
agents, err := services.LoadAgents(agentsDir, toolRegistry.Has)
|
||||||
|
if err != nil {
|
||||||
|
panic("assistant agents: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("assistant: %d agents loaded %v", len(agents), services.AgentNames(agents))
|
||||||
|
|
||||||
|
assistantService := services.NewAssistantService(toolRegistry, chat, agents)
|
||||||
|
// Why there is no model, if there is not. Passed through so /assistant/status
|
||||||
|
// can name the missing variable instead of just saying no.
|
||||||
|
if setter, ok := assistantService.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
|
||||||
|
setter.SetUnavailableReason(assistantWhy)
|
||||||
|
}
|
||||||
|
assistantController := controllers.NewAssistantController(assistantService)
|
||||||
|
|
||||||
|
// What is running here. Unauthenticated, booleans only — see healthController.go
|
||||||
|
// for why a server that cannot say which build it is costs a day.
|
||||||
|
healthController := controllers.NewHealthController(assistantService, db != nil)
|
||||||
|
|
||||||
|
// The second door. Same registry, same agents, same session — see
|
||||||
|
// controllers/mcpController.go for why it is a door rather than a service.
|
||||||
|
mcpController := controllers.NewMCPController(toolRegistry, agents)
|
||||||
|
|
||||||
return &Facade{
|
return &Facade{
|
||||||
UserController: userController,
|
UserController: userController,
|
||||||
ProductController: productController,
|
ProductController: productController,
|
||||||
@@ -106,6 +252,14 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
|||||||
StockRequestController: stockRequestController,
|
StockRequestController: stockRequestController,
|
||||||
CatalogueController: catalogueController,
|
CatalogueController: catalogueController,
|
||||||
PosController: posController,
|
PosController: posController,
|
||||||
|
LiveController: liveController,
|
||||||
|
CatalogueUploadController: catalogueUploadController,
|
||||||
|
ScanController: scanController,
|
||||||
|
DeliverySlotController: deliverySlotController,
|
||||||
|
AssistantController: assistantController,
|
||||||
|
HealthController: healthController,
|
||||||
|
MCPController: mcpController,
|
||||||
|
Tools: toolRegistry,
|
||||||
posService: posService,
|
posService: posService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
go.mod
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=
|
|
||||||
|
|||||||
10
init/.gitignore
vendored
Normal file
10
init/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Schema and data dumps. A dump taken from production carries real customers,
|
||||||
|
# real orders and cleartext passwords — none of it belongs in this repository,
|
||||||
|
# and a local seed is regenerated in one command anyway.
|
||||||
|
*.sql
|
||||||
|
|
||||||
|
# ...except the synthetic fixture. It invents a merchant rather than copying
|
||||||
|
# one, so there is nothing in it to leak and everybody gets the same shop to
|
||||||
|
# develop against.
|
||||||
|
!02-seed.sql
|
||||||
|
*.dump
|
||||||
79
init/README.md
Normal file
79
init/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Local database seed
|
||||||
|
|
||||||
|
Anything in `nearledb/` or `cataloguedb/` is applied by Postgres, in filename
|
||||||
|
order, **the first time the volume is created**. Editing a file later does
|
||||||
|
nothing on its own — drop the volume to re-apply:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose -f ../docker-compose.local.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this is not optional
|
||||||
|
|
||||||
|
Fiesta runs migrations on boot, and most of them assume tables that nothing in
|
||||||
|
this repository creates:
|
||||||
|
|
||||||
|
```
|
||||||
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS productimages
|
||||||
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS imageid
|
||||||
|
ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat
|
||||||
|
```
|
||||||
|
|
||||||
|
`AutoMigrate` covers only `stockrequests`, the two POS order tables and
|
||||||
|
`staffshifts`. Against an empty database the first `ALTER` fails and
|
||||||
|
`log.Fatal` stops the process — so a schema is required before the first run.
|
||||||
|
|
||||||
|
## Getting the schema
|
||||||
|
|
||||||
|
Structure only, no data, no ownership:
|
||||||
|
|
||||||
|
```
|
||||||
|
pg_dump --schema-only --no-owner --no-privileges \
|
||||||
|
-h <live-host> -p 5433 -U <user> -d nearledb \
|
||||||
|
> nearledb/01-schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Take the schema, not the data.** A dump with rows in it puts real customers,
|
||||||
|
real orders and real (cleartext) passwords on a laptop, and this directory is
|
||||||
|
inside a git repository. `.gitignore` excludes `*.sql` here for that reason.
|
||||||
|
|
||||||
|
## Getting something to test against
|
||||||
|
|
||||||
|
`nearledb/02-seed.sql` is committed and applied automatically, so a fresh
|
||||||
|
volume already has a merchant to sign into. It invents one rather than copying
|
||||||
|
one, which is why it can live here at all.
|
||||||
|
|
||||||
|
| Account | Password | Opens |
|
||||||
|
|---|---|---|
|
||||||
|
| `super@nearle.invalid` | `localdev` | Nearle Admin — the platform workspace |
|
||||||
|
| `admin@testmart.invalid` | `localdev` | Store Admin — all of Testmart's branches |
|
||||||
|
| `main@testmart.invalid` | `localdev` | Store user — Testmart Main only |
|
||||||
|
|
||||||
|
It also seeds the role ladder, three aisles under category 2, and a second
|
||||||
|
merchant (`Halfmart`) deliberately left in the broken `categoryid = 0` shape as
|
||||||
|
a permanent regression fixture. The sequences are moved past the seeded ids at
|
||||||
|
the end, so the first row you create locally does not come back as id 1.
|
||||||
|
|
||||||
|
If you need something it does not cover:
|
||||||
|
|
||||||
|
- **Onboard a tenant through the console.** That exercises the real path and is
|
||||||
|
usually what you want.
|
||||||
|
- **Copy a few rows** you actually need with `pg_dump --data-only --table=...`.
|
||||||
|
Check what you are copying: `app_users.password` is stored in clear.
|
||||||
|
|
||||||
|
## The catalogue database
|
||||||
|
|
||||||
|
`cataloguedb/02-seed.sql` is committed too, and also entirely invented. The
|
||||||
|
real catalogue is another team's scrape of real retailers and a dump of it does
|
||||||
|
not belong on a laptop.
|
||||||
|
|
||||||
|
Without it the catalogue database exists but holds no catalogue: every
|
||||||
|
`brand_*` table is missing, `getbrands` answers 500, and the global catalogue
|
||||||
|
screen, the import flow and `importcatalogueproduct` cannot be exercised at
|
||||||
|
all. The seed gives you two brands:
|
||||||
|
|
||||||
|
- `brand_testbrand` — every column the reader knows about, four products, one
|
||||||
|
of them deliberately with no images.
|
||||||
|
- `brand_sparsebrand` — only `id`, `product_name` and a price, to keep the
|
||||||
|
degraded-but-still-listed path covered. Brands are discovered by table name,
|
||||||
|
so adding another is just another `brand_*` table.
|
||||||
109
init/cataloguedb/02-seed.sql
Normal file
109
init/cataloguedb/02-seed.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
-- A synthetic global catalogue to develop against.
|
||||||
|
--
|
||||||
|
-- INVENTED DATA, exactly like `nearledb/02-seed.sql` and for the same reason:
|
||||||
|
-- the real catalogue is somebody else's scrape of real retailers, and a dump of
|
||||||
|
-- it does not belong on a laptop inside a git repository.
|
||||||
|
--
|
||||||
|
-- ── Why this file has to exist ──────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `init/cataloguedb/` was empty, so a local stack had a catalogue DATABASE with
|
||||||
|
-- no catalogue in it. Every `brand_*` table was missing, `getbrands` answered
|
||||||
|
-- 500, and the whole catalogue-import path — the global catalogue screen, the
|
||||||
|
-- import flow, `importcatalogueproduct` — could not be exercised locally at
|
||||||
|
-- all. It is a documented feature with its own integration doc and it had no
|
||||||
|
-- local coverage whatsoever.
|
||||||
|
--
|
||||||
|
-- ── The shape ───────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Brands are discovered from `information_schema` by table name, so a table
|
||||||
|
-- called `brand_<something>` IS a brand; there is no registry to add it to.
|
||||||
|
-- `catalogueCoreColumns` requires only `id` and `product_name` — everything
|
||||||
|
-- else is selected when present and replaced with NULL when absent, so a
|
||||||
|
-- partial table degrades rather than disappearing. These two are written full
|
||||||
|
-- so that the degraded path is a deliberate test, not the only thing available:
|
||||||
|
-- `brand_testbrand` has every column, and `brand_sparsebrand` deliberately has
|
||||||
|
-- only the core two plus a price, to exercise `columnsFor`.
|
||||||
|
--
|
||||||
|
-- `image_id` is the durable key across re-scrapes — catalogue ids are not
|
||||||
|
-- stable and `models.Products.Imageid` is what the import stores — so every
|
||||||
|
-- product here has one and they are distinct.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- ── A brand with the full column set ────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_testbrand (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_name TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
image_id TEXT,
|
||||||
|
size TEXT,
|
||||||
|
variant_key TEXT,
|
||||||
|
product_sku TEXT,
|
||||||
|
sku_source TEXT,
|
||||||
|
-- A RANGE, not a price. The global catalogue carries what retailers were
|
||||||
|
-- seen charging; the shop sets its own price at import time, which is why
|
||||||
|
-- the console collects one before an import can be enabled.
|
||||||
|
price_range TEXT,
|
||||||
|
providers TEXT[],
|
||||||
|
fssai_license TEXT,
|
||||||
|
highlights TEXT[],
|
||||||
|
nutrients TEXT[],
|
||||||
|
search_query TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
image_urls TEXT[],
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO brand_testbrand
|
||||||
|
(product_name, title, description, category, image_id, size, variant_key,
|
||||||
|
product_sku, sku_source, price_range, providers, fssai_license,
|
||||||
|
highlights, nutrients, search_query, image_url, image_urls)
|
||||||
|
VALUES
|
||||||
|
('Testbrand Basmati Rice 5kg', 'Testbrand Basmati Rice', 'Long grain basmati, aged twelve months.',
|
||||||
|
'Rice & Grains', 'IMG-TB-RICE-5K', '5 kg', 'rice-5kg', 'TB-RICE-5K', 'scrape',
|
||||||
|
'380-420', ARRAY['bigbasket','amazon'], '12345678901234',
|
||||||
|
ARRAY['Aged 12 months','Extra long grain'], ARRAY['Energy 350kcal','Protein 7g'],
|
||||||
|
'basmati rice 5kg', 'https://placehold.co/300x300?text=Rice5kg',
|
||||||
|
ARRAY['https://placehold.co/300x300?text=Rice5kg','https://placehold.co/300x300?text=Rice5kg-back']),
|
||||||
|
|
||||||
|
('Testbrand Basmati Rice 1kg', 'Testbrand Basmati Rice', 'Long grain basmati, aged twelve months.',
|
||||||
|
'Rice & Grains', 'IMG-TB-RICE-1K', '1 kg', 'rice-1kg', 'TB-RICE-1K', 'scrape',
|
||||||
|
'85-99', ARRAY['bigbasket'], '12345678901234',
|
||||||
|
ARRAY['Aged 12 months'], ARRAY['Energy 350kcal','Protein 7g'],
|
||||||
|
'basmati rice 1kg', 'https://placehold.co/300x300?text=Rice1kg',
|
||||||
|
ARRAY['https://placehold.co/300x300?text=Rice1kg']),
|
||||||
|
|
||||||
|
('Testbrand Sunflower Oil 1L', 'Testbrand Sunflower Oil', 'Refined sunflower oil, light and neutral.',
|
||||||
|
'Oils & Ghee', 'IMG-TB-OIL-1L', '1 L', 'oil-1l', 'TB-OIL-1L', 'scrape',
|
||||||
|
'150-185', ARRAY['bigbasket','jiomart'], '99999999999999',
|
||||||
|
ARRAY['Vitamin E','Light frying'], ARRAY['Energy 900kcal','Fat 100g'],
|
||||||
|
'sunflower oil 1 litre', 'https://placehold.co/300x300?text=Oil1L',
|
||||||
|
ARRAY['https://placehold.co/300x300?text=Oil1L']),
|
||||||
|
|
||||||
|
-- No images at all. `ImportCatalogueProduct` only sets `productimages` when
|
||||||
|
-- the product has photos, so this row is the one that proves an import still
|
||||||
|
-- works when it does not — the case that used to hit the jsonb empty-string
|
||||||
|
-- failure in `products`.
|
||||||
|
('Testbrand Salt 1kg', 'Testbrand Iodised Salt', 'Free-flowing iodised salt.',
|
||||||
|
'Everyday', 'IMG-TB-SALT-1K', '1 kg', 'salt-1kg', 'TB-SALT-1K', 'scrape',
|
||||||
|
'20-28', ARRAY['jiomart'], NULL,
|
||||||
|
NULL, NULL, 'iodised salt 1kg', NULL, NULL);
|
||||||
|
|
||||||
|
-- ── A brand with only the core columns ──────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Discovery used to demand all eighteen columns, which made a table like this
|
||||||
|
-- INVISIBLE rather than merely thin — 16 of 35 live brands were unreachable
|
||||||
|
-- from this side for exactly that reason. Keeping one here means the
|
||||||
|
-- degraded-but-listed path is covered by the seed and stays covered.
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_sparsebrand (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
product_name TEXT NOT NULL,
|
||||||
|
price_range TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO brand_sparsebrand (product_name, price_range) VALUES
|
||||||
|
('Sparsebrand Biscuits 100g', '20-30'),
|
||||||
|
('Sparsebrand Tea 250g', '110-140');
|
||||||
232
init/nearledb/02-seed.sql
Normal file
232
init/nearledb/02-seed.sql
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
-- A synthetic shop to develop against.
|
||||||
|
--
|
||||||
|
-- INVENTED DATA, and that is the point. Copying rows out of production to get a
|
||||||
|
-- working local console puts real customers, real orders and real (cleartext)
|
||||||
|
-- passwords on a laptop — so this file makes up a merchant instead, which means
|
||||||
|
-- it can be committed, shared, and reset without anybody thinking about what is
|
||||||
|
-- in it.
|
||||||
|
--
|
||||||
|
-- Everything uses ids from 9000 up, well clear of anything real, so a local
|
||||||
|
-- database that has also had production rows loaded into it will not collide.
|
||||||
|
--
|
||||||
|
-- What it gives you:
|
||||||
|
--
|
||||||
|
-- * a complete merchant (9001 Testmart) — profile filled in, so the setup
|
||||||
|
-- walkthrough shows it finished
|
||||||
|
-- * an INCOMPLETE merchant (9002 Halfmart) with `categoryid = 0` — the exact
|
||||||
|
-- shape that broke `gettenantinfo` and made the profile step impossible to
|
||||||
|
-- finish. Worth keeping as a permanent regression fixture.
|
||||||
|
-- * two branches, so the "All branches" tenant-wide read has something to
|
||||||
|
-- aggregate and cannot silently show one outlet
|
||||||
|
-- * one account per role, so every workspace can be signed into
|
||||||
|
-- * products in each of the three app-visibility states — on sale, no stock,
|
||||||
|
-- no price — because those three are what most console bugs turn out to be
|
||||||
|
--
|
||||||
|
-- Passwords are the literal string below. Fiesta compares passwords in clear,
|
||||||
|
-- which is a real problem in production and simply a fact here.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ── Masters the tenant joins hang off ───────────────────────────────────────
|
||||||
|
INSERT INTO app_location (applocationid, locationname, latitude, longitude, radius)
|
||||||
|
VALUES (9001, 'Testville', '11.0168', '76.9558', 25000)
|
||||||
|
ON CONFLICT (applocationid) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO app_category (categoryid, categoryname)
|
||||||
|
VALUES (9001, 'Grocery')
|
||||||
|
ON CONFLICT (categoryid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Merchant one: complete ──────────────────────────────────────────────────
|
||||||
|
INSERT INTO tenants (
|
||||||
|
tenantid, tenantname, companyname, configid, categoryid, applocationid,
|
||||||
|
primaryemail, primarycontact, address, suburb, city, state, postcode,
|
||||||
|
latitude, longitude, tenantimage, tenantinfo, licenseno, registrationno,
|
||||||
|
minorder, approved, status
|
||||||
|
) VALUES (
|
||||||
|
9001, 'Testmart', 'Testmart Retail', 1, 9001, 9001,
|
||||||
|
'owner@testmart.invalid', '9000000001', '1 Test Street', 'Testville', 'Coimbatore',
|
||||||
|
'Tamil Nadu', '641001', '11.0168', '76.9558',
|
||||||
|
'https://placehold.co/200x200?text=Testmart', 'Daily needs and fresh produce.',
|
||||||
|
'12345678901234', 'REG-TESTMART-1',
|
||||||
|
99, 1, 'Active'
|
||||||
|
) ON CONFLICT (tenantid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Merchant two: the regression fixture ────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `categoryid = 0` matches no `app_category` row. Both master joins in
|
||||||
|
-- GetTenantByID were INNER, so this tenant came back as an all-zero record —
|
||||||
|
-- the console showed an empty profile form over a real business, and the setup
|
||||||
|
-- walkthrough's first step could never complete. Four of two hundred live
|
||||||
|
-- tenants are in this state. Keep it: it is the cheapest possible guard against
|
||||||
|
-- that join going back.
|
||||||
|
INSERT INTO tenants (
|
||||||
|
tenantid, tenantname, configid, categoryid, applocationid,
|
||||||
|
primaryemail, primarycontact, address, city, state, postcode, approved, status
|
||||||
|
) VALUES (
|
||||||
|
9002, 'Halfmart', 1, 0, 9001,
|
||||||
|
'owner@halfmart.invalid', '9000000002', '2 Test Street', 'Coimbatore',
|
||||||
|
'Tamil Nadu', '641002', 1, 'Active'
|
||||||
|
) ON CONFLICT (tenantid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Two branches, so tenant-wide reads have something to aggregate ──────────
|
||||||
|
INSERT INTO tenantlocations (
|
||||||
|
locationid, tenantid, locationname, email, contactno, address, suburb, city,
|
||||||
|
state, postcode, latitude, longitude, opentime, closetime, applocationid,
|
||||||
|
deliveryradius, deliverymins, status
|
||||||
|
) VALUES
|
||||||
|
(9101, 9001, 'Testmart Main', 'main@testmart.invalid', '9000000011',
|
||||||
|
'1 Test Street', 'Testville', 'Coimbatore', 'Tamil Nadu', '641001',
|
||||||
|
'11.0168', '76.9558', '08:00', '22:00', 9001, 5000, 30, 'Active'),
|
||||||
|
(9102, 9001, 'Testmart North', 'north@testmart.invalid', '9000000012',
|
||||||
|
'9 North Road', 'Northville', 'Coimbatore', 'Tamil Nadu', '641004',
|
||||||
|
'11.0500', '76.9600', '09:00', '21:00', 9001, 5000, 30, 'Active'),
|
||||||
|
(9103, 9002, 'Halfmart Main', 'main@halfmart.invalid', '9000000021',
|
||||||
|
'2 Test Street', 'Testville', 'Coimbatore', 'Tamil Nadu', '641002',
|
||||||
|
'11.0170', '76.9560', '08:00', '22:00', 9001, 5000, 30, 'Active')
|
||||||
|
ON CONFLICT (locationid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── One account per role ────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `authname` and `configid` are both set on every row. Login is
|
||||||
|
-- `WHERE authname = ? AND configid = ?` and never looks at the email column, so
|
||||||
|
-- an account missing either is created, listed, and refused at the sign-in
|
||||||
|
-- screen. That was a real bug; these rows are what it looks like done right.
|
||||||
|
--
|
||||||
|
-- roleid decides the workspace: 1 and 3 reach Store Admin, everything else is a
|
||||||
|
-- branch user. 7 and 8 are till accounts and are excluded from every
|
||||||
|
-- back-office query by the backend itself.
|
||||||
|
INSERT INTO app_users (
|
||||||
|
userid, authname, firstname, lastname, email, dialcode, contactno,
|
||||||
|
configid, roleid, password, tenantid, locationid, applocationid,
|
||||||
|
status, issuperadmin
|
||||||
|
) VALUES
|
||||||
|
(9201, 'admin@testmart.invalid', 'Tessa', 'Admin', 'admin@testmart.invalid',
|
||||||
|
'+91', '9000000101', 1, 3, 'localdev', 9001, 9101, 9001, 'Active', false),
|
||||||
|
(9202, 'main@testmart.invalid', 'Mani', 'Manager', 'main@testmart.invalid',
|
||||||
|
'+91', '9000000102', 1, 4, 'localdev', 9001, 9101, 9001, 'Active', false),
|
||||||
|
-- Hired, not yet placed. `locationid = 0` is the state the people screen
|
||||||
|
-- exists to resolve, and it was invisible until GetStaffs stopped INNER
|
||||||
|
-- JOINing tenantlocations.
|
||||||
|
(9203, 'newhire@testmart.invalid', 'Nila', 'Newhire', 'newhire@testmart.invalid',
|
||||||
|
'+91', '9000000103', 1, 4, 'localdev', 9001, 0, 9001, 'Active', false),
|
||||||
|
(9204, 'super@testmart.invalid', 'Sup', 'Ervisor', 'super@testmart.invalid',
|
||||||
|
'+91', '9000000104', 1, 7, 'localdev', 9001, 9101, 9001, 'Active', false),
|
||||||
|
(9205, 'cash@testmart.invalid', 'Cash', 'Ier', 'cash@testmart.invalid',
|
||||||
|
'+91', '9000000105', 1, 8, 'localdev', 9001, 9101, 9001, 'Active', false),
|
||||||
|
(9206, 'admin@halfmart.invalid', 'Hal', 'Admin', 'admin@halfmart.invalid',
|
||||||
|
'+91', '9000000201', 1, 3, 'localdev', 9002, 9103, 9001, 'Active', false)
|
||||||
|
ON CONFLICT (userid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Products, in each of the three visibility states ────────────────────────
|
||||||
|
--
|
||||||
|
-- categoryid 2 is what the customer app browses. A product filed anywhere else
|
||||||
|
-- is invisible to shoppers however well priced and stocked, which is the single
|
||||||
|
-- most common cause of "it is not showing in the app".
|
||||||
|
INSERT INTO products (
|
||||||
|
productid, tenantid, categoryid, subcategoryid, productname, productbrand,
|
||||||
|
productsku, productunit, productcost, retailprice, taxpercent, approve,
|
||||||
|
productimage, productdesc
|
||||||
|
) VALUES
|
||||||
|
(9301, 9001, 2, 0, 'Test Rice 5kg', 'Testbrand', 'TM-RICE-5K', '5kg',
|
||||||
|
320, 395, 5, 1, 'https://placehold.co/120x120?text=Rice', 'Everyday long grain'),
|
||||||
|
(9302, 9001, 2, 0, 'Test Oil 1L', 'Testbrand', 'TM-OIL-1L', '1L',
|
||||||
|
150, 198, 5, 1, 'https://placehold.co/120x120?text=Oil', 'Cooking oil'),
|
||||||
|
(9303, 9001, 2, 0, 'Test Biscuits 100g', 'Testbrand', 'TM-BISC-100', '100g',
|
||||||
|
18, 25, 12, 1, 'https://placehold.co/120x120?text=Biscuits', 'Sweet biscuits'),
|
||||||
|
-- Filed under no category: priced, released and stocked below, and still
|
||||||
|
-- invisible to the app. The state that catches everybody.
|
||||||
|
(9304, 9001, 0, 0, 'Test Uncategorised', 'Testbrand', 'TM-UNCAT', '1pc',
|
||||||
|
10, 15, 0, 1, 'https://placehold.co/120x120?text=Uncat', 'No category on purpose')
|
||||||
|
ON CONFLICT (productid) DO NOTHING;
|
||||||
|
|
||||||
|
-- price + publishedat = priced and released. Both are needed: a product with
|
||||||
|
-- one and not the other looks identical on the shelf and cannot be sold.
|
||||||
|
INSERT INTO productlocations (
|
||||||
|
productlocationid, tenantid, locationid, productid, price, status, publishedat
|
||||||
|
) VALUES
|
||||||
|
(9401, 9001, 9101, 9301, 395, 'Active', NOW()), -- on sale
|
||||||
|
(9402, 9001, 9101, 9302, 198, 'Active', NOW()), -- released, no stock below
|
||||||
|
(9403, 9001, 9101, 9303, 0, 'Active', NULL), -- no price, not released
|
||||||
|
(9404, 9001, 9101, 9304, 15, 'Active', NOW()), -- everything but a category
|
||||||
|
(9405, 9001, 9102, 9301, 395, 'Active', NOW()) -- same product, second branch
|
||||||
|
ON CONFLICT (productlocationid) DO NOTHING;
|
||||||
|
|
||||||
|
-- Stock is SUM(in) - SUM(out) per outlet, never a stored figure.
|
||||||
|
INSERT INTO productstocks (
|
||||||
|
productstockid, tenantid, locationid, productid, stockdate, stocktype, quantity, status
|
||||||
|
) VALUES
|
||||||
|
(9501, 9001, 9101, 9301, NOW(), 'in', 40, 'Active'),
|
||||||
|
(9502, 9001, 9101, 9301, NOW(), 'out', 5, 'Active'), -- balance 35
|
||||||
|
(9503, 9001, 9101, 9304, NOW(), 'in', 10, 'Active'),
|
||||||
|
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
|
||||||
|
ON CONFLICT (productstockid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── The platform operator ───────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Without this there is nobody who can open the Nearle Admin workspace, which
|
||||||
|
-- is the one this console was built for first. `resolveRole` checks
|
||||||
|
-- `issuperadmin` BEFORE roleid — deliberately, because the flag is derived by
|
||||||
|
-- the server and a roleid is just a number in a row — so no amount of role 1
|
||||||
|
-- gets you in without it, and every local session landed in Store Admin
|
||||||
|
-- instead. The accounts above are one per role and this was the role they were
|
||||||
|
-- missing.
|
||||||
|
--
|
||||||
|
-- Not attached to either merchant in spirit, only in columns: a platform
|
||||||
|
-- operator has to carry a tenantid because the column is not nullable, and
|
||||||
|
-- nothing in the admin workspace reads it.
|
||||||
|
INSERT INTO app_users (
|
||||||
|
userid, authname, firstname, lastname, email, dialcode, contactno,
|
||||||
|
configid, roleid, password, tenantid, locationid, applocationid,
|
||||||
|
status, issuperadmin
|
||||||
|
) VALUES
|
||||||
|
(9299, 'super@nearle.invalid', 'Nearle', 'Operator', 'super@nearle.invalid',
|
||||||
|
'+91', '9000009999', 1, 1, 'localdev', 9001, 9101, 9001, 'Active', true)
|
||||||
|
ON CONFLICT (userid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── The role ladder ─────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- `getstaffs` LEFT JOINs app_roles for `rolename`, so an empty table is not an
|
||||||
|
-- error — every person on Users & access simply reads "—" where their role
|
||||||
|
-- should be. The ids are the ones the rest of the system already assumes:
|
||||||
|
-- 1 and 3 reach Store Admin, 4 is a branch manager, 7 and 8 are till accounts
|
||||||
|
-- and are excluded from every back-office query by the backend itself.
|
||||||
|
INSERT INTO app_roles (roleid, rolename, configid) VALUES
|
||||||
|
(1, 'Super admin', 1),
|
||||||
|
(3, 'Admin', 1),
|
||||||
|
(4, 'Manager', 1),
|
||||||
|
(7, 'Supervisor', 1),
|
||||||
|
(8, 'Cashier', 1)
|
||||||
|
ON CONFLICT (roleid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Aisles under the category the customer app browses ──────────────────────
|
||||||
|
--
|
||||||
|
-- categoryid 2 is the only category the app lists, and the aisle a shopper
|
||||||
|
-- reads is the SUBCATEGORY. With none of these the sheet importer has nothing
|
||||||
|
-- to resolve a row's category against, so every imported product falls back to
|
||||||
|
-- subcategoryid 0 and lands under "Uncategorized".
|
||||||
|
INSERT INTO productsubcategories (subcatid, categoryid, tenantid, subcatname, status, sortorder)
|
||||||
|
VALUES
|
||||||
|
(9601, 2, 9001, 'Rice & Grains', 'Active', 1),
|
||||||
|
(9602, 2, 9001, 'Oils & Ghee', 'Active', 2),
|
||||||
|
(9603, 2, 9001, 'Snacks', 'Active', 3)
|
||||||
|
ON CONFLICT (subcatid) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Move the sequences past the seeded ids ──────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Everything above inserts an explicit id, which does NOT advance the sequence
|
||||||
|
-- behind that column. So the first tenant, outlet or product created against a
|
||||||
|
-- fresh local database came back as id 1 — harmless here, but it means local
|
||||||
|
-- ids look nothing like the ones the same code produces in production, and a
|
||||||
|
-- seed that ever collides with a sequence value fails on a duplicate key.
|
||||||
|
--
|
||||||
|
-- `GREATEST(..., 1)` because setval refuses a value below the sequence minimum,
|
||||||
|
-- and a table the seed does not touch is legitimately empty.
|
||||||
|
SELECT setval('tenants_tenantid_seq', GREATEST((SELECT COALESCE(MAX(tenantid),0) FROM tenants), 1));
|
||||||
|
SELECT setval('tenantlocations_locationid_seq', GREATEST((SELECT COALESCE(MAX(locationid),0) FROM tenantlocations), 1));
|
||||||
|
SELECT setval('app_users_userid_seq', GREATEST((SELECT COALESCE(MAX(userid),0) FROM app_users), 1));
|
||||||
|
SELECT setval('products_productid_seq', GREATEST((SELECT COALESCE(MAX(productid),0) FROM products), 1));
|
||||||
|
SELECT setval('productlocations_productlocationid_seq', GREATEST((SELECT COALESCE(MAX(productlocationid),0) FROM productlocations), 1));
|
||||||
|
SELECT setval('productstocks_productstockid_seq', GREATEST((SELECT COALESCE(MAX(productstockid),0) FROM productstocks), 1));
|
||||||
|
SELECT setval('customers_customerid_seq', GREATEST((SELECT COALESCE(MAX(customerid),0) FROM customers), 1));
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
585
main.go
585
main.go
@@ -1,13 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"nearle/config"
|
||||||
"nearle/db"
|
"nearle/db"
|
||||||
"nearle/facade"
|
"nearle/facade"
|
||||||
"nearle/messaging"
|
"nearle/messaging"
|
||||||
|
"nearle/middleware"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
|
"nearle/repositories"
|
||||||
"nearle/routes"
|
"nearle/routes"
|
||||||
|
"nearle/utils"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,33 +22,77 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
// corsSettings is a function so it can be tested.
|
||||||
godotenv.Load()
|
//
|
||||||
|
// Inline, it could only be checked by starting the server and pointing a real
|
||||||
|
// browser at it — which is how the missing Authorization header reached
|
||||||
|
// production in the first place.
|
||||||
|
func corsSettings() cors.Config {
|
||||||
|
return cors.Config{
|
||||||
|
AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Authorization",
|
||||||
|
AllowOrigins: "*",
|
||||||
|
AllowCredentials: false,
|
||||||
|
AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Loads `.env.<APP_ENV>` (default `.env.local`) and `.env`, then checks
|
||||||
|
// every required setting at once. Nothing below runs against a half
|
||||||
|
// configured environment — see config/config.go for the precedence rules.
|
||||||
|
cfg := config.MustLoad()
|
||||||
|
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
|
|
||||||
app.Use(cors.New(cors.Config{
|
// Cross-origin access.
|
||||||
AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Access-Control-Allow-Origin",
|
//
|
||||||
AllowOrigins: "*",
|
// The console is served from app.nearledaily.com and calls this host
|
||||||
AllowCredentials: true,
|
// directly, so every request it makes is cross-origin and the browser
|
||||||
AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS",
|
// decides whether to allow it from the headers below.
|
||||||
}))
|
//
|
||||||
|
// ── Authorization has to be listed ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// It was not, and adding the session token to the console broke every call
|
||||||
|
// the moment it shipped. A request carrying `Authorization` is no longer a
|
||||||
|
// "simple" request, so the browser stops and asks permission first — and the
|
||||||
|
// answer has to name that header explicitly. It was never needed before
|
||||||
|
// because the console sent nothing but `Accept` and `Content-Type`.
|
||||||
|
//
|
||||||
|
// The failure is worth recognising again: the preflight returns 204 and
|
||||||
|
// looks healthy in a terminal, the server logs nothing, and only the browser
|
||||||
|
// refuses. `curl` cannot reproduce it, because curl does not enforce CORS.
|
||||||
|
//
|
||||||
|
// ── Credentials off, wildcard on ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `AllowOrigins: "*"` with `AllowCredentials: true` is not a valid pair: a
|
||||||
|
// browser rejects a credentialed response that carries a wildcard origin.
|
||||||
|
// That combination was here already and was harmless only because nothing
|
||||||
|
// used credentials — it would have become a second, identical-looking bug
|
||||||
|
// the day anything did.
|
||||||
|
//
|
||||||
|
// Credentials means cookies and TLS client certs, and this backend uses
|
||||||
|
// neither: authentication is a Bearer token, which is an ordinary header and
|
||||||
|
// needs no credentialed mode. Nothing in the console, the app or the POS
|
||||||
|
// sets `credentials: 'include'`, so turning it off costs nothing and makes
|
||||||
|
// the pair legal.
|
||||||
|
//
|
||||||
|
// The wildcard itself is worth revisiting — it lets any site on the internet
|
||||||
|
// call this API from a browser, and the tenant guard is what stops that
|
||||||
|
// mattering. Narrowing it to the known console origins is a separate change,
|
||||||
|
// and one that breaks local development if the list is got wrong.
|
||||||
|
app.Use(cors.New(corsSettings()))
|
||||||
|
|
||||||
fmt.Println("🌐 Connecting to databases...")
|
fmt.Println("🌐 Connecting to databases...")
|
||||||
db.Connect()
|
db.Connect(cfg)
|
||||||
fmt.Println("✅ Database connections established!")
|
fmt.Println("✅ Database connections established!")
|
||||||
|
|
||||||
// Shared with the express backend. POS terminal presence lives here under a
|
// Shared with the express backend. POS terminal presence lives here under a
|
||||||
// TTL; optional, because losing the health board is an inconvenience and
|
// TTL; optional, because losing the health board is an inconvenience and
|
||||||
// losing a sale is not.
|
// losing a sale is not.
|
||||||
db.InitRedis()
|
db.InitRedis(cfg.Redis)
|
||||||
|
|
||||||
// Ensure schema is updated
|
// Ensure schema is updated
|
||||||
db.DB.AutoMigrate(&models.StockRequest{})
|
db.DB.AutoMigrate(&models.StockRequest{})
|
||||||
@@ -55,7 +104,486 @@ func main() {
|
|||||||
log.Fatal("POS schema migration failed:", err)
|
log.Fatal("POS schema migration failed:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
// What Nearle Buddy did, and on whose behalf. Its own table: these rows are
|
||||||
|
// written on a different schedule from anything else and are the only record
|
||||||
|
// of an assistant acting for a merchant.
|
||||||
|
//
|
||||||
|
// Logged and carried on rather than fatal, unlike the migrations around it,
|
||||||
|
// and the difference is deliberate. Those create tables the product cannot
|
||||||
|
// trade without — a POS order has nowhere to land if its table is missing.
|
||||||
|
// This one serves an assistant that may not even be switched on, and taking
|
||||||
|
// the whole backend down over it would stop every shop taking orders to
|
||||||
|
// protect a log.
|
||||||
|
//
|
||||||
|
// The degradation is already built: `DBAudit` writes to the log as well as
|
||||||
|
// the table, and reports each failed insert as AUDIT ROW LOST. So a missing
|
||||||
|
// table costs the queryable trail and nothing else, loudly.
|
||||||
|
if err := db.DB.AutoMigrate(&models.AssistantAudit{}); err != nil {
|
||||||
|
log.Printf("assistant: audit table unavailable, the trail is log-only: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift windows for till staff. Additive — `app_users.shiftid` already
|
||||||
|
// existed and pointed at the rider table, so an account with no shift is
|
||||||
|
// simply unassigned rather than broken.
|
||||||
|
if err := db.DB.AutoMigrate(&models.StaffShifts{}); err != nil {
|
||||||
|
log.Fatal("staff shift schema migration failed:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest of a product's photos.
|
||||||
|
//
|
||||||
|
// An explicit ALTER rather than AutoMigrate on `models.Products`: that model
|
||||||
|
// has drifted from the live table over time, and letting GORM reconcile the
|
||||||
|
// whole thing to add one column would rewrite far more than anyone asked
|
||||||
|
// for. `IF NOT EXISTS` makes it a no-op on every boot after the first.
|
||||||
|
//
|
||||||
|
// Not fatal on failure. A missing column costs the extra images and nothing
|
||||||
|
// else — `productimage` still carries the first — and refusing to start the
|
||||||
|
// API over a gallery would be the worse trade.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS productimages jsonb`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the global catalogue knew about this product, kept.
|
||||||
|
//
|
||||||
|
// The import copies eight of the catalogue's eighteen fields onto the
|
||||||
|
// tenant's product and left the other ten behind — among them the FSSAI
|
||||||
|
// licence, the nutrition lines, the highlights, the provider list, the
|
||||||
|
// price range and the variant key. The console needs exactly those to
|
||||||
|
// decide what to charge, so `ProductDrawer` went back to the catalogue for
|
||||||
|
// them on every open.
|
||||||
|
//
|
||||||
|
// That lookup is not a substitute for storing them. A tenant's product is a
|
||||||
|
// SNAPSHOT and outlives its source row: the catalogue is re-scraped, a
|
||||||
|
// variant is retired, and the licence number and the nutrition panel for a
|
||||||
|
// product the shop is still selling are gone with no way back. Measured
|
||||||
|
// locally by retiring one row — the product survived, everything the drawer
|
||||||
|
// shows about it did not.
|
||||||
|
//
|
||||||
|
// One jsonb column rather than six typed ones, and rather than the
|
||||||
|
// `productspecs` table that has sat unused since the schema was written.
|
||||||
|
// The value is a snapshot of somebody else's record, read as a whole and
|
||||||
|
// displayed as a whole — it is never joined, aggregated or filtered — and
|
||||||
|
// the catalogue grows fields faster than this side can add migrations.
|
||||||
|
// Postgres can still reach inside it (`cataloguefacts->>'fssai_license'`)
|
||||||
|
// on the day somebody needs to. `productimages` beside it made the same
|
||||||
|
// call for the same reason.
|
||||||
|
//
|
||||||
|
// Not fatal on failure, exactly like the column above: a product without
|
||||||
|
// its catalogue facts is the product we have today.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS cataloguefacts jsonb`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether this shop shows a health score for this product.
|
||||||
|
//
|
||||||
|
// The shopkeeper's call, not ours. The score comes from a third party that
|
||||||
|
// matches a reference product by name — often at under 60% confidence — so a
|
||||||
|
// merchant who knows the packet in front of them may reasonably decide the
|
||||||
|
// rating does not describe what they are selling, and should be able to take
|
||||||
|
// it off their own shelf without taking it off everybody's.
|
||||||
|
//
|
||||||
|
// DEFAULT TRUE, so every product already imported keeps showing exactly what
|
||||||
|
// it shows today. A new column defaulting to false would silently strip the
|
||||||
|
// health score from every shelf on the platform, which is a change nobody
|
||||||
|
// asked for dressed up as a migration.
|
||||||
|
//
|
||||||
|
// Only the score. `nutrition` is unaffected and always sent: the figures are
|
||||||
|
// what the packet says, while the score is somebody's judgement of them.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS showhealthscore boolean NOT NULL DEFAULT true`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a shop delivers, and which window an order chose.
|
||||||
|
//
|
||||||
|
// Three named windows a day per BRANCH — see models/deliveryslot.go for why
|
||||||
|
// the scope is the branch and not the company.
|
||||||
|
//
|
||||||
|
// ── A branch with no rows here still trades ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Every tenant on the platform the day this ships has no slots, and all of
|
||||||
|
// them must keep taking orders exactly as before. No backfill, no defaults
|
||||||
|
// written here: absence means "order without a slot", and the app is
|
||||||
|
// required to treat an empty list as ordinary rather than as a closed shop.
|
||||||
|
// Seeding every existing branch with invented timings would have each one
|
||||||
|
// promising hours nobody agreed to.
|
||||||
|
if err := db.DB.Exec(`CREATE TABLE IF NOT EXISTS deliveryslots (
|
||||||
|
slotid SERIAL PRIMARY KEY,
|
||||||
|
tenantid INTEGER NOT NULL,
|
||||||
|
locationid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
slotkey TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
starttime TEXT NOT NULL,
|
||||||
|
endtime TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not create deliveryslots, delivery windows will be unavailable:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per key per branch. A shop has ONE morning, and a duplicate would
|
||||||
|
// show the shopper the same window twice with different hours — so the
|
||||||
|
// upsert that writes these leans on this constraint rather than on a
|
||||||
|
// read-then-write that two requests could interleave.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS deliveryslots_branch_key
|
||||||
|
ON deliveryslots (tenantid, locationid, slotkey)`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add the deliveryslots uniqueness index, a branch may end up with duplicate windows:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window an order chose, and the day it falls on.
|
||||||
|
//
|
||||||
|
// Both nullable, and both stay empty for every order placed without a slot —
|
||||||
|
// which is every order today and every order from a branch that never sets
|
||||||
|
// timings. Nothing downstream may require them.
|
||||||
|
//
|
||||||
|
// The DATE is not redundant. "evening" cannot say tonight or tomorrow night,
|
||||||
|
// and an order placed after the last window closes is for the next day.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotid INTEGER`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add orders.deliveryslotid, orders will not record a delivery window:", err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotdate DATE`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a branch is trading today.
|
||||||
|
//
|
||||||
|
// SEPARATE FROM tenantlocations.status, which is the platform's word for
|
||||||
|
// whether the branch exists at all — setting that to Inactive is how a
|
||||||
|
// branch is decommissioned, and the staff console reads it that way. A
|
||||||
|
// shopkeeper closing for an afternoon is saying something else entirely,
|
||||||
|
// and sharing one column would make a day off look like a shop that shut
|
||||||
|
// down.
|
||||||
|
//
|
||||||
|
// Default TRUE, so every branch trading the moment this ships keeps
|
||||||
|
// trading. There is no backfill and there must never be one.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS isopen boolean NOT NULL DEFAULT true`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add tenantlocations.isopen, every branch will keep reporting as open:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The day it opens again, when the shop knows it.
|
||||||
|
//
|
||||||
|
// Nullable, because a power cut has no end date. Nothing flips isopen back
|
||||||
|
// on a timer — models.StoreIsOpen works the answer out on read, so a branch
|
||||||
|
// that said "back on Monday" is open on Monday whether or not a job ran.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS closeduntil DATE`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add tenantlocations.closeduntil, branches will not reopen on their own:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a product became visible to a store, and the only thing that decides
|
||||||
|
// whether it is.
|
||||||
|
//
|
||||||
|
// `productlocations.status` cannot do this job and was never able to:
|
||||||
|
// syncProductLocationStatus overwrites it with 'available'/'outofstock' on
|
||||||
|
// every stock movement, so the 'Draft' the import wrote survived on exactly
|
||||||
|
// one row out of 6,755. A separate column is untouched by that, and "when
|
||||||
|
// was this published" is worth knowing regardless.
|
||||||
|
//
|
||||||
|
// The backfill is not optional and must land in the same deploy as the
|
||||||
|
// column. Membership of a store catalogue was the *existence* of the row,
|
||||||
|
// so reading `publishedat IS NOT NULL` without it empties every shop on the
|
||||||
|
// platform at once.
|
||||||
|
//
|
||||||
|
// The backfill runs ONCE — only on the boot that adds the column.
|
||||||
|
//
|
||||||
|
// It used to run on every boot, and that quietly cancelled the whole point
|
||||||
|
// of the column. `WHERE publishedat IS NULL` matches a legacy row on the
|
||||||
|
// first boot and, on every boot after, matches exactly the products
|
||||||
|
// somebody is deliberately holding back: imported, not yet priced, waiting
|
||||||
|
// for review. A restart published all of them and backdated the release to
|
||||||
|
// the row's `created`, so it did not even look recent.
|
||||||
|
//
|
||||||
|
// Observed 2026-08-31: seventeen products imported at R mart at 15:55 sat
|
||||||
|
// correctly unpublished, and the next deploy published all seventeen
|
||||||
|
// stamped 15:55. With several deploys a day, the admin-catalogue tier could
|
||||||
|
// not survive an afternoon.
|
||||||
|
//
|
||||||
|
// Detecting "the column was just added" rather than tracking a migration
|
||||||
|
// version: the question is answerable from the schema itself, so it needs
|
||||||
|
// no new table and cannot drift out of step with one.
|
||||||
|
var hadPublishedAt int64
|
||||||
|
if err := db.DB.Raw(`
|
||||||
|
SELECT COUNT(1) FROM information_schema.columns
|
||||||
|
WHERE table_name = 'productlocations' AND column_name = 'publishedat'`).
|
||||||
|
Scan(&hadPublishedAt).Error; err != nil {
|
||||||
|
log.Fatal("could not check productlocations.publishedat:", err)
|
||||||
|
}
|
||||||
|
if hadPublishedAt == 0 {
|
||||||
|
// First boot with the column. Every existing row predates publication
|
||||||
|
// as a concept, and membership of this table WAS publication — so
|
||||||
|
// leaving them null would empty every shop on the platform at once.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat timestamp`).Error; err != nil {
|
||||||
|
log.Fatal("could not add productlocations.publishedat:", err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(`
|
||||||
|
UPDATE productlocations
|
||||||
|
SET publishedat = COALESCE(created, NOW())
|
||||||
|
WHERE publishedat IS NULL`).Error; err != nil {
|
||||||
|
log.Fatal("could not backfill productlocations.publishedat:", err)
|
||||||
|
}
|
||||||
|
log.Println("productlocations.publishedat added and backfilled (one time)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key generator for productvariants.variantid.
|
||||||
|
//
|
||||||
|
// The column is NOT NULL with no default and no identity, unlike
|
||||||
|
// products.productid next door which is an identity column. So every insert
|
||||||
|
// had to supply the id by hand, and GORM does not — it sent nothing and
|
||||||
|
// Postgres refused the row with a not-null violation. That is why variants
|
||||||
|
// could never be attached to a product: the write could not land at all.
|
||||||
|
//
|
||||||
|
// Guarded on the absence of a default rather than tracked as a migration
|
||||||
|
// version, matching the checks above: the question is answerable from the
|
||||||
|
// schema itself. The sequence starts above whatever ids are already there,
|
||||||
|
// so the two rows on production keep theirs.
|
||||||
|
var variantKeyed int64
|
||||||
|
if err := db.DB.Raw(`
|
||||||
|
SELECT COUNT(1) FROM information_schema.columns
|
||||||
|
WHERE table_name = 'productvariants' AND column_name = 'variantid'
|
||||||
|
AND (column_default IS NOT NULL OR is_identity = 'YES')`).
|
||||||
|
Scan(&variantKeyed).Error; err != nil {
|
||||||
|
log.Fatal("could not check productvariants.variantid:", err)
|
||||||
|
}
|
||||||
|
if variantKeyed == 0 {
|
||||||
|
if err := db.DB.Exec(`
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS productvariants_variantid_seq
|
||||||
|
START WITH 1 OWNED BY productvariants.variantid`).Error; err != nil {
|
||||||
|
log.Fatal("could not create productvariants_variantid_seq:", err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(`
|
||||||
|
SELECT setval('productvariants_variantid_seq',
|
||||||
|
COALESCE((SELECT MAX(variantid) FROM productvariants), 0) + 1, false)`).Error; err != nil {
|
||||||
|
log.Fatal("could not position productvariants_variantid_seq:", err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(`
|
||||||
|
ALTER TABLE productvariants
|
||||||
|
ALTER COLUMN variantid SET DEFAULT nextval('productvariants_variantid_seq')`).Error; err != nil {
|
||||||
|
log.Fatal("could not default productvariants.variantid:", err)
|
||||||
|
}
|
||||||
|
log.Println("productvariants.variantid given a key generator (one time)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key generators for the two partner tables, for exactly the reason above.
|
||||||
|
//
|
||||||
|
// `partnerinfo.partnerid` and `partnerlocations.partnerlocationid` are both
|
||||||
|
// NOT NULL with no default and no identity, so GORM — which sends nothing
|
||||||
|
// for a key it expects the database to mint — had every insert refused with
|
||||||
|
// a not-null violation. `createpartner` therefore could not write a partner
|
||||||
|
// OR its regions: the endpoint exists, the form exists, and the row could
|
||||||
|
// never land. The five partners on the platform were all inserted by hand,
|
||||||
|
// which is the symptom rather than a choice.
|
||||||
|
//
|
||||||
|
// This matters more than one broken button. `GetPartners` now separates the
|
||||||
|
// partners registered through this console from the ones another product
|
||||||
|
// left in the shared `partnerinfo` by joining `partnerlocations` — and only
|
||||||
|
// a successful create writes that table. Without a key generator no partner
|
||||||
|
// can ever be registered, so nothing would ever have a link row and the
|
||||||
|
// Rider partners page would be empty forever.
|
||||||
|
//
|
||||||
|
// Both sequences start above the ids already there, so the hand-inserted
|
||||||
|
// rows keep theirs.
|
||||||
|
for _, key := range []struct{ table, column string }{
|
||||||
|
{"partnerinfo", "partnerid"},
|
||||||
|
{"partnerlocations", "partnerlocationid"},
|
||||||
|
} {
|
||||||
|
var keyed int64
|
||||||
|
if err := db.DB.Raw(`
|
||||||
|
SELECT COUNT(1) FROM information_schema.columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
AND (column_default IS NOT NULL OR is_identity = 'YES')`,
|
||||||
|
key.table, key.column).Scan(&keyed).Error; err != nil {
|
||||||
|
log.Fatalf("could not check %s.%s: %v", key.table, key.column, err)
|
||||||
|
}
|
||||||
|
if keyed > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := key.table + "_" + key.column + "_seq"
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS %s START WITH 1 OWNED BY %s.%s`,
|
||||||
|
seq, key.table, key.column)).Error; err != nil {
|
||||||
|
log.Fatalf("could not create %s: %v", seq, err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`SELECT setval('%s', COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false)`,
|
||||||
|
seq, key.column, key.table)).Error; err != nil {
|
||||||
|
log.Fatalf("could not position %s: %v", seq, err)
|
||||||
|
}
|
||||||
|
if err := db.DB.Exec(fmt.Sprintf(
|
||||||
|
`ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')`,
|
||||||
|
key.table, key.column, seq)).Error; err != nil {
|
||||||
|
log.Fatalf("could not default %s.%s: %v", key.table, key.column, err)
|
||||||
|
}
|
||||||
|
log.Printf("%s.%s given a key generator (one time)", key.table, key.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue's own stable key for an imported product.
|
||||||
|
//
|
||||||
|
// `catalogueid` was never able to be this. The catalogue is rebuilt by
|
||||||
|
// scrape and renumbered every time — pepsico's live ids run 3, 6, 9 … 27,
|
||||||
|
// 30 — so a product imported when it was id 26 now points at nothing.
|
||||||
|
// Measured 2026-08-31: eleven of the nineteen links on the platform were
|
||||||
|
// dangling, which silently breaks three things (the "already imported"
|
||||||
|
// ticks, re-importing, and dedupe on the next scrape).
|
||||||
|
//
|
||||||
|
// Additive and nullable: every existing row keeps working, and a re-import
|
||||||
|
// or `/relinkcatalogue` is how one acquires the key.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS imageid text`).Error; err != nil {
|
||||||
|
log.Fatal("could not add products.imageid:", err)
|
||||||
|
}
|
||||||
|
// Not unique: two tenants legitimately stock the same catalogue product,
|
||||||
|
// and each keeps its own snapshot row. The lookup is always per tenant.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`CREATE INDEX IF NOT EXISTS products_tenant_imageid_idx
|
||||||
|
ON products (tenantid, imageid)`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.imageid index:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipts for spreadsheets sent to the catalogue ingest service.
|
||||||
|
//
|
||||||
|
// The ingest service holds a drop on its own terms: an unreviewed one is
|
||||||
|
// deleted after seven days, the list of a sender's drops needs a credential
|
||||||
|
// production does not issue, and the batch id — which is the ONLY
|
||||||
|
// credential for reading a result back — is handed out once, to a browser.
|
||||||
|
// Lose it and the upload becomes unfindable even to the person who sent it.
|
||||||
|
// This table is where we keep it.
|
||||||
|
//
|
||||||
|
// Raw SQL rather than AutoMigrate, matching the two ALTERs above: GORM
|
||||||
|
// reconciling a model against a live table has rewritten more than was
|
||||||
|
// asked for here before, and `IF NOT EXISTS` makes this a no-op after the
|
||||||
|
// first boot.
|
||||||
|
if err := db.DB.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS catalogueuploads (
|
||||||
|
uploadid SERIAL PRIMARY KEY,
|
||||||
|
tenantid INT NOT NULL,
|
||||||
|
locationid INT NOT NULL DEFAULT 0,
|
||||||
|
categoryid INT NOT NULL DEFAULT 0,
|
||||||
|
batchid VARCHAR(64) NOT NULL,
|
||||||
|
runid VARCHAR(64),
|
||||||
|
filename TEXT,
|
||||||
|
sender TEXT,
|
||||||
|
uploadedby INT NOT NULL DEFAULT 0,
|
||||||
|
uploadedname TEXT,
|
||||||
|
rowcount INT NOT NULL DEFAULT 0,
|
||||||
|
laststatus VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
inserted INT NOT NULL DEFAULT 0,
|
||||||
|
backfilled INT NOT NULL DEFAULT 0,
|
||||||
|
skipped INT NOT NULL DEFAULT 0,
|
||||||
|
rejected INT NOT NULL DEFAULT 0,
|
||||||
|
shelvedcount INT NOT NULL DEFAULT 0,
|
||||||
|
skippedcount INT NOT NULL DEFAULT 0,
|
||||||
|
shelvedat TIMESTAMP,
|
||||||
|
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`).Error; err != nil {
|
||||||
|
log.Fatal("could not create catalogueuploads:", err)
|
||||||
|
}
|
||||||
|
// The upload is recorded the instant the drop is accepted and then polled,
|
||||||
|
// so a refresh, a retried request or a second tab would each insert the same
|
||||||
|
// receipt again. The constraint is what makes `Record` idempotent — without
|
||||||
|
// it one upload shows up three times and none of them is wrong.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS catalogueuploads_batchid_key
|
||||||
|
ON catalogueuploads (batchid)`).Error; err != nil {
|
||||||
|
log.Fatal("could not add catalogueuploads.batchid unique index:", err)
|
||||||
|
}
|
||||||
|
// Every read is "this shop's uploads, newest first".
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`CREATE INDEX IF NOT EXISTS catalogueuploads_scope_idx
|
||||||
|
ON catalogueuploads (tenantid, locationid, created DESC)`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add catalogueuploads scope index:", err)
|
||||||
|
}
|
||||||
|
// The sheet's own prices and opening stock, kept so shelving can happen
|
||||||
|
// after the browser that uploaded it is gone.
|
||||||
|
//
|
||||||
|
// Added separately from the CREATE above because the table already exists in
|
||||||
|
// production without it. The ingest service holds none of this — its
|
||||||
|
// catalogue is shared by every merchant and carries no price and no stock —
|
||||||
|
// so before this column the join could only be made in the tab that did the
|
||||||
|
// upload, and that tab is normally long closed by the time their admin
|
||||||
|
// releases the drop and the run finishes.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE catalogueuploads ADD COLUMN IF NOT EXISTS sheetrows jsonb`).Error; err != nil {
|
||||||
|
log.Fatal("could not add catalogueuploads.sheetrows:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model behind scan-to-order. Optional: without EMBEDDING_PROVIDER the
|
||||||
|
// search matches on words, which works but ranks less well.
|
||||||
|
embedder, err := utils.NewEmbedder(cfg.Embedding)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("embedding provider:", err)
|
||||||
|
}
|
||||||
|
if embedder == nil {
|
||||||
|
log.Println("scan: EMBEDDING_PROVIDER not set, product search is text-only")
|
||||||
|
} else {
|
||||||
|
log.Printf("scan: product search uses %s/%s", cfg.Embedding.Provider, cfg.Embedding.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model behind Nearle Buddy. Optional in the same way: without
|
||||||
|
// ASSISTANT_PROVIDER the tools still work and the panel says the assistant
|
||||||
|
// is not switched on, rather than the console showing a field that accepts
|
||||||
|
// text and swallows it.
|
||||||
|
chat, err := utils.NewChat(cfg.Assistant)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("assistant provider:", err)
|
||||||
|
}
|
||||||
|
if chat == nil {
|
||||||
|
log.Printf("assistant: OFF — %s", cfg.Assistant.Why())
|
||||||
|
} else {
|
||||||
|
log.Printf("assistant: %s, balanced tier is %s", cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASSISTANT_AGENTS_DIR replaces the compiled-in agent definitions wholesale.
|
||||||
|
// Empty uses the embedded ones, so a deployment cannot be broken by a missing
|
||||||
|
// directory.
|
||||||
|
// Mail, for the invitation a newly onboarded merchant is sent.
|
||||||
|
//
|
||||||
|
// Optional in the same way as the model and the embedder: without it the
|
||||||
|
// server still boots and still onboards tenants, and the create response
|
||||||
|
// says the invitation was not sent and which variable is missing. Refusing
|
||||||
|
// to start would make a mail relay a hard dependency of creating a shop,
|
||||||
|
// which it is not.
|
||||||
|
mailer, err := utils.NewMailer(cfg.Mail)
|
||||||
|
if err != nil {
|
||||||
|
// A configured-but-invalid sender, as opposed to no mail at all. That
|
||||||
|
// fails every message, so it is worth stopping for rather than
|
||||||
|
// discovering one silent invitation at a time.
|
||||||
|
log.Fatal("mail:", err)
|
||||||
|
}
|
||||||
|
if mailer == nil {
|
||||||
|
log.Printf("mail: OFF — %s", cfg.Mail.Why())
|
||||||
|
} else {
|
||||||
|
log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NUTRITION_BASE is the catalogue-intelligence host — the same service the
|
||||||
|
// console reads its health score card from. Unset means product screens
|
||||||
|
// carry no nutrition panel, and nothing else changes.
|
||||||
|
//
|
||||||
|
// Logged for the same reason mail is, and learned the same way: with it
|
||||||
|
// unset, `getproductbyvariant` simply omits `nutrition` and `healthscore`,
|
||||||
|
// which is indistinguishable from a product the service has not scored.
|
||||||
|
// A deploy that silently does nothing is one somebody has to reverse
|
||||||
|
// engineer from the outside, and this line is the difference.
|
||||||
|
nutritionBase := strings.TrimSpace(os.Getenv("NUTRITION_BASE"))
|
||||||
|
if nutritionBase == "" {
|
||||||
|
log.Printf("nutrition: OFF — NUTRITION_BASE is not set, so no product carries a nutrition panel or health score")
|
||||||
|
} else {
|
||||||
|
log.Printf("nutrition: reading panels and scores from %s", nutritionBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat,
|
||||||
|
os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail,
|
||||||
|
nutritionBase)
|
||||||
|
|
||||||
routes.RegisterRoutes(app, f)
|
routes.RegisterRoutes(app, f)
|
||||||
|
|
||||||
@@ -65,14 +593,43 @@ func main() {
|
|||||||
//
|
//
|
||||||
// A broker that is configured but unreachable is fatal on purpose: coming
|
// A broker that is configured but unreachable is fatal on purpose: coming
|
||||||
// up healthy while every till quietly queues is the worse failure.
|
// up healthy while every till quietly queues is the worse failure.
|
||||||
|
// The consumer is also the publisher for `nearle/pos/{loc}/catalogue`.
|
||||||
|
// Registering it here inverts the dependency: the repositories that move
|
||||||
|
// stock cannot import `messaging` — wiring runs this way and the reverse
|
||||||
|
// would be a cycle — so they hold an interface and this supplies it. Left
|
||||||
|
// unset the notification is simply skipped, which is what happens when the
|
||||||
|
// broker is unavailable and must not stop the API booting.
|
||||||
|
// The console's live stream listens to the same broker, read-only, and on
|
||||||
|
// EVERY replica — unlike the ingest consumer below, which is elected. A
|
||||||
|
// console connected to a non-elected replica must still see its tills.
|
||||||
|
// Never fatal: no broker simply means the console keeps polling.
|
||||||
|
messaging.StartLiveHub()
|
||||||
|
|
||||||
posMqtt, err := messaging.StartPosMqttConsumer(f.PosService())
|
posMqtt, err := messaging.StartPosMqttConsumer(f.PosService())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("POS MQTT consumer failed to start:", err)
|
log.Fatal("POS MQTT consumer failed to start:", err)
|
||||||
}
|
}
|
||||||
|
if posMqtt != nil {
|
||||||
|
repositories.SetCatalogueNotifier(posMqtt)
|
||||||
|
}
|
||||||
|
|
||||||
// Start server
|
// How much of the till fleet is carrying a session token, in the log every
|
||||||
|
// half hour.
|
||||||
|
//
|
||||||
|
// `POS_AUTH_REQUIRED` is off, and the only thing between here and switching
|
||||||
|
// it on is that number — nothing was recording it, so an untokened till was
|
||||||
|
// waved through in silence and the risk of flipping the flag could only be
|
||||||
|
// measured by flipping it. The same figures are on
|
||||||
|
// `GET /v1/web/pos/authadoption`, behind the session guard.
|
||||||
|
go middleware.LogPosAdoption(context.Background())
|
||||||
|
|
||||||
|
// Start server on APP_PORT (1122 locally, 1009 in production — see the
|
||||||
|
// env files). Running a second copy beside something else is a one-line
|
||||||
|
// change there rather than here.
|
||||||
|
port := cfg.Port
|
||||||
go func() {
|
go func() {
|
||||||
if err := app.Listen(":1122"); err != nil {
|
log.Printf("🚀 listening on :%s", port)
|
||||||
|
if err := app.Listen(":" + port); err != nil {
|
||||||
log.Fatal("Server failed to start:", err)
|
log.Fatal("Server failed to start:", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
113
main_test.go
Normal file
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
356
messaging/livehub.go
Normal file
356
messaging/livehub.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
package messaging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Live console events.
|
||||||
|
//
|
||||||
|
// The console polls every 30 seconds. That is a safe floor, and it means a bill
|
||||||
|
// rung at 10:00:01 shows up on the owner's screen at 10:00:30. This closes that
|
||||||
|
// gap: the tills already publish every sale to the broker, so a second consumer
|
||||||
|
// listens to the same stream and pushes a nudge to whichever consoles are
|
||||||
|
// watching that outlet.
|
||||||
|
//
|
||||||
|
// Two things it deliberately is NOT:
|
||||||
|
//
|
||||||
|
// - **It does not carry data.** An event says "stock at outlet 1185 changed,
|
||||||
|
// these product ids are involved" and nothing more. The console then re-reads
|
||||||
|
// through the normal API, which is the only thing that knows the authoritative
|
||||||
|
// number after locks, dedup and rejections. Pushing figures from here would
|
||||||
|
// mean a screen showing a total the database never agreed to.
|
||||||
|
// - **It does not replace the poll.** The console keeps its 30-second refetch.
|
||||||
|
// A dropped connection, a full buffer or a replica restart then costs latency
|
||||||
|
// rather than correctness, which is the trade an operations screen wants.
|
||||||
|
//
|
||||||
|
// ── Why a second consumer and not a hook in the ingest path ──────────────────
|
||||||
|
//
|
||||||
|
// `StartPosMqttConsumer` runs on ONE replica (see `posConsumerElected`) because
|
||||||
|
// two writers ingesting the same bills would fight over the same rows. Hanging
|
||||||
|
// this off that consumer would mean only consoles that happened to land on the
|
||||||
|
// elected replica ever received anything, which is a bug that looks like flaky
|
||||||
|
// network. This client only reads and touches no database, so every replica can
|
||||||
|
// safely run one and serve its own connected consoles.
|
||||||
|
//
|
||||||
|
// Enabled by MQTT_URL, the same switch the ingest consumer uses. Unset, the hub
|
||||||
|
// still runs and simply never emits — the console degrades to polling, which is
|
||||||
|
// exactly what it does today.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// How many events a single console connection may fall behind before it
|
||||||
|
// starts losing them. Small on purpose: these are nudges, and a client that
|
||||||
|
// cannot keep up with a handful is better served by its next poll than by a
|
||||||
|
// backlog of stale ones.
|
||||||
|
liveBuffer = 16
|
||||||
|
|
||||||
|
// Belt and braces around a burst. A terminal replaying a day of offline
|
||||||
|
// bills would otherwise emit one event per batch as fast as the broker can
|
||||||
|
// deliver them; the console cannot use more than a couple a second.
|
||||||
|
liveMinInterval = 400 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveEvent is one nudge. Kept small — it crosses the wire on every sale.
|
||||||
|
type LiveEvent struct {
|
||||||
|
// "sale", "customer" or "terminal".
|
||||||
|
Type string `json:"type"`
|
||||||
|
// tenantlocations.locationid. The topic's store segment carries it as a
|
||||||
|
// string (see models.PosOrderBatch).
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
// Which products moved, when the event knows. Empty means "something did".
|
||||||
|
Productids []int `json:"productids,omitempty"`
|
||||||
|
// Which till, for the presence board.
|
||||||
|
Terminalid string `json:"terminalid,omitempty"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type liveSubscriber struct {
|
||||||
|
ch chan LiveEvent
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveHub fans events out to the consoles currently watching an outlet.
|
||||||
|
type LiveHub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
subscribers map[int]map[*liveSubscriber]struct{}
|
||||||
|
client mqtt.Client
|
||||||
|
dropped uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub is the process-wide hub. Nil until StartLiveHub runs; every method
|
||||||
|
// tolerates a nil receiver so callers never have to check.
|
||||||
|
var Hub *LiveHub
|
||||||
|
|
||||||
|
// StartLiveHub creates the hub and, if a broker is configured, connects a
|
||||||
|
// read-only consumer to it.
|
||||||
|
//
|
||||||
|
// Never returns nil: a hub with no broker is a working hub with no events, and
|
||||||
|
// the SSE endpoint must still accept connections so the console's reconnect
|
||||||
|
// logic has something to talk to.
|
||||||
|
func StartLiveHub() *LiveHub {
|
||||||
|
hub := &LiveHub{subscribers: make(map[int]map[*liveSubscriber]struct{})}
|
||||||
|
Hub = hub
|
||||||
|
|
||||||
|
url := strings.TrimSpace(os.Getenv("MQTT_URL"))
|
||||||
|
if url == "" {
|
||||||
|
log.Println("live: MQTT_URL not set, console event stream will stay quiet")
|
||||||
|
return hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// A distinct client id per replica. Sharing one with the ingest consumer
|
||||||
|
// would make the broker disconnect whichever connected first.
|
||||||
|
clientID := "nearle-console-live-" + hostSuffix()
|
||||||
|
|
||||||
|
opts := mqtt.NewClientOptions().
|
||||||
|
AddBroker(url).
|
||||||
|
SetClientID(clientID).
|
||||||
|
SetAutoReconnect(true).
|
||||||
|
SetConnectRetry(true).
|
||||||
|
SetConnectRetryInterval(5 * time.Second).
|
||||||
|
SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise.
|
||||||
|
SetOrderMatters(false)
|
||||||
|
|
||||||
|
// MQTT_USER is the name the ingest consumer and the env files use. This
|
||||||
|
// read MQTT_USERNAME for a while, so the live stream connected to the
|
||||||
|
// production broker with no credentials at all; MQTT_USERNAME is still
|
||||||
|
// honoured for any deployment that set it.
|
||||||
|
user := strings.TrimSpace(os.Getenv("MQTT_USER"))
|
||||||
|
if user == "" {
|
||||||
|
user = strings.TrimSpace(os.Getenv("MQTT_USERNAME"))
|
||||||
|
}
|
||||||
|
if user != "" {
|
||||||
|
opts.SetUsername(user)
|
||||||
|
opts.SetPassword(os.Getenv("MQTT_PASSWORD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.OnConnect = func(client mqtt.Client) {
|
||||||
|
for topic, handler := range map[string]mqtt.MessageHandler{
|
||||||
|
topicOrders: hub.onOrders,
|
||||||
|
topicCustomers: hub.onCustomers,
|
||||||
|
topicHealth: hub.onHealth,
|
||||||
|
} {
|
||||||
|
// QoS 0. A missed nudge costs at most one poll interval, and QoS 1
|
||||||
|
// would have the broker retaining state for a listener that does
|
||||||
|
// not need it.
|
||||||
|
if token := client.Subscribe(topic, 0, handler); token.Wait() && token.Error() != nil {
|
||||||
|
log.Printf("live: could not subscribe to %s: %v", topic, token.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("live: watching %s", topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := mqtt.NewClient(opts)
|
||||||
|
hub.client = client
|
||||||
|
|
||||||
|
// Connect in the background: a broker that is slow to answer must not hold
|
||||||
|
// up the HTTP server coming online.
|
||||||
|
go func() {
|
||||||
|
if token := client.Connect(); token.Wait() && token.Error() != nil {
|
||||||
|
log.Printf("live: broker unreachable, console falls back to polling: %v", token.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return hub
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subscription ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// Subscribe registers a console connection watching one outlet.
|
||||||
|
//
|
||||||
|
// Returns the channel to read and the function that releases it. The caller
|
||||||
|
// MUST call release — an SSE handler that returns without it leaks a channel
|
||||||
|
// the hub goes on writing to for the life of the process.
|
||||||
|
func (h *LiveHub) Subscribe(locationid int) (<-chan LiveEvent, func()) {
|
||||||
|
if h == nil {
|
||||||
|
// A closed channel reads immediately and forever, which would spin the
|
||||||
|
// handler. An open one that never delivers is the honest no-op.
|
||||||
|
return make(chan LiveEvent), func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := &liveSubscriber{ch: make(chan LiveEvent, liveBuffer)}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.subscribers[locationid] == nil {
|
||||||
|
h.subscribers[locationid] = make(map[*liveSubscriber]struct{})
|
||||||
|
}
|
||||||
|
h.subscribers[locationid][sub] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
release := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
if set := h.subscribers[locationid]; set != nil {
|
||||||
|
delete(set, sub)
|
||||||
|
if len(set) == 0 {
|
||||||
|
delete(h.subscribers, locationid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
close(sub.ch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sub.ch, release
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast delivers an event to everyone watching that outlet.
|
||||||
|
//
|
||||||
|
// Never blocks. A subscriber whose buffer is full loses the event and is
|
||||||
|
// counted rather than waited for: one wedged console must not stall the fan-out
|
||||||
|
// to every other console, and the poll will collect what was missed.
|
||||||
|
func (h *LiveHub) Broadcast(event LiveEvent) {
|
||||||
|
if h == nil || event.Locationid == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.At == "" {
|
||||||
|
event.At = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write lock, not a read lock, even though this only walks the map: the
|
||||||
|
// loop below writes `sub.last` and `h.dropped`. Under RLock those are
|
||||||
|
// concurrent writes from every broker goroutine at once — a data race, and
|
||||||
|
// one `go test -race` would fail on. Fan-out is a handful of non-blocking
|
||||||
|
// sends, so holding the write lock costs nothing worth reclaiming.
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for sub := range h.subscribers[event.Locationid] {
|
||||||
|
// Coalesce a burst per subscriber, not globally: a busy outlet must not
|
||||||
|
// throttle a quiet one.
|
||||||
|
if now.Sub(sub.last) < liveMinInterval {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case sub.ch <- event:
|
||||||
|
sub.last = now
|
||||||
|
default:
|
||||||
|
h.dropped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Broker handlers ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func (h *LiveHub) onOrders(_ mqtt.Client, msg mqtt.Message) {
|
||||||
|
var batch models.PosOrderBatch
|
||||||
|
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
||||||
|
return // The ingest consumer logs the bad payload; one complaint is enough.
|
||||||
|
}
|
||||||
|
|
||||||
|
store, terminal := topicIdentity(msg.Topic())
|
||||||
|
if batch.Storeid == "" {
|
||||||
|
batch.Storeid = store
|
||||||
|
}
|
||||||
|
if batch.Terminalid == "" {
|
||||||
|
batch.Terminalid = terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicated: a bill with four lines of the same SKU is one product.
|
||||||
|
seen := make(map[int]struct{})
|
||||||
|
productids := make([]int, 0, 8)
|
||||||
|
for _, order := range batch.Orders {
|
||||||
|
for _, item := range order.Items {
|
||||||
|
id, err := strconv.Atoi(strings.TrimSpace(item.Productid))
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, done := seen[id]; done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
productids = append(productids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Broadcast(LiveEvent{
|
||||||
|
Type: "sale",
|
||||||
|
Locationid: locationFromStore(batch.Storeid),
|
||||||
|
Productids: productids,
|
||||||
|
Terminalid: batch.Terminalid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LiveHub) onCustomers(_ mqtt.Client, msg mqtt.Message) {
|
||||||
|
var batch models.PosCustomerBatch
|
||||||
|
if err := json.Unmarshal(msg.Payload(), &batch); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, terminal := topicIdentity(msg.Topic())
|
||||||
|
if batch.Storeid == "" {
|
||||||
|
batch.Storeid = store
|
||||||
|
}
|
||||||
|
h.Broadcast(LiveEvent{
|
||||||
|
Type: "customer",
|
||||||
|
Locationid: locationFromStore(batch.Storeid),
|
||||||
|
Terminalid: firstNonEmpty(batch.Terminalid, terminal),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// onHealth drives the presence board.
|
||||||
|
//
|
||||||
|
// Read from the topic rather than the body, the same rule bills follow: a till
|
||||||
|
// that could name its own store could appear in another tenant's console.
|
||||||
|
func (h *LiveHub) onHealth(_ mqtt.Client, msg mqtt.Message) {
|
||||||
|
store, terminal := topicIdentity(msg.Topic())
|
||||||
|
h.Broadcast(LiveEvent{
|
||||||
|
Type: "terminal",
|
||||||
|
Locationid: locationFromStore(store),
|
||||||
|
Terminalid: terminal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// locationFromStore parses the topic's store segment.
|
||||||
|
//
|
||||||
|
// `models.PosOrderBatch` documents it: "Storeid carries the numeric
|
||||||
|
// tenantlocations.locationid as a string." Anything unparseable yields 0, which
|
||||||
|
// Broadcast drops — an event with no outlet has nowhere to go.
|
||||||
|
func locationFromStore(store string) int {
|
||||||
|
id, err := strconv.Atoi(strings.TrimSpace(store))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostSuffix keeps each replica's client id distinct without needing config.
|
||||||
|
func hostSuffix() string {
|
||||||
|
if host := strings.TrimSpace(os.Getenv("HOSTNAME")); host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats reports what the hub is doing, for the health endpoint.
|
||||||
|
func (h *LiveHub) Stats() (outlets, connections int, dropped uint64) {
|
||||||
|
if h == nil {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, set := range h.subscribers {
|
||||||
|
connections += len(set)
|
||||||
|
}
|
||||||
|
return len(h.subscribers), connections, h.dropped
|
||||||
|
}
|
||||||
@@ -99,6 +99,18 @@ func (f *fakePosService) ListUsers(int, int, bool) ([]models.PosUser, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) ListStaffShifts(int, int, bool) ([]models.StaffShifts, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) CreateStaffShift(int, int, models.StaffShifts) (*models.StaffShifts, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) UpdateStaffShift(int, int, models.StaffShifts) (*models.StaffShifts, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakePosService) DeactivateUser(int, int, int) error { return nil }
|
func (f *fakePosService) DeactivateUser(int, int, int) error { return nil }
|
||||||
|
|
||||||
func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) {
|
func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) {
|
||||||
|
|||||||
@@ -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" }
|
||||||
111
models/catalogueupload.go
Normal file
111
models/catalogueupload.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CatalogueUpload is our own receipt for a spreadsheet sent to the ingest
|
||||||
|
// service — the record that the upload happened, who sent it, and for which
|
||||||
|
// shop.
|
||||||
|
//
|
||||||
|
// It exists because nothing else keeps one. The ingest service holds the drop,
|
||||||
|
// but on its terms and not ours:
|
||||||
|
//
|
||||||
|
// 1. **A drop nobody acts on is deleted after seven days**
|
||||||
|
// (`BATCH_RETENTION_DAYS`). If an admin never presses Start, the only proof
|
||||||
|
// the upload ever happened disappears — including from the sender.
|
||||||
|
// 2. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
|
||||||
|
// the credential that sent them, production has no API keys configured, and
|
||||||
|
// the one admin account is a superuser over their whole application. So the
|
||||||
|
// list is not available to us and should not be.
|
||||||
|
// 3. **The batch id is the credential.** `GET /api/uploads/catalog/{batch_id}`
|
||||||
|
// is anonymous by design — holding the id is the proof of having sent the
|
||||||
|
// drop. Which means whoever holds the id can read the result, and nobody
|
||||||
|
// else can. Losing the id loses the result permanently.
|
||||||
|
//
|
||||||
|
// So the id is the thing worth keeping, and this row is where we keep it. Every
|
||||||
|
// status field beside it is a CACHE of what the ingest service last told us,
|
||||||
|
// written by whichever browser was polling. It is never the authority — the
|
||||||
|
// service is — and it exists so a receipt reads sensibly before anyone re-polls
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// The tenant and branch are ours alone. The ingest service writes the GLOBAL
|
||||||
|
// catalogue and has no concept of either, so "which shop was this for" is a
|
||||||
|
// question only this row can answer.
|
||||||
|
type CatalogueUpload struct {
|
||||||
|
Uploadid int `json:"uploadid" gorm:"primaryKey;autoIncrement;column:uploadid"`
|
||||||
|
|
||||||
|
// Who the sheet was for. The ingest service knows neither.
|
||||||
|
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
|
||||||
|
Locationid int `json:"locationid" gorm:"column:locationid"`
|
||||||
|
// The category every row was filed under, chosen at upload time. Worth
|
||||||
|
// keeping: a product filed outside the category the app browses is
|
||||||
|
// invisible to shoppers, and this is the only record of what was chosen.
|
||||||
|
Categoryid int `json:"categoryid" gorm:"column:categoryid"`
|
||||||
|
|
||||||
|
// The drop id, and the only thing here that cannot be reconstructed.
|
||||||
|
Batchid string `json:"batchid" gorm:"column:batchid"`
|
||||||
|
// The run an admin released the drop into, once they have. Cached from
|
||||||
|
// `released_to` so a receipt can be followed without re-walking the drop.
|
||||||
|
Runid string `json:"runid" gorm:"column:runid"`
|
||||||
|
|
||||||
|
Filename string `json:"filename" gorm:"column:filename"`
|
||||||
|
// The label the ingest inbox shows their admin. Stored so we can tell,
|
||||||
|
// afterwards, what they were looking at when they approved it.
|
||||||
|
Sender string `json:"sender" gorm:"column:sender"`
|
||||||
|
|
||||||
|
Uploadedby int `json:"uploadedby" gorm:"column:uploadedby"`
|
||||||
|
Uploadedname string `json:"uploadedname" gorm:"column:uploadedname"`
|
||||||
|
|
||||||
|
// Rows we parsed in the browser, before sending. Independent of anything
|
||||||
|
// the service reports, so a drop that never runs still says how big it was.
|
||||||
|
Rowcount int `json:"rowcount" gorm:"column:rowcount"`
|
||||||
|
|
||||||
|
// The sheet itself, as the console parsed it: SKU, price and opening stock
|
||||||
|
// per row, as JSON.
|
||||||
|
//
|
||||||
|
// Stored because the ingest service cannot hold it and nothing else can.
|
||||||
|
// Their pipeline writes the GLOBAL catalogue, which every merchant shares
|
||||||
|
// and which therefore carries no price and no stock; both live only in the
|
||||||
|
// sheet. Shelving is the step that joins the two, and it used to be possible
|
||||||
|
// only in the browser tab that did the upload, because that tab was the only
|
||||||
|
// place the parsed rows existed.
|
||||||
|
//
|
||||||
|
// That failed in the ordinary case rather than a rare one. A drop waits for
|
||||||
|
// their admin to release it and the run then takes minutes, so by the time
|
||||||
|
// there is anything to shelve the tab is usually gone — and the products sit
|
||||||
|
// in the global catalogue, unpriced and unstocked, with no way left to
|
||||||
|
// finish. Keeping the rows here is what lets the Uploads page complete it
|
||||||
|
// days later.
|
||||||
|
Sheetrows string `json:"sheetrows" gorm:"column:sheetrows;type:jsonb"`
|
||||||
|
|
||||||
|
// ── Cached from the ingest service, by whoever last polled ──────────────
|
||||||
|
|
||||||
|
Laststatus string `json:"laststatus" gorm:"column:laststatus;default:pending"`
|
||||||
|
// Their counts, so a settled receipt reads correctly with no network call.
|
||||||
|
Inserted int `json:"inserted" gorm:"column:inserted"`
|
||||||
|
Backfilled int `json:"backfilled" gorm:"column:backfilled"`
|
||||||
|
Skipped int `json:"skipped" gorm:"column:skipped"`
|
||||||
|
Rejected int `json:"rejected" gorm:"column:rejected"`
|
||||||
|
|
||||||
|
// ── Ours: the half the ingest service cannot do ────────────────────────
|
||||||
|
//
|
||||||
|
// Their pipeline writes the global catalogue, which every merchant shares
|
||||||
|
// and which therefore holds no price and no stock. Shelving is what turns
|
||||||
|
// "the product exists" into "this shop can sell it", and it is a separate
|
||||||
|
// action that can be left undone — so it is recorded separately.
|
||||||
|
|
||||||
|
Shelvedcount int `json:"shelvedcount" gorm:"column:shelvedcount"`
|
||||||
|
Skippedcount int `json:"skippedcount" gorm:"column:skippedcount"`
|
||||||
|
Shelvedat *time.Time `json:"shelvedat" gorm:"column:shelvedat"`
|
||||||
|
|
||||||
|
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
||||||
|
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
|
||||||
|
|
||||||
|
// Joined for display, never stored. A receipt outlives the page that made
|
||||||
|
// it, so it has to be able to name its own shop.
|
||||||
|
Tenantname string `json:"tenantname" gorm:"->;column:tenantname"`
|
||||||
|
Locationname string `json:"locationname" gorm:"->;column:locationname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CatalogueUpload) TableName() string {
|
||||||
|
return "catalogueuploads"
|
||||||
|
}
|
||||||
@@ -24,8 +24,11 @@ type Customers struct {
|
|||||||
Landmark string `json:"landmark"`
|
Landmark string `json:"landmark"`
|
||||||
Doorno string `json:"doorno"`
|
Doorno string `json:"doorno"`
|
||||||
Postcode string `json:"postcode"`
|
Postcode string `json:"postcode"`
|
||||||
Latitude string `json:"latitude"`
|
// Numbers from a map picker, strings from a text field — see
|
||||||
Longitude string `json:"longitude"`
|
// Customerlocations below. A customer must exist before an order can be
|
||||||
|
// placed, so a 400 here blocks the whole flow, not just the address.
|
||||||
|
Latitude FlexibleString `json:"latitude"`
|
||||||
|
Longitude FlexibleString `json:"longitude"`
|
||||||
Applocationid int `json:"applocationid"`
|
Applocationid int `json:"applocationid"`
|
||||||
Locationid int `json:"locationid,omitempty" gorm:"-"`
|
Locationid int `json:"locationid,omitempty" gorm:"-"`
|
||||||
Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"`
|
Defaultaddress string `json:"defaultaddress,omitempty" gorm:"-"`
|
||||||
@@ -84,7 +87,9 @@ type CustomerLocationResult struct {
|
|||||||
type Customerlocations struct {
|
type Customerlocations struct {
|
||||||
Locationid int `json:"locationid" gorm:"Primary_Key"`
|
Locationid int `json:"locationid" gorm:"Primary_Key"`
|
||||||
Customerid int `json:"customerid"`
|
Customerid int `json:"customerid"`
|
||||||
Applocationid int `json:"applocationid" gorm:"-"`
|
// The query joins customers to select b.applocationid; `gorm:"-"` then threw
|
||||||
|
// it away, so every address came back with 0 however the join resolved.
|
||||||
|
Applocationid int `json:"applocationid" gorm:"->"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Suburb string `json:"suburb"`
|
Suburb string `json:"suburb"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
@@ -92,10 +97,25 @@ type Customerlocations struct {
|
|||||||
Landmark string `json:"landmark"`
|
Landmark string `json:"landmark"`
|
||||||
Doorno string `json:"doorno"`
|
Doorno string `json:"doorno"`
|
||||||
Postcode string `json:"postcode"`
|
Postcode string `json:"postcode"`
|
||||||
Latitude string `json:"latitude"`
|
// Latitude and longitude arrive as NUMBERS from a map picker and as strings
|
||||||
Longitude string `json:"longitude"`
|
// from a text field. A strict type rejected the first, and BodyParser fails
|
||||||
Primaryaddress int `json:"primaryaddress"`
|
// the WHOLE request on one unreadable field — so a shopper who set their
|
||||||
Status int `json:"status"`
|
// location on a map got 400 and no saved address. Same treatment the order
|
||||||
|
// model already gives Pickuplat and Pickuplong.
|
||||||
|
Latitude FlexibleString `json:"latitude"`
|
||||||
|
Longitude FlexibleString `json:"longitude"`
|
||||||
|
|
||||||
|
// A "make this my default" checkbox sends true, a form sends 1, a text
|
||||||
|
// input sends "1". All three mean the same thing.
|
||||||
|
Primaryaddress FlexibleInt `json:"primaryaddress"`
|
||||||
|
|
||||||
|
// Sent as 1, as "1", or as the word "Active", depending on the caller.
|
||||||
|
Status FlexibleInt `json:"status"`
|
||||||
|
|
||||||
|
// Selected by GetCustomerLocations and, until now, dropped on the floor:
|
||||||
|
// the struct had no field for it, so the column marking the chosen address
|
||||||
|
// never reached the app.
|
||||||
|
Defaultaddress string `json:"defaultaddress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomerRequest struct {
|
type CustomerRequest struct {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
116
models/flexibleInt.go
Normal file
116
models/flexibleInt.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FlexibleInt is an integer column that accepts what clients actually send.
|
||||||
|
//
|
||||||
|
// The counterpart to FlexibleString, and it exists for the same reason: a strict
|
||||||
|
// type here does not reject one field, it rejects the WHOLE request. Fiber's
|
||||||
|
// BodyParser fails on the first field it cannot convert, so `"status": "Active"`
|
||||||
|
// or `"primaryaddress": true` returns 400 "Invalid request body" and nothing is
|
||||||
|
// saved. On the address form that surfaced as a shopper adding a delivery
|
||||||
|
// address, seeing no error worth acting on, and then finding no address to pick
|
||||||
|
// at checkout — measured 2026-09-02: three plausible payloads, three 400s.
|
||||||
|
//
|
||||||
|
// Accepted, in the shapes a form or a phone actually produces:
|
||||||
|
//
|
||||||
|
// 1 a number
|
||||||
|
// "1" a number as a string, which is what a text input gives
|
||||||
|
// true a checkbox — "set as my default address"
|
||||||
|
// "" an untouched field, read as 0 rather than an error
|
||||||
|
// "Active" a word, mapped by meaning: active/yes/true/default are 1
|
||||||
|
//
|
||||||
|
// A word it does not recognise is an error, not a silent 0. Guessing there
|
||||||
|
// would store something the shopper did not choose.
|
||||||
|
type FlexibleInt int
|
||||||
|
|
||||||
|
func (fi *FlexibleInt) UnmarshalJSON(b []byte) error {
|
||||||
|
if len(b) == 0 || string(b) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if b[0] == '"' {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(b, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
*fi = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
*fi = FlexibleInt(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "active", "yes", "true", "default", "primary":
|
||||||
|
*fi = 1
|
||||||
|
case "inactive", "no", "false":
|
||||||
|
*fi = 0
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot read %q as a number", s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if b[0] == 't' || b[0] == 'f' {
|
||||||
|
var v bool
|
||||||
|
if err := json.Unmarshal(b, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v {
|
||||||
|
*fi = 1
|
||||||
|
} else {
|
||||||
|
*fi = 0
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A number, possibly written with a decimal point by a client that has no
|
||||||
|
// integer type of its own.
|
||||||
|
var f float64
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*fi = FlexibleInt(int(f))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi FlexibleInt) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(int(fi))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan reads the column back. bigint arrives as int64; the string cases cover a
|
||||||
|
// text column that holds a number, which this schema has in places.
|
||||||
|
func (fi *FlexibleInt) Scan(value interface{}) error {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
*fi = 0
|
||||||
|
case int64:
|
||||||
|
*fi = FlexibleInt(v)
|
||||||
|
case int:
|
||||||
|
*fi = FlexibleInt(v)
|
||||||
|
case float64:
|
||||||
|
*fi = FlexibleInt(int(v))
|
||||||
|
case []byte:
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(string(v)))
|
||||||
|
*fi = FlexibleInt(n)
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
*fi = FlexibleInt(n)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot read %T as a number", value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi FlexibleInt) Value() (driver.Value, error) {
|
||||||
|
return int64(fi), nil
|
||||||
|
}
|
||||||
88
models/flexibleInt_test.go
Normal file
88
models/flexibleInt_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
A strict type on one field rejects the whole request.
|
||||||
|
|
||||||
|
Fiber's BodyParser stops at the first field it cannot convert and returns 400
|
||||||
|
"Invalid request body" — so `"primaryaddress": true` from a checkbox threw away
|
||||||
|
the address, the street and the pincode with it. The shopper saw no error worth
|
||||||
|
acting on and then had no address to choose at checkout.
|
||||||
|
|
||||||
|
Measured 2026-09-02 against the running backend: three plausible payloads for
|
||||||
|
one address form, three 400s.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func readInt(t *testing.T, body string) (FlexibleInt, error) {
|
||||||
|
t.Helper()
|
||||||
|
var v struct {
|
||||||
|
N FlexibleInt `json:"n"`
|
||||||
|
}
|
||||||
|
err := json.Unmarshal([]byte(`{"n":`+body+`}`), &v)
|
||||||
|
return v.N, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRead(t *testing.T, body string, want FlexibleInt) {
|
||||||
|
t.Helper()
|
||||||
|
got, err := readInt(t, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", body, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s -> %d, want %d", body, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACheckboxIsANumber(t *testing.T) {
|
||||||
|
// "Set as my default address" sends a boolean.
|
||||||
|
mustRead(t, "true", 1)
|
||||||
|
mustRead(t, "false", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATextInputIsANumber(t *testing.T) {
|
||||||
|
mustRead(t, `"1"`, 1)
|
||||||
|
mustRead(t, `"0"`, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWordIsReadByMeaning(t *testing.T) {
|
||||||
|
mustRead(t, `"Active"`, 1)
|
||||||
|
mustRead(t, `"active"`, 1)
|
||||||
|
mustRead(t, `"Inactive"`, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUntouchedFieldIsZeroNotAnError(t *testing.T) {
|
||||||
|
mustRead(t, `""`, 0)
|
||||||
|
mustRead(t, "null", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClientWithNoIntegerTypeStillWorks(t *testing.T) {
|
||||||
|
// JavaScript has one number type, so 1 can arrive as 1.0.
|
||||||
|
mustRead(t, "1.0", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWordItCannotReadIsRefusedRatherThanGuessed(t *testing.T) {
|
||||||
|
// Storing 0 for something unrecognised would record a choice the shopper
|
||||||
|
// never made. Better to fail loudly than to invent an answer.
|
||||||
|
if _, err := readInt(t, `"maybe"`); err == nil {
|
||||||
|
t.Error(`"maybe" was silently accepted`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatitudeSurvivesBothShapes(t *testing.T) {
|
||||||
|
// A map picker sends a number; a text field sends a string. Both are the
|
||||||
|
// same place, and rejecting either loses the whole address.
|
||||||
|
var withNumber, withString Customerlocations
|
||||||
|
if err := json.Unmarshal([]byte(`{"latitude":11.0168}`), &withNumber); err != nil {
|
||||||
|
t.Fatalf("number latitude: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(`{"latitude":"11.0168"}`), &withString); err != nil {
|
||||||
|
t.Fatalf("string latitude: %v", err)
|
||||||
|
}
|
||||||
|
if withNumber.Latitude != withString.Latitude {
|
||||||
|
t.Errorf("%q from a number, %q from a string", withNumber.Latitude, withString.Latitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -103,6 +103,12 @@ type OrderInfo struct {
|
|||||||
Canceltime string `json:"canceltime"`
|
Canceltime string `json:"canceltime"`
|
||||||
Deliverycharge float32 `json:"deliverycharge"`
|
Deliverycharge float32 `json:"deliverycharge"`
|
||||||
Orderamount float32 `json:"orderamount"`
|
Orderamount float32 `json:"orderamount"`
|
||||||
|
// Ordervalue and Taxamount were absent from this struct, so even once the
|
||||||
|
// query selected them there was nowhere for GORM to put the values. The
|
||||||
|
// console reads `ordervalue || orderamount || deliveryamt` in that order,
|
||||||
|
// which means the field it prefers was the one it could never receive.
|
||||||
|
Ordervalue float32 `json:"ordervalue"`
|
||||||
|
Taxamount float32 `json:"taxamount"`
|
||||||
Customerid int `json:"customerid"`
|
Customerid int `json:"customerid"`
|
||||||
Pickupcustomer string `json:"pickupcustomer"`
|
Pickupcustomer string `json:"pickupcustomer"`
|
||||||
Pickupcontactno string `json:"pickupcontactno"`
|
Pickupcontactno string `json:"pickupcontactno"`
|
||||||
@@ -124,6 +130,22 @@ type OrderInfo struct {
|
|||||||
Deliverylat FlexibleString `json:"deliverylat"`
|
Deliverylat FlexibleString `json:"deliverylat"`
|
||||||
Deliverylong FlexibleString `json:"deliverylong"`
|
Deliverylong FlexibleString `json:"deliverylong"`
|
||||||
Deliverytype string `json:"deliverytype"`
|
Deliverytype string `json:"deliverytype"`
|
||||||
|
// The delivery window the customer asked for.
|
||||||
|
//
|
||||||
|
// ON THIS STRUCT, not only on Orders. GetTenantOrders — which is what the
|
||||||
|
// console and the app both read — scans into OrderInfo, so fields added to
|
||||||
|
// Orders alone never reach the list. That was the whole of the
|
||||||
|
// products.showhealthscore bug: written correctly, selected correctly,
|
||||||
|
// absent from the response, and every reading taken from it meaningless.
|
||||||
|
//
|
||||||
|
// The last four are joined from deliveryslots and read-only, so a shop that
|
||||||
|
// renames a window sees the new name on orders already placed.
|
||||||
|
Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
|
||||||
|
Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
|
||||||
|
Slotkey string `json:"slotkey" gorm:"->"`
|
||||||
|
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
|
||||||
|
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
|
||||||
|
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
|
||||||
Paymenttype int `json:"paymenttype"`
|
Paymenttype int `json:"paymenttype"`
|
||||||
Tenantname string `json:"tenantname"`
|
Tenantname string `json:"tenantname"`
|
||||||
Tenanttoken string `json:"tenanttoken"`
|
Tenanttoken string `json:"tenanttoken"`
|
||||||
@@ -240,6 +262,26 @@ type Orders struct {
|
|||||||
Orderdate string `json:"orderdate,omitempty"`
|
Orderdate string `json:"orderdate,omitempty"`
|
||||||
Deliverytime string `json:"deliverytime"`
|
Deliverytime string `json:"deliverytime"`
|
||||||
Deliverytype string `json:"deliverytype"`
|
Deliverytype string `json:"deliverytype"`
|
||||||
|
// The window the shopper chose, and the day it falls on.
|
||||||
|
//
|
||||||
|
// Both stay zero for every order placed without one — which is every order
|
||||||
|
// before this shipped, and every order from a branch that has set no
|
||||||
|
// windows. Nothing downstream may require them.
|
||||||
|
//
|
||||||
|
// Distinct from `Deliverytime` above, which is a TIMESTAMP of what happened
|
||||||
|
// and is defaulted to now() a few lines into CreateOrderv3. These two say
|
||||||
|
// what was asked for; that one says what occurred.
|
||||||
|
Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"`
|
||||||
|
Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"`
|
||||||
|
// Joined from deliveryslots, not stored on the order.
|
||||||
|
//
|
||||||
|
// So a shop that renames "Evening" to "After work" sees the new name on
|
||||||
|
// orders already placed — the window they chose has not changed, only what
|
||||||
|
// it is called. Read-only: nothing writes these back.
|
||||||
|
Slotkey string `json:"slotkey" gorm:"->"`
|
||||||
|
Deliveryslotname string `json:"deliveryslotname" gorm:"->"`
|
||||||
|
Deliveryslotstart string `json:"deliveryslotstart" gorm:"->"`
|
||||||
|
Deliveryslotend string `json:"deliveryslotend" gorm:"->"`
|
||||||
Orderstatus string `json:"orderstatus"`
|
Orderstatus string `json:"orderstatus"`
|
||||||
Pending string `json:"pending"`
|
Pending string `json:"pending"`
|
||||||
Processing string `json:"processing"`
|
Processing string `json:"processing"`
|
||||||
|
|||||||
@@ -61,10 +61,23 @@ type Partnerinfo struct {
|
|||||||
Postcode string `json:"postcode"`
|
Postcode string `json:"postcode"`
|
||||||
Partnerinfo string `json:"partnerinfo"`
|
Partnerinfo string `json:"partnerinfo"`
|
||||||
Partnerimage string `json:"partnerimage"`
|
Partnerimage string `json:"partnerimage"`
|
||||||
|
// Read back so a directory can show who the partner is and whether they
|
||||||
|
// are trading. Both columns have always been on the table; the struct had
|
||||||
|
// no field for them, so `getpartners` could not report either.
|
||||||
|
Companyname string `json:"companyname"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
// Every region this partner covers, from `partnerlocations`. A partner is
|
||||||
|
// not confined to the one city on their own row — that column records where
|
||||||
|
// they were set up, and the link table records where they actually work.
|
||||||
|
Locations []PartnerLocation `json:"locations" gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ridershifts struct {
|
type Ridershifts struct {
|
||||||
Shiftid int `json:"shiftid" gorm:"Primary_Key"`
|
Shiftid int `json:"shiftid" gorm:"Primary_Key"`
|
||||||
|
// The region this shift belongs to. The column has always been on the table
|
||||||
|
// — `GetRiderShifts` filters on it — but there was no field for it here, so
|
||||||
|
// nothing could write one. That is why shifts could only ever be read.
|
||||||
|
Applocationid int `json:"applocationid"`
|
||||||
Shiftdate string `json:"shiftdate"`
|
Shiftdate string `json:"shiftdate"`
|
||||||
Starttime string `json:"starttime"`
|
Starttime string `json:"starttime"`
|
||||||
Endtime string `json:"endtime"`
|
Endtime string `json:"endtime"`
|
||||||
@@ -129,3 +142,189 @@ type RiderlogDetails struct {
|
|||||||
Breakhours float32 `json:"breakhours"`
|
Breakhours float32 `json:"breakhours"`
|
||||||
Logstatus int `json:"logstatus"`
|
Logstatus int `json:"logstatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewRider is one rider being onboarded, as the console sends it.
|
||||||
|
//
|
||||||
|
// A rider is three rows, not one. `app_users` holds the person, `ridersettings`
|
||||||
|
// the vehicle and licence, and `app_userpools` their place in the availability
|
||||||
|
// pool — and `getriders` INNER JOINs all three, so a rider missing any of them
|
||||||
|
// is not a partial rider, they are no rider at all. The flat shape here is
|
||||||
|
// deliberate: the caller should not have to know the table layout to hire
|
||||||
|
// somebody.
|
||||||
|
//
|
||||||
|
// `Tenantid` is filled in by the controller from the caller's scope, never read
|
||||||
|
// from the body — a store admin must not be able to put a rider on another
|
||||||
|
// merchant's books by editing a payload.
|
||||||
|
type NewRider struct {
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
Firstname string `json:"firstname"`
|
||||||
|
Lastname string `json:"lastname"`
|
||||||
|
Contactno string `json:"contactno"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Suburb string `json:"suburb"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Postcode string `json:"postcode"`
|
||||||
|
|
||||||
|
// Whose rider they are, where they ride, and who they ride for.
|
||||||
|
//
|
||||||
|
// Tenantid and Partnerid are exclusive: a rider belongs to a shop or to a
|
||||||
|
// delivery partner, never to both and never to neither. The controller
|
||||||
|
// refuses either mistake.
|
||||||
|
//
|
||||||
|
// Locationid is the branch an OWN rider works out of. It is meaningless for
|
||||||
|
// a partner's rider — a partner supplies several merchants and is not tied
|
||||||
|
// to any one shop's outlet — so it is only ever set alongside a tenantid.
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Applocationid int `json:"applocationid"`
|
||||||
|
Partnerid int `json:"partnerid"`
|
||||||
|
Shiftid int `json:"shiftid"`
|
||||||
|
|
||||||
|
// The vehicle half — `ridersettings`.
|
||||||
|
Identificationno string `json:"identificationno"`
|
||||||
|
Vehiclename string `json:"vehiclename"`
|
||||||
|
Vehicleno string `json:"vehicleno"`
|
||||||
|
Licenseno string `json:"licenseno"`
|
||||||
|
Registrationno string `json:"registrationno"`
|
||||||
|
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiderRosterRow is one rider in the directory.
|
||||||
|
//
|
||||||
|
// Distinct from RiderInfo, which is the ON-DUTY read: that one requires a
|
||||||
|
// riderlog stamped today, which is right for an assignment picker and wrong for
|
||||||
|
// a staff list, where somebody who has not started their shift must still
|
||||||
|
// appear. This carries the last log rather than requiring one.
|
||||||
|
type RiderRosterRow struct {
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
Firstname string `json:"firstname"`
|
||||||
|
Lastname string `json:"lastname"`
|
||||||
|
Fullname string `json:"fullname"`
|
||||||
|
Contactno string `json:"contactno"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
Locationname string `json:"locationname"`
|
||||||
|
Applocationid int `json:"applocationid"`
|
||||||
|
Applocation string `json:"applocation"`
|
||||||
|
Partnerid int `json:"partnerid"`
|
||||||
|
Partnername string `json:"partnername"`
|
||||||
|
Shiftid int `json:"shiftid"`
|
||||||
|
Shiftname string `json:"shiftname"`
|
||||||
|
|
||||||
|
Identificationno string `json:"identificationno"`
|
||||||
|
Vehiclename string `json:"vehiclename"`
|
||||||
|
Vehicleno string `json:"vehicleno"`
|
||||||
|
Licenseno string `json:"licenseno"`
|
||||||
|
Registrationno string `json:"registrationno"`
|
||||||
|
|
||||||
|
// Duty state, as facts rather than as a filter.
|
||||||
|
Onduty int `json:"onduty"`
|
||||||
|
Lastlogdate string `json:"lastlogdate"`
|
||||||
|
// True when there is a log dated today with logstatus 0 — on shift right now.
|
||||||
|
Isonduty bool `json:"isonduty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ridersettings is the vehicle-and-licence half of a rider.
|
||||||
|
//
|
||||||
|
// `riderid` is DELIBERATELY ABSENT. It is a GENERATED ALWAYS identity column,
|
||||||
|
// and including it makes GORM send a zero, which Postgres rejects outright:
|
||||||
|
// "cannot insert a non-DEFAULT value into column riderid". Leaving it off the
|
||||||
|
// struct is what keeps the insert to the columns that are actually ours to set.
|
||||||
|
type Ridersettings struct {
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
Partnerid int `json:"partnerid"`
|
||||||
|
Shiftid int `json:"shiftid"`
|
||||||
|
Identificationno string `json:"identificationno"`
|
||||||
|
Vehiclename string `json:"vehiclename"`
|
||||||
|
Vehicleno string `json:"vehicleno"`
|
||||||
|
Licenseno string `json:"licenseno"`
|
||||||
|
Registrationno string `json:"registrationno"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appuserpools is a rider's place in the availability pool.
|
||||||
|
//
|
||||||
|
// `poolid` is absent for the same reason as `riderid` above — same identity
|
||||||
|
// column, same rejection.
|
||||||
|
//
|
||||||
|
// `Onduty` means "may be given work", not "on shift right now". The second
|
||||||
|
// question is answered by riderlogs, which the rider's own app writes.
|
||||||
|
type Appuserpools struct {
|
||||||
|
Userid int `json:"userid"`
|
||||||
|
Partnerid int `json:"partnerid"`
|
||||||
|
Onduty int `json:"onduty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartnerLocation is one region a partner covers — a row of `partnerlocations`.
|
||||||
|
type PartnerLocation struct {
|
||||||
|
Partnerlocationid int `json:"partnerlocationid" gorm:"column:partnerlocationid"`
|
||||||
|
Partnerid int `json:"partnerid" gorm:"column:partnerid"`
|
||||||
|
Applocationid int `json:"applocationid" gorm:"column:applocationid"`
|
||||||
|
Applocation string `json:"applocation" gorm:"column:applocation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPartner is everything the console collects to onboard a delivery partner.
|
||||||
|
//
|
||||||
|
// Separate from `Partnerinfo`, which is the READ shape. Binding a create to the
|
||||||
|
// read model is how a caller ends up able to set columns nobody meant to expose
|
||||||
|
// — `allocationid`, `partnertypeid`, the billing links — because a form happened
|
||||||
|
// to send them.
|
||||||
|
//
|
||||||
|
// A partner is a company that supplies riders. It is onboarded by the platform,
|
||||||
|
// never by a merchant: the merchant's side of this is being ASSIGNED one, which
|
||||||
|
// is `AssignPartner` on the tenant.
|
||||||
|
type NewPartner struct {
|
||||||
|
Partnerid int `json:"partnerid"`
|
||||||
|
Partnername string `json:"partnername"`
|
||||||
|
Companyname string `json:"companyname"`
|
||||||
|
Registrationno string `json:"registrationno"`
|
||||||
|
Primarycontact string `json:"primarycontact"`
|
||||||
|
Primaryemail string `json:"primaryemail"`
|
||||||
|
Contactno string `json:"contactno"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Suburb string `json:"suburb"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Postcode int `json:"postcode"`
|
||||||
|
Partnerinfo string `json:"partnerinfo"`
|
||||||
|
Partnerimage string `json:"partnerimage"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
Where they work — ONE district, not a set.
|
||||||
|
|
||||||
|
`Applocationid` is the home region and goes on the partner row itself,
|
||||||
|
because the rider app reads it. The same region is also written to
|
||||||
|
`partnerlocations`, which is the table that may hold SEVERAL — a partner
|
||||||
|
routinely serves more than one city, and that is why the link table
|
||||||
|
exists, and partners with two are live — partner 44 covers regions 1 and
|
||||||
|
2. Nothing on THIS path creates one: `regionsOf` returns this single
|
||||||
|
field and the console's form offers one district, never a set. So a
|
||||||
|
multi-region partner can be read and must be handled, but cannot yet be
|
||||||
|
made here.
|
||||||
|
|
||||||
|
`GetPartners` reads the link table rather than this field, for two
|
||||||
|
reasons. It is the column allowed to grow, so a partner who covers a
|
||||||
|
second city will be found there without another change. And
|
||||||
|
`partnerinfo` is shared with another product that writes no link rows,
|
||||||
|
so having one is what marks a partner as ours.
|
||||||
|
*/
|
||||||
|
Applocationid int `json:"applocationid"`
|
||||||
|
/*
|
||||||
|
The district by NAME, for one that is not open yet.
|
||||||
|
|
||||||
|
Nearle runs three of Tamil Nadu's thirty-eight districts, and a partner
|
||||||
|
could only be placed in those three because `partnerinfo.applocationid`
|
||||||
|
has to point at an `app_location` row. Sending the name instead opens the
|
||||||
|
district — see `EnsureRegion` — so the form can offer all 38 and mean it.
|
||||||
|
|
||||||
|
Ignored when `Applocationid` is set, which is the ordinary case.
|
||||||
|
*/
|
||||||
|
District string `json:"district"`
|
||||||
|
}
|
||||||
|
|||||||
126
models/pos.go
126
models/pos.go
@@ -226,24 +226,41 @@ type PosCatalogueResponse struct {
|
|||||||
|
|
||||||
// PosLoginRequest is what a till sends to sign in.
|
// PosLoginRequest is what a till sends to sign in.
|
||||||
//
|
//
|
||||||
// Authname or Contactno, matching the web console's own login — a shop should
|
// A mobile number and a four-digit PIN. That is what a person standing at a
|
||||||
// not need a second set of credentials just because the screen is a till.
|
// counter can actually type between customers, and it is the pair the console
|
||||||
|
// issues them — anything longer gets written on the side of the terminal, which
|
||||||
|
// is worse than a short credential.
|
||||||
//
|
//
|
||||||
// Locationid is optional and only means anything for a user entitled to more
|
// Locationid is optional and only means anything for a user entitled to more
|
||||||
// than one outlet: it says which of theirs this terminal is standing in. It is
|
// than one outlet: it says which of theirs this terminal is standing in. It is
|
||||||
// checked against what they may reach, never trusted on its own.
|
// checked against what they may reach, never trusted on its own.
|
||||||
type PosLoginRequest struct {
|
type PosLoginRequest struct {
|
||||||
Authname string `json:"authname"`
|
// The mobile number this person signs in with. Sent in whatever form they
|
||||||
|
// typed it — "+91 98765 43210", "098765-43210", "9876543210" — and reduced
|
||||||
|
// to ten digits by the server before it is matched.
|
||||||
Contactno string `json:"contactno"`
|
Contactno string `json:"contactno"`
|
||||||
Password string `json:"password"`
|
|
||||||
|
// A four-digit PIN, and the credential this endpoint now checks.
|
||||||
|
//
|
||||||
|
// Four digits is ten thousand guesses, which would be no barrier at all on
|
||||||
|
// its own — it is a barrier here only because it is checked against one
|
||||||
|
// mobile number, and a mobile number is unique among a tenant's till
|
||||||
|
// accounts. Rate limiting at the edge is what stands between that and a
|
||||||
|
// patient attacker; this endpoint cannot supply it.
|
||||||
|
Pin string `json:"pin"`
|
||||||
|
|
||||||
|
// A username and password, the way in before PINs.
|
||||||
|
//
|
||||||
|
// Kept working, not deprecated in place, because every till account on the
|
||||||
|
// platform predates the mobile number it now signs in with. Removing this
|
||||||
|
// before the back office has filled those in would close every shop on the
|
||||||
|
// same morning. See docs/POS_PHONE_PIN_LOGIN_HANDOVER.md §5.
|
||||||
|
Authname string `json:"authname,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
|
||||||
Configid int `json:"configid"`
|
Configid int `json:"configid"`
|
||||||
Locationid int `json:"location_id"`
|
Locationid int `json:"location_id"`
|
||||||
|
|
||||||
// A PIN, for signing on at a terminal a supervisor has already opened. Only
|
|
||||||
// honoured by the PIN route, which requires an existing session — four
|
|
||||||
// digits is no barrier to an anonymous caller.
|
|
||||||
Pin string `json:"pin"`
|
|
||||||
|
|
||||||
// Which physical till is asking. Recorded on the session so a stolen token
|
// Which physical till is asking. Recorded on the session so a stolen token
|
||||||
// can be told apart from the terminal it was issued to.
|
// can be told apart from the terminal it was issued to.
|
||||||
Terminalid string `json:"terminal_id"`
|
Terminalid string `json:"terminal_id"`
|
||||||
@@ -319,24 +336,25 @@ type PosSession struct {
|
|||||||
// what gets stamped on a bill as `cashiername` and settled against at the end
|
// what gets stamped on a bill as `cashiername` and settled against at the end
|
||||||
// of a shift.
|
// of a shift.
|
||||||
//
|
//
|
||||||
// The PIN travels in the clear, over TLS, and that is a considered choice
|
// The PIN used to travel down with this list, on the reasoning that a PIN was
|
||||||
// rather than an oversight. A four-digit PIN is brute-forceable in microseconds
|
// *shift attribution* rather than a security boundary: the token decided which
|
||||||
// whatever it is wrapped in, so hashing it here would buy the appearance of
|
// books a till could reach, and the PIN only decided which of the people
|
||||||
// strength and not the substance. What it would cost is real: the terminal
|
// already inside a shop got credited with a sale.
|
||||||
// salts every PIN with its own random salt before storing it, so a hash
|
|
||||||
// computed here could never be verified there without inventing a shared
|
|
||||||
// scheme and keeping two codebases agreeing about it for ever.
|
|
||||||
//
|
//
|
||||||
// The honest framing is that a PIN is *shift attribution*, not a security
|
// That reasoning ended when the PIN became half of the sign-in. A list of PINs
|
||||||
// boundary. The boundary is the session token — which is what stops a till
|
// is now a list of working credentials for the outlet — including the
|
||||||
// reaching another tenant's books at all. The PIN decides which of the people
|
// supervisor's, which carries `can_manage_staff` — so a cashier handed this
|
||||||
// already inside a shop gets credited with a sale, and the terminal still
|
// array could sign back in as their own manager. Hence `json:"-"`: the field is
|
||||||
// stores it hashed at rest.
|
// still read from the database, because the query needs it to drop two people
|
||||||
|
// who share a PIN, but it cannot reach the wire from here.
|
||||||
|
//
|
||||||
|
// Switching operator at an open terminal goes through `POST /pos/login/pin`,
|
||||||
|
// which checks the PIN against the outlet the caller's token already names.
|
||||||
type PosStaffMember struct {
|
type PosStaffMember struct {
|
||||||
Userid int `json:"user_id"`
|
Userid int `json:"user_id"`
|
||||||
Fullname string `json:"full_name"`
|
Fullname string `json:"full_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Pin string `json:"pin,omitempty"`
|
Pin string `json:"-"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,21 +411,37 @@ func PosRoleFromName(name string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PosRoleEligible reports whether a role may open a till at all.
|
||||||
|
//
|
||||||
|
// The terminal and the Nearle Daily application share one `app_users` table,
|
||||||
|
// and that is the only thing they share. An account belongs to one product or
|
||||||
|
// the other and never to both: a person who administers a shop from a browser
|
||||||
|
// does not thereby get a cash drawer, and a cashier does not thereby get the
|
||||||
|
// back office.
|
||||||
|
//
|
||||||
|
// Eligibility is therefore granted explicitly — by provisioning a Supervisor or
|
||||||
|
// a Cashier from the console — and is never inherited from a back-office role.
|
||||||
|
// Anything else is refused at sign-in, including roleid 0, which is not a role
|
||||||
|
// but the absence of one.
|
||||||
|
func PosRoleEligible(roleID int) bool {
|
||||||
|
return roleID == PosRoleSupervisor || roleID == PosRoleCashier
|
||||||
|
}
|
||||||
|
|
||||||
// PosRoleCanManageStaff reports whether a role may create and edit till users.
|
// PosRoleCanManageStaff reports whether a role may create and edit till users.
|
||||||
//
|
//
|
||||||
// Supervisors, plus the back office's own admin and manager roles — somebody
|
// Supervisors, and nobody else.
|
||||||
// who can already administer the shop from a browser is not made less
|
|
||||||
// privileged by standing at the counter.
|
|
||||||
//
|
//
|
||||||
// A cashier is never included, and neither is roleid 0. Zero is not a role: it
|
// This used to include the back office's own roles 1 to 6, on the reasoning
|
||||||
// is what an account carries when nobody set one, and live data has riders and
|
// that somebody who can already administer a shop from a browser is not made
|
||||||
// shop accounts sharing it.
|
// less privileged by standing at the counter. That was wrong, and live data
|
||||||
|
// showed how wrong: it handed till-supervisor powers to 68 accounts, 59 of them
|
||||||
|
// Nearle Daily Super admins, not one of whom is the administrator of anybody's
|
||||||
|
// POS. The actual shop accounts carry roleid 0 and were refused.
|
||||||
|
//
|
||||||
|
// The back office reaches the till by *provisioning* a supervisor from the
|
||||||
|
// console, not by becoming one at the counter.
|
||||||
func PosRoleCanManageStaff(roleID int) bool {
|
func PosRoleCanManageStaff(roleID int) bool {
|
||||||
switch roleID {
|
return roleID == PosRoleSupervisor
|
||||||
case PosRoleSupervisor, 1, 2, 3, 4, 5, 6:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PosUser is a person who signs in at a till.
|
// PosUser is a person who signs in at a till.
|
||||||
@@ -422,6 +456,18 @@ type PosUser struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Pin string `json:"pin,omitempty"`
|
Pin string `json:"pin,omitempty"`
|
||||||
Haspassword bool `json:"has_password"`
|
Haspassword bool `json:"has_password"`
|
||||||
|
|
||||||
|
// The shift this person works, resolved for display. Zero / empty when the
|
||||||
|
// account has none, which is every account created before shifts existed.
|
||||||
|
Shiftid int `json:"shift_id,omitempty"`
|
||||||
|
Shiftname string `json:"shift_name,omitempty"`
|
||||||
|
Shiftstart string `json:"shift_start,omitempty"`
|
||||||
|
Shiftend string `json:"shift_end,omitempty"`
|
||||||
|
|
||||||
|
// The password, returned only in the answer to a creation or a reset and
|
||||||
|
// never by a listing. An admin who loses it reissues rather than looks it
|
||||||
|
// up — the right shape even while the column behind it is plaintext.
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
Locationid int `json:"location_id"`
|
Locationid int `json:"location_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
@@ -439,7 +485,21 @@ type PosUserRequest struct {
|
|||||||
Pin string `json:"pin"`
|
Pin string `json:"pin"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Authname string `json:"authname"`
|
Authname string `json:"authname"`
|
||||||
|
|
||||||
|
// The mobile number this person signs in with.
|
||||||
|
//
|
||||||
|
// Normalised to ten digits before it is stored, because the till matches on
|
||||||
|
// it exactly and "+91 98765 43210" typed back as "9876543210" would not
|
||||||
|
// find the row. Unique among a tenant's till accounts.
|
||||||
Contactno string `json:"contactno"`
|
Contactno string `json:"contactno"`
|
||||||
|
|
||||||
|
// Which shift this person works — a staffshifts.staffshiftid, stored on
|
||||||
|
// app_users.shiftid. Zero leaves it unset.
|
||||||
|
//
|
||||||
|
// Informational. Nothing refuses a bill rung outside it; the terminal shows
|
||||||
|
// it so a counter knows who is due.
|
||||||
|
Shiftid int `json:"shift_id"`
|
||||||
|
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
models/posshift.go
Normal file
53
models/posshift.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StaffShifts is a working window at one outlet.
|
||||||
|
//
|
||||||
|
// Separate from `ridershifts`, which already exists and was the obvious thing
|
||||||
|
// to reuse — but is the wrong shape twice over. It carries delivery economics
|
||||||
|
// (`basefare`, `fuelcharge`, `additionalkm`, `firstmilecharge`) that mean
|
||||||
|
// nothing at a counter, and it is scoped by `applocationid`, a city, where a
|
||||||
|
// shop's hours belong to the shop. A supermarket in Selvapuram and one in
|
||||||
|
// Gandhipuram do not open at the same time because they share a city.
|
||||||
|
//
|
||||||
|
// A person is assigned exactly one of these via `app_users.shiftid`, which is
|
||||||
|
// the column that already existed. That makes a shift a *template* — "the
|
||||||
|
// morning shift" — rather than a roster of dated assignments. A roster is the
|
||||||
|
// richer model and the one to reach for if per-date planning is ever wanted;
|
||||||
|
// this is the smaller thing that answers "who is on the early shift" without a
|
||||||
|
// second table and a second screen.
|
||||||
|
//
|
||||||
|
// Informational. Nothing refuses a bill rung outside a shift: the terminal has
|
||||||
|
// never been run against this, and a cashier locked out mid-queue by a clock is
|
||||||
|
// a worse failure than a bill filed against the wrong window.
|
||||||
|
type StaffShifts struct {
|
||||||
|
Staffshiftid int `json:"staff_shift_id" gorm:"primaryKey;autoIncrement;column:staffshiftid"`
|
||||||
|
|
||||||
|
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||||
|
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
||||||
|
|
||||||
|
// What a person calls it — "Morning", "Evening", "Weekend cover".
|
||||||
|
Name string `json:"name" gorm:"column:name"`
|
||||||
|
|
||||||
|
// Stored as text in 24-hour `HH:MM`, matching how `ridershifts` already
|
||||||
|
// holds its own times. Not a `time` column: these are wall-clock windows
|
||||||
|
// that repeat, not instants, and a date component on them invites exactly
|
||||||
|
// the timezone confusion that put a day of POS bills under the wrong
|
||||||
|
// business date.
|
||||||
|
Starttime string `json:"start_time" gorm:"column:starttime"`
|
||||||
|
Endtime string `json:"end_time" gorm:"column:endtime"`
|
||||||
|
|
||||||
|
// Which days it runs, as a 7-character mask starting Monday — "1111100"
|
||||||
|
// is Monday to Friday. Empty means every day, so a shop that never varies
|
||||||
|
// its week does not have to say so.
|
||||||
|
Weekdays string `json:"weekdays" gorm:"column:weekdays"`
|
||||||
|
|
||||||
|
Status string `json:"status" gorm:"column:status"`
|
||||||
|
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
||||||
|
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (StaffShifts) TableName() string {
|
||||||
|
return "staffshifts"
|
||||||
|
}
|
||||||
@@ -21,6 +21,23 @@ type Productcount struct {
|
|||||||
Outofstock int `json:"outofstock"`
|
Outofstock int `json:"outofstock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProductCategoryUpdate is one row of a bulk re-filing: which product, which
|
||||||
|
// category it sits in, and — the field that actually decides what a shopper
|
||||||
|
// sees — which subcategory.
|
||||||
|
//
|
||||||
|
// Both are here because they do different jobs. GetProducts filters on
|
||||||
|
// categoryid (the customer app asks for 2), while GetProductsBySubcategory
|
||||||
|
// GROUPS on subcategoryid, so the aisle heading in the app comes from the
|
||||||
|
// second and the first only decides whether the product is returned at all.
|
||||||
|
// A caller re-filing products into aisles sends categoryid 2 unchanged and the
|
||||||
|
// subcategory it worked out.
|
||||||
|
type ProductCategoryUpdate struct {
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
Categoryid int `json:"categoryid"`
|
||||||
|
// Optional: 0 leaves whatever the product already has.
|
||||||
|
Subcategoryid int `json:"subcategoryid"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProductCategory struct {
|
type ProductCategory struct {
|
||||||
Categoryid int `json:"categoryid"`
|
Categoryid int `json:"categoryid"`
|
||||||
Moduleid int `json:"moduleid"`
|
Moduleid int `json:"moduleid"`
|
||||||
@@ -36,14 +53,59 @@ type ProductCategory struct {
|
|||||||
Updated time.Time `json:"updated"`
|
Updated time.Time `json:"updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One size of one product — "500ml" hanging under the parent "Coke".
|
||||||
|
//
|
||||||
|
// The table always had the two columns that make this a relationship rather
|
||||||
|
// than a list of words: `productid` names the PARENT, `variantproductid` names
|
||||||
|
// the product a shopper actually orders when they pick this size. Neither was
|
||||||
|
// mapped here, so nothing could read or write them: `createproductvariant`
|
||||||
|
// wrote rows with no parent, and `getproductvariants` returned a flat
|
||||||
|
// per-tenant list of names. Measured 2026-09-02 across ten tenants: two rows
|
||||||
|
// existed on the whole platform, named "testing" and "demo", attached to
|
||||||
|
// nothing.
|
||||||
|
//
|
||||||
|
// The consequence is the ordering bug. With no way to attach a variant to a
|
||||||
|
// product, the app had only `products.variants` — a bare group number, 0 on
|
||||||
|
// every product — to go on.
|
||||||
type Productvariant struct {
|
type Productvariant struct {
|
||||||
Variantid int `json:"variantid" gorm:"Primary_Key"`
|
Variantid int `json:"variantid" gorm:"primaryKey;autoIncrement"`
|
||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
|
|
||||||
|
// The parent. A variant is meaningless without one, so this is what
|
||||||
|
// AddProductVariant refuses to accept as zero.
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
|
||||||
|
// The product to actually put in the basket for this size. It is a real
|
||||||
|
// product row, so it carries its own price, stock and barcode — which is
|
||||||
|
// why a variant does not need to duplicate any of them.
|
||||||
|
Variantproductid int `json:"variantproductid"`
|
||||||
|
|
||||||
Variantname string `json:"variantname"`
|
Variantname string `json:"variantname"`
|
||||||
|
Varianttype string `json:"varianttype,omitempty"`
|
||||||
|
|
||||||
|
// A per-variant price override. 0 means "no override, use the product’s
|
||||||
|
// own price" — which is a real answer, so it is always emitted. With
|
||||||
|
// omitempty the key vanished at 0 and a client reading `price` got
|
||||||
|
// undefined, then rendered it.
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
|
||||||
Categoryid int `json:"categoryid" gorm:"default:0"`
|
Categoryid int `json:"categoryid" gorm:"default:0"`
|
||||||
Categoryname string `json:"categoryname" gorm:"-"`
|
Categoryname string `json:"categoryname" gorm:"-"`
|
||||||
Subcategoryid int `json:"subcategoryid"`
|
Subcategoryid int `json:"subcategoryid"`
|
||||||
Status string `json:"status" gorm:"default:active"`
|
Status string `json:"status" gorm:"default:Active"`
|
||||||
|
|
||||||
|
// Read-only, from the variant's own product row. The app needs a name and a
|
||||||
|
// price to draw a size picker; without these it would have to fetch each
|
||||||
|
// variant separately to render one screen.
|
||||||
|
Variantproductname string `json:"variantproductname" gorm:"->"`
|
||||||
|
Variantprice float64 `json:"variantprice" gorm:"->"`
|
||||||
|
Variantstock int `json:"variantstock" gorm:"->"`
|
||||||
|
|
||||||
|
// The size, as the product itself records it. Without these the only way to
|
||||||
|
// label a size was to join the parent’s unit fields to the variant’s name,
|
||||||
|
// which is how "null kg" reaches a screen.
|
||||||
|
Variantunitvalue string `json:"variantunitvalue" gorm:"->"`
|
||||||
|
Variantproductunit string `json:"variantproductunit" gorm:"->"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Products struct {
|
type Products struct {
|
||||||
@@ -56,18 +118,121 @@ type Products struct {
|
|||||||
Subcategoryid int `json:"subcategoryid,omitempty"`
|
Subcategoryid int `json:"subcategoryid,omitempty"`
|
||||||
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
|
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
|
||||||
Catalogueid int `json:"catalogueid,omitempty"`
|
Catalogueid int `json:"catalogueid,omitempty"`
|
||||||
|
// The catalogue's own stable key for this product, e.g. `cheetos_chips_2d6bf74f`.
|
||||||
|
//
|
||||||
|
// `catalogueid` cannot do this job and never could. The catalogue is
|
||||||
|
// rebuilt by scrape and every row is renumbered when it is: pepsico's live
|
||||||
|
// ids run 3, 6, 9 … 27, 30, so a product imported when it was id 26 now
|
||||||
|
// points at nothing. Eleven of the nineteen links on the platform were
|
||||||
|
// dangling this way, and none could be repaired — the re-scrape had also
|
||||||
|
// changed the pack sizes, so the product they named no longer existed.
|
||||||
|
//
|
||||||
|
// `image_id` is the key the catalogue itself deduplicates on and it
|
||||||
|
// survives both. Written on import; `catalogueid` is kept beside it for
|
||||||
|
// rows imported before this column existed, and as the id the import call
|
||||||
|
// still addresses.
|
||||||
|
Imageid string `json:"imageid,omitempty" gorm:"column:imageid"`
|
||||||
Addonid int `json:"addonid,omitempty"`
|
Addonid int `json:"addonid,omitempty"`
|
||||||
Discountid int `json:"discountid"`
|
Discountid int `json:"discountid"`
|
||||||
Discountvalue float64 `json:"discountvalue"`
|
Discountvalue float64 `json:"discountvalue"`
|
||||||
Pricingid int `json:"pricingid,omitempty"`
|
Pricingid int `json:"pricingid,omitempty"`
|
||||||
Productname string `json:"productname,omitempty"`
|
Productname string `json:"productname,omitempty"`
|
||||||
Productimage string `json:"productimage,omitempty"`
|
Productimage string `json:"productimage,omitempty"`
|
||||||
|
|
||||||
|
// Every photo the product has, as a JSON array of URLs.
|
||||||
|
//
|
||||||
|
// `Productimage` above stays the first of these and is left untouched: every
|
||||||
|
// existing reader — the store catalogue, the customer app, the POS catalogue
|
||||||
|
// pull, each order line — reads that column, and repointing them all at an
|
||||||
|
// array is a far larger change than giving the extra photos somewhere to live.
|
||||||
|
//
|
||||||
|
// Before this the import kept `Images[0]` and discarded the rest, so a product
|
||||||
|
// with ten photos in the global catalogue arrived in a shop with one. 90 of
|
||||||
|
// nestle's 123 products have more than one.
|
||||||
|
//
|
||||||
|
// Held as a string rather than a []string because GORM's raw scan-into-struct
|
||||||
|
// silently drops slice-kind destination fields — the same reason
|
||||||
|
// `catalogueProductColumns` casts its text[] columns to text.
|
||||||
|
Productimages string `json:"productimages,omitempty" gorm:"column:productimages;type:jsonb"`
|
||||||
|
|
||||||
|
// The catalogue's own record of this product, as it stood at import.
|
||||||
|
//
|
||||||
|
// Holds the fields the snapshot does not have columns for — fssai_license,
|
||||||
|
// highlights, nutrients, providers, price_range, variant_key, title,
|
||||||
|
// sku_source, search_query — so the console can show them without asking
|
||||||
|
// the catalogue again. It asked on every drawer open, and got nothing back
|
||||||
|
// the moment a re-scrape retired the source row, taking a licence number
|
||||||
|
// and a nutrition panel off a product the shop was still selling.
|
||||||
|
//
|
||||||
|
// Empty for anything that did not come from the catalogue: a sheet-imported
|
||||||
|
// product has no such record, and the drawer falls back to the live lookup
|
||||||
|
// for those exactly as before.
|
||||||
|
//
|
||||||
|
// A string for the same reason `Productimages` is one — GORM's raw
|
||||||
|
// scan-into-struct silently drops slice- and map-kind destination fields.
|
||||||
|
Cataloguefacts string `json:"cataloguefacts,omitempty" gorm:"column:cataloguefacts;type:jsonb"`
|
||||||
|
|
||||||
|
// The nutrition panel, for the product screen in the customer app.
|
||||||
|
//
|
||||||
|
// `gorm:"-"`: not a column. It is unpacked from Cataloguefacts above, which
|
||||||
|
// is where the import snapshots it — a second column holding the same facts
|
||||||
|
// is a second thing to keep in step, and this one has no writer of its own.
|
||||||
|
//
|
||||||
|
// ABSENT rather than null when a product has no nutrition. Most products on
|
||||||
|
// the platform have none today, and `"nutrition": null` on every row of a
|
||||||
|
// mobile response is payload spent saying nothing. An app should read a
|
||||||
|
// missing key as "not known", never as "this food has no nutrition".
|
||||||
|
//
|
||||||
|
// Set by the service, not the repository — see decorateNutrition.
|
||||||
|
Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"`
|
||||||
|
|
||||||
|
// The health score, for the same product screen and from the same record.
|
||||||
|
//
|
||||||
|
// `gorm:"-"`, absent when there is nothing safe to show, and independent of
|
||||||
|
// Nutrition above — a product can be scored with no figures published, and
|
||||||
|
// carry figures with no score.
|
||||||
|
//
|
||||||
|
// WITHHELD on anything that is not food. The upstream per-product endpoint
|
||||||
|
// is not gated for edibility and has rated insecticide 80/100; see
|
||||||
|
// models.IsEdible.
|
||||||
|
Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"`
|
||||||
|
|
||||||
|
// Whether this shop shows a health score for this product.
|
||||||
|
//
|
||||||
|
// A real column, unlike the two above. The shopkeeper's call: the score
|
||||||
|
// comes from a third party matching a reference product by name, often
|
||||||
|
// under 60% confidence, and a merchant who knows the packet may reasonably
|
||||||
|
// decide the rating does not describe what they sell.
|
||||||
|
//
|
||||||
|
// Defaults true, so every product imported before this column existed keeps
|
||||||
|
// showing what it shows today.
|
||||||
|
//
|
||||||
|
// Gates `Healthscore` and NOTHING else. `Nutrition` is always sent — the
|
||||||
|
// figures are what the packet says, the score is a judgement of them, and a
|
||||||
|
// merchant turning off the judgement is not disputing the grams.
|
||||||
|
//
|
||||||
|
// The PLATFORM console ignores this: Nearle staff see every score on every
|
||||||
|
// product, because the decision being made there is whether the data is good
|
||||||
|
// enough to publish at all.
|
||||||
|
//
|
||||||
|
// NO `default` IN THE GORM TAG, and that is not an oversight. GORM skips a
|
||||||
|
// zero-value field whose tag names a default, so `false` was never written:
|
||||||
|
// the INSERT omitted the column, the database default of true applied, and a
|
||||||
|
// product imported with the score switched off came back switched on. It
|
||||||
|
// shipped that way and was found by importing one and reading it back.
|
||||||
|
//
|
||||||
|
// The DEFAULT lives on the column instead, set by the migration in main.go.
|
||||||
|
// That still covers what it is for — rows that predate the column, and any
|
||||||
|
// INSERT that genuinely omits it — without teaching GORM to drop a
|
||||||
|
// deliberate false on the way past.
|
||||||
|
Showhealthscore bool `json:"showhealthscore" gorm:"column:showhealthscore"`
|
||||||
|
|
||||||
Productdesc string `json:"productdesc,omitempty"`
|
Productdesc string `json:"productdesc,omitempty"`
|
||||||
Productsku string `json:"productsku,omitempty"`
|
Productsku string `json:"productsku,omitempty"`
|
||||||
Brandid int `json:"brandid,omitempty"`
|
Brandid int `json:"brandid,omitempty"`
|
||||||
Productbrand string `json:"productbrand,omitempty"`
|
Productbrand string `json:"productbrand,omitempty"`
|
||||||
Productunit string `json:"productunit,omitempty"`
|
Productunit string `json:"productunit"`
|
||||||
Unitvalue string `json:"unitvalue,omitempty"`
|
Unitvalue string `json:"unitvalue"`
|
||||||
Toppicks string `json:"toppicks,omitempty"`
|
Toppicks string `json:"toppicks,omitempty"`
|
||||||
Productcost float64 `json:"productcost,omitempty"`
|
Productcost float64 `json:"productcost,omitempty"`
|
||||||
Taxamount float64 `json:"taxamount,omitempty"`
|
Taxamount float64 `json:"taxamount,omitempty"`
|
||||||
@@ -76,6 +241,19 @@ type Products struct {
|
|||||||
Productstock int `json:"productstock" gorm:"default:0"`
|
Productstock int `json:"productstock" gorm:"default:0"`
|
||||||
Productcombo int `json:"productcombo" gorm:"default:0"`
|
Productcombo int `json:"productcombo" gorm:"default:0"`
|
||||||
Variants int `json:"variants" gorm:"default:0"`
|
Variants int `json:"variants" gorm:"default:0"`
|
||||||
|
|
||||||
|
// The sizes hanging under this product, so one call draws the whole screen.
|
||||||
|
//
|
||||||
|
// A separate field from `Variants` above, which is the legacy group NUMBER
|
||||||
|
// and stays exactly as it was — renaming it would break every caller, and it
|
||||||
|
// is still what an old client reads. This is the list.
|
||||||
|
//
|
||||||
|
// Always present, and EMPTY for a product with no sizes. That is the case
|
||||||
|
// that matters: an empty list is a complete answer meaning "order this one
|
||||||
|
// directly", which is what lets the app proceed instead of stalling on a
|
||||||
|
// choice that does not exist.
|
||||||
|
Variantoptions []Productvariant `json:"variantoptions" gorm:"-"`
|
||||||
|
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
// Price is the EFFECTIVE selling price at the location a query was scoped
|
// Price is the EFFECTIVE selling price at the location a query was scoped
|
||||||
// to: productlocations.price when the store has set one, otherwise the
|
// to: productlocations.price when the store has set one, otherwise the
|
||||||
@@ -85,7 +263,7 @@ type Products struct {
|
|||||||
// returned only Retailprice, which the admin catalogue never writes.
|
// returned only Retailprice, which the admin catalogue never writes.
|
||||||
// Same meaning as Locationproducts.Price, so both product feeds agree.
|
// Same meaning as Locationproducts.Price, so both product feeds agree.
|
||||||
Price float64 `json:"price" gorm:"->"`
|
Price float64 `json:"price" gorm:"->"`
|
||||||
Retailprice float64 `json:"retailprice,omitempty"`
|
Retailprice float64 `json:"retailprice"`
|
||||||
Diffprice float64 `json:"diffprice,omitempty"`
|
Diffprice float64 `json:"diffprice,omitempty"`
|
||||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||||
Othercost float64 `json:"othercost,omitempty"`
|
Othercost float64 `json:"othercost,omitempty"`
|
||||||
@@ -119,10 +297,32 @@ type Locationproducts struct {
|
|||||||
Productimage string `json:"productimage,omitempty"`
|
Productimage string `json:"productimage,omitempty"`
|
||||||
Productdesc string `json:"productdesc,omitempty"`
|
Productdesc string `json:"productdesc,omitempty"`
|
||||||
Productsku string `json:"productsku,omitempty"`
|
Productsku string `json:"productsku,omitempty"`
|
||||||
|
|
||||||
|
// Three columns this read used to leave in the table.
|
||||||
|
//
|
||||||
|
// All three are stored on `products` and none of them reached the store
|
||||||
|
// catalogue screen, because this struct had no field to scan them into —
|
||||||
|
// so the console could not use what the import had gone to the trouble of
|
||||||
|
// saving:
|
||||||
|
//
|
||||||
|
// Imageid the catalogue's durable key, and what HealthScorePanel
|
||||||
|
// joins on. Absent, the panel reads it as "this product
|
||||||
|
// never came from the catalogue" and renders nothing — for
|
||||||
|
// EVERY product, including ones that plainly did.
|
||||||
|
// Productimages the rest of a product's photos. `imagesOf()` parses this
|
||||||
|
// and always got undefined, so the gallery fell back to
|
||||||
|
// the single `productimage` and the extra images — 90 of
|
||||||
|
// nestle's 123 products have them — were never shown.
|
||||||
|
// Cataloguefacts the licence, nutrition, highlights, providers and price
|
||||||
|
// range kept at import so they survive a re-scrape.
|
||||||
|
Imageid string `json:"imageid,omitempty"`
|
||||||
|
Productimages string `json:"productimages,omitempty"`
|
||||||
|
Cataloguefacts string `json:"cataloguefacts,omitempty"`
|
||||||
|
|
||||||
Brandid int `json:"brandid,omitempty"`
|
Brandid int `json:"brandid,omitempty"`
|
||||||
Productbrand string `json:"productbrand,omitempty"`
|
Productbrand string `json:"productbrand,omitempty"`
|
||||||
Productunit string `json:"productunit,omitempty"`
|
Productunit string `json:"productunit"`
|
||||||
Unitvalue string `json:"unitvalue,omitempty"`
|
Unitvalue string `json:"unitvalue"`
|
||||||
Toppicks string `json:"toppicks,omitempty"`
|
Toppicks string `json:"toppicks,omitempty"`
|
||||||
Productcost float64 `json:"productcost,omitempty"`
|
Productcost float64 `json:"productcost,omitempty"`
|
||||||
Taxamount float64 `json:"taxamount,omitempty"`
|
Taxamount float64 `json:"taxamount,omitempty"`
|
||||||
@@ -137,13 +337,31 @@ type Locationproducts struct {
|
|||||||
// productlocations row, not from products. Without it a store could set a
|
// productlocations row, not from products. Without it a store could set a
|
||||||
// price and never read it back, so the UI always showed the master price.
|
// price and never read it back, so the UI always showed the master price.
|
||||||
Price float64 `json:"price" gorm:"->"`
|
Price float64 `json:"price" gorm:"->"`
|
||||||
Retailprice float64 `json:"retailprice,omitempty"`
|
Retailprice float64 `json:"retailprice"`
|
||||||
Diffprice float64 `json:"diffprice,omitempty"`
|
Diffprice float64 `json:"diffprice,omitempty"`
|
||||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||||
Othercost float64 `json:"othercost,omitempty"`
|
Othercost float64 `json:"othercost,omitempty"`
|
||||||
Approve int `json:"approve" gorm:"default:0"`
|
Approve int `json:"approve" gorm:"default:0"`
|
||||||
|
// Whether this product's health score is shown to shoppers.
|
||||||
|
//
|
||||||
|
// It has to be HERE and not only on Products, because this is the struct the
|
||||||
|
// admin catalogue reads. Without it the console received no value at all,
|
||||||
|
// the drawer's switch rendered "on" for every product including the ones
|
||||||
|
// that were off, and a product already hidden showed no panel and so no way
|
||||||
|
// to turn it back on. The column was being written correctly the whole time
|
||||||
|
// and simply never read back — which also made every "it did not save"
|
||||||
|
// reading taken from this endpoint meaningless.
|
||||||
|
//
|
||||||
|
// No `omitempty`: a false has to survive the trip, and omitempty would drop
|
||||||
|
// exactly the value this field exists to carry.
|
||||||
|
Showhealthscore bool `json:"showhealthscore"`
|
||||||
// Productstatus string `json:"productstatus" gorm:"default:available"`
|
// Productstatus string `json:"productstatus" gorm:"default:available"`
|
||||||
Status string `json:"status" gorm:"default:outofstock"`
|
Status string `json:"status" gorm:"default:outofstock"`
|
||||||
|
|
||||||
|
// Set only when the admin has released this product to the shops; NULL
|
||||||
|
// while it sits in the admin catalogue awaiting a price. This — not
|
||||||
|
// `Status` — is what a store view filters on. See models.Productlocations.
|
||||||
|
Publishedat *time.Time `json:"publishedat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Productstocks struct {
|
type Productstocks struct {
|
||||||
@@ -249,6 +467,31 @@ type TenantInfo struct {
|
|||||||
Categoryid int `json:"categoryid"`
|
Categoryid int `json:"categoryid"`
|
||||||
Registrationno string `json:"registrationno"`
|
Registrationno string `json:"registrationno"`
|
||||||
Orderscount int `json:"orderscount"`
|
Orderscount int `json:"orderscount"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
Whether this branch is taking orders, and why not when it is not.
|
||||||
|
|
||||||
|
`Isopen` and `Closeduntil` are the stored columns; `Isaccepting` and
|
||||||
|
`Closedreason` are worked out per request by models.StoreIsOpen, which
|
||||||
|
also folds in `status` and lets a dated close expire on its own.
|
||||||
|
|
||||||
|
THE APP SHOULD READ `isaccepting`, not `isopen`. The raw flag says what
|
||||||
|
the shopkeeper last pressed; the computed one is the answer — a branch
|
||||||
|
that said "back on Monday" has isopen false on Monday and is open.
|
||||||
|
|
||||||
|
The branch is still RETURNED when closed rather than filtered out. A shop
|
||||||
|
that vanishes reads to a regular customer as gone for good; one marked
|
||||||
|
"closed today" brings them back tomorrow. An app that would rather hide
|
||||||
|
it can, from this field.
|
||||||
|
*/
|
||||||
|
// The BRANCH status, aliased in the query so it does not collide with the
|
||||||
|
// tenant status already on this struct. Feeds StoreIsOpen.
|
||||||
|
Locationstatus string `json:"-" gorm:"column:locationstatus"`
|
||||||
|
Isopen bool `json:"isopen"`
|
||||||
|
Closeduntil string `json:"closeduntil"`
|
||||||
|
Isaccepting bool `json:"isaccepting" gorm:"-"`
|
||||||
|
Closedreason string `json:"closedreason" gorm:"-"`
|
||||||
|
|
||||||
// Products []Products `json:"products" gorm:"-"`
|
// Products []Products `json:"products" gorm:"-"`
|
||||||
ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"`
|
ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"`
|
||||||
}
|
}
|
||||||
@@ -291,6 +534,13 @@ type TenantCategory struct {
|
|||||||
type ImportedCatalogueRef struct {
|
type ImportedCatalogueRef struct {
|
||||||
Brand string `json:"brand"`
|
Brand string `json:"brand"`
|
||||||
Catalogueid int64 `json:"catalogueid"`
|
Catalogueid int64 `json:"catalogueid"`
|
||||||
|
// The stable key, when the product carries one.
|
||||||
|
//
|
||||||
|
// A browse screen should match on this in preference to the id: the id is
|
||||||
|
// renumbered by every re-scrape, so a tick placed by catalogueid lands on
|
||||||
|
// whatever product now holds that number, or on nothing at all. Empty for a
|
||||||
|
// product imported before the column existed.
|
||||||
|
Imageid string `json:"imageid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportCatalogueProductRequest is the payload for importing a product from
|
// ImportCatalogueProductRequest is the payload for importing a product from
|
||||||
@@ -310,6 +560,14 @@ type ImportCatalogueProductRequest struct {
|
|||||||
Retailprice float64 `json:"retailprice"`
|
Retailprice float64 `json:"retailprice"`
|
||||||
Productcost float64 `json:"productcost"`
|
Productcost float64 `json:"productcost"`
|
||||||
Taxpercent float64 `json:"taxpercent"`
|
Taxpercent float64 `json:"taxpercent"`
|
||||||
|
|
||||||
|
// Whether this shop will show the product's health score.
|
||||||
|
//
|
||||||
|
// A POINTER so "not sent" and "sent as false" stay different answers. Every
|
||||||
|
// caller that predates this field omits it, and a bare bool would read those
|
||||||
|
// as a deliberate no and strip the score from every import made by an older
|
||||||
|
// console. Nil means "they did not say", which is treated as yes.
|
||||||
|
Showhealthscore *bool `json:"showhealthscore"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Productlocations struct {
|
type Productlocations struct {
|
||||||
@@ -324,6 +582,20 @@ type Productlocations struct {
|
|||||||
Quantity int `json:"quantity" gorm:"<-:false"`
|
Quantity int `json:"quantity" gorm:"<-:false"`
|
||||||
Stocktype string `json:"stocktype" gorm:"<-:false"`
|
Stocktype string `json:"stocktype" gorm:"<-:false"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
// When this product was released to the store, and the only thing that
|
||||||
|
// decides whether a store user can see it.
|
||||||
|
//
|
||||||
|
// NULL means the admin has imported it but not published it: it belongs to
|
||||||
|
// the admin catalogue alone. Deliberately NOT another `Status` value —
|
||||||
|
// syncProductLocationStatus rewrites that column on every stock movement,
|
||||||
|
// which is why the import's 'Draft' survived on one row out of 6,755.
|
||||||
|
//
|
||||||
|
// Read-only through this struct (`<-:false`). It is set by Publish and
|
||||||
|
// cleared by Unpublish, never as a side effect of an ordinary
|
||||||
|
// product-location write: setting a price must not be able to release a
|
||||||
|
// product to every shop in the tenant.
|
||||||
|
Publishedat *time.Time `json:"publishedat" gorm:"column:publishedat;<-:false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductLocationRef identifies a single (tenant, location, product) row in
|
// ProductLocationRef identifies a single (tenant, location, product) row in
|
||||||
|
|||||||
71
models/productWire_test.go
Normal file
71
models/productWire_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
A missing string must reach a client as "", never as an absent key.
|
||||||
|
|
||||||
|
`unitvalue` and `productunit` were both `omitempty`, and on this platform
|
||||||
|
`unitvalue` is empty for every product — measured 2026-09-02 on R mart: 24
|
||||||
|
products, 0 with both fields, 7 with a unit and no value. So the key vanished
|
||||||
|
from the JSON, a client read `undefined`, and joining it to the unit rendered
|
||||||
|
the literal text "null kg" on a shopper's screen.
|
||||||
|
|
||||||
|
The same applies to money: a price of 0 is a real answer ("this has no price
|
||||||
|
set"), and a client that receives no key at all cannot tell that from a field
|
||||||
|
it forgot to request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func fieldsOf(t *testing.T, v any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEmptyUnitIsSentAsEmptyNotOmitted(t *testing.T) {
|
||||||
|
got := fieldsOf(t, Products{Productid: 1})
|
||||||
|
for _, key := range []string{"unitvalue", "productunit"} {
|
||||||
|
if _, present := got[key]; !present {
|
||||||
|
t.Errorf("%q was omitted; a client reads that as null and renders it", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAZeroPriceIsSentAsZeroNotOmitted(t *testing.T) {
|
||||||
|
got := fieldsOf(t, Products{Productid: 1})
|
||||||
|
if _, present := got["retailprice"]; !present {
|
||||||
|
t.Error("retailprice was omitted; 0 is a real answer and must be visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVariantAlwaysCarriesItsPriceAndUnit(t *testing.T) {
|
||||||
|
got := fieldsOf(t, Productvariant{Variantid: 1})
|
||||||
|
for _, key := range []string{"price", "variantprice", "variantunitvalue", "variantproductunit"} {
|
||||||
|
if _, present := got[key]; !present {
|
||||||
|
t.Errorf("variant option omitted %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProductWithNoVariantsSendsAnEmptyListNotNull(t *testing.T) {
|
||||||
|
// The case that lets an order proceed: [] means "order this one directly".
|
||||||
|
// A JSON null would force every client to null-check before counting.
|
||||||
|
got := fieldsOf(t, Products{Productid: 1, Variantoptions: []Productvariant{}})
|
||||||
|
list, ok := got["variantoptions"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("variantoptions was %T, want a list", got["variantoptions"])
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Errorf("expected an empty list, got %d", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
models/storeopen.go
Normal file
89
models/storeopen.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Is a branch taking orders right now?
|
||||||
|
|
||||||
|
Two things have to be true: the branch exists as far as the platform is
|
||||||
|
concerned, and the shop has not closed itself for the day. They are different
|
||||||
|
questions with different owners — Nearle decommissions a branch, a shopkeeper
|
||||||
|
closes for an afternoon — and this is the only place they are answered
|
||||||
|
together.
|
||||||
|
|
||||||
|
── Reopening is worked out, not scheduled ──────────────────────────────────
|
||||||
|
|
||||||
|
A shop that said "back on Monday" is open on Monday because this function says
|
||||||
|
so, not because a job ran. Nothing has to fire, nothing can be missed, and a
|
||||||
|
server that was down over the weekend still gets it right. The alternative — a
|
||||||
|
nightly task flipping the flag — fails silently exactly once and leaves a shop
|
||||||
|
shut with nobody watching.
|
||||||
|
|
||||||
|
── Why the date means the day it REOPENS ───────────────────────────────────
|
||||||
|
|
||||||
|
"Closed until the 12th" reads, in English, as open on the 12th. So the date
|
||||||
|
names the first day back, and a shop closing for today alone sets tomorrow.
|
||||||
|
The console says "Reopens on" rather than "Closed until" so nobody has to
|
||||||
|
work that out.
|
||||||
|
*/
|
||||||
|
func StoreIsOpen(status string, isOpen bool, closedUntil string, now time.Time) bool {
|
||||||
|
// Decommissioned beats everything. A branch Nearle has switched off does
|
||||||
|
// not come back because a date passed.
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(status), "active") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOpen {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed, with a date to come back on.
|
||||||
|
reopen, ok := parseStoreDate(closedUntil)
|
||||||
|
if !ok {
|
||||||
|
// Closed with no end in sight — a power cut nobody can put a date on.
|
||||||
|
// Stays closed until a person says otherwise, which is the safe way
|
||||||
|
// round: a shop wrongly shown open takes orders it cannot fill.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
today := now.Truncate(24 * time.Hour)
|
||||||
|
return !today.Before(reopen.Truncate(24 * time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Why a shopper cannot order, in words fit to show them.
|
||||||
|
|
||||||
|
Empty when the branch is open. Returned straight to the app, so it says what a
|
||||||
|
person needs to know and not what the database holds.
|
||||||
|
*/
|
||||||
|
func StoreClosedReason(status string, isOpen bool, closedUntil string, now time.Time) string {
|
||||||
|
if StoreIsOpen(status, isOpen, closedUntil, now) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(status), "active") {
|
||||||
|
return "This store is not currently available"
|
||||||
|
}
|
||||||
|
if reopen, ok := parseStoreDate(closedUntil); ok {
|
||||||
|
return "This store is closed today and reopens on " + reopen.Format("2 Jan")
|
||||||
|
}
|
||||||
|
return "This store is closed today"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts a bare date or a timestamp, because the column is a DATE but GORM
|
||||||
|
// and the console have both been known to hand over a full RFC3339 string.
|
||||||
|
// Anything unreadable is "no date", never today.
|
||||||
|
func parseStoreDate(value string) (time.Time, bool) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if len(trimmed) >= 10 {
|
||||||
|
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
111
models/storeopen_test.go
Normal file
111
models/storeopen_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func at(day string) time.Time {
|
||||||
|
parsed, err := time.Parse("2006-01-02", day)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenByDefault(t *testing.T) {
|
||||||
|
// Every branch trading today has isopen true and no date. None of them may
|
||||||
|
// go dark the moment this ships.
|
||||||
|
if !StoreIsOpen("Active", true, "", at("2026-10-09")) {
|
||||||
|
t.Error("a normal trading branch was reported closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClosedWithNoDateStaysClosed(t *testing.T) {
|
||||||
|
// A power cut nobody can put an end to. It stays shut until a person says
|
||||||
|
// otherwise — the safe way round, because a shop wrongly shown open takes
|
||||||
|
// orders it cannot fill.
|
||||||
|
if StoreIsOpen("Active", false, "", at("2026-10-09")) {
|
||||||
|
t.Error("a branch closed with no reopen date was reported open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReopensOnTheDateItNamed(t *testing.T) {
|
||||||
|
// "Closed until the 12th" means open ON the 12th.
|
||||||
|
cases := []struct {
|
||||||
|
today string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"2026-10-10", false}, // still closed
|
||||||
|
{"2026-10-11", false}, // last closed day
|
||||||
|
{"2026-10-12", true}, // back
|
||||||
|
{"2026-10-13", true}, // and stays back
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := StoreIsOpen("Active", false, "2026-10-12", at(tc.today))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("on %s: open=%v, want %v", tc.today, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReopeningNeedsNobodyToRemember(t *testing.T) {
|
||||||
|
// The whole reason this is computed and not scheduled: no job ran, nothing
|
||||||
|
// flipped the flag, and the shop is open anyway.
|
||||||
|
if !StoreIsOpen("Active", false, "2026-10-01", at("2026-12-25")) {
|
||||||
|
t.Error("a branch whose reopen date passed months ago is still shut")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecommissionedBeatsEverything(t *testing.T) {
|
||||||
|
// Status is Nearle's word for whether the branch exists. A date passing
|
||||||
|
// must not bring back a branch that was switched off.
|
||||||
|
if StoreIsOpen("Inactive", true, "", at("2026-10-09")) {
|
||||||
|
t.Error("an inactive branch was reported open")
|
||||||
|
}
|
||||||
|
if StoreIsOpen("Inactive", false, "2026-10-01", at("2026-12-25")) {
|
||||||
|
t.Error("an inactive branch came back because a date passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreadableDateIsNotTreatedAsToday(t *testing.T) {
|
||||||
|
// A zero time would be before today and quietly reopen the shop.
|
||||||
|
if StoreIsOpen("Active", false, "not a date", at("2026-10-09")) {
|
||||||
|
t.Error("an unreadable reopen date reopened the branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptsATimestampAsWellAsADate(t *testing.T) {
|
||||||
|
// The column is a DATE, but GORM and the console have both been known to
|
||||||
|
// hand over a full timestamp.
|
||||||
|
if !StoreIsOpen("Active", false, "2026-10-12T00:00:00Z", at("2026-10-12")) {
|
||||||
|
t.Error("a timestamp form of the reopen date was not understood")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReasonIsWrittenForAShopper(t *testing.T) {
|
||||||
|
if msg := StoreClosedReason("Active", true, "", at("2026-10-09")); msg != "" {
|
||||||
|
t.Errorf("an open store gave a reason: %q", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := StoreClosedReason("Active", false, "2026-10-12", at("2026-10-09"))
|
||||||
|
if !strings.Contains(msg, "12 Oct") {
|
||||||
|
t.Errorf("the reason does not say when it reopens: %q", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No date: say it is closed, and do not invent a return.
|
||||||
|
plain := StoreClosedReason("Active", false, "", at("2026-10-09"))
|
||||||
|
if plain == "" || strings.Contains(plain, "reopens") {
|
||||||
|
t.Errorf("a close with no date promised a return: %q", plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusIsNotCaseSensitive(t *testing.T) {
|
||||||
|
// The column holds "Active" today, but nothing enforces the capital.
|
||||||
|
for _, s := range []string{"Active", "active", "ACTIVE", " Active "} {
|
||||||
|
if !StoreIsOpen(s, true, "", at("2026-10-09")) {
|
||||||
|
t.Errorf("status %q read as not active", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,28 @@ type Tenantinfo struct {
|
|||||||
Latitude string `json:"latitude"`
|
Latitude string `json:"latitude"`
|
||||||
Longitude string `json:"longitude"`
|
Longitude string `json:"longitude"`
|
||||||
Tenantimage string `json:"tenantimage"`
|
Tenantimage string `json:"tenantimage"`
|
||||||
|
// When the merchant was created.
|
||||||
|
//
|
||||||
|
// The column has always been there — `tenants.created` defaults to now() —
|
||||||
|
// and `GetTenantByID` already does SELECT a.*, so the value was in every
|
||||||
|
// result set and thrown away for want of a field to scan into. A shop
|
||||||
|
// profile that cannot say how long the business has been on the platform is
|
||||||
|
// missing a fact the database has held all along.
|
||||||
|
Created time.Time `json:"created"`
|
||||||
Tenantinfo string `json:"tenantinfo"`
|
Tenantinfo string `json:"tenantinfo"`
|
||||||
|
// The FSSAI or trade licence.
|
||||||
|
//
|
||||||
|
// The column has always existed and `GetCustomerTenants` already selects it
|
||||||
|
// for the customer app — it was missing from THIS struct alone, so
|
||||||
|
// `gettenantinfo` could never return it however well it was stored.
|
||||||
|
//
|
||||||
|
// That is not cosmetic. The shop-profile screen reads a merchant's own
|
||||||
|
// record back through this endpoint, and the setup walkthrough decides
|
||||||
|
// whether the profile step is finished by looking at exactly this field. A
|
||||||
|
// merchant could type their licence, save it, see it stored — and the step
|
||||||
|
// would stay open forever, because the read that judges it never carried
|
||||||
|
// the value.
|
||||||
|
Licenseno string `json:"licenseno"`
|
||||||
Paymenttype int `json:"paymenttype"`
|
Paymenttype int `json:"paymenttype"`
|
||||||
Paymode1 int `json:"paymode1"`
|
Paymode1 int `json:"paymode1"`
|
||||||
Paymode2 int `json:"paymode2"`
|
Paymode2 int `json:"paymode2"`
|
||||||
@@ -36,6 +57,13 @@ type Tenantinfo struct {
|
|||||||
Approved int `json:"approved"`
|
Approved int `json:"approved"`
|
||||||
Moduleid int `json:"moduleid"`
|
Moduleid int `json:"moduleid"`
|
||||||
Subcategoryname string `json:"subcategoryname"`
|
Subcategoryname string `json:"subcategoryname"`
|
||||||
|
// The app category this store trades under, by name.
|
||||||
|
//
|
||||||
|
// `GetTenantByID` has always joined `app_category b` and selected
|
||||||
|
// `b.categoryname`, and the struct had nowhere to put it — so the value was
|
||||||
|
// computed on every read and discarded, leaving the console to print
|
||||||
|
// "Category 2" at a merchant who trades under Daily Needs.
|
||||||
|
Categoryname string `json:"categoryname"`
|
||||||
Firstname string `json:"firstname"`
|
Firstname string `json:"firstname"`
|
||||||
Lastname string `json:"lastname"`
|
Lastname string `json:"lastname"`
|
||||||
Accountname string `json:"Accountname"`
|
Accountname string `json:"Accountname"`
|
||||||
@@ -43,6 +71,14 @@ type Tenantinfo struct {
|
|||||||
Allocationid int `json:"allocationid"`
|
Allocationid int `json:"allocationid"`
|
||||||
Allocationtype string `json:"allocationtype"`
|
Allocationtype string `json:"allocationtype"`
|
||||||
Allocationmode int `json:"allocationmode"`
|
Allocationmode int `json:"allocationmode"`
|
||||||
|
|
||||||
|
// How many outlets this merchant has.
|
||||||
|
//
|
||||||
|
// Only `GetAllTenants` fills this; it is 0 everywhere else, which is why it
|
||||||
|
// is last and optional rather than part of the record proper. The console's
|
||||||
|
// store list previously derived it by counting duplicate rows, and this
|
||||||
|
// endpoint has never returned duplicates — see the note on the query.
|
||||||
|
Branchcount int `json:"branchcount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tenantlocations struct {
|
type Tenantlocations struct {
|
||||||
@@ -68,6 +104,50 @@ type Tenantlocations struct {
|
|||||||
Deliverymins int `json:"deliverymins"`
|
Deliverymins int `json:"deliverymins"`
|
||||||
Cancelsecs int `json:"cancelsecs"`
|
Cancelsecs int `json:"cancelsecs"`
|
||||||
Status string `json:"status" gorm:"default:Active"`
|
Status string `json:"status" gorm:"default:Active"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
Is this branch trading today?
|
||||||
|
|
||||||
|
SEPARATE FROM `Status`, deliberately. Status is the platform's word for
|
||||||
|
whether a branch exists at all — setting it to Inactive is how a branch is
|
||||||
|
decommissioned, and the staff console reads it that way. A shopkeeper
|
||||||
|
closing for an afternoon because the power went out is saying something
|
||||||
|
completely different, and the two must not share a field: a day off would
|
||||||
|
read to Nearle as a shop that had shut down.
|
||||||
|
|
||||||
|
Default true. Every branch trading today keeps trading.
|
||||||
|
*/
|
||||||
|
Isopen bool `json:"isopen" gorm:"column:isopen;default:true"`
|
||||||
|
|
||||||
|
/*
|
||||||
|
The date it opens again, when the shop knows it.
|
||||||
|
|
||||||
|
"Closed until the 12th" means closed on the 11th and OPEN on the 12th —
|
||||||
|
the date names the first day back, which is how the phrase reads.
|
||||||
|
|
||||||
|
Nothing flips `Isopen` back on a timer. The answer is worked out on read
|
||||||
|
(see StoreIsOpen), so a branch that said "back on Monday" is open on
|
||||||
|
Monday whether or not anybody remembered. A job would drift; this cannot.
|
||||||
|
|
||||||
|
Empty means closed until somebody says otherwise, which is the right
|
||||||
|
default for a power cut nobody can put an end date on.
|
||||||
|
*/
|
||||||
|
Closeduntil string `json:"closeduntil" gorm:"column:closeduntil"`
|
||||||
|
|
||||||
|
// Who will run this outlet, when the caller already has somebody in mind.
|
||||||
|
//
|
||||||
|
// `gorm:"-"` because it is not a column — it names an existing `app_users`
|
||||||
|
// row to bind to the new branch instead of spawning a fresh login.
|
||||||
|
//
|
||||||
|
// Spawning was the only option, and it produced an account named after the
|
||||||
|
// SHOP, on the SHOP's email address, one per outlet. Two people behind the
|
||||||
|
// same counter shared one credential and nothing recorded which of them did
|
||||||
|
// anything. Naming a person here is what lets a merchant decide who runs a
|
||||||
|
// branch, and hire that person before the branch exists.
|
||||||
|
//
|
||||||
|
// Zero keeps the old behaviour exactly, so every existing caller is
|
||||||
|
// unaffected.
|
||||||
|
Operatorid int `json:"operatorid" gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tenantslot struct {
|
type Tenantslot struct {
|
||||||
@@ -147,6 +227,22 @@ type StaffInfo struct {
|
|||||||
Tenantid int `json:"tenantid"`
|
Tenantid int `json:"tenantid"`
|
||||||
Locationid int `json:"locationid"`
|
Locationid int `json:"locationid"`
|
||||||
Locationname string `json:"locationname"`
|
Locationname string `json:"locationname"`
|
||||||
|
// Whether this login still works, straight off `app_users.status`.
|
||||||
|
// Without it every row on the console's Users & access screen read
|
||||||
|
// "Unknown", because the field was never selected or sent.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// Whether they have ever chosen a password.
|
||||||
|
//
|
||||||
|
// Every back-office account is created with an empty one and emailed a link
|
||||||
|
// to set it. Until that link is used the person is in this list, in every
|
||||||
|
// branch picker, and cannot sign in — and `Status` does not say so: an
|
||||||
|
// Active account with no password is refused at the login screen like any
|
||||||
|
// other. `false` is the row that needs an action, which is why the directory
|
||||||
|
// reads it and offers a resend there and nowhere else.
|
||||||
|
//
|
||||||
|
// Computed in the query. `Password` is also on this struct, which is its own
|
||||||
|
// problem, but nothing should have to look at it to answer this.
|
||||||
|
IsSetUp bool `json:"issetup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tenantuser struct {
|
type Tenantuser struct {
|
||||||
|
|||||||
@@ -101,4 +101,11 @@ type TenantUserInfo struct {
|
|||||||
Categoryname string `json:"categoryname"`
|
Categoryname string `json:"categoryname"`
|
||||||
Subcategoryid int `json:"subcategoryid"`
|
Subcategoryid int `json:"subcategoryid"`
|
||||||
Issuperadmin bool `json:"issuperadmin"`
|
Issuperadmin bool `json:"issuperadmin"`
|
||||||
|
// Carried so this response is a SUPERSET of what `UserInfo` returned.
|
||||||
|
// `/mob/users/tenant/login` used to answer with UserInfo, and a shipped app
|
||||||
|
// may read any of these three; dropping them while repointing the route
|
||||||
|
// would have been a breaking change disguised as a fix.
|
||||||
|
Shiftid int `json:"shiftid"`
|
||||||
|
Shiftname string `json:"shiftname"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
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() }
|
||||||
242
repositories/catalogueColumns_test.go
Normal file
242
repositories/catalogueColumns_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bug: a brand table one column short of the old eighteen-column check was
|
||||||
|
// not degraded, it was INVISIBLE — missing from getbrands and "Unknown brand"
|
||||||
|
// on every read. 19 of the owning team's 35 brands were reachable; the other 16
|
||||||
|
// existed and could not be seen from this side at all.
|
||||||
|
|
||||||
|
func columnSet(names ...string) map[string]bool {
|
||||||
|
have := make(map[string]bool, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
have[n] = true
|
||||||
|
}
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
|
var everyColumn = func() map[string]bool {
|
||||||
|
names := append([]string{}, catalogueCoreColumns...)
|
||||||
|
for _, c := range catalogueOptionalColumns {
|
||||||
|
names = append(names, c.Name)
|
||||||
|
}
|
||||||
|
return columnSet(names...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
func TestAFullTableSelectsEveryRealColumn(t *testing.T) {
|
||||||
|
got := columnsFor(everyColumn)
|
||||||
|
if strings.Contains(got, "NULL::") {
|
||||||
|
t.Errorf("a complete table should select no NULL stand-ins:\n%s", got)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"product_sku", "providers::text AS providers", "fssai_license"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing %q from:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exact shape that used to vanish: everything but one enrichment column.
|
||||||
|
func TestATableMissingOneColumnIsStillReadable(t *testing.T) {
|
||||||
|
have := columnSet()
|
||||||
|
for name := range everyColumn {
|
||||||
|
have[name] = true
|
||||||
|
}
|
||||||
|
delete(have, "fssai_license")
|
||||||
|
|
||||||
|
got := columnsFor(have)
|
||||||
|
if !strings.Contains(got, "NULL::text AS fssai_license") {
|
||||||
|
t.Errorf("absent column should be selected as NULL:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, ", fssai_license,") {
|
||||||
|
t.Errorf("must not select a column the table does not have:\n%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "product_name") {
|
||||||
|
t.Errorf("the rest of the table must still be read:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A NULL stand-in has to carry the same cast as the real column, or the scan
|
||||||
|
// destination changes type between one brand table and the next.
|
||||||
|
func TestNullStandInsKeepTheColumnType(t *testing.T) {
|
||||||
|
bare := columnsFor(columnSet("id", "product_name"))
|
||||||
|
for _, want := range []string{
|
||||||
|
"NULL::text AS providers",
|
||||||
|
"NULL::text AS highlights",
|
||||||
|
"NULL::text AS nutrients",
|
||||||
|
"NULL::timestamptz AS created_at",
|
||||||
|
"NULL::timestamptz AS updated_at",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(bare, want) {
|
||||||
|
t.Errorf("missing %q from:\n%s", want, bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core columns are never substituted — without an id there is nothing to order
|
||||||
|
// or address rows by, which is why a table lacking them is skipped outright.
|
||||||
|
func TestCoreColumnsAreAlwaysSelectedPlainly(t *testing.T) {
|
||||||
|
got := columnsFor(columnSet("id", "product_name"))
|
||||||
|
if !strings.HasPrefix(got, "id, product_name") {
|
||||||
|
t.Errorf("core columns should lead the select list, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Where product photos come from.
|
||||||
|
|
||||||
|
The owning team records what its image search found, per product, in
|
||||||
|
`image_url` and `image_urls`. Most of those are EXTERNAL — bigbasket, amazon, a
|
||||||
|
shop's own CDN — and only a subset were ever mirrored into our bucket. Reading
|
||||||
|
the bucket alone found photos for 2 of britannia's 6 products while they had
|
||||||
|
URLs for all 6.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestJsonImageListIsRead(t *testing.T) {
|
||||||
|
// The shape their API returns today.
|
||||||
|
raw := `["https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg",` +
|
||||||
|
`"https://m.media-amazon.com/images/I/71n1Q3cQL3L.jpg"]`
|
||||||
|
got := parseImageList(raw)
|
||||||
|
if len(got) != 2 || got[0] != "https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg" {
|
||||||
|
t.Fatalf("json list not parsed: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same column stored as a Postgres text[] rather than jsonb. Which one it
|
||||||
|
// is belongs to the owning team, and this side should not break when it moves.
|
||||||
|
func TestPostgresArrayImageListIsRead(t *testing.T) {
|
||||||
|
got := parseImageList(`{"https://a.example/1.jpg","https://b.example/2.jpg"}`)
|
||||||
|
if len(got) != 2 || got[1] != "https://b.example/2.jpg" {
|
||||||
|
t.Fatalf("pg array not parsed: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyImageListsAreNotPhotos(t *testing.T) {
|
||||||
|
for _, raw := range []string{"", " ", "{}", "[]", "null"} {
|
||||||
|
if got := parseImageList(raw); len(got) != 0 {
|
||||||
|
t.Errorf("%q should yield no photos, got %#v", raw, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed value must cost the photos, not the product.
|
||||||
|
func TestMalformedImageListDoesNotBreakTheRow(t *testing.T) {
|
||||||
|
if got := parseImageList(`["unterminated`); len(got) != 0 {
|
||||||
|
t.Errorf("expected no photos from malformed json, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheListedUrlWinsAndDuplicatesCollapse(t *testing.T) {
|
||||||
|
row := catalogueProductRow{
|
||||||
|
ImageID: "britannia_britannia_marie_gold_250g",
|
||||||
|
ImageURL: "https://a.example/1.jpg",
|
||||||
|
ImageURLs: `["https://a.example/1.jpg","https://a.example/2.jpg"]`,
|
||||||
|
}
|
||||||
|
got := imagesFor("britannia", row)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("the primary url repeated in the list should appear once: %#v", got)
|
||||||
|
}
|
||||||
|
if got[0] != "https://a.example/1.jpg" || got[1] != "https://a.example/2.jpg" {
|
||||||
|
t.Errorf("order should follow image_urls: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no URLs recorded, the bucket listing is still consulted — it is the only
|
||||||
|
// source for anything ingested before these columns existed.
|
||||||
|
func TestNoRecordedUrlsFallsBackToTheBucket(t *testing.T) {
|
||||||
|
row := catalogueProductRow{ImageID: "cadbury_cadbury_5_star_5_gm"}
|
||||||
|
// db.GetImages returns nil when the image store was never initialised,
|
||||||
|
// which is the case here — the point is that it does not panic and does not
|
||||||
|
// invent a photo.
|
||||||
|
if got := imagesFor("cadbury", row); len(got) != 0 {
|
||||||
|
t.Errorf("expected no photos without an image store, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Brand names arrive in two spellings, and only one of them is a key.
|
||||||
|
|
||||||
|
The catalogue keys brands by table suffix (`24_mantra`); the ingest service's
|
||||||
|
run manifest reports the display name for the same brand ("24 Mantra"). Anything
|
||||||
|
acting on a manifest — the step that puts an uploaded sheet on a shop's shelf,
|
||||||
|
above all — holds the second and has to be able to look up the first.
|
||||||
|
|
||||||
|
Real values, taken from the manifest of the R mart upload on 2026-08-31, which
|
||||||
|
failed with "Unknown brand: 24 Mantra" and shelved nothing.
|
||||||
|
*/
|
||||||
|
func TestNormaliseBrandKeyFoldsDisplayNamesOntoTableKeys(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"24 Mantra": "24_mantra",
|
||||||
|
"Paper Boat": "paper_boat",
|
||||||
|
"Too Yumm": "too_yumm",
|
||||||
|
"Clinic All Clear": "clinic_all_clear",
|
||||||
|
"hindustan unilever": "hindustan_unilever",
|
||||||
|
"coca-cola": "coca_cola",
|
||||||
|
|
||||||
|
// Single-word brands were never broken; they must stay unbroken.
|
||||||
|
"Colin": "colin",
|
||||||
|
"Society": "society",
|
||||||
|
"itc": "itc",
|
||||||
|
"Kohinoor": "kohinoor",
|
||||||
|
|
||||||
|
// A key that is already in table form passes through untouched, which is
|
||||||
|
// what makes the fallback safe to apply to every lookup.
|
||||||
|
"24_mantra": "24_mantra",
|
||||||
|
|
||||||
|
// Separators collapse rather than doubling up, and the edges are clean —
|
||||||
|
// " P&G " must not become "__p_g_".
|
||||||
|
" P&G ": "p_g",
|
||||||
|
"Dabur Red": "dabur_red",
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := normaliseBrandKey(input); got != want {
|
||||||
|
t.Errorf("normaliseBrandKey(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand of nothing but punctuation yields an empty key rather than a stray
|
||||||
|
// underscore. Empty misses the map and answers "unknown brand", which is the
|
||||||
|
// right answer; "_" could in principle collide with a real table suffix.
|
||||||
|
func TestNormaliseBrandKeyRefusesToInventAKey(t *testing.T) {
|
||||||
|
for _, input := range []string{"", " ", "---", "&&&"} {
|
||||||
|
if got := normaliseBrandKey(input); got != "" {
|
||||||
|
t.Errorf("normaliseBrandKey(%q) = %q, want empty", input, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every word of the label was once required, so one word the catalogue does
|
||||||
|
// not use ("Parle G biscuit pack") kept the right product out of the result
|
||||||
|
// altogether and left the vector search to answer alone.
|
||||||
|
func TestMinTokenHitsAsksForMostWordsNotAllOfThem(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ tokens, want int }{
|
||||||
|
{1, 1}, // one word: it has to be there
|
||||||
|
{2, 2}, // "Parle G" — both, and both are in Parle-G
|
||||||
|
{3, 2}, // "Milk Bikis pack" — the pack is allowed to be missing
|
||||||
|
{4, 3}, // "Parle G biscuit pack"
|
||||||
|
{5, 4},
|
||||||
|
{6, 4},
|
||||||
|
} {
|
||||||
|
if got := minTokenHits(tc.tokens); got != tc.want {
|
||||||
|
t.Errorf("%d tokens: need %d, want %d", tc.tokens, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A threshold that could fall to 1 would let any single common word drag in
|
||||||
|
// whole brand tables; one that stayed at n would be the bug all over again.
|
||||||
|
func TestMinTokenHitsStaysBetweenTwoAndAll(t *testing.T) {
|
||||||
|
for n := 3; n <= 30; n++ {
|
||||||
|
got := minTokenHits(n)
|
||||||
|
if got < 2 {
|
||||||
|
t.Fatalf("%d tokens: %d is too loose", n, got)
|
||||||
|
}
|
||||||
|
if got >= n {
|
||||||
|
t.Fatalf("%d tokens: %d still demands every word", n, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
repositories/catalogueNotify.go
Normal file
85
repositories/catalogueNotify.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Telling tills that a shop's shelf has moved.
|
||||||
|
//
|
||||||
|
// The broker topic `nearle/pos/{loc}/catalogue` and its publisher have existed
|
||||||
|
// since the POS integration landed, retained flag and all — and **nothing ever
|
||||||
|
// called it**. Every terminal's stock figure was therefore whatever it last
|
||||||
|
// pulled, with no signal that an app order had just sold the last of something.
|
||||||
|
//
|
||||||
|
// It could not be called from here, which is why it never was: `repositories`
|
||||||
|
// does not import `messaging`, and must not — wiring runs the other way, from
|
||||||
|
// main.go, and reversing it would be an import cycle. So the dependency is
|
||||||
|
// inverted through this interface. `messaging.PosMqttConsumer` already
|
||||||
|
// satisfies it; main.go registers it once at startup.
|
||||||
|
//
|
||||||
|
// A package-level registration rather than a constructor parameter because the
|
||||||
|
// two write paths that need it — POS bill ingest and createOrderTx — are
|
||||||
|
// reached through several layers that would each have to grow a field for a
|
||||||
|
// notification neither of them cares about the result of.
|
||||||
|
|
||||||
|
// CatalogueNotifier tells every till at a store to pull the catalogue again.
|
||||||
|
type CatalogueNotifier interface {
|
||||||
|
PublishCatalogueChanged(storeID, revision string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
catalogueNotifierMu sync.RWMutex
|
||||||
|
catalogueNotifier CatalogueNotifier
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetCatalogueNotifier registers the publisher. Safe to leave unset — the API
|
||||||
|
// must run with the broker down, and a missed notification only means a till is
|
||||||
|
// stale until its next periodic pull.
|
||||||
|
func SetCatalogueNotifier(n CatalogueNotifier) {
|
||||||
|
catalogueNotifierMu.Lock()
|
||||||
|
defer catalogueNotifierMu.Unlock()
|
||||||
|
catalogueNotifier = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyCatalogueChanged asks the tills at one outlet to refresh.
|
||||||
|
//
|
||||||
|
// Three rules, all of them learned the hard way elsewhere in this codebase:
|
||||||
|
//
|
||||||
|
// 1. **Call it after the transaction commits, never inside.** A rolled-back
|
||||||
|
// sale must not announce a change that did not happen.
|
||||||
|
// 2. **It cannot fail a sale.** A publish error is logged and swallowed —
|
||||||
|
// exactly as syncProductLocationStatus already does for the availability
|
||||||
|
// flag. Stock is committed; a terminal being told about it late is not
|
||||||
|
// worth losing a bill over.
|
||||||
|
// 3. **Only the revision travels, never quantities.** The till pulls. Pushed
|
||||||
|
// numbers race with concurrent sales, and the catalogue endpoint already
|
||||||
|
// answers a delta from a revision.
|
||||||
|
//
|
||||||
|
// Fire-and-forget on its own goroutine: the publisher waits for the broker's
|
||||||
|
// acknowledgement, and a bill's response should not sit behind that.
|
||||||
|
func notifyCatalogueChanged(locationID int) {
|
||||||
|
if locationID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogueNotifierMu.RLock()
|
||||||
|
n := catalogueNotifier
|
||||||
|
catalogueNotifierMu.RUnlock()
|
||||||
|
if n == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamped a second in the past for the same reason Catalogue() does it: a
|
||||||
|
// revision must not claim to include a write that is still landing.
|
||||||
|
revision := posRevisionFor(locationID, time.Now().Add(-time.Second))
|
||||||
|
storeID := strconv.Itoa(locationID)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := n.PublishCatalogueChanged(storeID, revision); err != nil {
|
||||||
|
log.Printf("catalogue notify: store %s: %v", storeID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"nearle/db"
|
"nearle/db"
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -34,10 +37,86 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured
|
|||||||
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
||||||
// scan-into-struct silently drops slice-kind destination fields, so they
|
// scan-into-struct silently drops slice-kind destination fields, so they
|
||||||
// are read as text here and parsed into []string in scanProductRow.
|
// are read as text here and parsed into []string in scanProductRow.
|
||||||
|
|
||||||
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
|
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
|
||||||
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
|
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
|
||||||
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
|
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
|
||||||
|
|
||||||
|
// catalogueCoreColumns is the minimum a `brand_*` table must have to be worth
|
||||||
|
// reading: an id to order and address rows by, and a name to show. Everything
|
||||||
|
// else is enrichment, and a table missing some of it is still a perfectly good
|
||||||
|
// catalogue of products.
|
||||||
|
var catalogueCoreColumns = []string{"id", "product_name"}
|
||||||
|
|
||||||
|
// catalogueOptionalColumns is selected when present and replaced with NULL when
|
||||||
|
// it is not, so one absent column costs that column rather than the whole brand.
|
||||||
|
//
|
||||||
|
// The expression is carried next to the name because three of these are text[]
|
||||||
|
// and need casting; the NULL stand-in has to be cast the same way or the scan
|
||||||
|
// destination changes type from one table to the next.
|
||||||
|
var catalogueOptionalColumns = []struct{ Name, Present, Absent string }{
|
||||||
|
{"title", "title", "NULL::text AS title"},
|
||||||
|
{"description", "description", "NULL::text AS description"},
|
||||||
|
{"category", "category", "NULL::text AS category"},
|
||||||
|
{"image_id", "image_id", "NULL::text AS image_id"},
|
||||||
|
{"size", "size", "NULL::text AS size"},
|
||||||
|
{"variant_key", "variant_key", "NULL::text AS variant_key"},
|
||||||
|
{"product_sku", "product_sku", "NULL::text AS product_sku"},
|
||||||
|
{"sku_source", "sku_source", "NULL::text AS sku_source"},
|
||||||
|
{"price_range", "price_range", "NULL::text AS price_range"},
|
||||||
|
{"providers", "providers::text AS providers", "NULL::text AS providers"},
|
||||||
|
{"fssai_license", "fssai_license", "NULL::text AS fssai_license"},
|
||||||
|
{"highlights", "highlights::text AS highlights", "NULL::text AS highlights"},
|
||||||
|
{"nutrients", "nutrients::text AS nutrients", "NULL::text AS nutrients"},
|
||||||
|
{"search_query", "search_query", "NULL::text AS search_query"},
|
||||||
|
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
|
||||||
|
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
|
||||||
|
|
||||||
|
// Where the photos actually are.
|
||||||
|
//
|
||||||
|
// The catalogue pipeline records what it found per product: `image_url` for
|
||||||
|
// the primary and `image_urls` for the rest. Most are EXTERNAL — bigbasket,
|
||||||
|
// amazon, a shop's own CDN — because stage 6 searches the web and only some
|
||||||
|
// results get mirrored into our bucket.
|
||||||
|
//
|
||||||
|
// This side ignored both columns and instead listed bucket objects under
|
||||||
|
// `daily/brands/{brand}/{image_id}/`, so a product showed a photo only if it
|
||||||
|
// happened to have been mirrored. Measured on britannia: 2 of 6 products had
|
||||||
|
// images here while the owning team had URLs for all 6.
|
||||||
|
//
|
||||||
|
// Cast to text and parsed by hand: the column is an array, and GORM's raw
|
||||||
|
// scan silently drops slice-kind destination fields — the same reason
|
||||||
|
// providers, highlights and nutrients are read this way.
|
||||||
|
{"image_url", "image_url", "NULL::text AS image_url"},
|
||||||
|
{"image_urls", "image_urls::text AS image_urls", "NULL::text AS image_urls"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnsFor builds the SELECT list for one table from the columns it has.
|
||||||
|
//
|
||||||
|
// This is the fix for brands going missing. Discovery used to demand all
|
||||||
|
// eighteen columns — `HAVING COUNT(DISTINCT column_name) = 18` — so a brand
|
||||||
|
// table written by a newer pipeline, with one column renamed or not yet added,
|
||||||
|
// was not merely degraded but INVISIBLE: absent from `getbrands`, and "Unknown
|
||||||
|
// brand" on every read. Measured against the live catalogue, 19 of the owning
|
||||||
|
// team's 35 brands were reachable and the other 16 could not be seen from this
|
||||||
|
// side at all.
|
||||||
|
//
|
||||||
|
// Selecting NULL for what is absent turns that into the smaller, honest
|
||||||
|
// failure: the brand appears, its products list, and the fields nobody wrote
|
||||||
|
// come back empty.
|
||||||
|
func columnsFor(have map[string]bool) string {
|
||||||
|
parts := make([]string, 0, len(catalogueCoreColumns)+len(catalogueOptionalColumns))
|
||||||
|
parts = append(parts, catalogueCoreColumns...)
|
||||||
|
for _, col := range catalogueOptionalColumns {
|
||||||
|
if have[col.Name] {
|
||||||
|
parts = append(parts, col.Present)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, col.Absent)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// catalogueProductRow mirrors catalogueProductColumns for scanning; array
|
// catalogueProductRow mirrors catalogueProductColumns for scanning; array
|
||||||
// columns land here as their raw Postgres text[] literal.
|
// columns land here as their raw Postgres text[] literal.
|
||||||
type catalogueProductRow struct {
|
type catalogueProductRow struct {
|
||||||
@@ -59,6 +138,65 @@ type catalogueProductRow struct {
|
|||||||
SearchQuery string
|
SearchQuery string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
ImageURL string
|
||||||
|
ImageURLs string
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagesFor decides which photos a catalogue product carries.
|
||||||
|
//
|
||||||
|
// The pipeline's own `image_urls` first, then its single `image_url`, and the
|
||||||
|
// bucket listing last. That order is deliberate: the owning team records what
|
||||||
|
// it found for every product, and only a subset of those files were ever
|
||||||
|
// mirrored into our bucket — so listing the bucket alone finds photos for a
|
||||||
|
// minority of the catalogue and reports the rest as having none.
|
||||||
|
//
|
||||||
|
// The bucket stays as the fallback rather than being dropped. It is the only
|
||||||
|
// source for anything ingested before these columns existed, and it is the one
|
||||||
|
// source that cannot rot: a external URL is somebody else's CDN and will
|
||||||
|
// eventually 404, at which point the card falls through to the next photo.
|
||||||
|
func imagesFor(brand string, row catalogueProductRow) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
add := func(url string) {
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
if url == "" || seen[url] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[url] = true
|
||||||
|
out = append(out, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range parseImageList(row.ImageURLs) {
|
||||||
|
add(url)
|
||||||
|
}
|
||||||
|
add(row.ImageURL)
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return db.GetImages(brand, row.ImageID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageList reads the list whichever way it was stored.
|
||||||
|
//
|
||||||
|
// A Postgres text[] casts to `{a,b}` and a jsonb column to `["a","b"]`, and the
|
||||||
|
// column type is the owning team's to change. Handling both here costs four
|
||||||
|
// lines and removes a whole class of "it worked until they migrated".
|
||||||
|
func parseImageList(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || raw == "{}" || raw == "[]" || raw == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "[") {
|
||||||
|
var urls []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &urls); err == nil {
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
// Falls through on a malformed value rather than dropping the row.
|
||||||
|
log.Printf("catalogue: could not parse image_urls as JSON: %.120s", raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return models.ParsePGArray(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
||||||
@@ -70,7 +208,7 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
|||||||
Description: row.Description,
|
Description: row.Description,
|
||||||
Category: row.Category,
|
Category: row.Category,
|
||||||
ImageID: row.ImageID,
|
ImageID: row.ImageID,
|
||||||
Images: db.GetImages(brand, row.ImageID),
|
Images: imagesFor(brand, row),
|
||||||
Size: row.Size,
|
Size: row.Size,
|
||||||
VariantKey: row.VariantKey,
|
VariantKey: row.VariantKey,
|
||||||
ProductSKU: row.ProductSKU,
|
ProductSKU: row.ProductSKU,
|
||||||
@@ -92,6 +230,7 @@ type CatalogueRepository interface {
|
|||||||
GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error)
|
GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error)
|
||||||
GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error)
|
GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error)
|
||||||
GetProductByID(brand string, id int64) (*models.CatalogueProduct, error)
|
GetProductByID(brand string, id int64) (*models.CatalogueProduct, error)
|
||||||
|
GetProductByImageID(brand, imageID string) (*models.CatalogueProduct, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type catalogueRepository struct {
|
type catalogueRepository struct {
|
||||||
@@ -104,12 +243,210 @@ func NewCatalogueRepository(db *gorm.DB) CatalogueRepository {
|
|||||||
return &catalogueRepository{db: db}
|
return &catalogueRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableForBrand(brand string) (string, error) {
|
// brandTables is the allowlist actually used, discovered from the catalogue
|
||||||
table, ok := catalogueBrandTables[strings.ToLower(strings.TrimSpace(brand))]
|
// database rather than hardcoded.
|
||||||
if !ok {
|
//
|
||||||
|
// The literal map above only ever exposed six brands, so a `brand_*` table
|
||||||
|
// added to the catalogue DB was invisible to the whole product until somebody
|
||||||
|
// shipped a release. Discovery removes that, and keeps the property the
|
||||||
|
// allowlist existed for: **table names still never come from a request.** They
|
||||||
|
// come from the database's own catalog, and the `brand` parameter is only ever
|
||||||
|
// looked up in the result.
|
||||||
|
//
|
||||||
|
// Two things it checks that a name alone would not:
|
||||||
|
//
|
||||||
|
// - the table has the CORE columns — an id and a product name. It used to
|
||||||
|
// demand all eighteen, which turned "this table is a little different" into
|
||||||
|
// "this brand does not exist": 16 of 35 brands were unreachable that way.
|
||||||
|
// Anything beyond the core is now selected as NULL when absent, so a table
|
||||||
|
// of a different shape is read rather than hidden.
|
||||||
|
// - discovery returning nothing falls back to the literal map, so a
|
||||||
|
// permissions problem on information_schema cannot take the catalogue dark.
|
||||||
|
//
|
||||||
|
// Cached with a TTL so a brand added today appears without a restart.
|
||||||
|
var (
|
||||||
|
brandTablesMu sync.RWMutex
|
||||||
|
brandTablesData map[string]string
|
||||||
|
brandTablesAt time.Time
|
||||||
|
|
||||||
|
// The columns each discovered table actually has, filled by
|
||||||
|
// discoverBrandTables and read by columnsForTable. Kept beside the table map
|
||||||
|
// and refreshed with it, so the two can never describe different schemas.
|
||||||
|
brandColumnsMu sync.RWMutex
|
||||||
|
brandColumnsData map[string]map[string]bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// columnsForTable is the SELECT list for one table, or the full fixed list when
|
||||||
|
// discovery has not run — which is the built-in fallback case, where the tables
|
||||||
|
// are the known-good ones and every column is present by definition.
|
||||||
|
func columnsForTable(table string) string {
|
||||||
|
brandColumnsMu.RLock()
|
||||||
|
have := brandColumnsData[table]
|
||||||
|
brandColumnsMu.RUnlock()
|
||||||
|
if have == nil {
|
||||||
|
return catalogueProductColumns
|
||||||
|
}
|
||||||
|
return columnsFor(have)
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandTablesTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
func (r *catalogueRepository) brandTables() map[string]string {
|
||||||
|
brandTablesMu.RLock()
|
||||||
|
if brandTablesData != nil && time.Since(brandTablesAt) < brandTablesTTL {
|
||||||
|
defer brandTablesMu.RUnlock()
|
||||||
|
return brandTablesData
|
||||||
|
}
|
||||||
|
brandTablesMu.RUnlock()
|
||||||
|
|
||||||
|
discovered := catalogueBrandTables
|
||||||
|
if r.db != nil {
|
||||||
|
if found, err := r.discoverBrandTables(); err != nil {
|
||||||
|
log.Printf("catalogue: brand discovery failed, using the built-in list: %v", err)
|
||||||
|
} else if len(found) > 0 {
|
||||||
|
discovered = found
|
||||||
|
} else {
|
||||||
|
log.Println("catalogue: brand discovery found no usable tables, using the built-in list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
brandTablesMu.Lock()
|
||||||
|
brandTablesData, brandTablesAt = discovered, time.Now()
|
||||||
|
brandTablesMu.Unlock()
|
||||||
|
return discovered
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverBrandTables lists every `brand_*` table that can be read at all, and
|
||||||
|
// records which columns each one actually has.
|
||||||
|
//
|
||||||
|
// It asks for the CORE columns only. The previous version demanded all eighteen
|
||||||
|
// — `HAVING COUNT(DISTINCT column_name) = 18` — which sounds like a safety check
|
||||||
|
// and behaves like a filter: a table whose pipeline had renamed one column, or
|
||||||
|
// not written it yet, failed the count and disappeared from the product
|
||||||
|
// entirely. It was not listed by `getbrands` and every read of it answered
|
||||||
|
// "Unknown brand", so from this side the brand did not exist.
|
||||||
|
//
|
||||||
|
// That is how 19 of the owning team's 35 brands were visible. The missing ones
|
||||||
|
// were not broken and not empty; they were a column short of a test that had no
|
||||||
|
// need to be that strict, and nothing anywhere said so.
|
||||||
|
//
|
||||||
|
// The per-table column set is read in the same query and handed to columnsFor,
|
||||||
|
// which substitutes NULL for anything absent — so a table can now be missing
|
||||||
|
// enrichment without being missing.
|
||||||
|
func (r *catalogueRepository) discoverBrandTables() (map[string]string, error) {
|
||||||
|
// `IN (?)` with a slice rather than `= ANY(?)` with a driver array type:
|
||||||
|
// GORM expands the former itself, and the latter would pull in lib/pq for
|
||||||
|
// one call in a codebase that has no other use for it.
|
||||||
|
var rows []struct {
|
||||||
|
TableName string
|
||||||
|
ColumnName string
|
||||||
|
}
|
||||||
|
err := r.db.Raw(`
|
||||||
|
SELECT c.table_name, c.column_name
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema = 'public'
|
||||||
|
AND c.table_name LIKE 'brand\_%'
|
||||||
|
ORDER BY c.table_name`).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group the columns by table, then keep the tables that have the core set.
|
||||||
|
byTable := make(map[string]map[string]bool)
|
||||||
|
for _, row := range rows {
|
||||||
|
if byTable[row.TableName] == nil {
|
||||||
|
byTable[row.TableName] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
byTable[row.TableName][row.ColumnName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
columns := make(map[string]map[string]bool, len(byTable))
|
||||||
|
for table, have := range byTable {
|
||||||
|
usable := true
|
||||||
|
for _, core := range catalogueCoreColumns {
|
||||||
|
if !have[core] {
|
||||||
|
usable = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !usable {
|
||||||
|
// Named individually. A brand dropped here is a brand nobody can
|
||||||
|
// reach, and silence about it is what made the last one take a
|
||||||
|
// round trip through two teams to find.
|
||||||
|
log.Printf("catalogue: skipping %q — it lacks one of the core columns %v", table, catalogueCoreColumns)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, table)
|
||||||
|
columns[table] = have
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
brandColumnsMu.Lock()
|
||||||
|
brandColumnsData = columns
|
||||||
|
brandColumnsMu.Unlock()
|
||||||
|
|
||||||
|
out := make(map[string]string, len(names))
|
||||||
|
for _, table := range names {
|
||||||
|
brand := strings.TrimPrefix(table, "brand_")
|
||||||
|
if brand == "" || brand == table {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[strings.ToLower(brand)] = table
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableForBrand resolves a brand to its table, tolerating the display spelling.
|
||||||
|
//
|
||||||
|
// The catalogue keys brands by table suffix — `24_mantra`, `paper_boat`,
|
||||||
|
// `clinic_all_clear` — while the ingest service's run manifest reports the
|
||||||
|
// DISPLAY name for the same brand: "24 Mantra", "Paper Boat", "Clinic All
|
||||||
|
// Clear", "hindustan unilever", "coca-cola". They are the same brand written two
|
||||||
|
// ways, and anything holding a manifest is holding the second.
|
||||||
|
//
|
||||||
|
// That mismatch broke shelving outright. Putting an uploaded sheet on a shop's
|
||||||
|
// shelf reads the catalogue once per brand to resolve `image_id`, and a single
|
||||||
|
// multi-word brand failed the whole batch with "Unknown brand: 24 Mantra".
|
||||||
|
// Every brand of more than one word was affected, which in a twenty-product
|
||||||
|
// sheet is most of them.
|
||||||
|
//
|
||||||
|
// The exact key is still tried first, so a brand whose real key genuinely
|
||||||
|
// contains a separator can never be shadowed by the normalised form.
|
||||||
|
func (r *catalogueRepository) tableForBrand(brand string) (string, error) {
|
||||||
|
tables := r.brandTables()
|
||||||
|
|
||||||
|
if table, ok := tables[strings.ToLower(strings.TrimSpace(brand))]; ok {
|
||||||
|
return table, nil
|
||||||
|
}
|
||||||
|
if table, ok := tables[normaliseBrandKey(brand)]; ok {
|
||||||
|
return table, nil
|
||||||
|
}
|
||||||
return "", ErrUnknownBrand
|
return "", ErrUnknownBrand
|
||||||
}
|
}
|
||||||
return table, nil
|
|
||||||
|
// normaliseBrandKey folds a display brand name onto the catalogue's own key.
|
||||||
|
//
|
||||||
|
// Anything that is not a letter or a digit becomes an underscore, and runs of
|
||||||
|
// them collapse — which is exactly how the catalogue builds both its table names
|
||||||
|
// and the `image_id` prefix: "24 Mantra" becomes `24_mantra`, and the product
|
||||||
|
// key `24_mantra_24_mantra_organic_moong_dal_500g`. Deliberately not a general
|
||||||
|
// slugifier; matching that one convention is its whole job.
|
||||||
|
func normaliseBrandKey(brand string) string {
|
||||||
|
out := make([]rune, 0, len(brand))
|
||||||
|
lastWasSep := true // leading separators are dropped rather than kept
|
||||||
|
for _, r := range strings.ToLower(strings.TrimSpace(brand)) {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
out = append(out, r)
|
||||||
|
lastWasSep = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastWasSep {
|
||||||
|
out = append(out, '_')
|
||||||
|
lastWasSep = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(string(out), "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
|
func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
|
||||||
@@ -118,15 +455,29 @@ func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var brands []models.CatalogueBrand
|
var brands []models.CatalogueBrand
|
||||||
|
var failures int
|
||||||
|
|
||||||
for brand, table := range catalogueBrandTables {
|
for brand, table := range r.brandTables() {
|
||||||
var count int64
|
var count int64
|
||||||
if err := r.db.Table(table).Count(&count).Error; err != nil {
|
if err := r.db.Table(table).Count(&count).Error; err != nil {
|
||||||
return nil, fmt.Errorf("counting %s: %w", table, err)
|
// One brand's table being absent or unreadable must not hide the
|
||||||
|
// others. It used to abort the whole call, so a single missing
|
||||||
|
// table emptied the brand filter and — through
|
||||||
|
// getProductsAllBrands, which had the same flaw — the entire
|
||||||
|
// import screen, for every brand.
|
||||||
|
log.Printf("catalogue: skipping brand %q (%s): %v", brand, table, err)
|
||||||
|
failures++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
brands = append(brands, models.CatalogueBrand{Brand: brand, ProductCount: count})
|
brands = append(brands, models.CatalogueBrand{Brand: brand, ProductCount: count})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every table failing is a different thing from every table being empty:
|
||||||
|
// the first is an outage and must be reported, the second is a fact.
|
||||||
|
if failures == len(r.brandTables()) {
|
||||||
|
return nil, fmt.Errorf("no catalogue brand table could be read (%d configured)", failures)
|
||||||
|
}
|
||||||
|
|
||||||
return brands, nil
|
return brands, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +486,7 @@ func (r *catalogueRepository) GetCategories(brand string) ([]string, error) {
|
|||||||
return nil, ErrCatalogueDBUnavailable
|
return nil, ErrCatalogueDBUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
table, err := tableForBrand(brand)
|
table, err := r.tableForBrand(brand)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -172,7 +523,7 @@ func (r *catalogueRepository) GetProducts(brand, category, keyword string, pagen
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *catalogueRepository) getProductsForBrand(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
func (r *catalogueRepository) getProductsForBrand(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||||
table, err := tableForBrand(brand)
|
table, err := r.tableForBrand(brand)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -189,7 +540,7 @@ func (r *catalogueRepository) getProductsForBrand(brand, category, keyword strin
|
|||||||
var rows []catalogueProductRow
|
var rows []catalogueProductRow
|
||||||
dataQuery := fmt.Sprintf(
|
dataQuery := fmt.Sprintf(
|
||||||
`SELECT %s FROM %s WHERE %s ORDER BY id LIMIT ? OFFSET ?`,
|
`SELECT %s FROM %s WHERE %s ORDER BY id LIMIT ? OFFSET ?`,
|
||||||
catalogueProductColumns, table, whereClause,
|
columnsForTable(table), table, whereClause,
|
||||||
)
|
)
|
||||||
dataArgs := append(append([]interface{}{}, args...), pagesize, offset)
|
dataArgs := append(append([]interface{}{}, args...), pagesize, offset)
|
||||||
if err := r.db.Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil {
|
if err := r.db.Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil {
|
||||||
@@ -214,25 +565,39 @@ func (r *catalogueRepository) getProductsAllBrands(category, keyword string, pag
|
|||||||
whereClause, args := catalogueWhereClause(category, keyword)
|
whereClause, args := catalogueWhereClause(category, keyword)
|
||||||
|
|
||||||
brands := make([]string, 0, len(catalogueBrandTables))
|
brands := make([]string, 0, len(catalogueBrandTables))
|
||||||
for brand := range catalogueBrandTables {
|
tables := r.brandTables()
|
||||||
|
for brand := range tables {
|
||||||
brands = append(brands, brand)
|
brands = append(brands, brand)
|
||||||
}
|
}
|
||||||
sort.Strings(brands)
|
sort.Strings(brands)
|
||||||
|
|
||||||
var all []models.CatalogueProduct
|
var all []models.CatalogueProduct
|
||||||
|
var failures int
|
||||||
|
|
||||||
for _, brand := range brands {
|
for _, brand := range brands {
|
||||||
table := catalogueBrandTables[brand]
|
table := tables[brand]
|
||||||
|
|
||||||
var rows []catalogueProductRow
|
var rows []catalogueProductRow
|
||||||
dataQuery := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY id`, catalogueProductColumns, table, whereClause)
|
dataQuery := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY id`, columnsForTable(table), table, whereClause)
|
||||||
if err := r.db.Raw(dataQuery, args...).Scan(&rows).Error; err != nil {
|
if err := r.db.Raw(dataQuery, args...).Scan(&rows).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("querying %s: %w", table, err)
|
// Skip the brand rather than abandoning the merge. This aborted on
|
||||||
|
// the first failure, so one absent table returned 500 for a browse
|
||||||
|
// across all brands — which is what the import screen asks for by
|
||||||
|
// default, leaving it permanently empty while five of six brands
|
||||||
|
// were perfectly readable.
|
||||||
|
log.Printf("catalogue: skipping brand %q (%s) in all-brands browse: %v", brand, table, err)
|
||||||
|
failures++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
all = append(all, row.toModel(brand))
|
all = append(all, row.toModel(brand))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if failures == len(brands) {
|
||||||
|
return nil, 0, fmt.Errorf("no catalogue brand table could be read (%d configured)", failures)
|
||||||
|
}
|
||||||
|
|
||||||
sort.Slice(all, func(i, j int) bool {
|
sort.Slice(all, func(i, j int) bool {
|
||||||
return strings.ToLower(all[i].ProductName) < strings.ToLower(all[j].ProductName)
|
return strings.ToLower(all[i].ProductName) < strings.ToLower(all[j].ProductName)
|
||||||
})
|
})
|
||||||
@@ -274,7 +639,7 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
|
|||||||
return nil, ErrCatalogueDBUnavailable
|
return nil, ErrCatalogueDBUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
table, err := tableForBrand(brand)
|
table, err := r.tableForBrand(brand)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -282,7 +647,7 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
|
|||||||
var row catalogueProductRow
|
var row catalogueProductRow
|
||||||
query := fmt.Sprintf(
|
query := fmt.Sprintf(
|
||||||
`SELECT %s FROM %s WHERE product_sku = ? LIMIT 1`,
|
`SELECT %s FROM %s WHERE product_sku = ? LIMIT 1`,
|
||||||
catalogueProductColumns, table,
|
columnsForTable(table), table,
|
||||||
)
|
)
|
||||||
result := r.db.Raw(query, sku).Scan(&row)
|
result := r.db.Raw(query, sku).Scan(&row)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -296,12 +661,53 @@ func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.Catalo
|
|||||||
return &product, nil
|
return &product, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProductByImageID resolves a catalogue row by the id the ingest pipeline
|
||||||
|
// calls canonical.
|
||||||
|
//
|
||||||
|
// The pipeline reports what it wrote as a manifest of `image_id` values, and
|
||||||
|
// its own note is blunt about why: image_id is "the primary key every other
|
||||||
|
// product is deduplicated on", and a product name differing by one character
|
||||||
|
// is a different product. Importing into a shop needs `catalogueid` — the row
|
||||||
|
// id — so without this the console would have to match the manifest on NAME,
|
||||||
|
// which silently creates duplicates instead of updating.
|
||||||
|
//
|
||||||
|
// Alongside GetProductBySKU rather than replacing it: a sheet may leave the sku
|
||||||
|
// column blank, in which case the pipeline mints one and the sku is not a key
|
||||||
|
// the sender recognises. image_id is derived from brand, name and size and is
|
||||||
|
// stable across re-uploads.
|
||||||
|
func (r *catalogueRepository) GetProductByImageID(brand, imageID string) (*models.CatalogueProduct, error) {
|
||||||
|
if r.db == nil {
|
||||||
|
return nil, ErrCatalogueDBUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
table, err := r.tableForBrand(brand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var row catalogueProductRow
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT %s FROM %s WHERE image_id = ? LIMIT 1`,
|
||||||
|
columnsForTable(table), table,
|
||||||
|
)
|
||||||
|
result := r.db.Raw(query, strings.TrimSpace(imageID)).Scan(&row)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
product := row.toModel(strings.ToLower(brand))
|
||||||
|
return &product, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
|
func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
|
||||||
if r.db == nil {
|
if r.db == nil {
|
||||||
return nil, ErrCatalogueDBUnavailable
|
return nil, ErrCatalogueDBUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
table, err := tableForBrand(brand)
|
table, err := r.tableForBrand(brand)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -309,7 +715,7 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca
|
|||||||
var row catalogueProductRow
|
var row catalogueProductRow
|
||||||
query := fmt.Sprintf(
|
query := fmt.Sprintf(
|
||||||
`SELECT %s FROM %s WHERE id = ? LIMIT 1`,
|
`SELECT %s FROM %s WHERE id = ? LIMIT 1`,
|
||||||
catalogueProductColumns, table,
|
columnsForTable(table), table,
|
||||||
)
|
)
|
||||||
result := r.db.Raw(query, id).Scan(&row)
|
result := r.db.Raw(query, id).Scan(&row)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user