Compare commits
42 Commits
be47435547
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ed32a2620e | |||
| a30763d323 | |||
| 03ae7d310b | |||
| f67cbad79a | |||
| fc2caffcd1 | |||
| 97f277f424 | |||
| ea90b95cdc | |||
| d0804ae84f | |||
| c49f5372a5 | |||
| fb859ecda1 | |||
| f9fb405974 | |||
| b46902f51b | |||
| b18080d429 | |||
| 090e9c0c2f | |||
| 7fdcc92528 | |||
| d562691f42 | |||
| 276e12beb9 | |||
| db84a9a752 | |||
| 00317a00d8 | |||
| 299871b820 | |||
| cf3e4ea159 | |||
| bb14445e21 | |||
| 294fb8ab93 | |||
| 9698de32d5 | |||
| 697b77f8c1 | |||
| 8e1549764b | |||
| bb5f40926f | |||
| c516c224e5 | |||
| 771d6a51cf | |||
| 24339a8b51 | |||
| 01bc89ab77 | |||
| 692c10e553 | |||
| 2f1501883b | |||
| c06b029cb2 | |||
| 28af3e05f2 | |||
| 42ea007fe7 | |||
| 76bff883ec | |||
| aaea1bfc00 | |||
| 369e9fc7b4 | |||
| 72907dae74 | |||
| 1633617dc4 | |||
| 4474479735 |
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
||||
# Nothing in here reaches the image.
|
||||
#
|
||||
# `.env*` most of all: the Dockerfile does `COPY . .`, and the production
|
||||
# credentials in `.env.production` were being baked into every image built
|
||||
# from this folder. The running container gets its environment from the
|
||||
# platform (Dokploy / Kubernetes), never from a file.
|
||||
#
|
||||
# An exception for `.env.production` was added on 2026-09-25 so the container
|
||||
# could read its own configuration, and the deploy came back 502 on every
|
||||
# endpoint. Reverted. The committed file is a stale snapshot; letting it fill
|
||||
# whatever the platform leaves unset is not a safe default.
|
||||
.env*
|
||||
|
||||
.git
|
||||
.claude
|
||||
.DS_Store
|
||||
docs
|
||||
scratch
|
||||
init
|
||||
nearle
|
||||
server
|
||||
docker-compose.local.yml
|
||||
49
.env
49
.env
@@ -1,16 +1,18 @@
|
||||
# Fiesta configuration.
|
||||
# Fiesta configuration — the shared base file.
|
||||
#
|
||||
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one
|
||||
# exception is this file) — and they are gitignored for a reason: this
|
||||
# repository's history already contains a committed `.env` from before
|
||||
# 2026-08-03, so those database credentials are in the history and should be
|
||||
# rotated. Do not add another.
|
||||
# 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.
|
||||
#
|
||||
# `godotenv.Load()` in main.go reads `.env` from the working directory, so
|
||||
# `go run .` from this folder picks it up with no flags.
|
||||
# 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 is what production serves on. Change it locally to run a second copy
|
||||
# 1122 locally; production serves on 1009. Change it to run a second copy
|
||||
# beside something else; the console then points at the same number.
|
||||
APP_PORT=1122
|
||||
|
||||
@@ -59,5 +61,34 @@ 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
|
||||
|
||||
202
.env.example
202
.env.example
@@ -1,30 +1,41 @@
|
||||
# Fiesta configuration.
|
||||
# Fiesta configuration — the complete list of settings, with local values.
|
||||
#
|
||||
# Copy to `.env` and fill in. `.env` and `.env.*` are gitignored (the one
|
||||
# exception is this file) — and they are gitignored for a reason: this
|
||||
# repository's history already contains a committed `.env` from before
|
||||
# 2026-08-03, so those database credentials are in the history and should be
|
||||
# rotated. Do not add another.
|
||||
# How the files are picked (config/config.go):
|
||||
#
|
||||
# `godotenv.Load()` in main.go reads `.env` from the working directory, so
|
||||
# `go run .` from this folder picks it up with no flags.
|
||||
# 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 is what production serves on. Change it locally to run a second copy
|
||||
# beside something else; the console then points at the same number.
|
||||
# 1122 locally; production serves on 1009 (matching the Dockerfile's EXPOSE).
|
||||
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.
|
||||
# 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
|
||||
@@ -36,10 +47,9 @@ 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.
|
||||
# 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
|
||||
@@ -48,12 +58,158 @@ 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.
|
||||
# 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=
|
||||
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=
|
||||
USER_CONTEXT_KEY=nearle
|
||||
# true to make the POS routes require a terminal session.
|
||||
#POS_AUTH_REQUIRED=false
|
||||
|
||||
# ── Scan-to-order — the embedding model behind product recognition ─────────
|
||||
#
|
||||
# MUST be the model that filled the catalogue's `embedding` column: vectors
|
||||
# from two models are not comparable and pgvector will rank garbage without
|
||||
# complaint. The first search reads the column's width and refuses a mismatch
|
||||
# with an error that names both numbers.
|
||||
# Optional: with no provider the search matches on words alone (works, ranks
|
||||
# worse). openai = any OpenAI-compatible /embeddings endpoint (set
|
||||
# EMBEDDING_BASE_URL for Azure, Ollama, vLLM...); gemini = Google AI Studio.
|
||||
EMBEDDING_PROVIDER=
|
||||
EMBEDDING_MODEL=
|
||||
EMBEDDING_API_KEY=
|
||||
EMBEDDING_BASE_URL=
|
||||
# 0 = the model's default width.
|
||||
EMBEDDING_DIMENSIONS=0
|
||||
|
||||
# ── Geocoding ───────────────────────────────────────────────────────────────
|
||||
# Google Geocoding when set; OpenStreetMap's Nominatim otherwise.
|
||||
GEOCODER_API_KEY=
|
||||
# ── Nutrition ───────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The catalogue-intelligence service — the same host the console reads its
|
||||
# health score card from. The customer app product screen gets its nutrition
|
||||
# panel from here, through `getproductbyvariant`.
|
||||
#
|
||||
# Read server-side rather than by the app: the brand-spelling resolution below
|
||||
# would otherwise have to be reimplemented in the app, and a wrong spelling
|
||||
# returns a well-formed record with every figure null — indistinguishable from
|
||||
# a product nobody has scored.
|
||||
#
|
||||
# Unset means product screens carry no nutrition panel and nothing else changes.
|
||||
NUTRITION_BASE=https://mcp.nearle.ai.in/api
|
||||
|
||||
# ── Email ───────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Sending the first-password invitation a newly onboarded merchant receives.
|
||||
# Without MAIL_HOST the server still boots and still onboards tenants — the
|
||||
# create response comes back `invited: false` with the reason — but nobody is
|
||||
# emailed, and the only way into a new account is a Nearle staff member using
|
||||
# Resend invite.
|
||||
#
|
||||
# SMTP, because every provider speaks it. Any transactional service is the same
|
||||
# five variables: its host, 587, the API key as MAIL_PASSWORD, and whatever
|
||||
# username it documents.
|
||||
#
|
||||
# WE USE GOOGLE WORKSPACE SMTP, authenticating as care@nearledaily.com with a
|
||||
# 16-character App Password — never the account's login password, because an App
|
||||
# Password can be revoked on its own. See docs/MAIL_SETUP.md for the setup and
|
||||
# for the DNS records, which are what actually decide whether the invitation
|
||||
# reaches an inbox rather than a spam folder.
|
||||
#
|
||||
# Self-hosting (Postal) was the earlier plan and is the better answer at volume.
|
||||
# At a few dozen invitations a month the work is not the software, it is IP
|
||||
# reputation, rDNS and blocklists — so this buys the reputation instead.
|
||||
#
|
||||
# Google Workspace: smtp.gmail.com 587 an App Password
|
||||
# via an SMTP relay: smtp-relay.gmail.com 587 if an admin sets one up
|
||||
# Amazon SES: email-smtp.<region>.amazonaws.com 587
|
||||
# SendGrid: smtp.sendgrid.net 587 username literally "apikey"
|
||||
# Resend: smtp.resend.com 587 username literally "resend"
|
||||
#
|
||||
# Credentials belong in .env.secrets (git-ignored, read first), or in the
|
||||
# deployment platform's own environment — NOT in this file and not in .env.
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
# Optional. Leave both empty for a relay that authenticates by network rather
|
||||
# than by credentials.
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
# Who the invitation appears to come from. Separate from MAIL_USERNAME because
|
||||
# most providers authenticate as one identity and send as another, and using
|
||||
# the login as the From address is how mail lands in spam.
|
||||
#
|
||||
# ON NEARLEDAILY.COM, DELIBERATELY. The link in the mail points at
|
||||
# app.nearledaily.com, and a password link arriving from a DIFFERENT domain than
|
||||
# the one it sends you to is the exact shape of a phishing mail — to a filter
|
||||
# and to the merchant reading it. Sender and link stay on one domain.
|
||||
#
|
||||
# `care@` rather than `no-reply@`, also deliberately: somebody who replies "I
|
||||
# never got this" is the single most useful reply this system can receive, and
|
||||
# it should reach a person.
|
||||
MAIL_FROM=care@nearledaily.com
|
||||
MAIL_FROM_NAME=Nearle
|
||||
# Where the invitation link points — the MERCHANT console, always. A merchant
|
||||
# sets their password there and nowhere else, so this is never the platform
|
||||
# console's address.
|
||||
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||
|
||||
|
||||
# ── Nearle Buddy ────────────────────────────────────────────────────────────
|
||||
#
|
||||
# ONE variable. The provider, endpoint and model are defaults in config.go
|
||||
# (openai / api.groq.com / openai/gpt-oss-120b) because each has one right
|
||||
# answer for this product — and three variables that must be typed correctly
|
||||
# into a hosting platform are three ways for the assistant to sit silently off,
|
||||
# which is how it spent its first week.
|
||||
#
|
||||
# The key is the only one that differs per deployment and the only one that
|
||||
# cannot live in this repository. Locally it goes in `.env.secrets`, which git
|
||||
# ignores; in production it is set on the platform.
|
||||
ASSISTANT_API_KEY=
|
||||
|
||||
# Overrides, none of them needed for the shipped setup.
|
||||
# Ollama on a laptop: ASSISTANT_BASE_URL=http://localhost:11434/v1 and
|
||||
# ASSISTANT_MODEL=llama3 — a local endpoint needs no key.
|
||||
ASSISTANT_PROVIDER=
|
||||
ASSISTANT_BASE_URL=
|
||||
ASSISTANT_MODEL=
|
||||
# Per-tier overrides. ASSISTANT_MODEL alone sets all three.
|
||||
ASSISTANT_MODEL_FAST=
|
||||
ASSISTANT_MODEL_BALANCED=
|
||||
ASSISTANT_MODEL_DEEP=
|
||||
|
||||
31
.env.local
31
.env.local
@@ -7,13 +7,10 @@
|
||||
# Keep every host here pointing at localhost. The whole point of the split is
|
||||
# that running the server locally cannot reach live data by accident.
|
||||
#
|
||||
# `.env`, `.env.local` and `.env.production` are all gitignored (`.env.example`
|
||||
# is the one exception) — and for a reason: this repository's history already
|
||||
# contains a committed `.env` from before 2026-08-03, so those credentials are
|
||||
# in the history and should be rotated. Do not add another.
|
||||
#
|
||||
# `loadEnv()` in main.go reads `.env.$APP_ENV` from the working directory, so
|
||||
# `go run .` from this folder picks this file up with no flags.
|
||||
# `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
|
||||
@@ -64,5 +61,25 @@ 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
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
# 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.
|
||||
#
|
||||
# Gitignored by `.env.*`. Never commit it, and never paste it into a chat, an
|
||||
# issue or a PR description: the credentials below have to be rotated if it
|
||||
# leaves this machine.
|
||||
# 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.
|
||||
#
|
||||
# On the deployed host these values come from Dokploy's environment settings
|
||||
# rather than from this file. Keep the two in step — a variable added here and
|
||||
# not there is a variable that is unset in production.
|
||||
# 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
|
||||
@@ -76,3 +78,7 @@ 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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
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -54,3 +54,9 @@ Thumbs.db
|
||||
# that getting worse, but the existing history still has them and the password
|
||||
# should be rotated.
|
||||
|
||||
|
||||
# Secrets, for local runs only. Never committed — the rule below is what makes
|
||||
# that true, and it is why this file exists separately from .env.local, which
|
||||
# IS tracked and therefore cannot hold a key.
|
||||
|
||||
.env.secrets
|
||||
|
||||
65
Dockerfile
65
Dockerfile
@@ -4,7 +4,21 @@ FROM golang:1.24 AS builder
|
||||
WORKDIR /app
|
||||
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 ----------
|
||||
FROM alpine:latest
|
||||
@@ -14,6 +28,55 @@ WORKDIR /app
|
||||
COPY --from=builder /app/server /app
|
||||
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
|
||||
|
||||
CMD ["/app/server"]
|
||||
|
||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Fiesta backend (`nearle`)
|
||||
|
||||
The Go/Fiber API behind the Nearle Daily merchant console, the customer app,
|
||||
the rider app and the in-store POS terminals. Postgres (`nearledb`) for
|
||||
tenants, stores, products, stock and orders; a separate pgvector database for
|
||||
the global product catalogue; Redis for POS presence; MQTT for the tills.
|
||||
|
||||
This page is the map. Each section says what a thing is, how to use it, and
|
||||
where the detail lives.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
export PATH="$PATH:$HOME/go/bin" # Go 1.24 lives there on the dev Macs
|
||||
docker compose -f docker-compose.local.yml up -d # postgres :5433, pgvector :5434, redis :6379
|
||||
go run . # APP_ENV unset → .env.local, listens on :1122
|
||||
go test ./...
|
||||
```
|
||||
|
||||
An empty database is not enough — startup runs migrations that assume the
|
||||
live schema. `init/README.md` explains loading a schema dump first.
|
||||
|
||||
Startup prints what it loaded and where it is pointed:
|
||||
|
||||
```
|
||||
config: loaded .env.local
|
||||
config: APP_ENV=local, listening on :1122, database nearle@localhost:5433/nearledb
|
||||
scan: product search uses openai/all-minilm # or: EMBEDDING_PROVIDER not set, text-only
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything comes from environment variables, read once by `config.Load()`.
|
||||
|
||||
| You want to… | Do this |
|
||||
|---|---|
|
||||
| Run locally | Nothing — `.env.local` is loaded by default |
|
||||
| Run against production settings | `APP_ENV=production go run .` (⚠ every write is real) |
|
||||
| See every variable and what it does | `.env.example` |
|
||||
| Add a new setting | Add it to `config.Config` + `Load()` + `.env.example`, **and to the cluster** (`nearle-config` ConfigMap or `app-secrets` Secret in namespace `nearle`) — a variable in the file and not in the cluster is unset in production |
|
||||
| Find out why it won't boot | Read the message — it lists *every* missing variable at once |
|
||||
|
||||
Precedence is `real environment > .env.<APP_ENV> > .env`. The container gets
|
||||
no `.env` file at all (`.dockerignore`); the `Dockerfile` sets
|
||||
`APP_ENV=production` and the values come from Kubernetes.
|
||||
|
||||
Full detail, including the committed-credentials situation:
|
||||
**`docs/ENVIRONMENT.md`**.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
main.go boot: config → databases → migrations → routes → MQTT → listen
|
||||
config/ env-file loading, typed Config, validation
|
||||
db/ Postgres (nearledb + catalogue), Redis, S3 image store
|
||||
facade/ wires repositories → services → controllers (add new modules here)
|
||||
routes/ one file per module; /live/api/v1/web/... (console) and /v1/mob/... (apps)
|
||||
controllers/ HTTP in, HTTP out — parse, call the service, shape the envelope
|
||||
services/ the rules; no SQL, no HTTP
|
||||
repositories/ the SQL; nothing else
|
||||
models/ request/response and table shapes
|
||||
messaging/ MQTT ingest from tills, console live stream
|
||||
utils/ small shared helpers (tokens, geo, embeddings, geocoding)
|
||||
docs/ integration specs for the frontends and handoff notes
|
||||
scratch/ one-off read/verify tools run with `go run ./scratch/<name>`
|
||||
init/ schema/seed for the local database
|
||||
```
|
||||
|
||||
Every response uses the same envelope:
|
||||
`{ "code": 200, "status": true, "message": "…", "details": … }`.
|
||||
Business outcomes ("out of stock", "not registered") are 200s with a reason
|
||||
in the body; HTTP errors mean the request could not be served at all.
|
||||
|
||||
## Adding an endpoint
|
||||
|
||||
1. **Model** the request/response in `models/`.
|
||||
2. **Repository** method(s) in `repositories/` — SQL only, take a
|
||||
`context.Context`, return `error`.
|
||||
3. **Service** in `services/` — the rules, with sentinel errors
|
||||
(`ErrXxxBadRequest`, `ErrXxxNotFound`) the controller can map to statuses.
|
||||
4. **Controller** in `controllers/` — `BodyParser`/`Query`, call the service,
|
||||
map sentinel errors to 400/404/503, everything else to 500.
|
||||
5. **Routes** file in `routes/`, registered in `routes/routes.go`.
|
||||
6. **Wire** it in `facade/container.go`.
|
||||
7. **Test** the service with a fake repository (see `services/scan_test.go`
|
||||
for the pattern) — no database needed.
|
||||
|
||||
`services/scanService.go` + `controllers/scanController.go` are a complete,
|
||||
current example of all seven.
|
||||
|
||||
## Features with their own docs
|
||||
|
||||
| Feature | For | Doc |
|
||||
|---|---|---|
|
||||
| Environment & deployment | everyone | `docs/ENVIRONMENT.md` |
|
||||
| Scan-to-order (camera → product → nearest store with stock) | mobile app | `docs/SCAN_TO_ORDER.md` |
|
||||
| Catalogue import into a store | console | `docs/CATALOGUE_IMPORT_INTEGRATION.md` |
|
||||
| POS terminal ingest, login, API | POS / tills | `docs/POS_*.md` |
|
||||
| Access-control audit and what is still open | everyone | `docs/SECURITY_HANDOFF.md` |
|
||||
|
||||
## Things to know before you get surprised
|
||||
|
||||
- **There is no auth layer.** `customerid` / `tenantid` in a request are
|
||||
trusted. `docs/SECURITY_HANDOFF.md` §1 is the standing issue.
|
||||
- **Login errors mean what they say.** `409 Invalid Email` = the query ran
|
||||
and matched nobody. `500 Login is temporarily unavailable` = the database
|
||||
could not answer (it used to be reported as Invalid Email; see
|
||||
`services/userService.go` `lookupLogin`).
|
||||
- **Stock is a ledger.** Live stock is always `SUM(in) − SUM(out)` of
|
||||
`productstocks` at an outlet, never a stored number. Filter on the same
|
||||
expression you display (`services/productVisibility.go` explains why).
|
||||
- **The catalogue is a different database** and must never be reached
|
||||
through the `nearledb` handle. Its per-brand tables are discovered from
|
||||
`information_schema`; brands appear and columns vary.
|
||||
- **Catalogue ids are not stable** across re-scrapes; `imageid` is the
|
||||
durable key (`models.Products.Imageid`).
|
||||
- **Migrations run on boot** and are guarded by `IF NOT EXISTS` / schema
|
||||
checks, not a version table. Read the comments in `main.go` before adding
|
||||
one — several have bitten before.
|
||||
- **One MQTT client id per replica.** A second connection with the same id
|
||||
evicts the first. Never run a local process with the production
|
||||
`MQTT_URL`.
|
||||
- **`scratch/` tools read production** when run with `.env.production`, and
|
||||
most are read-only. Two are not — `termbackfill` and
|
||||
`cataloguefactsbackfill` repair rows that no endpoint can reach. Both
|
||||
default to a dry run that prints every change and write only when passed
|
||||
`apply`, and both print the SQL to undo themselves afterwards. A new tool
|
||||
that writes follows that shape or it does not write.
|
||||
|
||||
## Operations cheat-sheet (Kubernetes, namespace `nearle`)
|
||||
|
||||
```sh
|
||||
kubectl get pods -n nearle # fiesta-0/1/2 (StatefulSet)
|
||||
kubectl logs -n nearle fiesta-0 | grep -E 'config:|scan:|pos:'
|
||||
kubectl exec -n nearle fiesta-0 -- env | grep EMBEDDING_
|
||||
kubectl set env statefulset/fiesta -n nearle KEY=value # adds a var and rolls the pods
|
||||
kubectl rollout status statefulset/fiesta -n nearle
|
||||
```
|
||||
|
||||
The embedding model behind scan-to-order is served by the cluster's Ollama
|
||||
(`ollama.krow.svc.cluster.local:11434`); `docs/SCAN_TO_ORDER.md` has the
|
||||
exact settings and why that model.
|
||||
261
config/assistant_test.go
Normal file
261
config/assistant_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Why the assistant is off.
|
||||
//
|
||||
// "Off" was the same answer for four different mistakes, and the only symptom
|
||||
// was a disabled composer. Nobody could tell "we have not switched it on" from
|
||||
// "somebody misspelled a variable" — which is how it stayed off for days with
|
||||
// both of us guessing.
|
||||
|
||||
func TestAFullyConfiguredAssistantIsOn(t *testing.T) {
|
||||
cfg := AssistantConfig{
|
||||
Provider: "openai", BaseURL: "https://api.groq.com/openai/v1",
|
||||
APIKey: "k", Balanced: "openai/gpt-oss-120b",
|
||||
}
|
||||
if !cfg.Enabled() {
|
||||
t.Fatalf("a complete config was refused: %s", cfg.Why())
|
||||
}
|
||||
if cfg.Why() != "" {
|
||||
t.Fatalf("an enabled assistant gave a reason: %q", cfg.Why())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachMissingPieceNamesItself(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
cfg AssistantConfig
|
||||
says string
|
||||
}{
|
||||
// Nothing set at all names the MODEL, not the provider. The provider is
|
||||
// derived from the model now, so an empty one is a consequence rather
|
||||
// than a cause — and sending an operator to set ASSISTANT_PROVIDER, a
|
||||
// variable they no longer need, while the one they actually missed goes
|
||||
// unmentioned, is the same "off for four reasons" problem in new words.
|
||||
"nothing set at all": {AssistantConfig{}, "ASSISTANT_MODEL"},
|
||||
"no provider": {
|
||||
AssistantConfig{Balanced: "m", APIKey: "k"}, "ASSISTANT_PROVIDER"},
|
||||
"unknown provider": {
|
||||
AssistantConfig{Provider: "anthropik", Balanced: "m", APIKey: "k"}, "not one this server speaks"},
|
||||
"no model": {AssistantConfig{Provider: "openai", APIKey: "k"}, "ASSISTANT_MODEL"},
|
||||
"no api key": {AssistantConfig{Provider: "openai", Balanced: "m", BaseURL: "https://api.groq.com/openai/v1"}, "ASSISTANT_API_KEY"},
|
||||
} {
|
||||
why := tc.cfg.Why()
|
||||
if why == "" {
|
||||
t.Fatalf("%s: reported as working", name)
|
||||
}
|
||||
if !strings.Contains(why, tc.says) {
|
||||
t.Fatalf("%s: does not name the problem: %q", name, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestALocalModelNeedsNoKey(t *testing.T) {
|
||||
// Ollama and LM Studio need no credential, and demanding one would refuse
|
||||
// the setup a developer is most likely to have on their own machine.
|
||||
for _, base := range []string{
|
||||
"http://localhost:11434/v1",
|
||||
"http://127.0.0.1:1234/v1",
|
||||
"http://host.docker.internal:11434/v1",
|
||||
} {
|
||||
cfg := AssistantConfig{Provider: "openai", BaseURL: base, Balanced: "llama3"}
|
||||
if !cfg.Enabled() {
|
||||
t.Fatalf("%s was refused without a key: %s", base, cfg.Why())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHostedModelWithoutAKeyIsRefusedBeforeItFailsAtRuntime(t *testing.T) {
|
||||
// Otherwise the first question a shopkeeper asks comes back as a 401 from
|
||||
// the provider, which reads as the assistant being broken rather than as a
|
||||
// variable nobody set.
|
||||
cfg := AssistantConfig{Provider: "openai", BaseURL: "https://api.groq.com/openai/v1", Balanced: "m"}
|
||||
if cfg.Enabled() {
|
||||
t.Fatal("a hosted provider with no key reported as ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTierFallbackDoesNotHideAMissingModel(t *testing.T) {
|
||||
// `fast` and `deep` fall back to balanced, so a config with only those two
|
||||
// set has no model at all for the default tier.
|
||||
cfg := AssistantConfig{Provider: "openai", APIKey: "k", Fast: "small", Deep: "big"}
|
||||
if cfg.Enabled() {
|
||||
t.Fatal("an assistant with no balanced model reported as ready")
|
||||
}
|
||||
if cfg.ModelFor("fast") != "" && cfg.ModelFor("balanced") != "" {
|
||||
t.Fatal("balanced resolved to something despite being unset")
|
||||
}
|
||||
}
|
||||
|
||||
// Where a secret is allowed to live.
|
||||
//
|
||||
// `.env`, `.env.local` and `.env.production` are all tracked by git, so a key
|
||||
// written to any of them is a key published. There was nowhere else, and the
|
||||
// standing instruction was to export it in the shell on every run — which is
|
||||
// the kind of instruction people route around by editing a tracked file.
|
||||
func TestASecretsFileIsReadBeforeAnyTrackedEnvFile(t *testing.T) {
|
||||
order := envFileOrder("local")
|
||||
|
||||
if len(order) == 0 || order[0] != ".env.secrets" {
|
||||
t.Fatalf(".env.secrets is not read first, so a tracked file wins: %v", order)
|
||||
}
|
||||
// godotenv does not overwrite, so being first IS what makes it authoritative.
|
||||
// Being merely present would let .env.local decide the key instead.
|
||||
for _, tracked := range []string{".env.local", ".env"} {
|
||||
for i, name := range order {
|
||||
if name == tracked && i == 0 {
|
||||
t.Fatalf("%s is read first; a secret there would be committed", tracked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEnvironmentsOwnFileBeatsTheSharedOne(t *testing.T) {
|
||||
// `.env.production` must be consulted before the shared `.env`, or a
|
||||
// production deployment silently takes the local defaults.
|
||||
order := envFileOrder("production")
|
||||
|
||||
var production, shared int = -1, -1
|
||||
for i, name := range order {
|
||||
switch name {
|
||||
case ".env.production":
|
||||
production = i
|
||||
case ".env":
|
||||
shared = i
|
||||
}
|
||||
}
|
||||
if production < 0 || shared < 0 || production > shared {
|
||||
t.Fatalf("the environment's own file does not take precedence: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
// One variable, not four.
|
||||
//
|
||||
// The assistant sat switched off for days because `ASSISTANT_PROVIDER` had not
|
||||
// been typed into a hosting platform's environment tab — a variable whose only
|
||||
// correct value is "openai", because every endpoint this server speaks is
|
||||
// OpenAI-compatible. The base URL and the model had one right answer too.
|
||||
//
|
||||
// So three of the four are constants now. The key is the only one that varies
|
||||
// between deployments and the only one that cannot live in the repository.
|
||||
func TestTheKeyAloneSwitchesTheAssistantOn(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"ASSISTANT_PROVIDER", "ASSISTANT_BASE_URL", "ASSISTANT_MODEL",
|
||||
"ASSISTANT_MODEL_BALANCED", "ASSISTANT_MODEL_FAST", "ASSISTANT_MODEL_DEEP",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
t.Setenv("ASSISTANT_API_KEY", "gsk_not-a-real-key")
|
||||
|
||||
cfg := AssistantConfig{
|
||||
Provider: assistantProvider(),
|
||||
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
|
||||
APIKey: env("ASSISTANT_API_KEY", ""),
|
||||
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
|
||||
}
|
||||
|
||||
if !cfg.Enabled() {
|
||||
t.Fatalf("the key alone did not switch it on: %s", cfg.Why())
|
||||
}
|
||||
if cfg.Provider != "openai" {
|
||||
t.Fatalf("provider defaulted to %q", cfg.Provider)
|
||||
}
|
||||
if cfg.ModelFor("fast") != defaultAssistantModel {
|
||||
t.Fatalf("the fast tier fell through to %q", cfg.ModelFor("fast"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoKeyIsStillOffAndSaysWhich(t *testing.T) {
|
||||
// The defaults must not make an unconfigured deployment look ready. Without
|
||||
// a key every question would reach Groq and come back 401, which reads as
|
||||
// the assistant being broken rather than as not being set up.
|
||||
cfg := AssistantConfig{
|
||||
Provider: defaultAssistantProvider,
|
||||
BaseURL: defaultAssistantBaseURL,
|
||||
Balanced: defaultAssistantModel,
|
||||
}
|
||||
if cfg.Enabled() {
|
||||
t.Fatal("reported ready with no key")
|
||||
}
|
||||
if !strings.Contains(cfg.Why(), "ASSISTANT_API_KEY") {
|
||||
t.Fatalf("did not name the one variable left to set: %q", cfg.Why())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachDefaultIsStillOverridable(t *testing.T) {
|
||||
// Running against Ollama on a laptop must not need a code change.
|
||||
t.Setenv("ASSISTANT_PROVIDER", "ollama")
|
||||
t.Setenv("ASSISTANT_BASE_URL", "http://localhost:11434/v1")
|
||||
t.Setenv("ASSISTANT_MODEL", "llama3")
|
||||
|
||||
cfg := AssistantConfig{
|
||||
Provider: assistantProvider(),
|
||||
BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL),
|
||||
APIKey: env("ASSISTANT_API_KEY", ""),
|
||||
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)),
|
||||
}
|
||||
|
||||
if cfg.Provider != "ollama" || cfg.Balanced != "llama3" {
|
||||
t.Fatalf("an override was ignored: %+v", cfg)
|
||||
}
|
||||
// Local endpoints need no key, so this must be on without one.
|
||||
if !cfg.Enabled() {
|
||||
t.Fatalf("a local model was refused: %s", cfg.Why())
|
||||
}
|
||||
}
|
||||
|
||||
// The container reads its own configuration from a file beside the binary.
|
||||
//
|
||||
// The Dockerfile copies `.env.production` into the runtime image and sets
|
||||
// APP_ENV=production, so `loadEnvFiles` reads it on boot. This asserts the
|
||||
// mechanism rather than the Dockerfile — a COPY line is easy to check by eye
|
||||
// and easy to believe wrongly, and the failure it produces is a server that
|
||||
// starts fine with a variable silently unset.
|
||||
func TestTheEnvironmentFileBesideTheBinaryIsRead(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
if err := os.WriteFile(".env.production",
|
||||
[]byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil {
|
||||
t.Fatalf("writing the fixture: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("APP_ENV", "production")
|
||||
// Registered with t.Setenv first so it is restored on return, then removed:
|
||||
// godotenv does not overwrite a variable that is PRESENT, and an empty
|
||||
// string is present. Setting it to "" would have tested nothing.
|
||||
t.Setenv("ASSISTANT_API_KEY", "placeholder")
|
||||
os.Unsetenv("ASSISTANT_API_KEY")
|
||||
|
||||
loadEnvFiles()
|
||||
|
||||
if os.Getenv("ASSISTANT_API_KEY") != "from-the-file" {
|
||||
t.Fatal("the environment file beside the binary was not read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePlatformStillWinsOverTheFile(t *testing.T) {
|
||||
// godotenv never overwrites a variable already in the environment, so a
|
||||
// value set on the hosting platform overrides the committed file without
|
||||
// the file having to change. Both mechanisms work; neither fights the other.
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
if err := os.WriteFile(".env.production",
|
||||
[]byte("ASSISTANT_API_KEY=from-the-file\n"), 0o600); err != nil {
|
||||
t.Fatalf("writing the fixture: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("APP_ENV", "production")
|
||||
t.Setenv("ASSISTANT_API_KEY", "from-the-platform")
|
||||
|
||||
loadEnvFiles()
|
||||
|
||||
if got := os.Getenv("ASSISTANT_API_KEY"); got != "from-the-platform" {
|
||||
t.Fatalf("the file overrode the platform: ASSISTANT_API_KEY=%q", got)
|
||||
}
|
||||
}
|
||||
536
config/config.go
536
config/config.go
@@ -1,49 +1,537 @@
|
||||
// Package config is the one place the process reads its environment.
|
||||
//
|
||||
// Two jobs, in order:
|
||||
//
|
||||
// 1. Pick the right `.env` file for the environment we are in and load it.
|
||||
// 2. Read every setting into a typed Config and refuse to start if anything
|
||||
// required is missing — all of it, in one message, before a single
|
||||
// connection is attempted.
|
||||
//
|
||||
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
|
||||
// `.env.production` described an `APP_ENV` switch that did not exist, so the
|
||||
// only way to run against production was to overwrite `.env` by hand, and the
|
||||
// only way to find out a variable was missing was a `log.Fatalf` from inside
|
||||
// `db.Connect()` — one variable per restart.
|
||||
//
|
||||
// # Which file loads
|
||||
//
|
||||
// `APP_ENV` names the environment and defaults to "local":
|
||||
//
|
||||
// go run . → .env.local, then .env
|
||||
// APP_ENV=production go run . → .env.production, then .env
|
||||
//
|
||||
// `.env` is a shared base loaded after the environment file. godotenv never
|
||||
// overwrites a variable that is already set, so the order of precedence is:
|
||||
//
|
||||
// real environment > .env.<APP_ENV> > .env
|
||||
//
|
||||
// Neither file has to exist. On the deployed host every value comes from the
|
||||
// platform's environment settings (Dokploy today, ConfigMaps/Secrets under
|
||||
// Kubernetes) and there is no file at all — which is exactly why the
|
||||
// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the
|
||||
// image carries no `.env.*`, so it cannot fall back to localhost values that
|
||||
// happen to be lying around in the build context.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Environment names. Anything else is accepted (a staging file works the same
|
||||
// way) but only these two change behaviour.
|
||||
const (
|
||||
EnvLocal = "local"
|
||||
EnvProduction = "production"
|
||||
)
|
||||
|
||||
// Config is everything the process reads from its environment.
|
||||
//
|
||||
// A few settings are still read directly with os.Getenv at the point of use,
|
||||
// because they are consulted per request or per connection rather than once
|
||||
// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED
|
||||
// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_*
|
||||
// and POS_* settings in package messaging. They are listed and validated here
|
||||
// so that a misconfiguration is still caught at startup.
|
||||
type Config struct {
|
||||
Env string
|
||||
Port string
|
||||
DBName string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBPort string
|
||||
DBHost string
|
||||
UserContextKey string
|
||||
// AppEnv is the value of APP_ENV: "local" or "production".
|
||||
AppEnv string
|
||||
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
|
||||
Port string
|
||||
|
||||
DB DBConfig
|
||||
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
|
||||
Redis RedisConfig
|
||||
S3 S3Config
|
||||
MQTT MQTTConfig
|
||||
Embedding EmbeddingConfig
|
||||
// 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
|
||||
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{
|
||||
Env: getEnv("ENV", "production"),
|
||||
Port: getEnv("APP_PORT", "1009"),
|
||||
AppEnv: env("APP_ENV", EnvLocal),
|
||||
Port: env("APP_PORT", "1122"),
|
||||
|
||||
// ✅ STANDARDIZED DB ENV KEYS
|
||||
DBName: getEnv("DB_NAME", ""),
|
||||
DBUser: getEnv("DB_USER", ""),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DB: DBConfig{
|
||||
Host: env("DB_HOST", ""),
|
||||
Port: env("DB_PORT", "5433"),
|
||||
Name: env("DB_NAME", ""),
|
||||
User: env("DB_USER", ""),
|
||||
Password: env("DB_PASSWORD", ""),
|
||||
},
|
||||
Catalogue: DBConfig{
|
||||
Host: env("CATALOGUE_DB_HOST", ""),
|
||||
Port: env("CATALOGUE_DB_PORT", "5432"),
|
||||
Name: env("CATALOGUE_DB_NAME", ""),
|
||||
User: env("CATALOGUE_DB_USER", ""),
|
||||
Password: env("CATALOGUE_DB_PASSWORD", ""),
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Host: env("REDIS_HOST", ""),
|
||||
Port: env("REDIS_PORT", "6379"),
|
||||
User: env("REDIS_USER", "default"),
|
||||
Password: env("REDIS_PASSWORD", ""),
|
||||
},
|
||||
S3: S3Config{
|
||||
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
|
||||
Endpoint: env("S3_ENDPOINT", ""),
|
||||
Bucket: env("S3_BUCKET", ""),
|
||||
AccessKey: env("S3_ACCESS_KEY", ""),
|
||||
SecretKey: env("S3_SECRET_KEY", ""),
|
||||
Region: env("S3_REGION", ""),
|
||||
},
|
||||
MQTT: MQTTConfig{
|
||||
URL: env("MQTT_URL", ""),
|
||||
// MQTT_USERNAME is accepted because livehub.go read that name for
|
||||
// a while, so an existing deployment may still set it.
|
||||
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
|
||||
Password: env("MQTT_PASSWORD", ""),
|
||||
ClientID: env("MQTT_CLIENT_ID", ""),
|
||||
},
|
||||
|
||||
UserContextKey: getEnv("USER_CONTEXT_KEY", "nearle"),
|
||||
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
|
||||
Embedding: EmbeddingConfig{
|
||||
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
|
||||
Model: env("EMBEDDING_MODEL", ""),
|
||||
APIKey: env("EMBEDDING_API_KEY", ""),
|
||||
BaseURL: env("EMBEDDING_BASE_URL", ""),
|
||||
},
|
||||
|
||||
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 cfg.DBPassword == "" {
|
||||
log.Println("Warning: DB_PASSWORD is not set")
|
||||
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
|
||||
cfg.Redis.DB = db
|
||||
} else {
|
||||
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
|
||||
}
|
||||
|
||||
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
|
||||
n, err := strconv.Atoi(dims)
|
||||
if err != nil || n < 0 {
|
||||
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
|
||||
}
|
||||
cfg.Embedding.Dimensions = n
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// MustLoad is Load for main(): every problem is printed and the process exits.
|
||||
func MustLoad() *Config {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
|
||||
}
|
||||
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
|
||||
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
// validate collects every problem rather than stopping at the first, so one
|
||||
// restart is enough to learn everything that is wrong.
|
||||
func (c *Config) validate() error {
|
||||
var problems []string
|
||||
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
|
||||
|
||||
if c.DB.Host == "" {
|
||||
missing("DB_HOST")
|
||||
}
|
||||
if c.DB.User == "" {
|
||||
missing("DB_USER")
|
||||
}
|
||||
if c.DB.Password == "" {
|
||||
missing("DB_PASSWORD")
|
||||
}
|
||||
if c.DB.Name == "" {
|
||||
missing("DB_NAME")
|
||||
}
|
||||
|
||||
// Optional subsystems are either fully configured or absent. Half a
|
||||
// configuration used to be skipped with a warning, which reads as "fine"
|
||||
// in a log and turns into "why are there no images" a week later.
|
||||
if c.Catalogue.Enabled() {
|
||||
if c.Catalogue.User == "" {
|
||||
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
if c.Catalogue.Password == "" {
|
||||
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
if c.Catalogue.Name == "" {
|
||||
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
|
||||
}
|
||||
}
|
||||
if c.S3.Enabled {
|
||||
if c.S3.Endpoint == "" {
|
||||
missing("S3_ENDPOINT (USE_S3=true)")
|
||||
}
|
||||
if c.S3.Bucket == "" {
|
||||
missing("S3_BUCKET (USE_S3=true)")
|
||||
}
|
||||
if c.S3.AccessKey == "" {
|
||||
missing("S3_ACCESS_KEY (USE_S3=true)")
|
||||
}
|
||||
if c.S3.SecretKey == "" {
|
||||
missing("S3_SECRET_KEY (USE_S3=true)")
|
||||
}
|
||||
if c.S3.Region == "" {
|
||||
missing("S3_REGION (USE_S3=true)")
|
||||
}
|
||||
}
|
||||
|
||||
if c.Embedding.Enabled() {
|
||||
switch c.Embedding.Provider {
|
||||
case "openai", "gemini":
|
||||
default:
|
||||
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
|
||||
}
|
||||
if c.Embedding.Model == "" {
|
||||
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
|
||||
}
|
||||
if c.Embedding.APIKey == "" {
|
||||
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
|
||||
}
|
||||
}
|
||||
|
||||
if c.IsProduction() {
|
||||
// utils/postoken.go refuses to sign with a short secret at request
|
||||
// time; catching it here means the first till login is not the first
|
||||
// anyone hears of it.
|
||||
secret := c.POSTokenSecret
|
||||
if secret == "" {
|
||||
secret = c.JWTSecret
|
||||
}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
|
||||
} else if len(strings.TrimSpace(secret)) < 16 {
|
||||
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
|
||||
}
|
||||
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
|
||||
// Not fatal: a dump restored on another machine on the LAN is a valid
|
||||
// local setup. But `.env.local` pointing at the live host is the
|
||||
// mistake every comment in that file warns about, so say it out loud.
|
||||
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
|
||||
c.AppEnv, c.DB.Host)
|
||||
}
|
||||
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New(strings.Join(problems, "\n"))
|
||||
}
|
||||
|
||||
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
|
||||
//
|
||||
// APP_ENV is read from the real environment before any file, so a file cannot
|
||||
// change which environment it is loaded for.
|
||||
// `.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 fallback
|
||||
}
|
||||
|
||||
func isLocalHost(host string) bool {
|
||||
switch strings.ToLower(host) {
|
||||
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(host, "127.")
|
||||
}
|
||||
|
||||
251
config/config_test.go
Normal file
251
config/config_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every key Load reads, so a test starts from nothing rather than from
|
||||
// whatever the developer's shell happens to export.
|
||||
var allKeys = []string{
|
||||
"APP_ENV", "APP_PORT",
|
||||
"DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASSWORD",
|
||||
"CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_NAME", "CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD",
|
||||
"REDIS_HOST", "REDIS_PORT", "REDIS_USER", "REDIS_PASSWORD", "REDIS_DB",
|
||||
"USE_S3", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",
|
||||
"MQTT_URL", "MQTT_USER", "MQTT_USERNAME", "MQTT_PASSWORD", "MQTT_CLIENT_ID",
|
||||
"POS_TOKEN_SECRET", "JWT_SECRET_KEY", "USER_CONTEXT_KEY", "GEOCODER_API_KEY",
|
||||
"EMBEDDING_PROVIDER", "EMBEDDING_MODEL", "EMBEDDING_API_KEY", "EMBEDDING_BASE_URL", "EMBEDDING_DIMENSIONS",
|
||||
}
|
||||
|
||||
// cleanEnv clears every setting and moves into an empty directory so no
|
||||
// `.env` file is picked up by accident. t.Setenv registers the restore; the
|
||||
// Unsetenv after it matters because godotenv treats a variable that is present
|
||||
// but empty as set and will not fill it from a file.
|
||||
func cleanEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
unsetAll(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func unsetAll(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range allKeys {
|
||||
t.Setenv(k, "")
|
||||
os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
func setMainDB(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DB_HOST", "localhost")
|
||||
t.Setenv("DB_USER", "nearle")
|
||||
t.Setenv("DB_PASSWORD", "localdev")
|
||||
t.Setenv("DB_NAME", "nearledb")
|
||||
}
|
||||
|
||||
func write(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReportsEveryMissingRequiredKeyAtOnce(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error with no database configured")
|
||||
}
|
||||
for _, key := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error should name %s, got:\n%s", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.AppEnv != EnvLocal {
|
||||
t.Errorf("AppEnv = %q, want local", cfg.AppEnv)
|
||||
}
|
||||
if cfg.IsProduction() {
|
||||
t.Error("IsProduction should be false by default")
|
||||
}
|
||||
if cfg.Port != "1122" {
|
||||
t.Errorf("Port = %q, want 1122", cfg.Port)
|
||||
}
|
||||
if cfg.DB.Port != "5433" {
|
||||
t.Errorf("DB.Port = %q, want 5433 (the port production and the local compose share)", cfg.DB.Port)
|
||||
}
|
||||
if cfg.Catalogue.Enabled() || cfg.Redis.Enabled() || cfg.S3.Enabled || cfg.MQTT.Enabled() {
|
||||
t.Error("optional subsystems should be off when unset")
|
||||
}
|
||||
if cfg.Redis.User != "default" || cfg.Redis.Port != "6379" || cfg.Redis.DB != 0 {
|
||||
t.Errorf("redis defaults wrong: %+v", cfg.Redis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppEnvSelectsTheEnvFile(t *testing.T) {
|
||||
dir := cleanEnv(t)
|
||||
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=local\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1122\n")
|
||||
write(t, dir, ".env.production", "DB_HOST=db.internal\nDB_USER=prod\nDB_PASSWORD=x\nDB_NAME=nearledb\nAPP_PORT=1009\nPOS_TOKEN_SECRET=0123456789abcdef\n")
|
||||
// The shared base: only fills in what the environment file left unset.
|
||||
write(t, dir, ".env", "DB_USER=base\nUSER_CONTEXT_KEY=from-base\n")
|
||||
|
||||
t.Run("default is local", func(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DB.User != "local" || cfg.Port != "1122" {
|
||||
t.Errorf("expected .env.local values, got user=%s port=%s", cfg.DB.User, cfg.Port)
|
||||
}
|
||||
if cfg.UserContextKey != "from-base" {
|
||||
t.Errorf(".env should fill in what .env.local left unset, got %q", cfg.UserContextKey)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("APP_ENV=production", func(t *testing.T) {
|
||||
// Clears what godotenv loaded in the sibling above; restored on return.
|
||||
unsetAll(t)
|
||||
t.Setenv("APP_ENV", EnvProduction)
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.IsProduction() || cfg.DB.User != "prod" || cfg.Port != "1009" {
|
||||
t.Errorf("expected .env.production values, got env=%s user=%s port=%s", cfg.AppEnv, cfg.DB.User, cfg.Port)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRealEnvironmentBeatsTheFile(t *testing.T) {
|
||||
dir := cleanEnv(t)
|
||||
write(t, dir, ".env.local", "DB_HOST=localhost\nDB_USER=file\nDB_PASSWORD=x\nDB_NAME=nearledb\n")
|
||||
t.Setenv("DB_USER", "shell")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DB.User != "shell" {
|
||||
t.Errorf("a variable already in the environment must not be overwritten by the file, got %q", cfg.DB.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRequiresASigningSecret(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("APP_ENV", EnvProduction)
|
||||
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "POS_TOKEN_SECRET") {
|
||||
t.Fatalf("production without a secret should fail naming POS_TOKEN_SECRET, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "short")
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "16 characters") {
|
||||
t.Fatalf("a short secret should be refused, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "")
|
||||
t.Setenv("JWT_SECRET_KEY", "a-long-enough-fallback-secret")
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("JWT_SECRET_KEY should be accepted as the fallback, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHalfConfiguredSubsystemsAreRefused(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
|
||||
t.Setenv("CATALOGUE_DB_HOST", "localhost")
|
||||
t.Setenv("USE_S3", "true")
|
||||
t.Setenv("S3_BUCKET", "nearle")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
for _, want := range []string{"CATALOGUE_DB_USER", "CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME", "S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should name %s, got:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "S3_BUCKET") {
|
||||
t.Error("S3_BUCKET was set and must not be reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTUsernameFallback(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("MQTT_URL", "tcp://broker:1883")
|
||||
t.Setenv("MQTT_USERNAME", "legacy")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MQTT.User != "legacy" {
|
||||
t.Errorf("MQTT_USERNAME should still be honoured, got %q", cfg.MQTT.User)
|
||||
}
|
||||
|
||||
t.Setenv("MQTT_USER", "current")
|
||||
cfg, err = Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MQTT.User != "current" {
|
||||
t.Errorf("MQTT_USER should win over MQTT_USERNAME, got %q", cfg.MQTT.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisDBMustBeNumeric(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("REDIS_DB", "zero")
|
||||
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "REDIS_DB") {
|
||||
t.Fatalf("expected REDIS_DB error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingProviderNeedsModelAndKey(t *testing.T) {
|
||||
cleanEnv(t)
|
||||
setMainDB(t)
|
||||
t.Setenv("EMBEDDING_PROVIDER", "openai")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil || !strings.Contains(err.Error(), "EMBEDDING_MODEL") || !strings.Contains(err.Error(), "EMBEDDING_API_KEY") {
|
||||
t.Fatalf("a provider without model and key should be refused naming both, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMBEDDING_PROVIDER", "cohere")
|
||||
t.Setenv("EMBEDDING_MODEL", "x")
|
||||
t.Setenv("EMBEDDING_API_KEY", "y")
|
||||
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "EMBEDDING_PROVIDER") {
|
||||
t.Fatalf("an unknown provider should be refused, got %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("EMBEDDING_PROVIDER", "Gemini")
|
||||
t.Setenv("EMBEDDING_DIMENSIONS", "768")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Embedding.Provider != "gemini" || cfg.Embedding.Dimensions != 768 || !cfg.Embedding.Enabled() {
|
||||
t.Fatalf("unexpected embedding config: %+v", cfg.Embedding)
|
||||
}
|
||||
}
|
||||
153
config/mail.go
Normal file
153
config/mail.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Sending email.
|
||||
//
|
||||
// ── Why this exists at all ──────────────────────────────────────────────────
|
||||
//
|
||||
// A newly onboarded merchant's admin account arrives with no password, and the
|
||||
// only safe way to let them set one is a signed invitation sent to the primary
|
||||
// email they gave us. Until this, the server could not send email: no library,
|
||||
// no configuration, and `NotifyUser` is Firebase push rather than mail.
|
||||
//
|
||||
// ── Shaped like AssistantConfig, for the same reasons ───────────────────────
|
||||
//
|
||||
// Unconfigured is a deployment choice and not a fault, so `Enabled` reports it
|
||||
// and `Why` says which variable is missing. A server with no mail still boots
|
||||
// and still onboards tenants — the invitation is recorded as unsent rather than
|
||||
// failing the creation, because a tenant that exists and cannot be reached is
|
||||
// recoverable and a tenant that was rolled back by a mail outage is confusing.
|
||||
type MailConfig struct {
|
||||
// SMTP, because it is the one protocol every provider speaks. A transactional
|
||||
// service (SES, SendGrid, Resend) is reached the same way, with its own host
|
||||
// and an API key as the password — so choosing one later is configuration
|
||||
// rather than code.
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
// Who the invitation appears to come from. Separate from the username
|
||||
// because most providers authenticate as one identity and send as another,
|
||||
// and using the login as the From address is how mail ends up in spam.
|
||||
FromAddress string
|
||||
FromName string
|
||||
// Where the invitation link points. The merchant console, always — a
|
||||
// merchant sets their password there and nowhere else — and a build
|
||||
// variable rather than a constant because the site can move.
|
||||
ConsoleURL string
|
||||
}
|
||||
|
||||
func (m MailConfig) Enabled() bool { return m.Why() == "" }
|
||||
|
||||
// Why says what is missing, or "" when mail can be sent.
|
||||
//
|
||||
// A sentence rather than a bool. "Off" is the same answer for five different
|
||||
// mistakes, and the difference between "we have not set this up" and "somebody
|
||||
// misspelled a variable" is invisible from outside — which is exactly how the
|
||||
// assistant sat switched off for two days.
|
||||
func (m MailConfig) Why() string {
|
||||
if strings.TrimSpace(m.Host) == "" {
|
||||
return "MAIL_HOST is not set, so no invitation can be sent"
|
||||
}
|
||||
if m.Port <= 0 {
|
||||
return "MAIL_PORT is not a usable port number"
|
||||
}
|
||||
if strings.TrimSpace(m.FromAddress) == "" {
|
||||
return "MAIL_FROM is not set; an invitation needs a sender address"
|
||||
}
|
||||
// Username and password are deliberately NOT required. An internal relay
|
||||
// that authenticates by network is a real deployment, and demanding
|
||||
// credentials would refuse it.
|
||||
if strings.TrimSpace(m.ConsoleURL) == "" {
|
||||
return "MAIL_CONSOLE_URL is not set; the invitation would have nowhere to point"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Address is host:port, as the SMTP client wants it.
|
||||
func (m MailConfig) Address() string { return fmt.Sprintf("%s:%d", m.Host, m.Port) }
|
||||
|
||||
// InviteLink is where an invitation sends somebody.
|
||||
//
|
||||
// Built here rather than in the mailer so the shape is decided once, beside the
|
||||
// console URL it depends on. The token is the whole credential, so it is the
|
||||
// only thing in the query string — never an email address or a userid, which
|
||||
// would put both halves of an account into a URL that lands in server logs,
|
||||
// browser history and whatever proxy sits between.
|
||||
func (m MailConfig) InviteLink(token string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(m.ConsoleURL), "/")
|
||||
return base + "/set-password?t=" + token
|
||||
}
|
||||
|
||||
// MailFromEnv reads the mail settings.
|
||||
func MailFromEnv() MailConfig {
|
||||
port, err := strconv.Atoi(strings.TrimSpace(env("MAIL_PORT", "587")))
|
||||
if err != nil {
|
||||
// Zero rather than the default, so `Why` reports it instead of the
|
||||
// server quietly dialling a port nobody asked for.
|
||||
port = 0
|
||||
}
|
||||
|
||||
return MailConfig{
|
||||
Host: env("MAIL_HOST", ""),
|
||||
Port: port,
|
||||
Username: env("MAIL_USERNAME", ""),
|
||||
Password: smtpPassword(env("MAIL_HOST", ""), env("MAIL_PASSWORD", "")),
|
||||
// A name is optional; an address is not.
|
||||
FromAddress: env("MAIL_FROM", ""),
|
||||
FromName: env("MAIL_FROM_NAME", "Nearle"),
|
||||
ConsoleURL: env("MAIL_CONSOLE_URL", "https://app.nearledaily.com"),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
smtpPassword takes the spaces out of a Google App Password.
|
||||
|
||||
Google shows a 16-character App Password formatted for reading — "abcd efgh
|
||||
ijkl mnop" — and the spaces are presentation, not part of the secret. Pasted
|
||||
verbatim they survive into the credential and Gmail refuses the login, which
|
||||
Fiesta reports as "the mail server refused our credentials". That sends somebody
|
||||
to revoke a perfectly good password and generate another one with the same four
|
||||
spaces in it.
|
||||
|
||||
ONLY for Google's own SMTP hosts, and only when what is left is the 16
|
||||
alphanumeric characters an App Password actually is. A password is a secret and
|
||||
quietly editing one is normally the wrong thing: another relay's password may
|
||||
legitimately contain a space, and stripping it there would turn a working
|
||||
credential into a silent authentication failure — the exact bug this avoids,
|
||||
pointed the other way.
|
||||
*/
|
||||
func smtpPassword(host, password string) string {
|
||||
if !isGoogleSMTP(host) {
|
||||
return password
|
||||
}
|
||||
|
||||
stripped := strings.Join(strings.Fields(password), "")
|
||||
if stripped == password || len(stripped) != googleAppPasswordLength {
|
||||
return password
|
||||
}
|
||||
for _, r := range stripped {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
return password
|
||||
}
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
// googleAppPasswordLength is what Google issues: sixteen characters, shown in
|
||||
// four groups of four.
|
||||
const googleAppPasswordLength = 16
|
||||
|
||||
func isGoogleSMTP(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||
case "smtp.gmail.com", "smtp-relay.gmail.com", "aspmx.l.google.com":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
95
config/mail_test.go
Normal file
95
config/mail_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// Confirms docs/MAIL_SETUP.md is telling the truth about the committed `.env`:
|
||||
// a sender is set, a host is not, and the server therefore reports mail OFF with
|
||||
// a reason naming the variable — rather than trying and failing to send.
|
||||
func TestCommittedEnvLeavesMailOffWithAReason(t *testing.T) {
|
||||
t.Setenv("MAIL_HOST", "")
|
||||
t.Setenv("MAIL_PORT", "587")
|
||||
t.Setenv("MAIL_FROM", "care@nearledaily.com")
|
||||
t.Setenv("MAIL_FROM_NAME", "Nearle")
|
||||
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
|
||||
|
||||
cfg := MailFromEnv()
|
||||
if cfg.Enabled() {
|
||||
t.Fatal("mail reported as enabled with no host")
|
||||
}
|
||||
if cfg.Why() == "" || cfg.Why()[:9] != "MAIL_HOST" {
|
||||
t.Fatalf("the reason does not name the missing variable: %q", cfg.Why())
|
||||
}
|
||||
|
||||
// And with the Postal host supplied from .env.secrets, it comes on and the
|
||||
// link points at the MERCHANT console.
|
||||
t.Setenv("MAIL_HOST", "postal.nearledaily.com")
|
||||
on := MailFromEnv()
|
||||
if !on.Enabled() {
|
||||
t.Fatalf("still off with a host set: %s", on.Why())
|
||||
}
|
||||
if got := on.InviteLink("i1.abc.def"); got != "https://app.nearledaily.com/set-password?t=i1.abc.def" {
|
||||
t.Fatalf("the invitation would point at %q", got)
|
||||
}
|
||||
if on.Address() != "postal.nearledaily.com:587" {
|
||||
t.Fatalf("wrong SMTP address: %q", on.Address())
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Google App Passwords ────────────────────────────────────────────────── */
|
||||
|
||||
func TestAGoogleAppPasswordSurvivesBeingPastedWithItsSpaces(t *testing.T) {
|
||||
// Google shows it as "abcd efgh ijkl mnop". The spaces are presentation.
|
||||
// Pasted verbatim they reach Gmail, which refuses the login — reported as
|
||||
// "the mail server refused our credentials", sending somebody to revoke a
|
||||
// password that was fine.
|
||||
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||
t.Setenv("MAIL_PORT", "587")
|
||||
t.Setenv("MAIL_USERNAME", "care@nearledaily.com")
|
||||
t.Setenv("MAIL_PASSWORD", "abcd efgh ijkl mnop")
|
||||
t.Setenv("MAIL_FROM", "care@nearledaily.com")
|
||||
t.Setenv("MAIL_CONSOLE_URL", "https://app.nearledaily.com")
|
||||
|
||||
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
|
||||
t.Fatalf("password reached the relay as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAlreadyCleanAppPasswordIsUntouched(t *testing.T) {
|
||||
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||
t.Setenv("MAIL_PASSWORD", "abcdefghijklmnop")
|
||||
|
||||
if got := MailFromEnv().Password; got != "abcdefghijklmnop" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherRelaysPasswordIsNeverEdited(t *testing.T) {
|
||||
// A secret is a secret. Another relay's password may legitimately contain a
|
||||
// space, and stripping it there turns a working credential into a silent
|
||||
// authentication failure — this bug pointed the other way.
|
||||
for _, host := range []string{"smtp.sendgrid.net", "email-smtp.ap-south-1.amazonaws.com", "postal.nearledaily.com"} {
|
||||
t.Setenv("MAIL_HOST", host)
|
||||
t.Setenv("MAIL_PASSWORD", "two words here x")
|
||||
|
||||
if got := MailFromEnv().Password; got != "two words here x" {
|
||||
t.Errorf("%s: password was edited to %q", host, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotAnAppPasswordIsLeftAlone(t *testing.T) {
|
||||
// Only the exact shape Google issues — sixteen alphanumerics — is treated
|
||||
// as display formatting. Anything else is somebody's real password.
|
||||
t.Setenv("MAIL_HOST", "smtp.gmail.com")
|
||||
|
||||
for _, password := range []string{
|
||||
"short one", // not 16 after stripping
|
||||
"a much longer pass phrase here", // not 16
|
||||
"abcd efgh ijkl mno!", // punctuation: not an App Password
|
||||
} {
|
||||
t.Setenv("MAIL_PASSWORD", password)
|
||||
if got := MailFromEnv().Password; got != password {
|
||||
t.Errorf("%q was rewritten to %q", password, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
193
controllers/assistantController.go
Normal file
193
controllers/assistantController.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nearle/middleware"
|
||||
"nearle/services"
|
||||
"nearle/services/tools"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Nearle Buddy's HTTP surface.
|
||||
//
|
||||
// POST /v1/web/assistant/ask a question → an answer, and what it ran
|
||||
// POST /v1/web/assistant/approve a card the person pressed → the change, made
|
||||
// GET /v1/web/assistant/status is this switched on here?
|
||||
//
|
||||
// ── Where the caller comes from ─────────────────────────────────────────────
|
||||
//
|
||||
// `middleware.WebAuth` parks the verified claims on the request, and this
|
||||
// builds the tool caller from those and from nothing else. There is no tenant
|
||||
// field on the request body — deliberately, so there is nothing for a model or
|
||||
// a caller to fill in. The console asks "what is stuck?" and the server already
|
||||
// knows whose shop that means.
|
||||
type AssistantController struct {
|
||||
assistant services.AssistantService
|
||||
}
|
||||
|
||||
func NewAssistantController(assistant services.AssistantService) *AssistantController {
|
||||
return &AssistantController{assistant: assistant}
|
||||
}
|
||||
|
||||
type assistantApproveRequest struct {
|
||||
Agent string `json:"agent"`
|
||||
// The card exactly as it was handed out. Opaque to the console — it is
|
||||
// signed, and anything the browser changed stops it verifying.
|
||||
Card string `json:"card"`
|
||||
}
|
||||
|
||||
type assistantAskRequest struct {
|
||||
// Which agent to ask. The console sends the one matching the page the panel
|
||||
// is sitting beside; empty means orders, the only one phase 2 ships.
|
||||
Agent string `json:"agent"`
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
// Status lets the console decide what to render before anybody types.
|
||||
//
|
||||
// The composer is disabled when this says no, which is the honest thing: a
|
||||
// field that accepts text and then swallows it is worse than one that says it
|
||||
// is not connected. The console has shown "Not connected yet" since it was
|
||||
// built, and this is what finally answers that question at runtime rather than
|
||||
// at build time.
|
||||
func (ctl *AssistantController) Status(c *fiber.Ctx) error {
|
||||
details := fiber.Map{"available": ctl.assistant.Available()}
|
||||
// Named "reason" rather than "error": not having an assistant is a
|
||||
// deployment choice, and the same field answers "we have not switched it
|
||||
// on" and "somebody misspelled a variable" — which are the two states that
|
||||
// looked identical from outside.
|
||||
if why := ctl.assistant.Unavailable(); why != "" {
|
||||
details["reason"] = why
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Success", "details": details,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctl *AssistantController) Ask(c *fiber.Ctx) error {
|
||||
var req assistantAskRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return assistantRefuse(c, http.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
|
||||
caller, ok := callerFrom(c)
|
||||
if !ok {
|
||||
// Reachable only while WEB_AUTH_REQUIRED is off, where an untokened
|
||||
// request still reaches handlers. Every other endpoint answers such a
|
||||
// request; this one must not. Reading a shop's orders through a REST
|
||||
// call takes knowing the endpoints and the fields; through an
|
||||
// assistant it takes one sentence, so this surface holds the higher
|
||||
// bar from its first day rather than inheriting the rollout's.
|
||||
return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to use Nearle Buddy.")
|
||||
}
|
||||
|
||||
agent := strings.TrimSpace(req.Agent)
|
||||
if agent == "" {
|
||||
agent = "orders"
|
||||
}
|
||||
|
||||
ctx, cancel := services.WithTimeout(c.Context())
|
||||
defer cancel()
|
||||
|
||||
answer, err := ctl.assistant.Ask(ctx, agent, req.Question, caller)
|
||||
if err != nil {
|
||||
// "Not switched on here" is a deployment fact, not a fault, and it gets
|
||||
// its own status so the console can disable the composer rather than
|
||||
// showing an error the person can do nothing about.
|
||||
if errors.Is(err, utils.ErrChatNotConfigured) {
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusServiceUnavailable, "status": false,
|
||||
"message": "Nearle Buddy is not switched on for this deployment.",
|
||||
})
|
||||
}
|
||||
// Asking too fast gets its own status, so the console and whatever
|
||||
// watches it can tell "you are going too quickly" apart from "that
|
||||
// question was malformed". The message already says how long to wait.
|
||||
var tooFast services.ErrTooFast
|
||||
if errors.As(err, &tooFast) {
|
||||
return assistantRefuse(c, http.StatusTooManyRequests, err.Error())
|
||||
}
|
||||
// The provider's quota, as opposed to our own limiter above. Same status
|
||||
// for the same reason — it is not a bad question, it is a busy minute —
|
||||
// and the message is ours rather than Groq's, which names our billing
|
||||
// account and the tokens-per-minute arithmetic behind it.
|
||||
if errors.Is(err, utils.ErrBusy) {
|
||||
return assistantRefuse(c, http.StatusTooManyRequests, utils.ErrBusy.Error())
|
||||
}
|
||||
return assistantRefuse(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Success", "details": answer,
|
||||
})
|
||||
}
|
||||
|
||||
// Approve performs a change the person pressed the button on.
|
||||
//
|
||||
// Its own endpoint, not a flag on /ask, because it is a different kind of act:
|
||||
// no question, no model, no conversation. The card names the action and the
|
||||
// session names the person, and the registry re-checks both against the live
|
||||
// database before anything is written.
|
||||
func (ctl *AssistantController) Approve(c *fiber.Ctx) error {
|
||||
var req assistantApproveRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return assistantRefuse(c, http.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
if strings.TrimSpace(req.Card) == "" {
|
||||
return assistantRefuse(c, http.StatusBadRequest, "Nothing to approve.")
|
||||
}
|
||||
|
||||
caller, ok := callerFrom(c)
|
||||
if !ok {
|
||||
return assistantRefuse(c, http.StatusUnauthorized, "Sign in again to approve this.")
|
||||
}
|
||||
|
||||
agent := strings.TrimSpace(req.Agent)
|
||||
if agent == "" {
|
||||
agent = "orders"
|
||||
}
|
||||
|
||||
ctx, cancel := services.WithTimeout(c.Context())
|
||||
defer cancel()
|
||||
|
||||
answer, err := ctl.assistant.Approve(ctx, agent, req.Card, caller)
|
||||
if err != nil {
|
||||
// A refused approval is a business outcome, not a server fault: the card
|
||||
// expired, somebody else already approved it, the request was withdrawn.
|
||||
// The person needs the reason, and the console renders it beside the
|
||||
// card rather than as an error page.
|
||||
return assistantRefuse(c, http.StatusConflict, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Success", "details": answer,
|
||||
})
|
||||
}
|
||||
|
||||
// callerFrom turns a verified session into a tool caller.
|
||||
//
|
||||
// The one place the two vocabularies meet. Staff (`issuperadmin`) carry no
|
||||
// tenant, and the registry lets them through — but a tool that reads a shop's
|
||||
// data refuses them until they have picked one, because "every tenant at once"
|
||||
// is not an answer to "what is stuck?".
|
||||
func callerFrom(c *fiber.Ctx) (tools.Caller, bool) {
|
||||
claims, ok := middleware.WebClaimsFrom(c)
|
||||
if !ok {
|
||||
return tools.Caller{}, false
|
||||
}
|
||||
return tools.Caller{
|
||||
Userid: claims.Userid,
|
||||
Tenantid: claims.Tenantid,
|
||||
Locationid: claims.Locationid,
|
||||
Superadmin: claims.Superadmin,
|
||||
}, true
|
||||
}
|
||||
|
||||
func assistantRefuse(c *fiber.Ctx, code int, message string) error {
|
||||
return c.Status(code).JSON(fiber.Map{"code": code, "status": false, "message": message})
|
||||
}
|
||||
443
controllers/assistantHTTP_test.go
Normal file
443
controllers/assistantHTTP_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/config"
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"nearle/services/tools"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Nearle Buddy over HTTP, through the guard, as the console reaches it.
|
||||
//
|
||||
// Everything else tests one layer. The service tests call `Ask` directly with a
|
||||
// caller already built; the live tests talk to a real model but never touch a
|
||||
// route. Neither would notice the thing most likely to break on a deploy: the
|
||||
// seam where a session token becomes a tool caller.
|
||||
//
|
||||
// That seam has four parts, and a mistake in any one of them produces a console
|
||||
// showing an empty panel and a server logging nothing —
|
||||
//
|
||||
// the route sits under /v1/web, so WebAuth runs at all
|
||||
// WebAuth verifies the token and parks the claims
|
||||
// callerFrom reads those claims rather than the request body
|
||||
// the answer comes back inside `details`, where the console's client looks
|
||||
//
|
||||
// No database: every tool is handed a fake, so this runs in CI beside the unit
|
||||
// tests. The ones that need a model skip without a key.
|
||||
|
||||
const testSecret = "a-test-signing-secret-of-ample-length"
|
||||
|
||||
var testCaller = utils.WebClaims{Userid: 904, Tenantid: 1147}
|
||||
|
||||
// ── the shop these tests run against ────────────────────────────────────────
|
||||
|
||||
type fakeShop struct {
|
||||
deliveries []models.Deliveryinfo
|
||||
requests []models.StockRequest
|
||||
// approved records what reached the write half, so the approval test can
|
||||
// assert the change happened rather than that it was described.
|
||||
approved []string
|
||||
}
|
||||
|
||||
func (f *fakeShop) GetDeliveries(models.DeliveryQuery) []models.Deliveryinfo { return f.deliveries }
|
||||
|
||||
func (f *fakeShop) GetStockRequests(tenantID, _ int, status, _ string, _, _ int) ([]models.StockRequest, error) {
|
||||
// Honours the tenant on purpose. A fake that returned rows to anybody would
|
||||
// let an ownership bug pass this test.
|
||||
if tenantID != testCaller.Tenantid || !strings.EqualFold(status, "Pending") {
|
||||
return nil, nil
|
||||
}
|
||||
return f.requests, nil
|
||||
}
|
||||
|
||||
func (f *fakeShop) UpdateStockRequest(requestID int, status string) error {
|
||||
f.approved = append(f.approved, status+" #"+strconv.Itoa(requestID))
|
||||
for i := range f.requests {
|
||||
if f.requests[i].Requestid == requestID {
|
||||
// Drops out of the pending list, as the real update does. Without
|
||||
// this, approving the same card twice would succeed twice.
|
||||
f.requests = append(f.requests[:i], f.requests[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The tools these tests do not exercise still have to exist, because the
|
||||
// shipped agents name them and LoadAgents refuses an agent naming a tool that
|
||||
// is absent. An empty answer is the honest fake: a shop with nothing to report.
|
||||
func (f *fakeShop) GetLocationOrderSummary(int) ([]models.Ordersummarylocation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeShop) GetProductStocks(string, string) ([]models.Productstocks, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeShop) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeShop) GetRevenueSummary(int, int, string, string) (*models.TenantRevenueSummary, error) {
|
||||
return &models.TenantRevenueSummary{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeShop) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
||||
return &models.PosSalesSummary{}, nil
|
||||
}
|
||||
|
||||
func newShop() *fakeShop {
|
||||
now := time.Now()
|
||||
stamp := func(minutesAgo int) string {
|
||||
return now.Add(-time.Duration(minutesAgo) * time.Minute).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return &fakeShop{
|
||||
deliveries: []models.Deliveryinfo{
|
||||
{Deliveryid: 4412, Orderid: "ORD-4412", Orderstatus: "pending", Assigntime: stamp(41),
|
||||
Ridername: "Varun", Locationname: "R Mart"},
|
||||
{Deliveryid: 4421, Orderid: "ORD-4421", Orderstatus: "delivered", Assigntime: stamp(200)},
|
||||
},
|
||||
requests: []models.StockRequest{{
|
||||
Requestid: 41, Productname: "Sona Masoori rice 25kg", Qty: 12,
|
||||
Locationname: "R Mart", Status: "Pending", Created: now.Add(-36 * time.Hour),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// ── the server, wired the way production wires it ───────────────────────────
|
||||
|
||||
func buildApp(t *testing.T, chat utils.Chat) (*fiber.App, *fakeShop) {
|
||||
t.Helper()
|
||||
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
||||
|
||||
shop := newShop()
|
||||
|
||||
corpus, err := tools.LoadHelp()
|
||||
if err != nil {
|
||||
t.Fatalf("help corpus: %v", err)
|
||||
}
|
||||
|
||||
registry := tools.New(tools.DiscardAudit{})
|
||||
for _, tool := range []tools.Tool{
|
||||
tools.StuckOrders(shop, nil),
|
||||
tools.DeliveryProgress(shop),
|
||||
tools.BranchPerformance(shop),
|
||||
tools.PendingApprovals(shop, nil),
|
||||
tools.LowStock(shop),
|
||||
tools.TillsNotSyncing(shop),
|
||||
tools.SalesByChannel(shop, shop, nil),
|
||||
tools.Help(corpus),
|
||||
tools.ApproveStockRequest(shop, shop),
|
||||
} {
|
||||
if err := registry.Register(tool); err != nil {
|
||||
t.Fatalf("registering %s: %v", tool.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped agent definitions, not a hand-built stand-in. A typo in
|
||||
// agents/inventory.yaml should fail here rather than on deploy.
|
||||
agents, err := services.LoadAgents("", registry.Has)
|
||||
if err != nil {
|
||||
t.Fatalf("agents: %v", err)
|
||||
}
|
||||
|
||||
assistant := services.NewAssistantService(registry, chat, agents)
|
||||
// Mirrors facade.NewFacade: with no model, the reason the config gives is
|
||||
// threaded through to the service so /status can name the missing variable.
|
||||
// Built the same way here, or this would assert a string production never
|
||||
// produces.
|
||||
if setter, ok := assistant.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
|
||||
setter.SetUnavailableReason(config.AssistantConfig{}.Why())
|
||||
}
|
||||
|
||||
controller := NewAssistantController(assistant)
|
||||
|
||||
app := fiber.New()
|
||||
// nil is the branch-ownership checker, consulted only when a request names
|
||||
// a branch. The assistant's body names none — that is the design — so
|
||||
// nothing here can reach it.
|
||||
app.Use(middleware.WebAuth(nil))
|
||||
web := app.Group("/live/api/v1/web")
|
||||
web.Get("/assistant/status", controller.Status)
|
||||
web.Post("/assistant/ask", controller.Ask)
|
||||
web.Post("/assistant/approve", controller.Approve)
|
||||
|
||||
return app, shop
|
||||
}
|
||||
|
||||
// webSession mints a session for THIS test's own user.
|
||||
//
|
||||
// One user id across the file put every test in one rate-limit bucket — six
|
||||
// questions and then 429 for ten seconds — so the suite passed test by test and
|
||||
// failed when run together, which is the worst way round: green locally, red in
|
||||
// CI, and the failure blamed on the model.
|
||||
//
|
||||
// A per-test user is also the truthful shape. The limiter is per person, and
|
||||
// two tests are two people.
|
||||
func webSession(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
claims := testCaller
|
||||
// Stable across runs and distinct per test, so a failure names the same
|
||||
// user every time. The fakes key on tenant, never on this.
|
||||
claims.Userid = testCaller.Userid + int(crc32.ChecksumIEEE([]byte(t.Name()))%10_000)
|
||||
|
||||
token, _, err := utils.MintWebToken(claims, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("minting a session: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// envelope is the shape every Fiesta handler answers with, and the shape the
|
||||
// console's client unwraps. Asserting on it rather than on the Go struct is the
|
||||
// point: a controller returning the answer at the top level would pass a
|
||||
// service-level test and hand the console `undefined`.
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Status bool `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Details services.AssistantAnswer `json:"details"`
|
||||
}
|
||||
|
||||
const (
|
||||
statusPath = "/live/api/v1/web/assistant/status"
|
||||
askPath = "/live/api/v1/web/assistant/ask"
|
||||
approvePath = "/live/api/v1/web/assistant/approve"
|
||||
)
|
||||
|
||||
func post(t *testing.T, app *fiber.App, path, token, body string) (int, envelope, string) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var out envelope
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return resp.StatusCode, out, string(raw)
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
out, _ := json.Marshal(s)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ── the guard ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAnUntokenedQuestionIsRefusedOverHTTP(t *testing.T) {
|
||||
// WEB_AUTH_REQUIRED defaults on now, so the middleware turns this away
|
||||
// before the controller sees it. Either refusal is correct; what must never
|
||||
// happen is an answer.
|
||||
app, _ := buildApp(t, nil)
|
||||
|
||||
status, _, body := post(t, app, askPath, "", `{"agent":"orders","question":"what is stuck?"}`)
|
||||
|
||||
if status == fiber.StatusOK {
|
||||
t.Fatalf("an untokened question was answered: %s", body)
|
||||
}
|
||||
if status != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATamperedTokenIsRefusedOverHTTP(t *testing.T) {
|
||||
app, _ := buildApp(t, nil)
|
||||
|
||||
// Three characters at the end — the edit somebody would actually attempt.
|
||||
broken := webSession(t)
|
||||
broken = broken[:len(broken)-3] + "AAA"
|
||||
|
||||
status, _, body := post(t, app, askPath, broken, `{"question":"what is stuck?"}`)
|
||||
if status != fiber.StatusUnauthorized {
|
||||
t.Fatalf("a tampered session was not refused: %d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusNamesTheMissingVariable(t *testing.T) {
|
||||
// Why the field exists: "available: false" alone is the same answer for "we
|
||||
// have not switched it on" and "somebody misspelled a variable", and those
|
||||
// need different actions from whoever is looking.
|
||||
app, _ := buildApp(t, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", statusPath, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+webSession(t))
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var out struct {
|
||||
Details struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("status is not the envelope the console unwraps: %s", raw)
|
||||
}
|
||||
if out.Details.Available {
|
||||
t.Fatal("reported available with no model configured")
|
||||
}
|
||||
if out.Details.Reason == "" {
|
||||
t.Fatalf("said no without saying why: %s", raw)
|
||||
}
|
||||
// Names the variable, not merely the symptom. "no assistant model is
|
||||
// configured" is what the service says on its own, and it is the answer
|
||||
// that left this switched off without anybody being able to tell which
|
||||
// variable was wrong.
|
||||
if !strings.Contains(out.Details.Reason, "ASSISTANT_") {
|
||||
t.Fatalf("the reason names no variable to go and set: %q", out.Details.Reason)
|
||||
}
|
||||
t.Logf("reason: %s", out.Details.Reason)
|
||||
}
|
||||
|
||||
// ── the live path ───────────────────────────────────────────────────────────
|
||||
|
||||
func liveHTTPChat(t *testing.T) utils.Chat {
|
||||
t.Helper()
|
||||
|
||||
// Read exactly as production reads it, so this proves the shipped defaults
|
||||
// work rather than quietly testing a configuration of its own.
|
||||
cfg := config.AssistantFromEnv()
|
||||
if !cfg.Enabled() {
|
||||
t.Skipf("no model configured: %s", cfg.Why())
|
||||
}
|
||||
|
||||
chat, err := utils.NewChat(cfg)
|
||||
if err != nil || chat == nil {
|
||||
t.Skipf("gateway not built: %v", err)
|
||||
}
|
||||
return chat
|
||||
}
|
||||
|
||||
func TestLiveAQuestionAnswersThroughTheWholeStack(t *testing.T) {
|
||||
app, _ := buildApp(t, liveHTTPChat(t))
|
||||
|
||||
status, out, body := post(t, app, askPath, webSession(t),
|
||||
`{"agent":"orders","question":"Which orders are stuck?"}`)
|
||||
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("HTTP %d: %s", status, body)
|
||||
}
|
||||
if !out.Status {
|
||||
t.Fatalf("envelope says failure: %s", out.Message)
|
||||
}
|
||||
// Inside `details`, where the console's client reads. A correct answer at
|
||||
// the top level is still a broken console.
|
||||
if strings.TrimSpace(out.Details.Reply) == "" {
|
||||
t.Fatalf("no reply in details: %s", body)
|
||||
}
|
||||
if len(out.Details.Used) == 0 {
|
||||
t.Fatalf("answered without running a tool — it invented it: %s", out.Details.Reply)
|
||||
}
|
||||
t.Logf("used: %+v", out.Details.Used)
|
||||
t.Logf("reply: %s", out.Details.Reply)
|
||||
}
|
||||
|
||||
func TestLiveTheAnswerIsScopedToTheSessionsTenant(t *testing.T) {
|
||||
// The claim the whole design rests on. The request body carries no tenant,
|
||||
// so rows can only be reached through the token — and a session whose shop
|
||||
// has nothing pending must not be handed a list.
|
||||
app, shop := buildApp(t, liveHTTPChat(t))
|
||||
shop.requests = nil
|
||||
|
||||
status, out, body := post(t, app, askPath, webSession(t),
|
||||
`{"agent":"inventory","question":"What stock requests are waiting for approval?"}`)
|
||||
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("HTTP %d: %s", status, body)
|
||||
}
|
||||
if strings.Contains(out.Details.Reply, "Sona Masoori") {
|
||||
t.Fatalf("named a row this session cannot see: %s", out.Details.Reply)
|
||||
}
|
||||
t.Logf("reply: %s", out.Details.Reply)
|
||||
}
|
||||
|
||||
// ── the approval card, end to end ───────────────────────────────────────────
|
||||
|
||||
func TestLiveAnApprovalCardRoundTripsAndWrites(t *testing.T) {
|
||||
// The one path that has never run whole. The model proposes, the card comes
|
||||
// back signed, the console sends it in unchanged, and only then does
|
||||
// anything change. Each half has unit tests; this is the join.
|
||||
app, shop := buildApp(t, liveHTTPChat(t))
|
||||
token := webSession(t)
|
||||
|
||||
status, out, body := post(t, app, askPath, token,
|
||||
`{"agent":"inventory","question":"Approve stock request 41."}`)
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("asking: HTTP %d: %s", status, body)
|
||||
}
|
||||
if out.Details.Awaiting == nil {
|
||||
t.Fatalf("no approval card came back — nothing to press: %s", out.Details.Reply)
|
||||
}
|
||||
card := out.Details.Awaiting.Card
|
||||
t.Logf("card: %s", out.Details.Awaiting.Summary)
|
||||
|
||||
// Nothing may have happened yet. A write at proposal time is the failure
|
||||
// the whole two-step exists to prevent.
|
||||
if len(shop.approved) != 0 {
|
||||
t.Fatalf("the change was made before anybody agreed to it: %v", shop.approved)
|
||||
}
|
||||
|
||||
status, done, body := post(t, app, approvePath, token,
|
||||
`{"agent":"inventory","card":`+quote(card)+`}`)
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("approving: HTTP %d: %s", status, body)
|
||||
}
|
||||
if len(shop.approved) != 1 || shop.approved[0] != "Approved #41" {
|
||||
t.Fatalf("the write did not reach the service: %v", shop.approved)
|
||||
}
|
||||
t.Logf("after approval: %s", done.Details.Reply)
|
||||
|
||||
// Pressing twice must not approve twice. The card still verifies; the row
|
||||
// is no longer pending, and the re-check at execute time is what notices.
|
||||
status, _, _ = post(t, app, approvePath, token,
|
||||
`{"agent":"inventory","card":`+quote(card)+`}`)
|
||||
if status == fiber.StatusOK {
|
||||
t.Fatal("the same card approved the same request twice")
|
||||
}
|
||||
if len(shop.approved) != 1 {
|
||||
t.Fatalf("a second write got through: %v", shop.approved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAForgedCardIsRefused(t *testing.T) {
|
||||
// No model needed: a card that does not verify must be refused before
|
||||
// anything reads what it claims.
|
||||
app, shop := buildApp(t, nil)
|
||||
|
||||
status, _, body := post(t, app, approvePath, webSession(t),
|
||||
`{"agent":"inventory","card":"w1.bm90LWEtcmVhbC1jYXJk.c2lnbmF0dXJl"}`)
|
||||
|
||||
if status == fiber.StatusOK {
|
||||
t.Fatalf("a forged card was accepted: %s", body)
|
||||
}
|
||||
if len(shop.approved) != 0 {
|
||||
t.Fatalf("a forged card changed something: %v", shop.approved)
|
||||
}
|
||||
}
|
||||
146
controllers/deliverySlotController.go
Normal file
146
controllers/deliverySlotController.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
)
|
||||
|
||||
type DeliverySlotController struct {
|
||||
service services.DeliverySlotService
|
||||
}
|
||||
|
||||
func NewDeliverySlotController(service services.DeliverySlotService) *DeliverySlotController {
|
||||
return &DeliverySlotController{service: service}
|
||||
}
|
||||
|
||||
/*
|
||||
GET /v1/web/deliveryslots?tenantid&locationid
|
||||
|
||||
Everything a branch has configured, active or not, for the console's editor.
|
||||
A branch that has set nothing returns an empty list — see the note on Available
|
||||
about why that is never an error.
|
||||
*/
|
||||
func (ctl *DeliverySlotController) ListDeliverySlots(c *fiber.Ctx) error {
|
||||
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||
|
||||
if tenantID <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
slots, err := ctl.service.ListForBranch(tenantID, locationID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"status": true,
|
||||
"details": slots,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
PUT /v1/web/deliveryslots
|
||||
|
||||
The branch's windows, all three together rather than one at a time: they are
|
||||
edited as a set on one screen, and sending them together is what lets the
|
||||
service reject the whole edit when one row is wrong instead of applying part of
|
||||
it.
|
||||
|
||||
A business objection — an unreadable time, a window ending before it starts,
|
||||
the same key twice — is 409 and not 500. It is an answer about the request, and
|
||||
the message is written to be shown to the person who typed it.
|
||||
*/
|
||||
func (ctl *DeliverySlotController) SaveDeliverySlots(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Slots []models.DeliverySlots `json:"slots"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "Invalid request body",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if req.Tenantid <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.service.Save(req.Tenantid, req.Locationid, req.Slots); err != nil {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Successfully Updated",
|
||||
"status": true,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
GET /v1/mob/deliveryslots/available?tenantid&locationid
|
||||
|
||||
What the shopper may pick, already filtered and dated. The app renders this list
|
||||
and does no time arithmetic of its own — see the service for why one clock has
|
||||
to be authoritative.
|
||||
|
||||
An empty list is 200 with `details: []`, NOT an error. It means this branch has
|
||||
set no windows, which is the state every shop is in today, and the app is
|
||||
required to fall back to ordering without one. Returning 404 here would turn an
|
||||
ordinary shop into a broken one.
|
||||
*/
|
||||
func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error {
|
||||
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
locationID, _ := strconv.Atoi(c.Query("locationid"))
|
||||
|
||||
if tenantID <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "tenantid is required",
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
slots, err := ctl.service.Available(tenantID, locationID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"status": true,
|
||||
"details": slots,
|
||||
})
|
||||
}
|
||||
113
controllers/healthController.go
Normal file
113
controllers/healthController.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// What is running here, and is it wired up?
|
||||
//
|
||||
// ── Why this exists ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// On 2026-09-24 the assistant sat switched off in production for most of a day,
|
||||
// and neither of us could establish WHY from outside the container. Two
|
||||
// questions had no answer:
|
||||
//
|
||||
// 1. which build is deployed? A redeploy can reuse a cached image, so
|
||||
// "I pushed it" and "it is running" are different facts.
|
||||
// 2. does the server have a model? `/assistant/status` knows, but it sits
|
||||
// behind the session guard, and a 401 from `/v1/web` proves nothing —
|
||||
// the middleware answers before routing, so a route that does not exist
|
||||
// returns exactly the same 401 as one that does.
|
||||
//
|
||||
// Every diagnosis that day was guesswork for want of one request. Hours went
|
||||
// into probing CORS headers and comparing nginx versions to infer a commit,
|
||||
// which is what people do when a server will not simply say.
|
||||
//
|
||||
// ── What it deliberately does not say ───────────────────────────────────────
|
||||
//
|
||||
// Booleans, never values. "The assistant has a model" is operational; WHICH
|
||||
// model, at which endpoint, under which key is not, and the reason string on
|
||||
// `/assistant/status` names environment variables — that stays behind the
|
||||
// guard. Nothing here distinguishes a tenant, so there is nothing to scope.
|
||||
//
|
||||
// Unauthenticated on purpose. A health check that needs a credential cannot be
|
||||
// used by the person trying to work out why credentials are not working, and
|
||||
// that is precisely when it is wanted.
|
||||
type HealthController struct {
|
||||
assistant services.AssistantService
|
||||
// hasDatabase is a construction-time fact, not a live ping. A query per
|
||||
// health check is a query per uptime probe, and "configured" is the thing
|
||||
// that actually differs between a broken deployment and a working one.
|
||||
hasDatabase bool
|
||||
}
|
||||
|
||||
func NewHealthController(assistant services.AssistantService, hasDatabase bool) *HealthController {
|
||||
return &HealthController{assistant: assistant, hasDatabase: hasDatabase}
|
||||
}
|
||||
|
||||
// Version is stamped at build time:
|
||||
//
|
||||
// go build -ldflags "-X nearle/controllers.Version=$(git rev-parse --short HEAD)"
|
||||
//
|
||||
// Left as "unknown" when nothing stamps it, which is honest — and itself worth
|
||||
// seeing, because it means the image was not built by the pipeline that does.
|
||||
var Version = "unknown"
|
||||
|
||||
// buildVersion falls back to whatever the toolchain recorded.
|
||||
//
|
||||
// `debug.ReadBuildInfo` carries the VCS revision for a build made inside a git
|
||||
// checkout, so even an image built by hand usually knows its own commit. The
|
||||
// ldflag is preferred because a Docker build copies the tree without `.git`.
|
||||
func buildVersion() string {
|
||||
if Version != "unknown" && strings.TrimSpace(Version) != "" {
|
||||
return Version
|
||||
}
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Key == "vcs.revision" && setting.Value != "" {
|
||||
if len(setting.Value) > 7 {
|
||||
return setting.Value[:7]
|
||||
}
|
||||
return setting.Value
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (ctl *HealthController) Health(c *fiber.Ctx) error {
|
||||
assistant := false
|
||||
if ctl.assistant != nil {
|
||||
assistant = ctl.assistant.Available()
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Success",
|
||||
"details": fiber.Map{
|
||||
"version": buildVersion(),
|
||||
// Can this server issue console sessions at all?
|
||||
//
|
||||
// `attachWebSession` logs a minting failure and lets the login
|
||||
// succeed without a token, so a server with no signing secret hands
|
||||
// out sessions that cannot authenticate: the console renders, and
|
||||
// every request after it comes back 401 with no `authorization`
|
||||
// header on it. False here is that, stated once, instead of found
|
||||
// by reading request headers on a Friday morning.
|
||||
"sessions": utils.WebTokenConfigured(),
|
||||
// True when a model is configured and the assistant can answer. False
|
||||
// is the answer to "I set the key and redeployed, did it take?" —
|
||||
// which took a day to establish without it.
|
||||
"assistant": assistant,
|
||||
"database": ctl.hasDatabase,
|
||||
},
|
||||
})
|
||||
}
|
||||
194
controllers/health_test.go
Normal file
194
controllers/health_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/services"
|
||||
"nearle/services/tools"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
/*
|
||||
A server that can say what it is.
|
||||
|
||||
This exists because of a day spent unable to answer two questions about a
|
||||
running deployment: which build is it, and does the assistant have a model. Both
|
||||
were knowable inside the container and neither was reachable from outside —
|
||||
`/assistant/status` sits behind the session guard, and a 401 from `/v1/web`
|
||||
proves nothing, because the middleware answers before routing and a route that
|
||||
does not exist returns the same 401 as one that does.
|
||||
|
||||
So the tests that matter here are about what it answers WITHOUT a session, and
|
||||
about what it refuses to include.
|
||||
*/
|
||||
|
||||
func healthApp(t *testing.T, assistantReady bool, hasDatabase bool) *fiber.App {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New()
|
||||
controller := NewHealthController(stubAssistant{ready: assistantReady}, hasDatabase)
|
||||
app.Get("/live/api/v1/health", controller.Health)
|
||||
return app
|
||||
}
|
||||
|
||||
func readHealth(t *testing.T, app *fiber.App) (int, map[string]any, string) {
|
||||
t.Helper()
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("health: %v", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var envelope struct {
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("not the envelope the console unwraps: %s", raw)
|
||||
}
|
||||
return resp.StatusCode, envelope.Details, string(raw)
|
||||
}
|
||||
|
||||
func TestHealthAnswersWithoutASession(t *testing.T) {
|
||||
// The point. A health check that needs a credential cannot be used by the
|
||||
// person working out why credentials are not working — which is exactly
|
||||
// when somebody reaches for it.
|
||||
status, details, body := readHealth(t, healthApp(t, true, true))
|
||||
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("HTTP %d without a session: %s", status, body)
|
||||
}
|
||||
if details["version"] == nil {
|
||||
t.Fatalf("no build id: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthSaysWhetherTheAssistantHasAModel(t *testing.T) {
|
||||
// "I set the key and redeployed — did it take?" took a day to answer. This
|
||||
// is that answer, in one unauthenticated request.
|
||||
_, ready, _ := readHealth(t, healthApp(t, true, true))
|
||||
if ready["assistant"] != true {
|
||||
t.Fatalf("a configured assistant reported as %v", ready["assistant"])
|
||||
}
|
||||
|
||||
_, off, body := readHealth(t, healthApp(t, false, true))
|
||||
if off["assistant"] != false {
|
||||
t.Fatalf("an unconfigured assistant reported as %v: %s", off["assistant"], body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthNeverLeaksTheConfiguration(t *testing.T) {
|
||||
// Booleans, never values. WHICH model, at which endpoint, under which key is
|
||||
// not operational information, and the `reason` string on /assistant/status
|
||||
// names environment variables — that stays behind the guard.
|
||||
_, _, body := readHealth(t, healthApp(t, false, true))
|
||||
|
||||
for _, secret := range []string{
|
||||
"ASSISTANT_", "api.groq.com", "gsk_", "openai/gpt-oss", "POS_TOKEN", "password",
|
||||
} {
|
||||
if strings.Contains(strings.ToLower(body), strings.ToLower(secret)) {
|
||||
t.Fatalf("%q is exposed on an unauthenticated endpoint: %s", secret, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthSurvivesAServerWithNothingWiredUp(t *testing.T) {
|
||||
// A deployment with no database and no model must still ANSWER. This is the
|
||||
// state in which somebody is most likely to ask, and a 500 here would leave
|
||||
// them exactly where they started.
|
||||
app := fiber.New()
|
||||
app.Get("/live/api/v1/health", NewHealthController(nil, false).Health)
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("health: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("a bare server could not report its own health: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var envelope struct {
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &envelope)
|
||||
|
||||
if envelope.Details["assistant"] != false || envelope.Details["database"] != false {
|
||||
t.Fatalf("a bare server claimed to be wired up: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnstampedBuildSaysSoRatherThanGuessing(t *testing.T) {
|
||||
// "unknown" is informative: it means nothing stamped the image, so the
|
||||
// version cannot be trusted to date it. Inventing one would be worse than
|
||||
// admitting it.
|
||||
original := Version
|
||||
Version = "unknown"
|
||||
defer func() { Version = original }()
|
||||
|
||||
got := buildVersion()
|
||||
// Either the toolchain recorded a revision, or it says unknown. What it must
|
||||
// not do is return an empty string, which renders as a blank field and reads
|
||||
// like the endpoint is broken.
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Fatal("the build id is blank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStampedBuildIsReported(t *testing.T) {
|
||||
original := Version
|
||||
Version = "abc1234"
|
||||
defer func() { Version = original }()
|
||||
|
||||
_, details, body := readHealth(t, healthApp(t, true, true))
|
||||
if details["version"] != "abc1234" {
|
||||
t.Fatalf("the stamped build id was not reported: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// stubAssistant is only ever asked one question.
|
||||
type stubAssistant struct{ ready bool }
|
||||
|
||||
func (s stubAssistant) Available() bool { return s.ready }
|
||||
func (s stubAssistant) Unavailable() string { return "" }
|
||||
func (s stubAssistant) Ask(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) {
|
||||
return services.AssistantAnswer{}, nil
|
||||
}
|
||||
func (s stubAssistant) Approve(_ context.Context, _, _ string, _ tools.Caller) (services.AssistantAnswer, error) {
|
||||
return services.AssistantAnswer{}, nil
|
||||
}
|
||||
|
||||
func TestHealthSaysWhetherSessionsCanBeIssued(t *testing.T) {
|
||||
// The failure this exists for: `attachWebSession` logs a minting failure and
|
||||
// lets the login succeed anyway, so a server with no signing secret issues
|
||||
// sessions that cannot authenticate. The console renders, every request
|
||||
// after it 401s with no `authorization` header, and nothing says why.
|
||||
t.Setenv("POS_TOKEN_SECRET", "")
|
||||
t.Setenv("JWT_SECRET_KEY", "")
|
||||
_, broken, body := readHealth(t, healthApp(t, true, true))
|
||||
if broken["sessions"] != false {
|
||||
t.Fatalf("a server that cannot sign a session claimed it could: %s", body)
|
||||
}
|
||||
|
||||
t.Setenv("POS_TOKEN_SECRET", "a-secret-of-quite-sufficient-length")
|
||||
_, working, _ := readHealth(t, healthApp(t, true, true))
|
||||
if working["sessions"] != true {
|
||||
t.Fatal("a server with a signing secret reported it could not issue sessions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthDoesNotLeakTheSigningSecret(t *testing.T) {
|
||||
// A boolean about the secret, never the secret.
|
||||
t.Setenv("POS_TOKEN_SECRET", "correct-horse-battery-staple")
|
||||
_, _, body := readHealth(t, healthApp(t, true, true))
|
||||
|
||||
if strings.Contains(body, "correct-horse") {
|
||||
t.Fatalf("the signing secret is on an unauthenticated endpoint: %s", body)
|
||||
}
|
||||
}
|
||||
270
controllers/mcpController.go
Normal file
270
controllers/mcpController.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nearle/services"
|
||||
"nearle/services/tools"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The MCP door.
|
||||
//
|
||||
// A second way into the same registry. An outside client — Claude Desktop, an
|
||||
// IDE, another service — speaks Model Context Protocol and reaches exactly the
|
||||
// tools Nearle Buddy reaches, through exactly the same checks.
|
||||
//
|
||||
// ── Why it is a door and not a second implementation ────────────────────────
|
||||
//
|
||||
// `tools/list` is `Registry.Definitions`, and `tools/call` is `Registry.Call`.
|
||||
// Nothing here knows what a tool does, what a tenant is, or how a scope is
|
||||
// enforced. If this file grew its own idea of any of those, the two doors would
|
||||
// drift and one of them would be the unguarded one — which is the usual way a
|
||||
// system with two entrances ends up with one that skips the checks.
|
||||
//
|
||||
// ── The session is the same session ─────────────────────────────────────────
|
||||
//
|
||||
// Mounted under `/v1/web`, so `middleware.WebAuth` has already verified a
|
||||
// console token and parked the claims before this runs. There is no second
|
||||
// credential and no API key: whoever holds a console session gets exactly what
|
||||
// that session gets, and somebody with no session gets nothing.
|
||||
//
|
||||
// ── Read-only, deliberately ─────────────────────────────────────────────────
|
||||
//
|
||||
// Write tools are filtered out of both `tools/list` and `tools/call`. A write
|
||||
// resolves into an approval card, and the card is a thing a PERSON reads in the
|
||||
// console — the quantity, the branch, the id — before pressing a button. An MCP
|
||||
// client has no way to render that, and handing it a card to approve on its own
|
||||
// would turn a human gate into a JSON field. So the door offers the reads and
|
||||
// says plainly that changes happen in the console.
|
||||
type MCPController struct {
|
||||
registry *tools.Registry
|
||||
agents map[string]services.Agent
|
||||
assistant services.AssistantService
|
||||
}
|
||||
|
||||
func NewMCPController(registry *tools.Registry, agents map[string]services.Agent) *MCPController {
|
||||
return &MCPController{registry: registry, agents: agents}
|
||||
}
|
||||
|
||||
// The protocol version this speaks. Sent back on initialize so a client that
|
||||
// expects something else can say so rather than failing later on a shape it
|
||||
// did not anticipate.
|
||||
const mcpProtocolVersion = "2024-11-05"
|
||||
|
||||
/* ── JSON-RPC 2.0 ──────────────────────────────────────────────────────── */
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// The JSON-RPC codes this uses. Only the ones with a real meaning here — a
|
||||
// server that returns -32603 for everything tells a client nothing.
|
||||
const (
|
||||
rpcParseError = -32700
|
||||
rpcInvalidRequest = -32600
|
||||
rpcMethodNotFound = -32601
|
||||
rpcInvalidParams = -32602
|
||||
rpcInternalError = -32603
|
||||
)
|
||||
|
||||
func rpcOK(c *fiber.Ctx, id json.RawMessage, result any) error {
|
||||
// HTTP 200 even for a JSON-RPC error, which is the protocol's own
|
||||
// convention: the transport succeeded, and the error is in the envelope.
|
||||
return c.Status(http.StatusOK).JSON(rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
|
||||
}
|
||||
|
||||
func rpcFail(c *fiber.Ctx, id json.RawMessage, code int, message string) error {
|
||||
return c.Status(http.StatusOK).JSON(rpcResponse{
|
||||
JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
|
||||
/* ── The endpoint ──────────────────────────────────────────────────────── */
|
||||
|
||||
// Handle serves one JSON-RPC request.
|
||||
func (ctl *MCPController) Handle(c *fiber.Ctx) error {
|
||||
var req rpcRequest
|
||||
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||
return rpcFail(c, nil, rpcParseError, "that is not valid JSON")
|
||||
}
|
||||
if req.Method == "" {
|
||||
return rpcFail(c, req.ID, rpcInvalidRequest, "no method")
|
||||
}
|
||||
|
||||
// A notification — a request with no id — expects no response at all.
|
||||
// `initialized` is the one every client sends after the handshake, and
|
||||
// answering it with a result is a protocol error on our side.
|
||||
if len(req.ID) == 0 {
|
||||
return c.SendStatus(http.StatusAccepted)
|
||||
}
|
||||
|
||||
caller, ok := callerFrom(c)
|
||||
if !ok {
|
||||
return rpcFail(c, req.ID, rpcInvalidRequest,
|
||||
"this door needs a console session; sign in to Nearle and use that token")
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return rpcOK(c, req.ID, fiber.Map{
|
||||
"protocolVersion": mcpProtocolVersion,
|
||||
// Tools only. No resources, no prompts, no sampling — claiming a
|
||||
// capability this does not have makes a client fail on a call that
|
||||
// looked supported.
|
||||
"capabilities": fiber.Map{"tools": fiber.Map{}},
|
||||
"serverInfo": fiber.Map{"name": "nearle", "version": "1"},
|
||||
"instructions": "Read-only access to this merchant's own shop data. " +
|
||||
"Changes are made in the Nearle console, where they are confirmed by a person.",
|
||||
})
|
||||
|
||||
case "tools/list":
|
||||
return rpcOK(c, req.ID, fiber.Map{"tools": ctl.list(c)})
|
||||
|
||||
case "tools/call":
|
||||
return ctl.call(c, req, caller)
|
||||
|
||||
default:
|
||||
return rpcFail(c, req.ID, rpcMethodNotFound, "this server does not do "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// list is Definitions, with writes removed and the key renamed.
|
||||
//
|
||||
// MCP spells it `inputSchema`; the registry speaks `input_schema` because that
|
||||
// is what reads clearly and what the model gateway already converts from. The
|
||||
// rename happens here rather than in the registry so neither door dictates the
|
||||
// other's vocabulary.
|
||||
func (ctl *MCPController) list(c *fiber.Ctx) []fiber.Map {
|
||||
agent := ctl.agentFor(c)
|
||||
defined := ctl.registry.Definitions(tools.Agent{Name: agent.Name, Tools: agent.Tools})
|
||||
|
||||
out := make([]fiber.Map, 0, len(defined))
|
||||
for _, definition := range defined {
|
||||
name, _ := definition["name"].(string)
|
||||
// A write is not described at all, rather than described and refused.
|
||||
// A client told about a tool it will always be denied reads that as the
|
||||
// server malfunctioning.
|
||||
if ctl.isWrite(name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fiber.Map{
|
||||
"name": definition["name"],
|
||||
"description": definition["description"],
|
||||
"inputSchema": definition["input_schema"],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (ctl *MCPController) call(c *fiber.Ctx, req rpcRequest, caller tools.Caller) error {
|
||||
var params struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"arguments"`
|
||||
}
|
||||
if len(req.Params) > 0 {
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
return rpcFail(c, req.ID, rpcInvalidParams, "arguments are not valid JSON")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(params.Name) == "" {
|
||||
return rpcFail(c, req.ID, rpcInvalidParams, "no tool named")
|
||||
}
|
||||
|
||||
// Checked before the registry sees it. The registry would refuse a write
|
||||
// anyway — it returns a proposal rather than performing one — but a card
|
||||
// handed to a client with nothing to render it is worse than a plain "not
|
||||
// here", and this keeps the two doors' answers honest about why.
|
||||
if ctl.isWrite(params.Name) {
|
||||
return rpcFail(c, req.ID, rpcInvalidParams,
|
||||
params.Name+" changes data, and changes are confirmed by a person in the Nearle console")
|
||||
}
|
||||
|
||||
agent := ctl.agentFor(c)
|
||||
ctx, cancel := services.WithTimeout(c.Context())
|
||||
defer cancel()
|
||||
|
||||
result, err := ctl.registry.Call(ctx, tools.Agent{Name: agent.Name, Tools: agent.Tools},
|
||||
params.Name, params.Args, caller)
|
||||
if err != nil {
|
||||
// A refusal is returned as a tool result with `isError`, not as a
|
||||
// JSON-RPC error. The distinction is the protocol's: a transport fault
|
||||
// is an RPC error, and "that tool needs a branch" is an answer the
|
||||
// client should show its user.
|
||||
if errors.Is(err, tools.ErrUnknownTool) || errors.Is(err, tools.ErrNotAllowed) {
|
||||
return rpcFail(c, req.ID, rpcMethodNotFound, err.Error())
|
||||
}
|
||||
return rpcOK(c, req.ID, fiber.Map{
|
||||
"isError": true,
|
||||
"content": []fiber.Map{{"type": "text", "text": err.Error()}},
|
||||
})
|
||||
}
|
||||
|
||||
// The rows go back as JSON text, which is what MCP carries and what a model
|
||||
// on the other end reads most reliably. `note` and `covers` ride alongside
|
||||
// rather than inside, so an instruction about truncation cannot be mistaken
|
||||
// for a row.
|
||||
payload := fiber.Map{"rows": result.Rows, "count": result.Count}
|
||||
if result.Scope != "" {
|
||||
payload["covers"] = result.Scope
|
||||
}
|
||||
if result.Truncated {
|
||||
payload["truncated"] = true
|
||||
}
|
||||
if result.Note != "" {
|
||||
payload["note"] = result.Note
|
||||
}
|
||||
if result.Source != "" {
|
||||
payload["see"] = result.Source
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return rpcFail(c, req.ID, rpcInternalError, "the result could not be encoded")
|
||||
}
|
||||
return rpcOK(c, req.ID, fiber.Map{
|
||||
"content": []fiber.Map{{"type": "text", "text": string(encoded)}},
|
||||
})
|
||||
}
|
||||
|
||||
// isWrite reports whether a tool changes anything.
|
||||
func (ctl *MCPController) isWrite(name string) bool {
|
||||
tool, ok := ctl.registry.Tool(name)
|
||||
return ok && tool.Scope == tools.ScopeWrite
|
||||
}
|
||||
|
||||
// agentFor picks which agent's allow-list applies.
|
||||
//
|
||||
// An MCP client has no page to sit beside, so there is no route to read one
|
||||
// from. It gets `console` — the broadest of the read agents, matching what a
|
||||
// person sees on the overview — and it is still an allow-list rather than
|
||||
// "every tool": a door with no agent at all would be wider than any of the ones
|
||||
// the console offers.
|
||||
func (ctl *MCPController) agentFor(*fiber.Ctx) services.Agent {
|
||||
if agent, ok := ctl.agents["console"]; ok {
|
||||
return agent
|
||||
}
|
||||
// Named rather than defaulted to everything: a deployment whose agent files
|
||||
// do not define `console` gets a door that lists nothing, which is visible,
|
||||
// rather than one that offers the lot.
|
||||
return services.Agent{Name: "mcp"}
|
||||
}
|
||||
306
controllers/mcp_test.go
Normal file
306
controllers/mcp_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/middleware"
|
||||
"nearle/services"
|
||||
"nearle/services/tools"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The MCP door, held to the same rules as the console's.
|
||||
//
|
||||
// The point of these is not that JSON-RPC is spelled correctly — it is that a
|
||||
// second entrance did not arrive with its own, looser idea of who may read what.
|
||||
|
||||
func readTool(name string) tools.Tool {
|
||||
return tools.Tool{
|
||||
Name: name,
|
||||
Description: "a read tool with a description long enough to choose by, for testing",
|
||||
Scope: tools.ScopeRead,
|
||||
Schema: tools.Schema{Fields: []tools.Field{{
|
||||
Name: "limit", Description: "how many", Kind: tools.KindInt, Min: 1, Max: 50, Default: 10,
|
||||
}}},
|
||||
Handler: func(_ context.Context, req tools.Request) (tools.Result, error) {
|
||||
return tools.Result{
|
||||
Rows: []map[string]any{{"id": 1}}, Count: 1,
|
||||
Scope: "all branches", Source: "/admin/dispatch",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeToolFor(t *testing.T, name string) tools.Tool {
|
||||
t.Helper()
|
||||
return tools.WriteTool(
|
||||
tools.Tool{
|
||||
Name: name,
|
||||
Description: "a write tool with a description long enough to choose by, for testing",
|
||||
Schema: tools.Schema{},
|
||||
},
|
||||
func(context.Context, tools.Request) (tools.Proposal, error) {
|
||||
return tools.Proposal{Summary: "change something"}, nil
|
||||
},
|
||||
func(context.Context, tools.Request) (tools.Result, error) {
|
||||
t.Fatal("a write executed through the MCP door")
|
||||
return tools.Result{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// mcpApp mounts the door with a session already verified, as WebAuth would.
|
||||
func mcpApp(t *testing.T, claims *utils.WebClaims, toolset ...tools.Tool) *fiber.App {
|
||||
t.Helper()
|
||||
|
||||
registry := tools.New(nil)
|
||||
names := make([]string, 0, len(toolset))
|
||||
for _, tool := range toolset {
|
||||
if err := registry.Register(tool); err != nil {
|
||||
t.Fatalf("registering %s: %v", tool.Name, err)
|
||||
}
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
|
||||
agents := map[string]services.Agent{"console": {Name: "console", Tools: names}}
|
||||
ctl := NewMCPController(registry, agents)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/mcp", func(c *fiber.Ctx) error {
|
||||
if claims != nil {
|
||||
c.Locals(middleware.WebLocalsKey, *claims)
|
||||
}
|
||||
return ctl.Handle(c)
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func rpc(t *testing.T, app *fiber.App, body string) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("calling: %v", err)
|
||||
}
|
||||
if resp.StatusCode == fiber.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decoding: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var session = &utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172}
|
||||
|
||||
/* ── The handshake ─────────────────────────────────────────────────────── */
|
||||
|
||||
func TestInitializeClaimsOnlyWhatItCanDo(t *testing.T) {
|
||||
// Claiming a capability this does not have makes a client fail later, on a
|
||||
// call that looked supported.
|
||||
app := mcpApp(t, session, readTool("stuck"))
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"initialize"}`)
|
||||
|
||||
result, _ := out["result"].(map[string]any)
|
||||
caps, _ := result["capabilities"].(map[string]any)
|
||||
if _, ok := caps["tools"]; !ok {
|
||||
t.Fatalf("tools not offered: %v", caps)
|
||||
}
|
||||
for _, unsupported := range []string{"resources", "prompts", "sampling"} {
|
||||
if _, claimed := caps[unsupported]; claimed {
|
||||
t.Fatalf("claimed %q, which this server does not do", unsupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANotificationGetsNoResponse(t *testing.T) {
|
||||
// `initialized` arrives with no id after every handshake. Answering it with
|
||||
// a result is a protocol error on our side.
|
||||
app := mcpApp(t, session, readTool("stuck"))
|
||||
if out := rpc(t, app, `{"jsonrpc":"2.0","method":"notifications/initialized"}`); out != nil {
|
||||
t.Fatalf("a notification was answered: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownMethodIsRefusedByName(t *testing.T) {
|
||||
app := mcpApp(t, session, readTool("stuck"))
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"resources/list"}`)
|
||||
|
||||
rpcErr, _ := out["error"].(map[string]any)
|
||||
if rpcErr == nil {
|
||||
t.Fatalf("an unsupported method succeeded: %v", out)
|
||||
}
|
||||
if !strings.Contains(rpcErr["message"].(string), "resources/list") {
|
||||
t.Fatalf("the refusal does not say what was asked for: %v", rpcErr)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The same door, the same guard ─────────────────────────────────────── */
|
||||
|
||||
func TestNoSessionMeansNoTools(t *testing.T) {
|
||||
// There is no API key and no second credential. Whoever holds a console
|
||||
// session gets what that session gets; somebody with none gets nothing.
|
||||
app := mcpApp(t, nil, readTool("stuck"))
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`)
|
||||
|
||||
if out["error"] == nil {
|
||||
t.Fatalf("an unauthenticated call was answered: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDoorOffersOnlyTheAgentsAllowList(t *testing.T) {
|
||||
// The registry's allow-list, not a second one written here.
|
||||
registry := tools.New(nil)
|
||||
_ = registry.Register(readTool("stuck"))
|
||||
_ = registry.Register(readTool("secret"))
|
||||
|
||||
agents := map[string]services.Agent{"console": {Name: "console", Tools: []string{"stuck"}}}
|
||||
ctl := NewMCPController(registry, agents)
|
||||
app := fiber.New()
|
||||
app.Post("/mcp", func(c *fiber.Ctx) error {
|
||||
c.Locals(middleware.WebLocalsKey, *session)
|
||||
return ctl.Handle(c)
|
||||
})
|
||||
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
result, _ := out["result"].(map[string]any)
|
||||
listed, _ := result["tools"].([]any)
|
||||
if len(listed) != 1 {
|
||||
t.Fatalf("the door listed %d tools, not the agent's one", len(listed))
|
||||
}
|
||||
|
||||
// And calling the one it did not list is refused.
|
||||
denied := rpc(t, app, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"secret"}}`)
|
||||
if denied["error"] == nil {
|
||||
t.Fatalf("a tool off the allow-list was callable: %v", denied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCallerComesFromTheSessionNotTheRequest(t *testing.T) {
|
||||
// Same property as the console door: the model, or whatever is driving this
|
||||
// client, has no say in whose data is read.
|
||||
var seen tools.Caller
|
||||
tool := readTool("stuck")
|
||||
tool.Handler = func(_ context.Context, req tools.Request) (tools.Result, error) {
|
||||
seen = req.Caller
|
||||
return tools.Result{Count: 0, Scope: "all branches"}, nil
|
||||
}
|
||||
|
||||
app := mcpApp(t, session, tool)
|
||||
rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"tenantid":916}}}`)
|
||||
|
||||
if seen.Tenantid != 1147 {
|
||||
t.Fatalf("the tool ran for tenant %d", seen.Tenantid)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Read-only ─────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestAWriteIsNotEvenListed(t *testing.T) {
|
||||
// Described and then refused reads to a client as the server malfunctioning.
|
||||
app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something"))
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
|
||||
result, _ := out["result"].(map[string]any)
|
||||
for _, listed := range result["tools"].([]any) {
|
||||
entry, _ := listed.(map[string]any)
|
||||
if entry["name"] == "change_something" {
|
||||
t.Fatal("a write tool was offered over MCP")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWriteCannotBeCalledAndTheRefusalSaysWhere(t *testing.T) {
|
||||
// The write's execute half fails the test if it runs. The refusal has to
|
||||
// point somewhere useful, or a person is stuck.
|
||||
app := mcpApp(t, session, readTool("stuck"), writeToolFor(t, "change_something"))
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"change_something"}}`)
|
||||
|
||||
rpcErr, _ := out["error"].(map[string]any)
|
||||
if rpcErr == nil {
|
||||
t.Fatalf("a write was accepted over MCP: %v", out)
|
||||
}
|
||||
if !strings.Contains(rpcErr["message"].(string), "console") {
|
||||
t.Fatalf("the refusal does not say where changes happen: %v", rpcErr)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Results ───────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestAResultCarriesItsRowsAndItsCaveats(t *testing.T) {
|
||||
tool := readTool("stuck")
|
||||
tool.Handler = func(context.Context, tools.Request) (tools.Result, error) {
|
||||
return tools.Result{
|
||||
Rows: []map[string]any{{"id": 1}}, Count: 60, Truncated: true,
|
||||
Note: "60 jobs are waiting; the 50 longest are listed.",
|
||||
Scope: "all branches", Source: "/admin/dispatch",
|
||||
}, nil
|
||||
}
|
||||
app := mcpApp(t, session, tool)
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck"}}`)
|
||||
|
||||
result, _ := out["result"].(map[string]any)
|
||||
content, _ := result["content"].([]any)
|
||||
first, _ := content[0].(map[string]any)
|
||||
text, _ := first["text"].(string)
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||||
t.Fatalf("the content is not JSON: %v", err)
|
||||
}
|
||||
for _, want := range []string{"rows", "count", "covers", "truncated", "note", "see"} {
|
||||
if _, ok := payload[want]; !ok {
|
||||
t.Fatalf("the result dropped %q: %v", want, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedToolIsAResultNotATransportError(t *testing.T) {
|
||||
// The protocol's own distinction: a transport fault is an RPC error, and
|
||||
// "that tool needs a branch" is an answer the client should show its user.
|
||||
app := mcpApp(t, session, readTool("stuck"))
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"stuck","arguments":{"limit":999}}}`)
|
||||
|
||||
if out["error"] != nil {
|
||||
t.Fatalf("a bad argument was reported as a transport fault: %v", out["error"])
|
||||
}
|
||||
result, _ := out["result"].(map[string]any)
|
||||
if result["isError"] != true {
|
||||
t.Fatalf("a refusal was reported as success: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSchemaIsSpelledTheWayMCPExpects(t *testing.T) {
|
||||
// The registry says `input_schema`; MCP says `inputSchema`. The rename lives
|
||||
// at the door so neither side dictates the other's vocabulary.
|
||||
app := mcpApp(t, session, readTool("stuck"))
|
||||
out := rpc(t, app, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
|
||||
result, _ := out["result"].(map[string]any)
|
||||
first, _ := result["tools"].([]any)[0].(map[string]any)
|
||||
if _, ok := first["inputSchema"]; !ok {
|
||||
t.Fatalf("no inputSchema on a listed tool: %v", first)
|
||||
}
|
||||
if _, stillSnake := first["input_schema"]; stillSnake {
|
||||
t.Fatal("the registry's spelling leaked through the door")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedJSONIsRefusedWithoutPanicking(t *testing.T) {
|
||||
app := mcpApp(t, session, readTool("stuck"))
|
||||
for _, body := range []string{"", "{", "not json", `{"jsonrpc":"2.0","id":1}`} {
|
||||
out := rpc(t, app, body)
|
||||
if out != nil && out["error"] == nil && out["result"] == nil {
|
||||
t.Fatalf("%q produced neither a result nor an error", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,117 +16,126 @@ import (
|
||||
|
||||
type OrderController struct {
|
||||
orderService services.OrderService
|
||||
// Asked whether a chosen delivery window is still open. Held here rather
|
||||
// than reimplemented, so the app's list and this check can never disagree
|
||||
// about where a window ends.
|
||||
deliverySlotService services.DeliverySlotService
|
||||
}
|
||||
|
||||
func NewOrderController(orderService services.OrderService) *OrderController {
|
||||
return &OrderController{orderService: orderService}
|
||||
func NewOrderController(
|
||||
orderService services.OrderService,
|
||||
deliverySlotService services.DeliverySlotService,
|
||||
) *OrderController {
|
||||
return &OrderController{
|
||||
orderService: orderService,
|
||||
deliverySlotService: deliverySlotService,
|
||||
}
|
||||
}
|
||||
|
||||
func (ctl *OrderController) GetOrders(c *fiber.Ctx) error {
|
||||
|
||||
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||
cid, _ := strconv.Atoi(c.Query("customerid"))
|
||||
mid, _ := strconv.Atoi(c.Query("moduleid"))
|
||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||
uid, _ := strconv.Atoi(c.Query("appuserid"))
|
||||
lid, _ := strconv.Atoi(c.Query("locationid"))
|
||||
configid, _ := strconv.Atoi(c.Query("configid"))
|
||||
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||
cid, _ := strconv.Atoi(c.Query("customerid"))
|
||||
mid, _ := strconv.Atoi(c.Query("moduleid"))
|
||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||
uid, _ := strconv.Atoi(c.Query("appuserid"))
|
||||
lid, _ := strconv.Atoi(c.Query("locationid"))
|
||||
configid, _ := strconv.Atoi(c.Query("configid"))
|
||||
|
||||
stat := c.Query("status")
|
||||
fdate := c.Query("fromdate")
|
||||
tdate := c.Query("todate")
|
||||
keyword := c.Query("keyword")
|
||||
stat := c.Query("status")
|
||||
fdate := c.Query("fromdate")
|
||||
tdate := c.Query("todate")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||
|
||||
if pageno <= 0 {
|
||||
pageno = 1
|
||||
}
|
||||
if pagesize <= 0 {
|
||||
pagesize = 10
|
||||
}
|
||||
if pageno <= 0 {
|
||||
pageno = 1
|
||||
}
|
||||
if pagesize <= 0 {
|
||||
pagesize = 10
|
||||
}
|
||||
|
||||
// Build dynamic query struct
|
||||
query := models.DeliveryQuery{
|
||||
Tenantid: tid,
|
||||
Partnerid: pid,
|
||||
Customerid: cid,
|
||||
Moduleid: mid,
|
||||
Applocationid: aid,
|
||||
Locationid: lid,
|
||||
UserID: uid,
|
||||
Appuserid: uid,
|
||||
Configid: configid,
|
||||
Fromdate: fdate,
|
||||
ToDate: tdate,
|
||||
Status: stat,
|
||||
Keyword: keyword,
|
||||
Pageno: pageno,
|
||||
Pagesize: pagesize,
|
||||
}
|
||||
// Build dynamic query struct
|
||||
query := models.DeliveryQuery{
|
||||
Tenantid: tid,
|
||||
Partnerid: pid,
|
||||
Customerid: cid,
|
||||
Moduleid: mid,
|
||||
Applocationid: aid,
|
||||
Locationid: lid,
|
||||
UserID: uid,
|
||||
Appuserid: uid,
|
||||
Configid: configid,
|
||||
Fromdate: fdate,
|
||||
ToDate: tdate,
|
||||
Status: stat,
|
||||
Keyword: keyword,
|
||||
Pageno: pageno,
|
||||
Pagesize: pagesize,
|
||||
}
|
||||
|
||||
var (
|
||||
orders []models.OrderInfo
|
||||
err error
|
||||
)
|
||||
var (
|
||||
orders []models.OrderInfo
|
||||
err error
|
||||
)
|
||||
|
||||
// --------------------------
|
||||
// 🔥 DYNAMIC ROUTING LOGIC
|
||||
// --------------------------
|
||||
// --------------------------
|
||||
// 🔥 DYNAMIC ROUTING LOGIC
|
||||
// --------------------------
|
||||
|
||||
if tid != 0 && lid != 0 {
|
||||
// ⭐ Both tenant & location → special handler
|
||||
orders, err = ctl.orderService.GetTenantLocationOrders(query)
|
||||
if tid != 0 && lid != 0 {
|
||||
// ⭐ Both tenant & location → special handler
|
||||
orders, err = ctl.orderService.GetTenantLocationOrders(query)
|
||||
|
||||
} else if tid != 0 {
|
||||
// Tenant only
|
||||
orders, err = ctl.orderService.GetTenantOrders(query)
|
||||
} else if tid != 0 {
|
||||
// Tenant only
|
||||
orders, err = ctl.orderService.GetTenantOrders(query)
|
||||
|
||||
} else if pid != 0 {
|
||||
// Partner
|
||||
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
|
||||
} else if pid != 0 {
|
||||
// Partner
|
||||
orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword)
|
||||
|
||||
} else if cid != 0 {
|
||||
// Customer
|
||||
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
|
||||
} else if cid != 0 {
|
||||
// Customer
|
||||
orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword)
|
||||
|
||||
} else if aid != 0 {
|
||||
// App-location orders
|
||||
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
|
||||
} else if aid != 0 {
|
||||
// App-location orders
|
||||
orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword)
|
||||
|
||||
} else if uid != 0 {
|
||||
// User orders
|
||||
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
|
||||
} else if uid != 0 {
|
||||
// User orders
|
||||
orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword)
|
||||
|
||||
} else {
|
||||
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
|
||||
// Refuse instead of silently returning every order in the database.
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"status": false,
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid).
|
||||
// Refuse instead of silently returning every order in the database.
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"status": false,
|
||||
"code": http.StatusBadRequest,
|
||||
"message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required",
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"status": false,
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"status": false,
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"status": true,
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"details": orders,
|
||||
})
|
||||
return c.JSON(fiber.Map{
|
||||
"status": true,
|
||||
"code": http.StatusOK,
|
||||
"message": "Success",
|
||||
"details": orders,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
||||
tid, _ := strconv.Atoi(c.Query("tenantid"))
|
||||
pid, _ := strconv.Atoi(c.Query("partnerid"))
|
||||
@@ -160,7 +169,6 @@ func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error {
|
||||
tenantIDStr := c.Query("tenantid")
|
||||
tenantID, _ := strconv.Atoi(tenantIDStr)
|
||||
@@ -350,6 +358,29 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
|
||||
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
@@ -592,7 +623,7 @@ func (ctl *OrderController) GetTimeSeries(c *fiber.Ctx) error {
|
||||
"status": false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
if granularity == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
|
||||
@@ -73,6 +73,40 @@ func (ctl *PartnerController) GetPartners(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRiderShift opens a working window in a delivery region.
|
||||
//
|
||||
// Riders cannot be hired without one, and until this existed the table could
|
||||
// only be read — a region that shipped with no shift rows was a region no rider
|
||||
// could ever be added to, with nothing in the product able to change that.
|
||||
//
|
||||
// The region comes from the body rather than the query because this is a write
|
||||
// and the whole shift is one object; `getridershifts` beside it reads the same
|
||||
// id from a param, which is the existing convention for reads here.
|
||||
func (ctl *PartnerController) CreateRiderShift(c *fiber.Ctx) error {
|
||||
|
||||
var shift models.Ridershifts
|
||||
if err := c.BodyParser(&shift); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"status": false, "code": http.StatusBadRequest, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
result, err := ctl.partnerService.CreateRiderShift(shift)
|
||||
if err != nil {
|
||||
// 400, not 500. Every failure here is something the person typed — a
|
||||
// region that is not configured, a window that already exists, a time
|
||||
// that is not a time — and each message says which.
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"status": false, "code": http.StatusBadRequest, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||
"status": true, "code": http.StatusCreated,
|
||||
"message": "Shift created", "details": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctl *PartnerController) GetRiderShifts(c *fiber.Ctx) error {
|
||||
|
||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||
|
||||
@@ -771,6 +771,20 @@ func posClaimError(c *fiber.Ctx, err error) error {
|
||||
// once the console can hold a session.
|
||||
|
||||
// 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 {
|
||||
if tenantID <= 0 {
|
||||
return fmt.Errorf("tenantid is required")
|
||||
@@ -921,9 +935,18 @@ func (ctl *PosController) WebListStaffShifts(c *fiber.Ctx) error {
|
||||
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||
|
||||
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
||||
// Tenant-scoped, because a shift belongs to the business rather than to one
|
||||
// of its shops. An outlet may still be named to narrow the list, and is
|
||||
// checked for ownership when it is — omitting it is not a way to read
|
||||
// somebody else's, because the tenant comes from the signed session.
|
||||
if err := ctl.posTenantScope(tenantID); err != nil {
|
||||
return posBadRequest(c, err)
|
||||
}
|
||||
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"))
|
||||
@@ -944,9 +967,19 @@ func (ctl *PosController) WebCreateStaffShift(c *fiber.Ctx) error {
|
||||
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||
}
|
||||
|
||||
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||
// A shift with no outlet belongs to the tenant and every branch it owns,
|
||||
// which is the ordinary case — a business that works 07:00–15:00 works
|
||||
// those hours at every shop, and entering them per outlet is how the third
|
||||
// branch quietly ends up on 07:00–15:30. An outlet is named only when one
|
||||
// shop really does differ, and is checked for ownership then.
|
||||
if err := ctl.posTenantScope(req.Tenantid); err != nil {
|
||||
return posBadRequest(c, err)
|
||||
}
|
||||
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 {
|
||||
@@ -982,3 +1015,20 @@ func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
|
||||
"message": "Shift updated", "details": shift,
|
||||
})
|
||||
}
|
||||
|
||||
// AuthAdoption reports how much of the till fleet is carrying a session token.
|
||||
//
|
||||
// The answer to "is it safe to set POS_AUTH_REQUIRED=true yet". Every outlet it
|
||||
// lists is a till that would stop being able to ring a bill the moment
|
||||
// enforcement goes on.
|
||||
//
|
||||
// Behind the web session guard on purpose: that list is also a map of which
|
||||
// shops are reachable without a credential today.
|
||||
func (ctl *PosController) AuthAdoption(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"message": "Success",
|
||||
"details": middleware.PosAdoptionReport(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,7 +191,14 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.productService.CreateProduct(product); err != nil {
|
||||
// The created row, not the parsed body.
|
||||
//
|
||||
// This returned the struct it had just parsed off the request, which by
|
||||
// definition carried `productid: 0` — the id is assigned by the database a
|
||||
// moment later and was never read back. Every caller that needed the id
|
||||
// went and looked the product up again by SKU.
|
||||
created, err := ctl.productService.CreateProduct(product)
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusInternalServerError,
|
||||
"message": "Failed to create product",
|
||||
@@ -203,7 +210,7 @@ func (ctl *ProductController) CreateProduct(c *fiber.Ctx) error {
|
||||
"code": http.StatusCreated,
|
||||
"message": "Product created successfully",
|
||||
"status": true,
|
||||
"data": product,
|
||||
"data": created,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1001,3 +1008,44 @@ func (ctl *ProductController) RelinkCatalogue(c *fiber.Ctx) error {
|
||||
}
|
||||
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report})
|
||||
}
|
||||
|
||||
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||
//
|
||||
// Scoped twice over: `middleware.WebAuth` refuses a request naming a tenant the
|
||||
// session does not own — it reads `tenantid` from the body as well as the query
|
||||
// — and the repository's UPDATE carries the tenant in its WHERE clause. A write
|
||||
// that changes what a shopper sees should not rest on one guard being mounted
|
||||
// correctly.
|
||||
func (ctl *ProductController) SetShowHealthScore(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Productid int `json:"productid"`
|
||||
// A POINTER so a body that forgot the field is refused rather than read
|
||||
// as "turn it off". The whole point of this endpoint is the difference
|
||||
// between the two.
|
||||
Showhealthscore *bool `json:"showhealthscore"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
if req.Showhealthscore == nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "showhealthscore is required: send true or false.",
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.productService.SetShowHealthScore(req.Tenantid, req.Productid, *req.Showhealthscore); err != nil {
|
||||
// 409, not 500. "No such product for this business" is a fact the
|
||||
// caller can act on, not a fault in the server.
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||
})
|
||||
}
|
||||
|
||||
210
controllers/resendInvite_test.go
Normal file
210
controllers/resendInvite_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/middleware"
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
/*
|
||||
Who may re-issue a first-password link, and for whom.
|
||||
|
||||
This endpoint mints a credential, so most of what matters is what it refuses.
|
||||
The service layer refuses the business cases — an account that already has a
|
||||
password, a tenant whose primary email matches no login — and those are covered
|
||||
in `services/resendInvite_test.go`. This file is about the door: who gets
|
||||
through it, and which account a request actually names.
|
||||
*/
|
||||
|
||||
// resendService answers both resends and records which was called. Only the two
|
||||
// methods under test are real; the rest of TenantService is embedded nil, which
|
||||
// panics if anything else is reached — exactly the signal wanted.
|
||||
type resendService struct {
|
||||
services.TenantService
|
||||
byTenant int
|
||||
byUser int
|
||||
outcome services.InviteOutcome
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *resendService) ResendInvite(tenantID int) (services.InviteOutcome, error) {
|
||||
s.byTenant = tenantID
|
||||
return s.outcome, s.err
|
||||
}
|
||||
|
||||
func (s *resendService) ResendInviteToUser(userID int) (services.InviteOutcome, error) {
|
||||
s.byUser = userID
|
||||
return s.outcome, s.err
|
||||
}
|
||||
|
||||
func resendApp(t *testing.T, service *resendService) *fiber.App {
|
||||
t.Helper()
|
||||
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
||||
|
||||
app := fiber.New()
|
||||
// The real guard, mounted as routes.go mounts it: this endpoint sits behind
|
||||
// the session, and the handler then requires a platform account on top.
|
||||
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
|
||||
app.Post("/live/api/v1/web/tenants/resendinvite", NewTenantController(service).ResendInvite)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// staffToken is a signed session for a Nearle staff account.
|
||||
//
|
||||
// `Superadmin` is the signal, and it is minted from `app_users.issuperadmin` —
|
||||
// not from the tenant being zero and not from a role id. Both of those look
|
||||
// equivalent and are not: `app_roles` calls roleid 1 "Super admin" and
|
||||
// onboarding wrote 1 for every shop owner, and a zero tenant is what an
|
||||
// unfilled column looks like. See `utils.WebClaims`.
|
||||
func staffToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintWebToken(utils.WebClaims{
|
||||
Userid: 12, Roleid: 1, Configid: 1, Superadmin: true,
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// merchantToken is a signed session for a shop's own admin.
|
||||
func merchantToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintWebToken(utils.WebClaims{
|
||||
Userid: 904, Tenantid: 1147, Roleid: 3, Configid: 1,
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func postAs(t *testing.T, app *fiber.App, token, body string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest("POST", "/live/api/v1/web/tenants/resendinvite",
|
||||
strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("resendinvite: %v", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
func TestAMerchantCannotResendAnything(t *testing.T) {
|
||||
// A merchant's session is pinned to their own tenant, so the worst they could
|
||||
// do is re-invite themselves — and the service refuses that, because an
|
||||
// account signing in to ask already has a password. Refusing here as well
|
||||
// means the endpoint does not rely on two other checks to make the wrong case
|
||||
// impossible.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, merchantToken(t), `{"tenantid":1147}`)
|
||||
|
||||
if status != 403 {
|
||||
t.Fatalf("a merchant was let through: %d %s", status, body)
|
||||
}
|
||||
if service.byTenant != 0 || service.byUser != 0 {
|
||||
t.Fatal("the service was reached by a caller who should have been refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearleStaffCanResendToATenantsOwner(t *testing.T) {
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
|
||||
|
||||
if status != 200 {
|
||||
t.Fatalf("refused Nearle staff: %d %s", status, body)
|
||||
}
|
||||
if service.byTenant != 1147 {
|
||||
t.Fatalf("resent for tenant %d, want 1147", service.byTenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUseridNamesOnePersonRatherThanTheOwner(t *testing.T) {
|
||||
// The reason this parameter exists. Staff added after onboarding, and the
|
||||
// login every branch spawns, are created with no password too — and a
|
||||
// business has many of them, so "the tenant's invitation" cannot reach them.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{"userid":7781}`)
|
||||
|
||||
if status != 200 {
|
||||
t.Fatalf("refused: %d %s", status, body)
|
||||
}
|
||||
if service.byUser != 7781 {
|
||||
t.Fatalf("resent for user %d, want 7781", service.byUser)
|
||||
}
|
||||
if service.byTenant != 0 {
|
||||
t.Fatal("emailed the owner when a person was named")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUseridWinsOverATenantid(t *testing.T) {
|
||||
// A caller that sent a person's id meant that person. Falling back to the
|
||||
// owner would be the wrong mailbox with nothing on the response to say so.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
if status, body := postAs(t, app, staffToken(t), `{"tenantid":1147,"userid":7781}`); status != 200 {
|
||||
t.Fatalf("refused: %d %s", status, body)
|
||||
}
|
||||
if service.byUser != 7781 || service.byTenant != 0 {
|
||||
t.Fatalf("resolved to the wrong account: user=%d tenant=%d", service.byUser, service.byTenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyBodyIsRefusedRatherThanSentToTenantZero(t *testing.T) {
|
||||
// `{}` parses cleanly into two zeroes. Without this check it would reach the
|
||||
// service as tenant 0 and come back "tenant 0 has no account matching its
|
||||
// primary email address", which describes nothing the caller did.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{}`)
|
||||
|
||||
if status != 400 {
|
||||
t.Fatalf("an empty request was accepted: %d %s", status, body)
|
||||
}
|
||||
if service.byTenant != 0 || service.byUser != 0 {
|
||||
t.Fatal("the service was called with nothing to act on")
|
||||
}
|
||||
if !strings.Contains(body, "tenantid") || !strings.Contains(body, "userid") {
|
||||
t.Errorf("the refusal does not say what to send: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailThatDidNotLeaveIsReportedAsAFailure(t *testing.T) {
|
||||
// The operator pressed a button expecting an email to go. "Success" with no
|
||||
// mail sent is the one answer they cannot act on.
|
||||
service := &resendService{outcome: services.InviteOutcome{
|
||||
Sent: false, Reason: "MAIL_HOST is not set",
|
||||
}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
|
||||
|
||||
if status != 409 {
|
||||
t.Fatalf("an unsent invitation was reported as sent: %d %s", status, body)
|
||||
}
|
||||
if !strings.Contains(body, "MAIL_HOST") {
|
||||
t.Errorf("the reason was lost: %s", body)
|
||||
}
|
||||
}
|
||||
101
controllers/scanController.go
Normal file
101
controllers/scanController.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"net/http"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// ScanController is the scan-to-order surface for the customer app:
|
||||
//
|
||||
// POST /v1/mob/scan/lookup a label → the product, and which of my stores has it
|
||||
// POST /v1/mob/scan/confirm I picked a store and a size → still there? else where?
|
||||
// GET /v1/mob/scan/stores my stores, nearest first
|
||||
//
|
||||
// Business outcomes ("out of stock", "not registered with that store") are
|
||||
// 200s with a reason in the body: the app renders them, it does not retry
|
||||
// them. HTTP errors are reserved for a request that cannot be served at all.
|
||||
type ScanController struct {
|
||||
scanService services.ScanService
|
||||
}
|
||||
|
||||
func NewScanController(scanService services.ScanService) *ScanController {
|
||||
return &ScanController{scanService: scanService}
|
||||
}
|
||||
|
||||
func (ctl *ScanController) Lookup(c *fiber.Ctx) error {
|
||||
var req models.ScanLookupRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return scanBadRequest(c, "Invalid request body")
|
||||
}
|
||||
resp, err := ctl.scanService.Lookup(c.Context(), req)
|
||||
if err != nil {
|
||||
return scanError(c, err, "Could not look up that product")
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"message": resp.Message,
|
||||
"details": resp,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctl *ScanController) Confirm(c *fiber.Ctx) error {
|
||||
var req models.ScanConfirmRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return scanBadRequest(c, "Invalid request body")
|
||||
}
|
||||
resp, err := ctl.scanService.Confirm(c.Context(), req)
|
||||
if err != nil {
|
||||
return scanError(c, err, "Could not check that store")
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"message": resp.Message,
|
||||
"details": resp,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctl *ScanController) Stores(c *fiber.Ctx) error {
|
||||
customerid, _ := c.QueryInt("customerid"), 0
|
||||
stores, err := ctl.scanService.Stores(c.Context(), customerid,
|
||||
models.FlexibleString(c.Query("latitude")), models.FlexibleString(c.Query("longitude")))
|
||||
if err != nil {
|
||||
return scanError(c, err, "Could not list your stores")
|
||||
}
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"code": http.StatusOK,
|
||||
"status": true,
|
||||
"message": "Success",
|
||||
"details": stores,
|
||||
})
|
||||
}
|
||||
|
||||
func scanBadRequest(c *fiber.Ctx, msg string) error {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest,
|
||||
"status": false,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
func scanError(c *fiber.Ctx, err error, fallback string) error {
|
||||
code, msg := http.StatusInternalServerError, fallback
|
||||
switch {
|
||||
case errors.Is(err, services.ErrScanBadRequest):
|
||||
code, msg = http.StatusBadRequest, err.Error()
|
||||
case errors.Is(err, services.ErrScanCustomerNotFound):
|
||||
code, msg = http.StatusNotFound, "Customer not found"
|
||||
case errors.Is(err, services.ErrScanCatalogueDown):
|
||||
code, msg = http.StatusServiceUnavailable, "Product search is temporarily unavailable"
|
||||
}
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"code": code,
|
||||
"status": false,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
302
controllers/setPassword_test.go
Normal file
302
controllers/setPassword_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
fiberv1 "github.com/gofiber/fiber"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
/*
|
||||
Setting a first password, with no session and no way to get one.
|
||||
|
||||
A branch login created by `createtenantlocation` arrives with an empty password.
|
||||
The console signs in, is told to set one, and does — and until now it did that
|
||||
through `PUT /users/update`, which is behind the session guard. Once
|
||||
WEB_AUTH_REQUIRED began defaulting on, that answered
|
||||
|
||||
401 "a session token is required; sign in again"
|
||||
|
||||
to somebody who could not sign in, because signing in needs the password they
|
||||
were trying to set. Every such account was unusable, and the 401 read as an
|
||||
authentication bug rather than a deadlock.
|
||||
|
||||
`publicWebPaths` had named `/users/setpassword` since the guard was written. The
|
||||
path was reserved; the handler never existed, so it answered 404.
|
||||
|
||||
The tests that matter are about the two halves: it must be reachable WITHOUT a
|
||||
session, and it must refuse everything except the one case it exists for.
|
||||
*/
|
||||
|
||||
type fakePasswords struct {
|
||||
// set records what reached the write, so a refusal can be shown to have
|
||||
// refused rather than merely reported.
|
||||
set []string
|
||||
lastUserid int
|
||||
refuseIt error
|
||||
}
|
||||
|
||||
func (f *fakePasswords) SetInitialPassword(userid int, password string) error {
|
||||
f.lastUserid = userid
|
||||
if f.refuseIt != nil {
|
||||
return f.refuseIt
|
||||
}
|
||||
f.set = append(f.set, password)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The rest of UserService, unused here.
|
||||
func (f *fakePasswords) GetAllUsers(int, int, int, int, string) ([]models.UserInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakePasswords) GetUserByID(int) (models.UserInfo, error) { return models.UserInfo{}, nil }
|
||||
func (f *fakePasswords) Login(models.User) (models.UserInfo, error) {
|
||||
return models.UserInfo{}, nil
|
||||
}
|
||||
func (f *fakePasswords) TenantLogin(models.User) (models.TenantUserInfo, error) {
|
||||
return models.TenantUserInfo{}, nil
|
||||
}
|
||||
func (f *fakePasswords) UpdateStaff(models.User) error { return nil }
|
||||
func (f *fakePasswords) AppLogin(models.User) (models.TenantUserInfo, fiberv1.Map, error) {
|
||||
return models.TenantUserInfo{}, fiberv1.Map{}, nil
|
||||
}
|
||||
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, services.InviteOutcome, error) {
|
||||
return models.UserInfo{}, services.InviteOutcome{}, nil
|
||||
}
|
||||
|
||||
func passwordApp(t *testing.T, service *fakePasswords) *fiber.App {
|
||||
t.Helper()
|
||||
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
||||
|
||||
app := fiber.New()
|
||||
// The real guard, mounted exactly as routes.go mounts it. The point of this
|
||||
// file is which side of it this endpoint lands on.
|
||||
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
|
||||
app.Post("/live/api/v1/web/users/setpassword", NewUserController(service).SetPassword)
|
||||
app.Put("/live/api/v1/web/users/update", NewUserController(service).UpdateStaff)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func send(t *testing.T, app *fiber.App, method, path, body string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
func TestAFirstPasswordCanBeSetWithoutASession(t *testing.T) {
|
||||
// The whole point. There is no session to present and no way to obtain one.
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
if status == fiber.StatusUnauthorized {
|
||||
t.Fatalf("the guard blocked the one call that cannot present a token: %s", body)
|
||||
}
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("HTTP %d: %s", status, body)
|
||||
}
|
||||
if len(service.set) != 1 || service.set[0] != "opensesame" {
|
||||
t.Fatalf("the password did not reach the service: %v", service.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGeneralUpdateStaysBehindTheGuard(t *testing.T) {
|
||||
// The reason this is a new endpoint rather than `/users/update` being
|
||||
// opened up: that one writes whatever struct it is handed, so unauthenticated
|
||||
// it would let anybody change any field of any user.
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "PUT", "/live/api/v1/web/users/update",
|
||||
`{"userid":904,"roleid":1,"tenantid":9}`)
|
||||
|
||||
if status != fiber.StatusUnauthorized {
|
||||
t.Fatalf("an untokened user update was not refused: %d %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountThatAlreadyHasOneIsRefusedAsAConflict(t *testing.T) {
|
||||
// 409, never 401. Nothing here is an authentication failure — the caller is
|
||||
// not supposed to have a session — and a 401 would send the console into its
|
||||
// sign-out-and-reload path on the one screen with nothing to sign out of.
|
||||
service := &fakePasswords{refuseIt: errAlreadySet{}}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
if status != fiber.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d: %s", status, body)
|
||||
}
|
||||
if len(service.set) != 0 {
|
||||
t.Fatalf("a refused call still wrote: %v", service.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRefusalDoesNotSayWhichAccountsExist(t *testing.T) {
|
||||
// "No such user" and "already has a password" must read identically, or
|
||||
// this becomes a way to ask whether a userid exists and whether it has been
|
||||
// set up — unauthenticated, one request at a time.
|
||||
service := &fakePasswords{refuseIt: errAlreadySet{}}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
_, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
for _, leak := range []string{"not found", "no such", "does not exist"} {
|
||||
if strings.Contains(strings.ToLower(body), leak) {
|
||||
t.Fatalf("the refusal distinguishes a missing account: %s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMalformedBodyIsRefusedWithoutPanicking(t *testing.T) {
|
||||
app := passwordApp(t, &fakePasswords{})
|
||||
|
||||
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword", `{"userid":`)
|
||||
if status != fiber.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAnswerIsTheEnvelopeTheConsoleUnwraps(t *testing.T) {
|
||||
// A handler answering at the top level passes a service test and hands the
|
||||
// console `undefined`.
|
||||
_, body := send(t, passwordApp(t, &fakePasswords{}), "POST",
|
||||
"/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
var envelope struct {
|
||||
Status bool `json:"status"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &envelope); err != nil {
|
||||
t.Fatalf("not an envelope: %s", body)
|
||||
}
|
||||
if !envelope.Status || envelope.Code != fiber.StatusOK {
|
||||
t.Fatalf("success did not read as success: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
type errAlreadySet struct{}
|
||||
|
||||
func (errAlreadySet) Error() string {
|
||||
return "that account cannot have its password set here — it may already have one"
|
||||
}
|
||||
|
||||
func (f *fakePasswords) TenantWebLogin(models.User) (models.TenantUserInfo, map[string]interface{}) {
|
||||
return models.TenantUserInfo{}, map[string]interface{}{}
|
||||
}
|
||||
func (f *fakePasswords) DeleteUser(int) error { return nil }
|
||||
|
||||
// invite mints a real invitation for the test's account.
|
||||
//
|
||||
// A helper rather than a literal, because the token is signed: a hand-written
|
||||
// string would test the refusal path and nothing else, and the point of these
|
||||
// is what happens when a genuine invitation arrives.
|
||||
func invite(t *testing.T, userid int) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintInviteToken(utils.InviteClaims{Userid: userid, Tenantid: 1147}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("minting an invitation: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/*
|
||||
The invitation replaced a userid, and that was a security fix rather than a
|
||||
tidy-up.
|
||||
|
||||
`applogin` answers a POST carrying an email and no password with 409 and the
|
||||
userid, for any account that has not set one. So the recipe was: know a
|
||||
merchant's primary email — usually printed on their shopfront — POST it, receive
|
||||
their userid, set their password, own the business's admin account. No guessing
|
||||
at any step, and the empty-password check was no defence because an un-set-up
|
||||
account is exactly what such an attacker wants.
|
||||
*/
|
||||
|
||||
func TestAUseridIsNoLongerEnoughToSetAPassword(t *testing.T) {
|
||||
// The hole, asserted closed. A body carrying a userid and no invitation
|
||||
// must not set anything, whatever the userid is.
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"userid":904,"password":"opensesame"}`)
|
||||
|
||||
if status == fiber.StatusOK {
|
||||
t.Fatalf("a bare userid still set a password: %s", body)
|
||||
}
|
||||
if len(service.set) != 0 {
|
||||
t.Fatalf("a bare userid reached the service: %v", service.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationSetsThePasswordForTheAccountItNames(t *testing.T) {
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("HTTP %d: %s", status, body)
|
||||
}
|
||||
if len(service.set) != 1 || service.set[0] != "opensesame" {
|
||||
t.Fatalf("the password did not reach the service: %v", service.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUseridComesFromTheSignatureNotTheRequest(t *testing.T) {
|
||||
// An invitation for 904 with a `userid` field claiming 999 must set 904's
|
||||
// password. If the body could override it, the token would be decoration.
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+invite(t, 904)+`","userid":999,"password":"opensesame"}`)
|
||||
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("a valid invitation was refused: %d", status)
|
||||
}
|
||||
if service.lastUserid != 904 {
|
||||
t.Fatalf("the request's userid won: set the password for %d", service.lastUserid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAForgedInvitationIsRefused(t *testing.T) {
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
for _, token := range []string{"", "i1.forged.signature", "not-a-token", "w1.a.b"} {
|
||||
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+token+`","password":"opensesame"}`)
|
||||
if status == fiber.StatusOK {
|
||||
t.Fatalf("%q was accepted as an invitation", token)
|
||||
}
|
||||
}
|
||||
if len(service.set) != 0 {
|
||||
t.Fatalf("a forged invitation wrote: %v", service.set)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package controllers
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"net/http"
|
||||
@@ -44,6 +45,25 @@ func (ctl *TenantController) SearchTenant(c *fiber.Ctx) error {
|
||||
func (ctl *TenantController) GetAllTenants(c *fiber.Ctx) error {
|
||||
pageno, _ := strconv.Atoi(c.Query("pageno"))
|
||||
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
|
||||
|
||||
// Paging is defaulted, not required.
|
||||
//
|
||||
// The repository builds LIMIT/OFFSET from these directly, so a caller that
|
||||
// omitted either — or sent pageno=0 — got an empty result reported as
|
||||
// `code 200, status true, message "Success"`. "There are no tenants on the
|
||||
// platform" and "you forgot a query parameter" are very different answers
|
||||
// and this endpoint gave the first for the second.
|
||||
//
|
||||
// Defaulted rather than rejected with a 400: every existing caller that
|
||||
// works today keeps working, and a platform list with no paging asked for
|
||||
// has an obvious right answer — the first page.
|
||||
if pageno < 1 {
|
||||
pageno = 1
|
||||
}
|
||||
if pagesize < 1 {
|
||||
pagesize = 50
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
aid, _ := strconv.Atoi(c.Query("applocationid"))
|
||||
tenanttype := c.Query("tenanttype")
|
||||
@@ -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
|
||||
// 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
|
||||
@@ -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{
|
||||
"code": http.StatusCreated,
|
||||
"message": "Staff created successfully",
|
||||
"status": true,
|
||||
"code": http.StatusCreated,
|
||||
"message": "Staff created successfully",
|
||||
"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.Error() == "Tenant Already Exists" {
|
||||
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{
|
||||
"code": 201,
|
||||
"status": true,
|
||||
"message": "Successfully Created",
|
||||
"details": result,
|
||||
"invited": invite.Sent,
|
||||
// Omitted when it sent, so a successful onboarding carries no apology.
|
||||
"invitereason": invite.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -757,3 +795,79 @@ func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
|
||||
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||
})
|
||||
}
|
||||
|
||||
// ResendInvite re-issues a merchant's first-password link.
|
||||
//
|
||||
// ── Why this is platform staff only ─────────────────────────────────────────
|
||||
//
|
||||
// It mints a credential. `middleware.WebAuth` already pins a merchant's session
|
||||
// to their own tenant, so a shop could at most re-invite itself — but the
|
||||
// account it would be inviting is the one signing in to ask, which can only
|
||||
// happen if that account already has a password, and the service refuses that
|
||||
// case outright.
|
||||
//
|
||||
// So the only caller this is for is Nearle's own staff, chasing a merchant who
|
||||
// never received the mail. Saying so explicitly is better than relying on two
|
||||
// other checks to make the wrong case impossible.
|
||||
func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
|
||||
claims, ok := middleware.WebClaimsFrom(c)
|
||||
if !ok || !claims.IsPlatformAccount() {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"code": http.StatusForbidden, "status": false,
|
||||
"message": "Only Nearle staff can resend an invitation.",
|
||||
})
|
||||
}
|
||||
|
||||
// Either a tenant — meaning its owner, the one account onboarding created —
|
||||
// or one named person. Staff added later and the login every branch spawns
|
||||
// are created with no password too, and a business has many of them, so
|
||||
// "the tenant's invitation" cannot reach them.
|
||||
var req struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Userid int `json:"userid"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
if req.Tenantid <= 0 && req.Userid <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "Send a tenantid to re-invite the owner, or a userid to re-invite one person.",
|
||||
})
|
||||
}
|
||||
|
||||
// `userid` wins when both arrive. It is the more specific of the two, and a
|
||||
// caller that sent a person's id meant that person — silently emailing the
|
||||
// owner instead would be the wrong mailbox with no sign anything was off.
|
||||
var (
|
||||
outcome services.InviteOutcome
|
||||
err error
|
||||
)
|
||||
if req.Userid > 0 {
|
||||
outcome, err = ctl.tenantService.ResendInviteToUser(req.Userid)
|
||||
} else {
|
||||
outcome, err = ctl.tenantService.ResendInvite(req.Tenantid)
|
||||
}
|
||||
if err != nil {
|
||||
// 409, not 500. Every failure here is a business fact the operator can
|
||||
// act on — no such tenant, an address that matches no login, a merchant
|
||||
// already set up — rather than a fault in the server.
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
if !outcome.Sent {
|
||||
// The tenant is fine and the mail did not go. Reported as a failure
|
||||
// because the operator pressed a button expecting an email to leave,
|
||||
// and the reason names what to fix.
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict, "status": false, "message": outcome.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,63 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
"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 {
|
||||
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 {
|
||||
// Use resp.Code if present, fallback to 409
|
||||
code := http.StatusConflict
|
||||
@@ -189,6 +236,8 @@ func (ctl *UserController) AppLogin(c *fiber.Ctx) error {
|
||||
return c.Status(code).JSON(resp)
|
||||
}
|
||||
|
||||
attachWebSession(resp, info)
|
||||
|
||||
// ✅ Always return resp
|
||||
return c.Status(http.StatusOK).JSON(resp)
|
||||
}
|
||||
@@ -206,7 +255,7 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Call service
|
||||
info, err := ctl.userService.CreateUser(user)
|
||||
info, invite, err := ctl.userService.CreateUser(user)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"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{
|
||||
"code": http.StatusCreated,
|
||||
"status": true,
|
||||
"message": "Success",
|
||||
"details": info,
|
||||
"code": http.StatusCreated,
|
||||
"status": true,
|
||||
"message": "Success",
|
||||
"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)
|
||||
if code == fiber.StatusOK {
|
||||
resp["details"] = info
|
||||
attachWebSession(resp, info)
|
||||
}
|
||||
|
||||
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 (
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/config"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -23,14 +23,18 @@ var (
|
||||
// DATABASE CONNECTION
|
||||
// --------------------
|
||||
|
||||
func Connect() {
|
||||
// Connect opens the main database and then the optional catalogue database
|
||||
// and image store. Everything it needs has already been validated by
|
||||
// config.Load, so a missing variable can no longer surface here as a
|
||||
// log.Fatal halfway through boot.
|
||||
func Connect(cfg *config.Config) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata",
|
||||
mustEnv("DB_HOST"),
|
||||
mustEnv("DB_USER"),
|
||||
mustEnv("DB_PASSWORD"),
|
||||
mustEnv("DB_NAME"),
|
||||
getEnv("DB_PORT", "5433"),
|
||||
cfg.DB.Host,
|
||||
cfg.DB.User,
|
||||
cfg.DB.Password,
|
||||
cfg.DB.Name,
|
||||
cfg.DB.Port,
|
||||
)
|
||||
|
||||
var err error
|
||||
@@ -42,17 +46,16 @@ func Connect() {
|
||||
setupDB(DB)
|
||||
fmt.Println("✅ Database connected")
|
||||
|
||||
connectCatalogueDB()
|
||||
connectImageStore()
|
||||
connectCatalogueDB(cfg.Catalogue)
|
||||
connectImageStore(cfg.S3)
|
||||
}
|
||||
|
||||
// connectCatalogueDB opens the read-only connection to the catalogue
|
||||
// (pgvector) database. If its env vars are not set, catalogue endpoints
|
||||
// are simply unavailable — this must never block startup of the main app.
|
||||
func connectCatalogueDB() {
|
||||
host := getEnv("CATALOGUE_DB_HOST", "")
|
||||
if host == "" {
|
||||
fmt.Println("⚠️ Catalogue DB env vars not set, skipping catalogue DB connection")
|
||||
func connectCatalogueDB(c config.DBConfig) {
|
||||
if !c.Enabled() {
|
||||
fmt.Println("⚠️ CATALOGUE_DB_HOST not set, skipping catalogue DB connection")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,9 +65,9 @@ func connectCatalogueDB() {
|
||||
// quoting/comment syntax.
|
||||
dsnURL := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(mustEnv("CATALOGUE_DB_USER"), mustEnv("CATALOGUE_DB_PASSWORD")),
|
||||
Host: fmt.Sprintf("%s:%s", host, getEnv("CATALOGUE_DB_PORT", "5432")),
|
||||
Path: "/" + mustEnv("CATALOGUE_DB_NAME"),
|
||||
User: url.UserPassword(c.User, c.Password),
|
||||
Host: fmt.Sprintf("%s:%s", c.Host, c.Port),
|
||||
Path: "/" + c.Name,
|
||||
}
|
||||
q := dsnURL.Query()
|
||||
q.Set("sslmode", "disable")
|
||||
@@ -108,22 +111,3 @@ func CloseDB() {
|
||||
}
|
||||
fmt.Println("Connection closed Successfully")
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// ENV HELPERS
|
||||
// --------------------
|
||||
|
||||
func mustEnv(key string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
log.Fatalf("Missing required env variable: %s", key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/config"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -33,23 +34,20 @@ var ImageStore *imageStore
|
||||
|
||||
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
|
||||
// used to resolve catalogue product images. Like the catalogue DB, this must
|
||||
// never block or fail app startup — if S3 env vars are absent, image URLs
|
||||
// are simply omitted from catalogue responses.
|
||||
func connectImageStore() {
|
||||
if getEnv("USE_S3", "") != "true" {
|
||||
fmt.Println("⚠️ S3 not enabled, skipping image store")
|
||||
// never block or fail app startup — with USE_S3 unset, image URLs are simply
|
||||
// omitted from catalogue responses. (USE_S3=true with a key missing is caught
|
||||
// by config.Load before we get here.)
|
||||
func connectImageStore(c config.S3Config) {
|
||||
if !c.Enabled {
|
||||
fmt.Println("⚠️ USE_S3 not set, skipping image store")
|
||||
return
|
||||
}
|
||||
|
||||
endpoint := getEnv("S3_ENDPOINT", "")
|
||||
bucket := getEnv("S3_BUCKET", "")
|
||||
accessKey := getEnv("S3_ACCESS_KEY", "")
|
||||
secretKey := getEnv("S3_SECRET_KEY", "")
|
||||
region := getEnv("S3_REGION", "")
|
||||
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
|
||||
fmt.Println("⚠️ S3 env vars incomplete, skipping image store")
|
||||
return
|
||||
}
|
||||
endpoint := c.Endpoint
|
||||
bucket := c.Bucket
|
||||
accessKey := c.AccessKey
|
||||
secretKey := c.SecretKey
|
||||
region := c.Region
|
||||
|
||||
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com).
|
||||
// The SDK's virtual-hosted-style client re-prepends the bucket to whatever
|
||||
|
||||
19
db/redis.go
19
db/redis.go
@@ -3,9 +3,7 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"nearle/config"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -31,23 +29,18 @@ var RedisCtx = context.Background()
|
||||
// Redis is optional here: without it the POS health board goes dark, but bills
|
||||
// still arrive and commit. That is the right failure — losing presence is an
|
||||
// inconvenience, losing a sale is not — so this never aborts startup.
|
||||
func InitRedis() {
|
||||
host := strings.TrimSpace(os.Getenv("REDIS_HOST"))
|
||||
if host == "" {
|
||||
func InitRedis(c config.RedisConfig) {
|
||||
if !c.Enabled() {
|
||||
log.Println("redis: REDIS_HOST not set, POS presence disabled")
|
||||
return
|
||||
}
|
||||
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
dbIndex, err := strconv.Atoi(getEnv("REDIS_DB", "0"))
|
||||
if err != nil {
|
||||
dbIndex = 0
|
||||
}
|
||||
host, port, dbIndex := c.Host, c.Port, c.DB
|
||||
|
||||
Rdb = redis.NewClient(&redis.Options{
|
||||
Addr: host + ":" + port,
|
||||
Username: getEnv("REDIS_USER", "default"),
|
||||
Password: os.Getenv("REDIS_PASSWORD"),
|
||||
Username: c.User,
|
||||
Password: c.Password,
|
||||
DB: dbIndex,
|
||||
|
||||
// Short on purpose. A degraded Redis must fail fast rather than tie up
|
||||
|
||||
218
docs/DELIVERY_SLOTS_APP.md
Normal file
218
docs/DELIVERY_SLOTS_APP.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Delivery windows — the app contract
|
||||
|
||||
Shoppers now choose when their order arrives: one of three windows a day —
|
||||
morning, afternoon, evening — set per branch by the shop.
|
||||
|
||||
Two things to build: show the choice at checkout, and send it with the order.
|
||||
Everything else is done.
|
||||
|
||||
---
|
||||
|
||||
## 1. What a shopper may pick
|
||||
|
||||
```
|
||||
GET https://fiesta.nearle.app/live/api/v1/mob/deliveryslots/available?tenantid=&locationid=
|
||||
```
|
||||
|
||||
No authentication. Call it at checkout, once the branch is known.
|
||||
|
||||
**Real response** (branch 1179, taken at 19:02 IST):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "Success",
|
||||
"status": true,
|
||||
"details": [
|
||||
{ "deliveryslotid": 3, "slotkey": "evening", "name": "Evening",
|
||||
"starttime": "17:00", "endtime": "20:00",
|
||||
"slotdate": "2026-10-06", "istomorrow": false },
|
||||
{ "deliveryslotid": 1, "slotkey": "morning", "name": "Morning",
|
||||
"starttime": "08:00", "endtime": "10:00",
|
||||
"slotdate": "2026-10-07", "istomorrow": true },
|
||||
{ "deliveryslotid": 2, "slotkey": "afternoon", "name": "Afternoon",
|
||||
"starttime": "12:00", "endtime": "15:00",
|
||||
"slotdate": "2026-10-07", "istomorrow": true },
|
||||
{ "deliveryslotid": 3, "slotkey": "evening", "name": "Evening",
|
||||
"starttime": "17:00", "endtime": "20:00",
|
||||
"slotdate": "2026-10-07", "istomorrow": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Morning and afternoon are absent from today because both had ended by 19:02.
|
||||
**That filtering is already done — render the list as given.**
|
||||
|
||||
### Do no time arithmetic
|
||||
|
||||
Do not compare `starttime`/`endtime` against the device clock to decide what to
|
||||
show. The server owns that rule, and the device's clock, timezone and locale are
|
||||
all things we do not control. If the app re-derives it, the two will disagree
|
||||
and the shopper will be offered a window the server then rejects.
|
||||
|
||||
The fields are there to display — "Evening, 5–8pm" — not to filter on.
|
||||
|
||||
### Ordering
|
||||
|
||||
Already sorted: today's remaining windows first, then tomorrow's, each by start
|
||||
time. Render in the order given.
|
||||
|
||||
`istomorrow` is there so you can put "Tomorrow" beside a name without comparing
|
||||
dates yourself.
|
||||
|
||||
---
|
||||
|
||||
## 2. An empty list is normal
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "Success", "status": true, "details": [] }
|
||||
```
|
||||
|
||||
**This is not an error, and it is the common case today.** Most branches have
|
||||
not set windows yet, and they are trading normally right now.
|
||||
|
||||
When `details` is empty:
|
||||
|
||||
- Do not show the window picker
|
||||
- Do not show an error, a retry, or "this shop is closed"
|
||||
- **Let the order go through with no window**, exactly as before this feature
|
||||
|
||||
The whole rollout depends on this. A branch with no windows is an ordinary
|
||||
branch, and treating it as broken would take every shop on the platform offline.
|
||||
|
||||
It is always `[]`, never `null`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sending the choice
|
||||
|
||||
```
|
||||
POST https://fiesta.nearle.app/live/api/v1/mob/orders/createorder
|
||||
```
|
||||
|
||||
Two new **optional** fields on the existing body:
|
||||
|
||||
```json
|
||||
{
|
||||
"deliveryslotid": 3,
|
||||
"deliveryslotdate": "2026-10-06"
|
||||
}
|
||||
```
|
||||
|
||||
Send both or neither. Copy them straight from the chosen entry — do not
|
||||
recompute the date.
|
||||
|
||||
Omitting them creates an order with no window, which is valid and unchanged
|
||||
from today's behaviour.
|
||||
|
||||
---
|
||||
|
||||
## 4. The one error to handle
|
||||
|
||||
A window takes orders **right up until it ends**, then stops. So a shopper who
|
||||
opens checkout at 09:58 and pays at 10:02 has chosen a window that closed while
|
||||
they were deciding.
|
||||
|
||||
The server re-checks on every order and answers:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 409,
|
||||
"status": false,
|
||||
"message": "the morning window has closed for today — please choose another"
|
||||
}
|
||||
```
|
||||
|
||||
**On 409:** re-fetch `available`, show the fresh list, ask again. The `message`
|
||||
is written to be shown to the shopper as-is.
|
||||
|
||||
Other 409 messages from the same check, all safe to display:
|
||||
|
||||
- `that delivery window is not one this shop offers`
|
||||
- `the evening window is not currently available` — the shop switched it off
|
||||
- `that delivery window has already passed`
|
||||
- `a delivery date is required with a delivery window`
|
||||
|
||||
This is worth handling properly rather than as a generic failure. It is the one
|
||||
case that will happen to real people in normal use.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reading it back
|
||||
|
||||
Orders carry what was chosen:
|
||||
|
||||
```json
|
||||
{ "deliveryslotid": 3, "deliveryslotdate": "2026-10-06" }
|
||||
```
|
||||
|
||||
Both absent or `0`/empty on orders placed without a window. Show the window on
|
||||
the confirmation screen and in order history; treat absence as "no window was
|
||||
asked for", never as missing data.
|
||||
|
||||
---
|
||||
|
||||
## 6. What the window means
|
||||
|
||||
**A preference, not a promise.**
|
||||
|
||||
- Every order is accepted. A window never fills up and never blocks a sale.
|
||||
- There is no capacity limit, and no "slots remaining".
|
||||
- It tells the shop when to group the drop, and the shopper roughly when to
|
||||
expect it.
|
||||
|
||||
Please do not word it in the app as a guaranteed delivery time. "Arrives
|
||||
between 5 and 8pm" is right; "Guaranteed by 8pm" is not something the backend
|
||||
can honour.
|
||||
|
||||
---
|
||||
|
||||
## 7. Done on our side
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `deliveryslots` table, per branch | ✅ live |
|
||||
| `GET /v1/mob/deliveryslots/available` | ✅ live, filtering and dating already applied |
|
||||
| `orders.deliveryslotid` + `deliveryslotdate` | ✅ live |
|
||||
| `createorder` accepts and validates both | ✅ live, 409 on a closed window |
|
||||
| Server timezone (IST) | ✅ fixed — windows close on the shop's clock |
|
||||
| Shops set their windows at onboarding | ✅ live in both consoles |
|
||||
| Shops edit them later (Store profile → Settings) | ✅ live |
|
||||
| Window shown on the order in the console | ✅ live |
|
||||
|
||||
Verified end to end on live data: saved through the console, served to the app
|
||||
already filtered, with today's closed windows correctly absent.
|
||||
|
||||
**Not done:** grouping the dispatch queue by window. That is a console concern
|
||||
and does not affect anything above.
|
||||
|
||||
---
|
||||
|
||||
## 8. A branch you can test against
|
||||
|
||||
**Tenant `1141`, branch `1179`** — three windows configured:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Morning | 08:00–10:00 |
|
||||
| Afternoon | 12:00–15:00 |
|
||||
| Evening | 17:00–20:00 |
|
||||
|
||||
```
|
||||
GET /live/api/v1/mob/deliveryslots/available?tenantid=1141&locationid=1179
|
||||
```
|
||||
|
||||
Call it at different times of day and the list shortens as windows close —
|
||||
that is the easiest way to see the rule working.
|
||||
|
||||
For the empty-list path, use any other branch: most have no windows set, which
|
||||
is exactly the case you need to handle.
|
||||
|
||||
---
|
||||
|
||||
## Questions
|
||||
|
||||
The rule lives in one place server-side (`services/deliverySlotService.go`), so
|
||||
if anything about open/closed looks wrong, it is one function and not a
|
||||
disagreement between us. Ask rather than working around it in the app — a
|
||||
workaround on the device is how the two clocks drift apart.
|
||||
103
docs/ENVIRONMENT.md
Normal file
103
docs/ENVIRONMENT.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Environment & configuration
|
||||
|
||||
Everything the API reads from its environment goes through `config/config.go`.
|
||||
It loads the right `.env` file, reads every setting into one `Config`, and
|
||||
refuses to start — listing *everything* that is wrong in one message — before
|
||||
a single connection is attempted.
|
||||
|
||||
## Which file loads
|
||||
|
||||
`APP_ENV` names the environment. It defaults to `local`.
|
||||
|
||||
| Command | Files loaded, in order |
|
||||
|----------------------------------|---------------------------------|
|
||||
| `go run .` | `.env.local`, then `.env` |
|
||||
| `APP_ENV=production go run .` | `.env.production`, then `.env` |
|
||||
| `APP_ENV=staging go run .` | `.env.staging`, then `.env` |
|
||||
|
||||
Precedence, highest first:
|
||||
|
||||
```
|
||||
real environment > .env.<APP_ENV> > .env
|
||||
```
|
||||
|
||||
A variable that is already set is never overwritten by a file, and no file has
|
||||
to exist. `APP_ENV` itself is read from the real environment before any file
|
||||
is opened — a file cannot decide which file gets loaded.
|
||||
|
||||
| File | Role |
|
||||
|-------------------|------------------------------------------------------------|
|
||||
| `.env.example` | The complete list of settings, with comments. Start here. |
|
||||
| `.env.local` | The docker-compose stack. Every host is `localhost`. |
|
||||
| `.env.production` | The live hosts. Loaded only when asked for. |
|
||||
| `.env` | Shared base: fallbacks for whatever the file above left out. Keep it local. |
|
||||
|
||||
## Running locally
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.local.yml up -d # postgres :5433, pgvector :5434, redis :6379
|
||||
go run . # APP_ENV unset → .env.local
|
||||
```
|
||||
|
||||
An empty database is not enough — `main.go` runs migrations that assume the
|
||||
live schema. See `init/README.md` for loading a schema dump first.
|
||||
|
||||
Startup prints what it loaded and where it is pointed:
|
||||
|
||||
```
|
||||
config: loaded .env.local
|
||||
config: loaded .env
|
||||
config: APP_ENV=local, listening on :1122, database nearle@localhost:5433/nearledb
|
||||
```
|
||||
|
||||
If `DB_HOST` is not a local address under `APP_ENV=local`, it says so:
|
||||
|
||||
```
|
||||
⚠️ APP_ENV=local but DB_HOST=66.116.x.x is not a local address — every write goes to that database for real
|
||||
```
|
||||
|
||||
That is a warning, not a stop. There is no "local mode" that protects
|
||||
production: `go run .` against the live host creates real tenants and real
|
||||
logins, and runs schema migrations on boot.
|
||||
|
||||
## Running in production
|
||||
|
||||
The container gets **no `.env` file at all** — `.dockerignore` keeps every
|
||||
`.env*` out of the image — and the `Dockerfile` sets `APP_ENV=production`.
|
||||
Every value comes from the platform's environment settings (Dokploy today;
|
||||
ConfigMaps/Secrets under Kubernetes).
|
||||
|
||||
Under `APP_ENV=production` startup additionally insists on:
|
||||
|
||||
- `POS_TOKEN_SECRET` (or `JWT_SECRET_KEY` as a fallback), at least 16 characters.
|
||||
|
||||
A variable added to `.env.production` and not to the platform is a variable
|
||||
that is unset in production. Missing required ones stop the boot with the
|
||||
full list; missing optional ones (`MQTT_URL`, `REDIS_HOST`, `USE_S3`,
|
||||
`CATALOGUE_DB_HOST`) silently disable that subsystem — check the startup log
|
||||
lines when something is "not working".
|
||||
|
||||
## What is required
|
||||
|
||||
| Always | Only when enabled |
|
||||
|----------------------------------------------|---------------------------------------------------------|
|
||||
| `DB_HOST` `DB_USER` `DB_PASSWORD` `DB_NAME` | `CATALOGUE_DB_HOST` set → `CATALOGUE_DB_USER/PASSWORD/NAME` |
|
||||
| (production) `POS_TOKEN_SECRET` | `USE_S3=true` → `S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY/REGION` |
|
||||
|
||||
A half-configured subsystem is an error, not a warning: a warning reads as
|
||||
"fine" in a log and turns into "why are there no images" a week later.
|
||||
|
||||
## The committed credentials
|
||||
|
||||
The three `.env` files, including `.env.production`, are currently tracked in
|
||||
git (commit `be47435`), and an earlier `.env` was committed before
|
||||
2026-08-03. Every credential in them has to be treated as public:
|
||||
|
||||
1. Rotate the database, catalogue, Spaces, MQTT and Redis credentials and the
|
||||
POS signing secret, and update them in the platform.
|
||||
2. Take the files back out of the index and restore the ignore rules:
|
||||
```sh
|
||||
git rm --cached .env .env.local .env.production
|
||||
printf '.env\n.env.*\n!.env.example\n' >> .gitignore
|
||||
```
|
||||
The files stay on disk; they just stop being committed.
|
||||
176
docs/MAIL_SETUP.md
Normal file
176
docs/MAIL_SETUP.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Mail setup — Google Workspace SMTP, sending as care@nearledaily.com
|
||||
|
||||
What this is for: the first-password invitation. Every back-office account on
|
||||
Fiesta is created with an empty password, and the link in this email is the only
|
||||
way to set one — the sign-in screen no longer offers a form, because a public one
|
||||
meant that knowing a merchant's email address was enough to claim their account.
|
||||
|
||||
So this is not newsletter plumbing. **If the mail lands in spam, a business that
|
||||
was just onboarded cannot sign in**, and the first anyone hears of it is a phone
|
||||
call. Step 3 is the one that decides that, and it is the one people skip.
|
||||
|
||||
---
|
||||
|
||||
## The decisions
|
||||
|
||||
| | | why |
|
||||
|---|---|---|
|
||||
| Relay | Google Workspace SMTP | no server to run, no IP to warm, no port 25 exception to beg for. At a few dozen invitations a month that is the whole argument |
|
||||
| Sender | `care@nearledaily.com` | the link points at `app.nearledaily.com`; a password mail whose sender and destination are different domains is the shape of a phishing mail |
|
||||
| `care@` not `no-reply@` | | somebody replying "I never got this" is the most useful reply this system can receive, and it should reach a person |
|
||||
| Auth | an App Password, never the login password | it can be revoked on its own if it leaks |
|
||||
|
||||
This replaces an earlier plan to self-host Postal. Postal is the better answer at
|
||||
volume; it is the wrong answer for tens of emails a month, because the work is
|
||||
not the software — it is IP reputation, rDNS and blocklists.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Google Workspace on nearledaily.com
|
||||
|
||||
1. Sign up at workspace.google.com with `nearledaily.com`. Business Starter is
|
||||
enough.
|
||||
2. Verify the domain with the TXT record Google gives you.
|
||||
3. Create `care@nearledaily.com`. It is a real mailbox and **somebody has to read
|
||||
it** — merchant replies and bounce notices both land there, and a bounce is how
|
||||
you learn an invitation never arrived.
|
||||
|
||||
## Step 2 — DNS on nearledaily.com
|
||||
|
||||
| Record | Name | Value |
|
||||
|---|---|---|
|
||||
| MX | `nearledaily.com` | `smtp.google.com` (priority 1) |
|
||||
| TXT (SPF) | `nearledaily.com` | `v=spf1 include:_spf.google.com ~all` |
|
||||
| TXT (DKIM) | `google._domainkey` | the key from Step 3 |
|
||||
| TXT (DMARC) | `_dmarc` | `v=DMARC1; p=none; rua=mailto:care@nearledaily.com` |
|
||||
|
||||
- **One SPF record only.** If the domain already has one, merge
|
||||
`include:_spf.google.com` into it. Two SPF records is a permerror and fails
|
||||
every check.
|
||||
- **Remove old MX records** if the domain receives mail somewhere else today, or
|
||||
that mail keeps going to the old place.
|
||||
- **Keep DMARC at `p=none`** for a couple of weeks, read the reports, then move to
|
||||
`p=quarantine`. Going straight to `p=reject` is how you find a misaligned
|
||||
sender by losing its mail.
|
||||
|
||||
## Step 3 — DKIM (the step people skip)
|
||||
|
||||
Admin console → Apps → Google Workspace → Gmail → **Authenticate email**.
|
||||
|
||||
1. **Generate new record** (2048-bit), add the TXT record it prints to DNS.
|
||||
2. Wait for DNS to propagate — minutes to hours.
|
||||
3. Come back and click **Start authentication**.
|
||||
|
||||
Until you click that last button the mail is unsigned, and unsigned mail carrying
|
||||
a password link goes to spam.
|
||||
|
||||
## Step 4 — An App Password for Fiesta
|
||||
|
||||
1. Sign in as `care@nearledaily.com` → Google Account → Security → turn on
|
||||
**2-Step Verification**.
|
||||
2. Security → **App passwords** → create one named `Fiesta`. You get 16
|
||||
characters.
|
||||
3. That is what Fiesta uses. Never the account's real password.
|
||||
|
||||
No App passwords option? An admin has to allow it, or set up Admin console →
|
||||
Gmail → Routing → **SMTP relay service** with "require SMTP authentication" and
|
||||
"require TLS". In that case `MAIL_HOST` becomes `smtp-relay.gmail.com`.
|
||||
|
||||
## Step 5 — Point Fiesta at it
|
||||
|
||||
Credentials go in **`.env.secrets`**, which is read first and is the only env
|
||||
file git ignores. Never in `.env` — that one is tracked and shared.
|
||||
|
||||
```sh
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_USERNAME=care@nearledaily.com
|
||||
MAIL_PASSWORD=<16-character app password>
|
||||
```
|
||||
|
||||
Already set in `.env`:
|
||||
|
||||
```sh
|
||||
MAIL_PORT=587
|
||||
MAIL_FROM=care@nearledaily.com
|
||||
MAIL_FROM_NAME=Nearle
|
||||
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
||||
```
|
||||
|
||||
Google shows the App Password as four groups — `abcd efgh ijkl mnop`. Paste it
|
||||
with or without the spaces; Fiesta strips them for Google SMTP hosts only, and
|
||||
only when what remains is the sixteen alphanumerics an App Password actually is.
|
||||
Another relay's password is never edited.
|
||||
|
||||
`MAIL_CONSOLE_URL` is the **merchant** console and never the platform one — a
|
||||
merchant sets their password at `app.nearledaily.com/set-password` and nowhere
|
||||
else.
|
||||
|
||||
Restart. The log says which state it is in:
|
||||
|
||||
```
|
||||
mail: sending as care@nearledaily.com via smtp.gmail.com:587
|
||||
mail: OFF — <reason naming the missing variable>
|
||||
```
|
||||
|
||||
**On a hosted deployment these belong in the platform's own environment**
|
||||
(Dokploy), not in a file in the repo. A `.env` committed to the repository is
|
||||
overwritten at build time — that is how the nutrition service shipped switched
|
||||
off.
|
||||
|
||||
### What Fiesta does with them
|
||||
|
||||
`utils/mail.go` upgrades to TLS with STARTTLS before authenticating, and
|
||||
**refuses to send at all if a relay offers no encryption while credentials are
|
||||
configured**. Go's own `smtp.PlainAuth` would decline to hand over the password
|
||||
anyway, so nothing leaks either way — but it reports that as the server refusing
|
||||
the credentials, which sends somebody to check the password when the problem is
|
||||
the connection. It also closes a downgrade, where an attacker strips STARTTLS
|
||||
from the greeting.
|
||||
|
||||
## Step 6 — Check the DNS
|
||||
|
||||
```sh
|
||||
dig TXT nearledaily.com +short # SPF, with _spf.google.com
|
||||
dig TXT google._domainkey.nearledaily.com +short # DKIM key
|
||||
dig TXT _dmarc.nearledaily.com +short # DMARC
|
||||
dig MX nearledaily.com +short # smtp.google.com
|
||||
```
|
||||
|
||||
Google's Check MX tool at toolbox.googleapps.com does the same job.
|
||||
|
||||
## Step 7 — Prove it end to end
|
||||
|
||||
Not "the config looks right" — watch one arrive.
|
||||
|
||||
1. Send a test to a [mail-tester.com](https://mail-tester.com) address. Aim for
|
||||
9/10 or better **before** a real merchant sees one.
|
||||
2. Onboard a test merchant with an address you can read.
|
||||
3. Confirm it is in the **inbox, not spam**. In Gmail, "Show original" should
|
||||
show SPF, DKIM and DMARC all PASS.
|
||||
4. Follow the link, set a password, sign in at `app.nearledaily.com`.
|
||||
5. Press **Resend invite**. It must refuse, naming the business:
|
||||
*"… has already set a password — send them to the sign-in page instead."*
|
||||
That refusal is what stops this becoming a password reset.
|
||||
|
||||
---
|
||||
|
||||
## Worth knowing
|
||||
|
||||
- **Limit:** about 2,000 messages a day per user. Onboarding runs at a few dozen
|
||||
a month, so this is not a constraint.
|
||||
- **Bounces** arrive as "Delivery failed" in the `care@` inbox. Nothing in Fiesta
|
||||
watches for them, so somebody has to read that mailbox after onboarding.
|
||||
- **Not for bulk.** Google does not permit marketing sends through Workspace. If
|
||||
newsletters are ever wanted, that is a separate provider — not this mailbox.
|
||||
- **If the App Password leaks:** revoke it in Google Account → Security, issue a
|
||||
new one, update `.env.secrets`. Nothing else has to change.
|
||||
|
||||
## What the merchant receives
|
||||
|
||||
Plain text, deliberately. A password link arriving as an image-heavy HTML
|
||||
template is the shape of a phishing mail, and plain text renders identically
|
||||
everywhere. The body names the business, puts the link on its own line, and says
|
||||
it expires in seven days — because an invitation found three weeks later needs to
|
||||
explain itself rather than look broken.
|
||||
|
||||
The wording is `inviteMessage` in `services/inviteService.go`.
|
||||
115
docs/NUTRITION_API.md
Normal file
115
docs/NUTRITION_API.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Nutrition and health score — the app contract
|
||||
|
||||
`GET /live/api/v1/mob/products/getproductbyvariant?tenantid=&productid=&variantid=`
|
||||
|
||||
Each product in `details[]` may now carry two extra keys. Both come from the
|
||||
catalogue-intelligence service (`mcp.nearle.ai.in`) — the same records behind the
|
||||
health score card in the console — fetched server-side, so the app needs no
|
||||
second host, no second failure mode, and no copy of the rules below.
|
||||
|
||||
---
|
||||
|
||||
## nutrition
|
||||
|
||||
```json
|
||||
"nutrition": {
|
||||
"per": "100g",
|
||||
"servingsize": "1 mini (11 g)",
|
||||
"items": [
|
||||
{ "name": "Energy", "value": 545, "unit": "kcal" },
|
||||
{ "name": "Protein", "value": 7.5, "unit": "g" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Absent when unknown.** Not `null`, not `{}`. A missing key means "we do not
|
||||
know", never "this food has no nutrition".
|
||||
- `items` is never empty when `nutrition` is present.
|
||||
- `per` is `"100g"` for everything the service returns today. `servingsize` is
|
||||
often absent — show the basis only when it is there.
|
||||
- `value` may be a decimal. `unit` is free text and may be absent.
|
||||
- Rows appear only when the service stated them. A null field is omitted; a
|
||||
stated zero is kept, because "no fibre" is a fact and a dash is not.
|
||||
|
||||
## healthscore
|
||||
|
||||
```json
|
||||
"healthscore": {
|
||||
"score": 65,
|
||||
"band": "good",
|
||||
"label": "Healthy",
|
||||
"positives": ["Good source of protein (7.5 g per 100 g)."],
|
||||
"cautions": ["High in saturated fat (14.4 g per 100 g)."],
|
||||
"diettags": ["High Fiber", "Vegetarian"],
|
||||
"allergens": [],
|
||||
"allergensunconfirmed": true,
|
||||
"caveat": "Matched to a reference product with 61% confidence — treat these figures as a guide.",
|
||||
"source": { "label": "openfoodfacts", "url": "https://..." }
|
||||
}
|
||||
```
|
||||
|
||||
- **Absent when there is nothing safe to show** — unscored, not food, or no
|
||||
record at all. All three read as "not rated yet".
|
||||
- `band` is one of `excellent` | `good` | `fair` | `poor`, for styling. `label`
|
||||
is what a shopper reads. Use the label; do not re-derive it.
|
||||
- `score` is 0–100, already rounded.
|
||||
- `positives`, `cautions` and `diettags` are sentences the service wrote for a
|
||||
person. Render as given.
|
||||
|
||||
### Two rules the app MUST honour
|
||||
|
||||
**`caveat`, when present, has to be on screen.** It means the underlying source
|
||||
match was weak — most are; the service matches down to 0.32 confidence. A
|
||||
nutrition table presented as fact on a 61% match is a claim the data does not
|
||||
support.
|
||||
|
||||
**`allergensunconfirmed: true` means an empty `allergens` list must NOT be
|
||||
rendered as "contains none".** Say "not confirmed — check the packet". Silence
|
||||
standing in for "none" is the one failure here that can put somebody in hospital.
|
||||
A declared allergen is always sent and must always be shown.
|
||||
|
||||
---
|
||||
|
||||
## Why the judgement is server-side
|
||||
|
||||
The service returns a raw number and, from this endpoint, no band. Deciding which
|
||||
band, whether the match is strong enough to state plainly, and whether the
|
||||
product is even food is a set of rules that already exists in the console. Two
|
||||
implementations would drift and disagree about the same product on two screens.
|
||||
|
||||
The edibility guard is the sharpest of them. The upstream per-product endpoint is
|
||||
**not** gated for it: on 4 Sep 2026 it rated Godrej Hit insecticide 80/100 with
|
||||
`data_status: "verified"`, and soap and shampoo both scored 37.5. Those records
|
||||
now read "unavailable", and the guard stays — this tenant sells soap and
|
||||
toothpaste beside its biscuits.
|
||||
|
||||
## Coverage today
|
||||
|
||||
Measured 29 Sep 2026 against tenant 1147: **6 of 15 catalogue-linked products
|
||||
have nutrition**, and fewer have a score. The Patanjali ghee this work started
|
||||
from has neither.
|
||||
|
||||
Test with **product 7101, Balaji Wafers Simply Salted** — a full panel and a
|
||||
65/100 score.
|
||||
|
||||
```sh
|
||||
go run ./scratch/nutritionproof # three real products
|
||||
go run ./scratch/nutritionproof <brand> <image_id> # any product
|
||||
```
|
||||
|
||||
## Known bad data upstream
|
||||
|
||||
Balaji Wafers reports `sodium_mg: 0.967` — under 1 mg per 100 g, for salted
|
||||
crisps, where 500–900 mg is normal. The `Salt` figure of 0.002 g is wrong the
|
||||
same way. It looks like a grams/milligrams mix-up at the source.
|
||||
|
||||
Fiesta passes the value through as given rather than scaling it: silently
|
||||
"correcting" a food label is how wrong data becomes invisible. It will look wrong
|
||||
in the app until the agent team fixes the unit.
|
||||
|
||||
## Configuration
|
||||
|
||||
`NUTRITION_BASE=https://mcp.nearle.ai.in/api`, in `.env`. Unset means neither key
|
||||
is ever sent and nothing else changes. Lookups are cached six hours, capped at
|
||||
three seconds, and every failure costs that product its panel rather than the
|
||||
response.
|
||||
778
docs/PORTFOLIO.md
Normal file
778
docs/PORTFOLIO.md
Normal file
@@ -0,0 +1,778 @@
|
||||
# Nearle "Fiesta" backend — what was built
|
||||
|
||||
A Go monolith that serves a multi-tenant retail platform: neighbourhood shops
|
||||
(tenants) with one or more outlets, selling through a consumer app, a rider
|
||||
delivery fleet, and — the newest and largest piece of work — **physical
|
||||
point-of-sale terminals sitting on shop counters**.
|
||||
|
||||
There are really five distinct systems in here. They are ordered below by how
|
||||
much original engineering they represent.
|
||||
|
||||
---
|
||||
|
||||
## 1. The POS terminal integration (the centrepiece)
|
||||
|
||||
### What it is
|
||||
|
||||
Retail tills in shops run a Flutter app with its own local SQLite database. They
|
||||
keep selling with **no network at all** — a village shop's connection drops for
|
||||
hours. When connectivity returns, each till uploads the bills it rang while
|
||||
offline, pulls down an updated product catalogue, and reports its own health.
|
||||
|
||||
This backend is the other end of that conversation. It has to solve the classic
|
||||
offline-first sync problem under a hard constraint: *a sale that has already been
|
||||
paid for in cash must never be lost, and must never be counted twice.*
|
||||
|
||||
The problem it solves for the business: shops that were doing counter sales
|
||||
entirely off-platform now have those sales in the same database as their app
|
||||
orders, deducting from the same stock, appearing in the same revenue reports.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Till (Flutter + SQLite) — not in this repo │
|
||||
│ sale committed locally first, sync_status = 0 │
|
||||
└───────────────┬──────────────────────────┬──────────────┘
|
||||
│ │
|
||||
(transport A) MQTT (transport B) HTTPS
|
||||
nearle/pos/{loc}/{term}/order POST /live/api/v1/pos/orders
|
||||
nearle/pos/{loc}/{term}/customer POST .../customers
|
||||
nearle/pos/{loc}/{term}/health POST .../health
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌────────────────────────┐
|
||||
│ messaging/posmqtt.go │ │ controllers/ │
|
||||
│ • leader election │ │ posController.go │
|
||||
│ • bounded worker │ │ • PosAuth middleware │
|
||||
│ pools (ingest, │ │ verifies token + │
|
||||
│ health) │ │ outlet ownership │
|
||||
└──────────┬───────────┘ └───────────┬────────────┘
|
||||
└────────────┬──────────────┘
|
||||
▼
|
||||
services.PosService ← one code path for both
|
||||
│
|
||||
┌─────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
posRepository posSalesRepository posPresence
|
||||
(ingest, catalogue) (read-back) (Redis)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────┐ ┌──────────────┐
|
||||
│ POSTGRES (nearledb) │ │ REDIS │
|
||||
│ pos_orders │ │ pos:terminal:│
|
||||
│ pos_order_items │ │ {id} HASH │
|
||||
│ productstocks ← shared │ │ TTL 90s │
|
||||
│ customers, app_users │ │ pos:location:│
|
||||
└──────────────────────────────┘ │ {id} SET │
|
||||
└──────────────┘
|
||||
│
|
||||
▼
|
||||
ack → nearle/pos/{loc}/{term}/ack (or HTTP 200 body)
|
||||
│
|
||||
▼
|
||||
Till marks bill synced, keeps its copy 7 more days
|
||||
```
|
||||
|
||||
The storage split is deliberate and explained in the code:
|
||||
|
||||
- **Postgres `pos_orders` / `pos_order_items`** — the permanent record of counter
|
||||
bills, kept *separate* from the app's `orders` table. A bill carries a cashier,
|
||||
a terminal id, a rounding adjustment, promo campaigns, loyalty movement and a
|
||||
payment split across several tenders; `orders` has nowhere to put any of that.
|
||||
The stated cost of the split is that every revenue query has to union both —
|
||||
which was done, in `orderRepository.posRevenue` and `posSalesTotals`.
|
||||
- **Postgres `productstocks`** — stock is deliberately *not* split. A counter sale
|
||||
writes the same "out" ledger rows an app order does, through the same helper, so
|
||||
the catalogue pushed down to a till reflects the till's own trading.
|
||||
- **Redis** — terminal presence only, with a 90-second TTL. Shared with a separate
|
||||
Express backend (the rider app reads the board), namespaced `pos:*` so it cannot
|
||||
collide with that service's `delivery:*` / `city:*` keys.
|
||||
|
||||
### How the problem was solved — the approach
|
||||
|
||||
Six interlocking decisions.
|
||||
|
||||
**(a) The acknowledgement protocol is the whole design.** Delivery is
|
||||
at-least-once. The rule, stated in `models.PosAck`: *a till marks a record synced
|
||||
if and only if its id appears in `accepted`.* Silence is not acceptance — an empty
|
||||
ack, a dropped connection, or a 200 with no body all leave the record pending, and
|
||||
it gets re-sent. `rejected` is a separate, deliberate verdict meaning "stop
|
||||
retrying this one, fetch a human" — used for a malformed bill, never for "the
|
||||
database is having a bad minute." That distinction is enforced right up in the
|
||||
HTTP layer: `posIngestError` decides 4xx (permanent — the till halts and shows a
|
||||
person) versus 5xx (unknown — the till keeps everything and backs off).
|
||||
|
||||
**(b) Idempotency: a duplicate is a success, not a failure.** Each bill carries a
|
||||
UUID minted at the till, stored as `terminalorderid` with a unique index. On
|
||||
arrival, `importPosOrder` opens a transaction, takes
|
||||
`pg_advisory_xact_lock(hashtext('possale:' || id))`, then checks whether the bill
|
||||
is already held. If it is, the transaction rolls back and the bill is
|
||||
**accepted** — stock untouched. Calling a re-delivered bill a failure would strand
|
||||
a day's takings on the till forever. The advisory lock turns what would otherwise
|
||||
be a unique-constraint violation into an orderly "already held," and closes the
|
||||
race where two redeliveries arrive simultaneously.
|
||||
|
||||
**(c) Ordering inside the transaction — locks, then availability, then writes.**
|
||||
`stockLedger.go` holds the shared machinery. `lockStockRows` takes
|
||||
`SELECT … FOR UPDATE` on every `(tenant, location, product)` the sale touches,
|
||||
**sorted by (productid, locationid)** so two concurrent sales sharing products
|
||||
always contend in the same sequence and block rather than deadlock. Only then does
|
||||
`assertStockAvailable` read balances, and only then are rows written. This is the
|
||||
same path an app order and a spreadsheet import take — the code was extracted
|
||||
specifically so there aren't three implementations of the anti-overselling rule
|
||||
drifting apart.
|
||||
|
||||
**(d) Line-item reconciliation.** A subtle one. The till has already apportioned
|
||||
bill-level discounts across its lines to get tax right, but it sends each line at
|
||||
its *pre-apportionment* value. Left alone, summing line items gives the subtotal
|
||||
while the header carries the total, and two reports disagree. So the code computes
|
||||
`amountFactor = (total − roundoff) / Σ line_total` and
|
||||
`taxFactor = header_tax / Σ line_tax`, and scales each line onto what was actually
|
||||
collected. The till stays authoritative for the bill as a whole; this only decides
|
||||
attribution *within* it.
|
||||
|
||||
**(e) The catalogue downlink is a delta protocol with a safety invariant.**
|
||||
`GET /pos/catalogue` answers either a full snapshot or a change set. The terminal
|
||||
treats `is_delta: false` as a snapshot and **withdraws every product the response
|
||||
does not mention** — so mislabelling a filtered result empties the shop's shelf.
|
||||
The code therefore derives both the filter and the flag from one value:
|
||||
`cutoff := posRevisionCutoff(...)`; zero cutoff ⇒ no filter ⇒ `is_delta: false`,
|
||||
non-zero ⇒ filtered ⇒ `is_delta: true`. There is no path that filters without
|
||||
setting the flag. Supporting details:
|
||||
|
||||
- The revision is an opaque token `loc{id}-{YYYYMMDDTHHMMSSZ}` the till stores and
|
||||
hands back. If it is unreadable, malformed, or belongs to a *different outlet*,
|
||||
the cutoff is zero and you get a full snapshot — failing toward "send
|
||||
everything" is the only safe direction.
|
||||
- **The revision only advances on the final page.** Mid-pagination it echoes back
|
||||
whatever the till already had. A till that dies half way through a paginated
|
||||
pull must not end up holding a revision claiming it saw pages it never received
|
||||
— those products would be excluded from every future delta, silently, forever.
|
||||
- The revision stamp is taken **one second in the past**, so a product written
|
||||
during the same second the query ran cannot land on the wrong side of the next
|
||||
cutoff. Costs one redundant row; cannot lose one.
|
||||
- "Changed" is one predicate covering three things: the product row, its
|
||||
per-location row (price/availability), or its stock ledger. Stock is in there
|
||||
because a shop's count drifts on every sale rung at another counter.
|
||||
- Acknowledged limitation, in a comment: a product *deleted* from
|
||||
`productlocations` leaves no tombstone, so a delta cannot know to withdraw it.
|
||||
Only a full pull collects those — hence "pull without a revision every morning."
|
||||
|
||||
**(f) Presence is a TTL, not a table.** A heartbeat is a fact with an expiry date.
|
||||
In Postgres it would be ~288k writes/day across a hundred tills plus a reaper job
|
||||
to mark them dead. A Redis hash with a 90s TTL (three missed 30s heartbeats — "two
|
||||
would make a GPRS hiccup look like a dead till; five would take 2½ minutes to
|
||||
notice a real one") ages out for free. The location→terminals SET has *no* TTL:
|
||||
it is an index of what exists, not a claim anything is alive. A till whose hash
|
||||
expired comes back as a stub marked `offline` with a reason, rather than being
|
||||
omitted — because the missing till is exactly what someone is looking for. The
|
||||
write also `HDEL`s fields the till stopped reporting, so a hash never lingers at a
|
||||
stale battery reading.
|
||||
|
||||
#### Tricky cases the code explicitly handles
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Bill with no id | Rejected — nothing to dedupe on, and it would double on every retry |
|
||||
| Fractional quantity (1.5 kg onions) vs integer stock column | `roundStockQty` rounds **up** — conservative, never records more stock than is physically there. Flagged in-code as a workaround, not a fix |
|
||||
| Timestamps without a timezone offset (older terminal builds) | Accepted, with a comment stating the instant will be wrong by the offset and is unrecoverable — but the *business date* is right, which is what daily figures use |
|
||||
| `jsonb` columns left at Go's zero value | Forced through `posJSON`, which emits `"null"` — an empty string reaches Postgres as invalid JSON and takes the whole bill down |
|
||||
| Terminal id present on the batch but not the bill | `posTerminalFor` falls back, trimming first so `" "` is not mistaken for a real code |
|
||||
| Broker Last Will (`{"status":"offline"}`) | Arrives on the same handler and is recorded verbatim — exactly right for a till that lost power |
|
||||
| Customer registrations replayed | Insert-if-absent, **never update** — a profile corrected at head office must not be reverted by a till replaying months-old data |
|
||||
| Loyalty points on the uplink | Deliberately absent from the wire format. Points are derived from the bill stream (idempotent, sees every counter); accepting a till's local balance would make "last till to sync wins" |
|
||||
|
||||
#### Trade-offs, and what they buy
|
||||
|
||||
- Bills separate from `orders` → full fidelity, at the cost of every report needing
|
||||
a union.
|
||||
- Payment mode denormalised to "largest tender" for grouping, with the full split
|
||||
kept verbatim in `paymentsjson` → fast reports, no lost reconciliation data.
|
||||
- `businessdate` denormalised as a `YYYY-MM-DD` string → a day's takings is one
|
||||
indexed equality match instead of a range scan with timezone arithmetic.
|
||||
- Barcode generation: `products.productsku` cannot be trusted for the till's
|
||||
*unique* barcode index (in live data, thousands of products share a single SKU
|
||||
value), so a SKU is only used if it looks like a real EAN/UPC — 8–14 digits —
|
||||
and otherwise the product id stands in. Scanning physical barcodes will not work
|
||||
until real ones are populated; the comment says so plainly and notes it starts
|
||||
working with no code change.
|
||||
|
||||
---
|
||||
|
||||
## 2. Transport, concurrency and delivery guarantees
|
||||
|
||||
### What it is
|
||||
|
||||
The same ingest, over two transports (MQTT and HTTP), running under multiple
|
||||
replicas, with backpressure that reaches all the way back to the till.
|
||||
|
||||
### How it's built
|
||||
|
||||
`messaging/posmqtt.go` (broker client, topic routing, ack publishing) plus
|
||||
`messaging/posworkers.go` (a bounded worker pool). Both hand off to the same
|
||||
`PosService` the HTTP controller uses — the facade exposes `f.PosService()`
|
||||
specifically so a bill cannot behave differently depending on how it arrived.
|
||||
|
||||
### The approach
|
||||
|
||||
**Leader election with no coordination service.** MQTT has no queue groups — every
|
||||
subscriber gets every message, so three replicas would each commit the same bill
|
||||
and publish three acks. The ingest is idempotent so nothing double-counts, but it
|
||||
is 3× the database work. The fix: a StatefulSet gives pods stable ordinal names,
|
||||
so **ordinal 0 is the elected consumer** — no lease, no lock, no extra dependency.
|
||||
Overridable via `POS_MQTT_CONSUMER=always|never`; a non-ordinal hostname (bare
|
||||
container, local dev) is elected, because "a single instance that refused to
|
||||
consume would be a far more confusing failure."
|
||||
|
||||
**Backpressure by construction.** paho delivers on one goroutine, so naively every
|
||||
bill commits serially — a bill is a full transaction (advisory lock, dedup, row
|
||||
locks, availability, four inserts, commit) at ~10–30 ms, giving 30–100 bills/sec,
|
||||
and a shop-wide backlog after an outage takes minutes. paho *can* call handlers
|
||||
concurrently, but it spawns without limit — a storm would open a transaction per
|
||||
message, exhaust the connection pool, and stall everything at once.
|
||||
|
||||
So: a fixed pool behind a bounded queue, and `submit` **blocks** when the queue is
|
||||
full. That is the point — paho stops acking, the broker's in-flight window fills,
|
||||
it stops sending, and the till holds its bills and retries. `SetOrderMatters(true)`
|
||||
is kept on precisely because single-goroutine delivery is what makes that chain
|
||||
work; concurrent delivery would let paho keep reading no matter how far behind the
|
||||
workers were.
|
||||
|
||||
**Separate pools for bills and heartbeats.** A heartbeat is one Redis write; a bill
|
||||
is a transaction. Sharing a queue would delay presence behind a bill backlog, and
|
||||
every till would appear to go dark at the exact moment the system was busiest.
|
||||
|
||||
**The pool's shutdown race is handled explicitly.** A plain `select` over a
|
||||
done-channel and the job channel is not enough — once both are ready Go picks at
|
||||
random, and picking the send panics on a closed channel. So there is an `RWMutex`
|
||||
held for *reading across the whole of `submit`*, and `stop` takes the write lock
|
||||
before closing. The comment works through why this cannot deadlock: workers only
|
||||
exit once the channel is closed, which happens under the write lock the in-flight
|
||||
send is holding off. Post-close submissions run **inline** rather than being
|
||||
dropped — discarding a bill that already reached you is worse than doing it slowly.
|
||||
|
||||
**Identity comes from the topic, never the body.** `topicIdentity` parses store and
|
||||
terminal out of `nearle/pos/{store}/{terminal}/{kind}`. A till that could name its
|
||||
own store in a payload could post sales into another shop's books. There is a test
|
||||
named exactly that: `TestABodyCannotOverrideTheTopicIdentity`.
|
||||
|
||||
**Shutdown order is load-bearing.** `Close()` drains the worker pools *before*
|
||||
disconnecting, so a bill mid-commit still gets its ack out. Disconnecting first
|
||||
would strand it — committed here, unacknowledged there, re-sent on the till's next
|
||||
attempt.
|
||||
|
||||
Payloads are copied in `wrapHandler` because paho reuses its buffer once the
|
||||
handler returns, and the work now happens after that.
|
||||
|
||||
The test suite here is genuinely good: bounded concurrency, blocking-not-dropping,
|
||||
drain-on-stop, idempotent stop, payload copying, ordinal election, and "a failed
|
||||
presence write does not stop the till."
|
||||
|
||||
---
|
||||
|
||||
## 3. Terminal authentication and shop-managed staff
|
||||
|
||||
### What it is
|
||||
|
||||
Before this work, the POS surface was completely open: a till held a store id
|
||||
typed into a Settings screen and a password compiled into the app, so
|
||||
`store_id=1185` in a URL was enough to read another tenant's catalogue or post
|
||||
bills into their books. One leaked build opened every tenant on the platform. This
|
||||
subsystem replaces that with a real session, and adds shop-run staff management on
|
||||
top.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
POST /pos/login (the only unguarded route — it's where tokens come from)
|
||||
│ authname|contactno + password [+ optional configid, location_id]
|
||||
▼
|
||||
posAuthRepository.PosLogin
|
||||
│ reads the SAME app_users rows the web console authenticates against
|
||||
│ resolves the outlet FROM the user's record — never from the wire
|
||||
▼
|
||||
posService.mint → utils.MintPosToken
|
||||
│ base64url(payload) "." base64url(HMAC-SHA256)
|
||||
│ claims: uid, tid, lid, rid, cid, trm, iat, exp TTL 30 days
|
||||
▼
|
||||
PosSession { token, expires_at, role, can_manage_staff,
|
||||
tenant + GSTIN + address (for the printed invoice),
|
||||
locations[] (picker for multi-outlet owners),
|
||||
staff[] (so the till can trade immediately) }
|
||||
│
|
||||
▼
|
||||
every other /pos/* route → middleware.PosAuth
|
||||
1. verify signature 2. is the named outlet owned by the token's tenant?
|
||||
```
|
||||
|
||||
### The approach
|
||||
|
||||
**A signed, self-describing token rather than a session table.** Reasoning given in
|
||||
`utils/postoken.go`: a till is not a browser. It signs in when the shop opens and
|
||||
bills for a whole day on a connection that comes and goes, so the credential must
|
||||
survive reboot, network loss, and an hour in a drawer. A server-side session table
|
||||
fails that (a till that cannot reach you must still be able to prove who it is when
|
||||
it returns), and so does a short expiry.
|
||||
|
||||
**Deliberately not JWT.** One issuer, one audience, one algorithm — the header JWT
|
||||
spends bytes negotiating is a constant. And `alg` is the source of JWT's
|
||||
worst-known footgun (`alg: none`); a format with no algorithm field cannot have
|
||||
that bug. The MAC is taken over the *encoded* payload so verification never
|
||||
re-serialises anything.
|
||||
|
||||
**Verification order is deliberate:** signature first, *then* expiry. Reading `exp`
|
||||
out of an unverified payload would mean taking the attacker's word for when their
|
||||
own token runs out. Comparison is `hmac.Equal` (constant time). A token that
|
||||
verifies but names no outlet is refused, so it cannot be mistaken for one that
|
||||
authorises everything.
|
||||
|
||||
**The middleware's second check is the one that matters.** A valid token is not a
|
||||
licence to name *any* outlet — it is a licence to name *your* outlets.
|
||||
`requestedLocation` reads all three spellings the routes use (`store_id`,
|
||||
`locationid`, `location_id`) rather than breaking terminals in the field by
|
||||
normalising, and — crucially — **searches the JSON body, not just the query
|
||||
string**, because the two routes that *write* carry `store_id` in the batch and
|
||||
never in the URL. It handles the id being sent quoted or bare, since accepting only
|
||||
one shape would silently skip the check, and "a skipped check reads exactly like a
|
||||
passed one."
|
||||
|
||||
**A migration escape hatch, honestly labelled.** `POS_AUTH_REQUIRED` defaults to
|
||||
**off**, because terminals are already in shops billing real customers against
|
||||
unauthenticated endpoints and flipping enforcement at deploy would stop every one
|
||||
of them mid-trade. While off, a token that *is* sent is still fully verified and a
|
||||
wrong-tenant request is still refused — the flag only governs requests carrying
|
||||
none.
|
||||
|
||||
**Two-tier sign-in: password opens the terminal, PIN switches the operator.**
|
||||
|
||||
- The session token is the security boundary. A four-digit PIN is not:
|
||||
`POST /pos/login/pin` sits *behind* the guard, so guesses are confined to one
|
||||
already-opened outlet's own staff.
|
||||
- PIN login mints a **fresh** token rather than reusing the presented one, so a
|
||||
cashier taking over from a supervisor drops the supervisor's permissions instead
|
||||
of inheriting them.
|
||||
- PINs travel in the clear over TLS, and the model file argues the case rather than
|
||||
hiding it: four digits is brute-forceable in microseconds whatever it is wrapped
|
||||
in, so hashing here buys the appearance of strength; meanwhile the terminal salts
|
||||
every PIN with its own random salt, so a hash computed server-side could never be
|
||||
verified there without inventing and maintaining a shared scheme across two
|
||||
codebases. The honest framing: **a PIN is shift attribution, not a security
|
||||
boundary.**
|
||||
|
||||
**Schema archaeology, handled rather than wished away:**
|
||||
|
||||
- `authname` is not unique in `app_users` — live data has the same address twice
|
||||
under one config. Rather than `LIMIT 1` (which would let a stranger's account
|
||||
shadow the one a person meant, and on a POS means billing into the wrong tenant),
|
||||
multiple matches are **refused** with an actionable message.
|
||||
- Inactive accounts are excluded from the *match*, not matched-then-refused, so a
|
||||
deactivated leaver cannot make a live login ambiguous.
|
||||
- `configid` (which tenant portal an account belongs to) is asked for by the web
|
||||
console because the browser knows it — but a person at a counter has never seen
|
||||
the number. So it is honoured when sent, inferred when not, and an ambiguous
|
||||
inference is reported rather than guessed. `PosConfigidFor` infers it from
|
||||
whichever value the tenant's existing accounts most commonly carry.
|
||||
- Staff come from **two** sources unioned: the purpose-built `tenantstaffs` table
|
||||
(a dozen rows on the entire platform) and `app_users.locationid` (where staff
|
||||
actually ended up). Reading either alone returns the wrong answer.
|
||||
- Duplicate PINs are dropped from the response, because live data has one PIN
|
||||
shared across many accounts — a shared PIN would attribute a bill to whichever
|
||||
row was read first.
|
||||
- PINs are bounded 1000–9999 with **no leading zero**, because `app_users.pin` is a
|
||||
`bigint`: "0451" stores as 451, and the cashier types four digits and is refused
|
||||
forever. That costs 1000 of 10000 combinations and buys a PIN that means the same
|
||||
thing in both directions. Obvious PINs (1234, 1111, …) are rejected.
|
||||
- `userid` is left to Postgres's identity column, with a comment explaining the
|
||||
earlier mistake: `information_schema.column_default` is empty for identity
|
||||
columns, which reads like "no default," and a hand-rolled MAX+1 leaves two
|
||||
allocators racing.
|
||||
- Emails go through `NULLIF(?, '')` because a unique constraint means a second
|
||||
PIN-only cashier would collide on the empty string, whereas NULLs do not collide
|
||||
in Postgres.
|
||||
|
||||
**The inversion, applied to people.** `PosUserRequest` has no tenant and no
|
||||
location field. A supervisor creating staff can only ever create them at their own
|
||||
outlet, and *no field in the struct can say otherwise* — the same inversion that
|
||||
stopped a till naming its own shop. Updates are scoped by tenant *and* location in
|
||||
the `WHERE` clause rather than checked first, so a wrong user id updates zero rows
|
||||
and is reported, instead of quietly editing another shop's staff. Deactivation is a
|
||||
status change, never a delete, because bills carry the cashier's name. You cannot
|
||||
deactivate the account you are signed in as, or the last supervisor could lock the
|
||||
whole shop out with one tap.
|
||||
|
||||
The same service calls are exposed to the web console under `/web/tenants/*` and
|
||||
`/mob/tenants/*` — deliberately the same code, not a parallel implementation,
|
||||
"because two code paths writing one table is exactly how that stops being true."
|
||||
The route file itself flags that this half is weaker: the console *asserts* its
|
||||
outlet where a terminal *proves* it, and says these should move behind a session
|
||||
guard as soon as the console can hold one.
|
||||
|
||||
---
|
||||
|
||||
## 4. The shared order + stock engine
|
||||
|
||||
### What it is
|
||||
|
||||
One transactional path that every sale in the platform goes through, regardless of
|
||||
channel: an app order, a spreadsheet import of historical counter sales, or a POS
|
||||
bill.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
CreateOrder (app) UploadOfflineSales (spreadsheet) importPosOrder (till)
|
||||
│ │ │
|
||||
│ per-bill tx + advisory lock per-bill tx + advisory lock
|
||||
│ + remarks-based dedup + terminalorderid dedup
|
||||
▼ ▼ │
|
||||
┌──────────────────────── createOrderTx ──────────────────────┐ │
|
||||
│ 0. lockStockRows (FOR UPDATE, sorted, deduped) │ │
|
||||
│ 1. assertStockAvailable (ledger balance, all lines first) │◄───────┤ (uses the same
|
||||
│ 1b. priceOrderLines (fill unpriced lines from catalogue) │ │ stockLedger.go
|
||||
│ 2. nextSequenceNo (UPDATE…RETURNING inside the tx) │ │ helpers directly)
|
||||
│ 3. insert header, insert lines, recordStockOut per line │ │
|
||||
│ → syncProductLocationStatus re-derives availability │ │
|
||||
└─────────────────────────────────────────────────────────────┘ │
|
||||
│ contract: on failure it has already rolled back; │
|
||||
│ on success tx is left OPEN so the caller can │
|
||||
│ include its own dedup guard in the same tx │
|
||||
▼ ▼
|
||||
COMMIT pos_orders + productstocks
|
||||
```
|
||||
|
||||
### The approach
|
||||
|
||||
**Stock is derived, never stored.** Availability is `SUM(in) − SUM(out)` over
|
||||
`productstocks`, computed under the row locks. `productlocations.status` is a
|
||||
*derived cache* re-synced from that balance after every movement, in the same
|
||||
transaction, by the same rule on both the sale side and the receiving side. A
|
||||
commit message captures the earlier bug this replaced: receiving stock used to
|
||||
overwrite `products.productstatus` — a per-product **lifecycle** column — with an
|
||||
**availability** value, destroying the lifecycle state of well over a hundred
|
||||
products. Availability is a per-outlet fact and a single column on `products`
|
||||
cannot express it, since the same product can be stocked at one outlet and empty at
|
||||
another.
|
||||
|
||||
**Order-number allocation.** `nextSequenceNo` does read-and-increment in a single
|
||||
`UPDATE … RETURNING` inside the caller's transaction. The doc comment is a small
|
||||
forensic report on the previous implementation: two separate calls on `r.db` (not
|
||||
the transaction), so concurrent orders read the same value; a `NULL` counter made
|
||||
`COALESCE(MAX(x)+1, 1)` evaluate `NULL+1 = NULL` and fall through to a hardcoded
|
||||
`"<tenantid>-1"`, so a whole cohort of live orders share one id; tenants with
|
||||
multiple sequence rows hit a `GROUP BY` where the read kept the first row and the
|
||||
write updated all of them. The fix pins to `MIN(sequenceid)`, seeds a NULL from the
|
||||
tenant's existing order count (guaranteed ≥ any id already issued, so recovery never
|
||||
reissues), and creates the row on first use.
|
||||
|
||||
**Two rounding conventions, kept apart on purpose.** `legacyOrderQty` (truncate,
|
||||
floor at 1) is preserved *exactly* for app orders — changing it would silently
|
||||
alter stock deduction for every order in production. `roundStockQty` (ceil) is used
|
||||
by the POS path. Both are named, tested, and flagged in a comment as something to
|
||||
reconcile once someone owns the decision. That is the right call: the divergence is
|
||||
documented rather than papered over.
|
||||
|
||||
**Deduplication without a natural key.** The spreadsheet importer dedupes on
|
||||
`orders.remarks = "OFFLINE:<billno>"` under an advisory lock. When the sheet has no
|
||||
bill number, an **FNV-1a hash of the bill's own contents** (date, mobile, payment
|
||||
mode, and each line's product/qty/price) stands in — so re-uploading the same file
|
||||
is a no-op rather than a double stock deduction. The trade-off is stated: two
|
||||
genuinely separate identical baskets on the same day with no bill numbers will
|
||||
collide, and the result *names* the collision rather than hiding it.
|
||||
|
||||
**Referential scaffolding.** The order-listing query INNER JOINs five tables, so an
|
||||
imported order with a zero `applocationid` or `customerid` would be written
|
||||
successfully and then be **invisible in every screen**.
|
||||
`resolveOfflineLocationContext` is the single place where "this location belongs to
|
||||
this tenant" is established (so editing a locationid in a spreadsheet reaches
|
||||
nothing), and it fills the scaffolding from `tenantlocations` plus the most recent
|
||||
real order at that outlet — because `tenantlocations` carries 0 for
|
||||
`moduleid`/`partnerid` at outlets whose live orders use non-zero values. It refuses
|
||||
outright rather than writing an order that will never be visible.
|
||||
|
||||
**Batch semantics.** Each bill is its own transaction, so one bad row cannot undo
|
||||
the rest, and the response says exactly which landed, which were duplicates, and
|
||||
which failed with why. Branch context and catalogue are memoised per outlet so a
|
||||
workbook covering six branches does not re-run both queries per bill.
|
||||
|
||||
Also here: `priceOrderLines` fills lines the client sent unpriced from the
|
||||
merchant's own catalogue, using arithmetic that *deliberately matches* the offline
|
||||
importer exactly — gross, minus discount, tax extracted from the landing amount
|
||||
because shelf prices are MRP (tax inside). One convention for both channels, so the
|
||||
same basket rings up the same either way. This fixed a real bug where
|
||||
catalogue-imported products had no per-store price, so real delivered orders
|
||||
recorded zero revenue.
|
||||
|
||||
---
|
||||
|
||||
## 5. Brand catalogue bridge and the rest of the platform
|
||||
|
||||
### What it is
|
||||
|
||||
A **second, isolated Postgres database** holds a curated master catalogue of
|
||||
packaged goods, organised one table per brand, with rich metadata (title,
|
||||
description, nutrients, highlights, FSSAI licence, size, variant key, providers).
|
||||
Shop owners browse it and "import" products into their own store catalogue rather
|
||||
than typing them in. Product photography lives in S3-compatible object storage.
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
CatalogueDB (separate conn, may be nil) Object storage (S3-compatible)
|
||||
brand_<name> tables daily/brands/{brand}/{image_id}/*
|
||||
│ │
|
||||
│ catalogueRepository │ db/imagestore.go
|
||||
│ (table name from a fixed allowlist) │ full LIST → in-memory map,
|
||||
│ │ swapped atomically, 30-min refresh
|
||||
└──────────────┬───────────────────────────────┘
|
||||
▼
|
||||
productService.ImportCatalogueProduct
|
||||
│ snapshot into tenant `products` (keyed brand+catalogueid)
|
||||
│ + link via productlocations (price, stock, status)
|
||||
▼
|
||||
nearledb: products / productlocations / productstocks
|
||||
▼
|
||||
POS catalogue pull · customer app · order lines
|
||||
```
|
||||
|
||||
### The approach
|
||||
|
||||
- **Isolation is enforced structurally.** `NewCatalogueRepository` takes the
|
||||
catalogue connection and *never* `db.DB`. If the catalogue env vars are absent,
|
||||
the connection is simply nil and catalogue endpoints return a normal error — it
|
||||
must never block startup of the main app. Same rule for Redis and the image
|
||||
store: optional dependencies degrade, they do not kill the process. The one
|
||||
dependency that *is* fatal on misconfiguration is the MQTT broker, and the
|
||||
comment says why: "coming up healthy while every till quietly queues is the worse
|
||||
failure."
|
||||
- **Table names cannot be parameterised in SQL**, so brand → table goes through a
|
||||
fixed allowlist map. That is the correct pattern.
|
||||
- The catalogue DSN is built as a URL and percent-encoded via `net/url` rather than
|
||||
a `keyword=value` DSN, because that password contains characters the keyword
|
||||
format would misparse as quoting/comment syntax.
|
||||
- GORM's raw scan silently drops slice-kind destination fields, so `text[]` columns
|
||||
are cast to text in SQL and parsed in Go.
|
||||
- Cross-brand browsing merges and sorts in Go, with an explicit note that this is
|
||||
fine at a few hundred rows and should become a `UNION ALL` if it grows.
|
||||
- The image store caches a full object listing so no GET ever calls out to S3,
|
||||
rebuilt from scratch every 30 minutes and swapped under a write lock. A nice
|
||||
deployment detail: the endpoint is bucket-qualified, and the SDK's
|
||||
virtual-hosted-style client re-prepends the bucket — so the client is pointed at
|
||||
the bare region host or requests get addressed to `bucket.bucket.…`.
|
||||
- Import is idempotent on `(tenant, brand, catalogueid)`: re-import updates pricing
|
||||
rather than creating a duplicate product.
|
||||
|
||||
**The surrounding platform** — roughly two thirds of the file count — is a
|
||||
conventional layered Fiber/GORM app: orders, deliveries (rider dispatch, status
|
||||
lifecycle with mirrored timestamps, rider/report summaries), products and stock,
|
||||
tenants and outlets, customers, partners, users, and FCM push. Most of it is CRUD
|
||||
and reporting SQL. The parts worth noting are the *repairs*, which are documented
|
||||
in-place with the evidence that motivated them:
|
||||
|
||||
- `UpdateDelivery` used to write the parent order with `WHERE orderheaderid = ?`
|
||||
from a client-supplied field. Clients often omitted it, making it `= 0`, matching
|
||||
nothing — and GORM reports no error for an update affecting zero rows, so the API
|
||||
answered success while the order silently kept its old status. Hundreds of
|
||||
deliveries were marked delivered against orders still reading pending. Fixed by
|
||||
deriving the link from `deliveryid` (the one field every caller must send) and
|
||||
failing loudly when the row is not there.
|
||||
- The rider push-notification route had been commented out since the initial commit
|
||||
— the handler, the model, the Firebase service account and the Dockerfile `COPY`
|
||||
were all in place; only the route registration was missing. So every rider push
|
||||
the admin console ever sent returned 404, and riders were assigned deliveries and
|
||||
never told.
|
||||
- New store outlets and their logins were being forced to `InActive`, which blocked
|
||||
the spawned manager login before it could ever reach the password-setup screen.
|
||||
- Tenant onboarding created admin users with `configid = 0`, which the web login
|
||||
(which queries `configid = 1`) could never find — permanently unfindable accounts.
|
||||
- Order line items were being silently dropped.
|
||||
|
||||
---
|
||||
|
||||
## The stack
|
||||
|
||||
**Language / runtime**
|
||||
|
||||
- Go 1.24
|
||||
|
||||
**Web / API**
|
||||
|
||||
- Fiber v2 (`gofiber/fiber/v2`), CORS middleware, custom `PosAuth` middleware
|
||||
|
||||
**Data**
|
||||
|
||||
- PostgreSQL (primary, `nearledb`) via GORM 1.25 + `pgx/v5` driver — heavily raw
|
||||
SQL, GORM mostly as a connection/scan layer
|
||||
- A second PostgreSQL instance for the brand catalogue (described in code as
|
||||
pgvector)
|
||||
- Redis 7-family via `go-redis/v9` — TTL-based presence, shared with a separate
|
||||
Node/Express service
|
||||
- Postgres features used directly: advisory locks (`pg_advisory_xact_lock`),
|
||||
`SELECT … FOR UPDATE`, `UPDATE … RETURNING`, `jsonb`, identity columns
|
||||
|
||||
**Messaging**
|
||||
|
||||
- Eclipse Mosquitto over MQTT, `eclipse/paho.mqtt.golang` v1.5 — QoS 1, persistent
|
||||
sessions, retained messages, Last Will
|
||||
|
||||
**Crypto / auth**
|
||||
|
||||
- `crypto/hmac` + SHA-256, custom compact token format (not JWT)
|
||||
|
||||
**Cloud / integrations**
|
||||
|
||||
- AWS SDK Go v2 S3 client pointed at an S3-compatible object store
|
||||
- Firebase Cloud Messaging via `golang.org/x/oauth2` JWT service-account flow
|
||||
(`firebase.google.com/go` present)
|
||||
|
||||
**Config / ops**
|
||||
|
||||
- `godotenv`, `spf13/viper`, `time/tzdata` (Asia/Kolkata baked in)
|
||||
- Multi-stage Dockerfile → static binary on Alpine
|
||||
- Kubernetes StatefulSet *(inferred — from the `HOSTNAME` ordinal election logic
|
||||
and the `-0` convention, not from manifests in this repo)*
|
||||
|
||||
**Testing**
|
||||
|
||||
- Go stdlib `testing`, table-driven, with hand-rolled fakes for the MQTT client and
|
||||
the POS service — no mocking framework
|
||||
|
||||
**Consumers of this API** *(not in this repo; described in docs and comments)*: a
|
||||
Flutter POS terminal app with local SQLite, a React/TypeScript merchant console, a
|
||||
consumer mobile app, a rider app, and a separate Node/Express backend sharing the
|
||||
Redis instance.
|
||||
|
||||
---
|
||||
|
||||
## What's genuinely hard here
|
||||
|
||||
**1. The ack protocol and idempotency, together.** Anyone can write "insert if not
|
||||
exists." What is hard is the discipline that follows from at-least-once delivery
|
||||
when the payload is *money that has already changed hands*: a duplicate must be
|
||||
reported as success, silence must never be interpreted as acceptance, "reject" must
|
||||
be reserved for permanent faults, transport errors must produce *no* ack at all,
|
||||
and the whole thing has to hold under a shutdown. The code gets all five right and
|
||||
the reasoning is written down at each decision point. The
|
||||
advisory-lock-then-check pattern — turning a constraint violation into an orderly
|
||||
"already held" — is the specific move to point to.
|
||||
|
||||
**2. The delta/snapshot invariant in the catalogue pull.** The failure mode —
|
||||
`is_delta: false` on a filtered response empties a real shop's shelf — is the kind
|
||||
of bug that only shows up in a store, at a counter, with a queue. Deriving the
|
||||
filter and the flag from a *single* value so no code path can set them
|
||||
inconsistently is the right structural answer, not a defensive check. The
|
||||
pagination detail (never advance the revision mid-pull) and the one-second cutoff
|
||||
overlap are both real distributed-systems reasoning: each is a choice about which
|
||||
direction to fail in, and each picks "redundant work" over "silent permanent data
|
||||
loss."
|
||||
|
||||
**3. Backpressure that reaches the physical device.** The chain is: bounded queue
|
||||
blocks → paho's single delivery goroutine stalls → broker's in-flight window fills
|
||||
→ broker stops sending → till holds its bills. Every link is a deliberate
|
||||
configuration choice (`SetOrderMatters(true)` exists solely to preserve link two).
|
||||
The alternative designs are both worse in ways that are only obvious once you have
|
||||
reasoned it through: unbounded goroutines exhaust the connection pool and stall
|
||||
everything at once; dropping work loses a sale you had already accepted. Plus the
|
||||
pool's close race — recognising that `select` over done-and-jobs picks randomly and
|
||||
can panic on a closed channel, and solving it with a read-lock held *across the
|
||||
send* — is a genuinely subtle piece of Go concurrency.
|
||||
|
||||
**4. Retrofitting authorisation onto a live, unauthenticated fleet.** The
|
||||
intellectual move is small and correct: **invert the direction of the store id.**
|
||||
It was an input (typed into Settings, believed on the wire); it becomes an output
|
||||
(derived from the authenticated user's record, sealed under a signature).
|
||||
Everything else follows — `PosUserRequest` having no tenant field, the middleware's
|
||||
tenant-owns-outlet cross-check, the read-the-body-not-just-the-query detail that
|
||||
closes the hole on the exact routes that write. The rollout strategy (ship the
|
||||
endpoint, let the fleet adopt, then flip `POS_AUTH_REQUIRED`) is how you do this
|
||||
without stopping a hundred shops trading, and the code is honest that the flag is a
|
||||
temporary state and not a design.
|
||||
|
||||
**5. Sharing one transactional path across three channels without forking it.**
|
||||
`createOrderTx`'s contract — *on failure it has already rolled back; on success the
|
||||
transaction is left open so the caller can put its own dedup guard inside the same
|
||||
transaction* — is unusual and slightly dangerous, but it is what allows an app
|
||||
order, a spreadsheet import and a POS bill to share row-locking, availability
|
||||
checks, ledger writes and sequence allocation. The alternative (three
|
||||
implementations of the anti-overselling rule) is exactly the drift that produces
|
||||
"empty in the database, full on the shelf."
|
||||
|
||||
**6. Making a hostile schema work without a migration.** This is unglamorous and it
|
||||
is a lot of the actual difficulty. Non-unique `authname`; a `bigint` PIN column
|
||||
that eats leading zeros; a `roleid` of 0 that is not a role; `app_roles` with six
|
||||
rows for four roles and most accounts carrying an id that is not in it; a
|
||||
`registrationno` column that is really the GSTIN; `configid` varying per tenant
|
||||
with no way to look it up; a SKU column where thousands of products share one
|
||||
value; tax rates in live data that include 3, 7, 15 and −1 when Indian GST only has
|
||||
0/5/12/18/28. Each of these gets a handler *and a written justification measured
|
||||
against the actual data*, rather than a schema change nobody has the appetite to
|
||||
run. The negative-GST floor is a good example of why this matters: a negative rate
|
||||
would put negative tax on a bill and a negative figure in a slab on a **filed tax
|
||||
return**.
|
||||
|
||||
**7. The commenting itself.** Worth calling out explicitly. Nearly every non-obvious
|
||||
decision carries a comment that states the alternative considered, the failure it
|
||||
prevents, and often the count of live rows that motivated it. Several read as small
|
||||
post-mortems (the sequence-number one, the delivery-status one). That is a real
|
||||
engineering artefact, not decoration — it is what makes the codebase maintainable
|
||||
by someone who was not there.
|
||||
|
||||
---
|
||||
|
||||
## Flags before publishing any of this
|
||||
|
||||
### Security issues found while reading
|
||||
|
||||
1. **A Firebase service-account JSON key is committed to the repository** and
|
||||
`COPY`'d into the Docker image by the Dockerfile. That is a live private key in
|
||||
version control. Rotate it and move it to a secret/volume mount.
|
||||
2. **`.env` was tracked until 2026-08-03.** The `.gitignore` says so itself and
|
||||
notes the database credentials are still in history and the password should be
|
||||
rotated. That does not appear to have happened.
|
||||
3. **SQL injection in `tenantRepository.CheckTenantByNo`** — the contact number is
|
||||
string-concatenated into raw SQL. Everything else in the codebase
|
||||
parameterises; this one does not.
|
||||
4. **Passwords are stored and compared in plaintext platform-wide.** There is an
|
||||
explicit `TODO` acknowledging it, correctly noting a POS token minted off a
|
||||
plaintext password is only as good as that column. The comparison is at least
|
||||
constant-time.
|
||||
5. **The main web/mobile API has no authentication at all.**
|
||||
`SECURITY_HANDOFF.md` documents this: identity comes from client-supplied query
|
||||
params, so requesting another tenant's id returns their data. Eight IDOR
|
||||
endpoints were patched with controller-level scoping guards, but the doc is
|
||||
clear that the root fix has not started.
|
||||
6. **`POS_AUTH_REQUIRED` defaults to false**, so the POS surface is open unless
|
||||
explicitly enabled — intentional and documented, but worth confirming whether it
|
||||
has been flipped in production.
|
||||
7. **The `/web/tenants/*` and `/mob/tenants/*` staff routes mint till credentials
|
||||
on unauthenticated requests.** The route file flags this itself.
|
||||
8. **CORS is `AllowOrigins: "*"` with `AllowCredentials: true`** — that combination
|
||||
is rejected by browsers and is a smell either way.
|
||||
|
||||
### Commercially sensitive — genericise before this goes public
|
||||
|
||||
- **Named brand partners** in the catalogue allowlist (six FMCG brands, some of
|
||||
them major). That is a partnership roster.
|
||||
- **The production hostname** and the deployed API base path, which appear in the
|
||||
proof scripts under `scratch/`.
|
||||
- **Live customer/tenant identifiers** — the scratch scripts and doc examples
|
||||
contain real tenant ids, location ids, store names and at least one real email
|
||||
address. All of them have been kept out of this document.
|
||||
- **Data-quality statistics about the live estate** (row counts, how many products
|
||||
share a SKU, how many accounts use a given PIN, duplicate-order-id counts,
|
||||
desynced-delivery counts). These make the writeup much more convincing, but they
|
||||
are an unflattering audit of a client's production data. Keep the reasoning and
|
||||
drop or round the numbers — "thousands of products shared a single SKU value"
|
||||
carries the point without publishing the audit. Exact figures have been rounded
|
||||
or removed here already.
|
||||
- **The terminal sync contract itself** (topic structure, ack semantics, revision
|
||||
format). It is the interface between two of their products; the *techniques* are
|
||||
portfolio-safe, the exact wire protocol less so.
|
||||
|
||||
### Inferred rather than read directly
|
||||
|
||||
Deployment as a Kubernetes StatefulSet (from the ordinal election logic, not from
|
||||
manifests); the existence and behaviour of the Flutter till app, the React console,
|
||||
the consumer/rider apps and the Express backend (from docs and comments — none are
|
||||
in this repo); and that the catalogue database uses pgvector (asserted in comments,
|
||||
but nothing in this repo issues a vector query).
|
||||
441
docs/SCAN_TO_ORDER.md
Normal file
441
docs/SCAN_TO_ORDER.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# Scan-to-order — mobile integration
|
||||
|
||||
A customer photographs a product. Google Lens (on the phone) turns the photo
|
||||
into a label — `"Milk Bikis"`, `"Dabur Honey 500g"`. The app sends that label
|
||||
here and gets back: what the product is, which of the customer's stores sell
|
||||
it, in which sizes, with live stock, nearest first, and which store we
|
||||
recommend. When the customer taps a store and a size, a second call confirms
|
||||
the shelf still has it — and if it does not, names the next-nearest store
|
||||
that does.
|
||||
|
||||
When the label fits several products — `"britannia"` names 258 of them — it
|
||||
answers with a short "did you mean?" list instead of picking one, because a
|
||||
confident price on the wrong biscuit is worse than one extra tap.
|
||||
|
||||
Base path: `/live/api/v1/mob/scan`. Every response uses the usual envelope
|
||||
`{ code, status, message, details }`; the shapes below are `details`.
|
||||
|
||||
## The flow
|
||||
|
||||
```
|
||||
photo ──Lens──▶ label
|
||||
│
|
||||
▼
|
||||
POST /lookup ───▶ ambiguous:true + candidates[] "did you mean?"
|
||||
│ │
|
||||
│ customer taps one candidate
|
||||
│ │
|
||||
│ POST /lookup { brand, catalogueid }
|
||||
│ │
|
||||
└───▶ match + stores[] (recommended first) ◀──┘
|
||||
│
|
||||
customer taps a store + a size
|
||||
│
|
||||
▼
|
||||
POST /confirm ───▶ ok:true → add to basket with existing order APIs
|
||||
ok:false + alternative → offer the other store
|
||||
```
|
||||
|
||||
**`/lookup` has two possible answers and the app must handle both.** A label
|
||||
that names one product comes back with `match` + `stores`. A label that fits
|
||||
several — a bare brand name like `"britannia"`, a generic word like
|
||||
`"biscuits"` — comes back with `ambiguous: true` and `candidates`, and the
|
||||
app asks the customer which one before any price is shown. Lens returns a
|
||||
bare wordmark often, because it is usually the biggest thing printed on a
|
||||
packet, so this is a normal path and not an error case.
|
||||
|
||||
`GET /stores` is for the "choose another shop" sheet: the customer's
|
||||
registered stores, nearest first, independent of any product.
|
||||
|
||||
## `POST /lookup`
|
||||
|
||||
Note the `//` notes below are annotations, not JSON — strip them.
|
||||
|
||||
```json
|
||||
{
|
||||
"customerid": 5123,
|
||||
"label": "Milk Bikis",
|
||||
"latitude": 11.0290, // phone fix; optional — saved address is used without it
|
||||
"longitude": 77.0290,
|
||||
"tenantids": [1135, 1140], // optional: what the app THINKS the customer joined
|
||||
"limit": 0 // optional: max stores, 0 = all
|
||||
|
||||
// Instead of a label: name the product outright. This is how you resolve
|
||||
// a candidate the customer tapped, and how a deep link or a "buy again"
|
||||
// skips recognition. With both set, `label` is ignored.
|
||||
// "brand": "britannia", "catalogueid": 7
|
||||
}
|
||||
```
|
||||
|
||||
`label` is required **unless** `brand` and `catalogueid` are both given.
|
||||
|
||||
`tenantids` is verified, never trusted: the server intersects it with the
|
||||
`tenantcustomers` table. Ids the customer is not actually registered with
|
||||
come back in `unregistered_tenantids` — treat that as "refresh the local
|
||||
list". A list that matches nothing at all is treated as stale and all
|
||||
registered stores are used.
|
||||
|
||||
### Response A — one product identified
|
||||
|
||||
`ambiguous: false`, `match` set, `candidates` empty.
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "Milk Bikis",
|
||||
"match": {
|
||||
"brand": "britannia", "catalogueid": 7, "imageid": "britannia_milk_bikis_100g",
|
||||
"product_name": "Milk Bikis", "size": "100 g", "variant_key": "milk_bikis",
|
||||
"image": "https://…", "score": 0.94, "method": "vector+text"
|
||||
},
|
||||
"catalogue_variants": [ { "…same shape…": "100 g" }, { "…": "200 g" } ],
|
||||
"ambiguous": false,
|
||||
"candidates": [],
|
||||
"confidence": 0.94,
|
||||
"available": true,
|
||||
"recommended_locationid": 20,
|
||||
"stores": [
|
||||
{
|
||||
"tenantid": 2, "tenantname": "R Mart", "locationid": 20, "locationname": "Hopes",
|
||||
"latitude": 11.01, "longitude": 77.0, "distance_km": 3.8, "open": true,
|
||||
"deliveryradius": 5, "deliverymins": 30,
|
||||
"recommended": true, "available": true,
|
||||
"options": [
|
||||
{ "productid": 200, "productname": "Milk Bikis 100g", "size": "100 g", "price": 12, "stock": 6,
|
||||
"available": true, "is_variant": false, "matched_by": "imageid", "image": "…" },
|
||||
{ "productid": 201, "productname": "Milk Bikis 200g", "size": "200 g", "price": 22, "stock": 3,
|
||||
"available": true, "is_variant": true, "variantname": "200 g", "matched_by": "variant-of:200" }
|
||||
]
|
||||
},
|
||||
{ "locationid": 10, "locationname": "Peelamedu", "distance_km": 0.9, "available": false, "recommended": false,
|
||||
"options": [ { "productid": 100, "stock": 0, "available": false, "…": "…" } ] }
|
||||
],
|
||||
"unregistered_tenantids": [],
|
||||
"message": "Available at 1 of your stores."
|
||||
}
|
||||
```
|
||||
|
||||
### Response B — several products fit, none clearly
|
||||
|
||||
`ambiguous: true`, `match: null`, `stores: []`. Show a "did you mean?" list.
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "britannia",
|
||||
"match": null,
|
||||
"ambiguous": true,
|
||||
"candidates": [
|
||||
{ "brand": "britannia", "catalogueid": 23, "product_name": "Britannia Marie Gold",
|
||||
"size": "250 g", "image": "https://…", "score": 0.95, "method": "text", "available": true },
|
||||
{ "brand": "britannia", "catalogueid": 22, "product_name": "Britannia Good Day Butter Cookies",
|
||||
"image": "https://…", "score": 0.95, "method": "text" },
|
||||
{ "brand": "britannia", "catalogueid": 21, "product_name": "Britannia Good Day Cashew Cookies",
|
||||
"image": "https://…", "score": 0.95, "method": "text" }
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"available": false,
|
||||
"stores": [],
|
||||
"catalogue_variants": [],
|
||||
"message": "Which one is it? 1 of these 3 are in stock near you."
|
||||
}
|
||||
```
|
||||
|
||||
- **`confidence` is not low here, and that is not a bug.** "britannia" really
|
||||
does appear in all three names, so relevance is high — what is missing is
|
||||
*identification*. Gate on `ambiguous`, never on `confidence`: an app that
|
||||
reads 0.95 as "sure enough to show a price" reintroduces the exact bug this
|
||||
path exists to prevent.
|
||||
- **`available` on a candidate** means at least one of the customer's
|
||||
registered stores has it in stock right now. Candidates are ordered
|
||||
available-first, so the list can show what is buyable before what is not
|
||||
— and the field is absent (not `false`) when unavailable, so read it as
|
||||
falsy, not as a required key.
|
||||
- **To resolve a pick**, call `/lookup` again with that candidate's `brand`
|
||||
and `catalogueid` and no label. You get Response A for that exact product,
|
||||
with `method: "direct"` and `confidence: 1`.
|
||||
- At most 10 candidates come back.
|
||||
|
||||
### How to read either response
|
||||
|
||||
- `match == null && !ambiguous` → nothing recognised; show `message` and let
|
||||
them retry with a clearer photo.
|
||||
- `ambiguous: true` → ask, do not guess. Never show a price on this path;
|
||||
`stores` is deliberately empty.
|
||||
- `confidence` below ~0.5 with a `match` → recognised but unsure; worth
|
||||
confirming the name before showing prices. `method: "text"` means no
|
||||
embedding model was involved (not configured, or it timed out) — be a
|
||||
little more cautious. `method: "direct"` means the caller named the
|
||||
product, so nothing was recognised at all.
|
||||
- `stores` is ordered **in-stock first, then nearest**. Exactly one store has
|
||||
`recommended: true` — the nearest with stock — and only when `available`
|
||||
is true. Stores that sell it but have nothing on the shelf are still listed
|
||||
(so the customer understands why they are not recommended); stores that do
|
||||
not sell it are not.
|
||||
- `options` are the things that can actually go in a basket at that store —
|
||||
the matched product and each of its sizes — each a real product with its
|
||||
own `productid`, price and live `stock`. Use `productid` in the existing
|
||||
cart/order calls exactly as you would from the catalogue screen.
|
||||
- `distance_km: -1` means the distance is unknown (no fix from the phone and
|
||||
no saved address, or the store has no coordinates). Do not render it as 0.
|
||||
Send `latitude`/`longitude` on `/confirm` too if you display distance from
|
||||
its reply: the saved address is only consulted there when the shelf is
|
||||
empty and alternatives have to be ranked, so without a fix the store you
|
||||
tapped comes back `-1`.
|
||||
|
||||
## `POST /confirm`
|
||||
|
||||
Sent when the customer taps a store and an option. Re-reads live stock —
|
||||
nothing is cached on this path.
|
||||
|
||||
```json
|
||||
{ "customerid": 5123, "tenantid": 1, "locationid": 10, "productid": 100, "quantity": 2,
|
||||
"latitude": 11.029, "longitude": 77.029 }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"reason": "out_of_stock", // in_stock | insufficient_stock | out_of_stock | not_sold_here | store_not_registered
|
||||
"store": { "…the store they tapped…" }, // distance_km filled from the fix you send
|
||||
"option": { "productid": 100, "stock": 0, "…": "…" },
|
||||
"requested": 2,
|
||||
"alternative": { // absent when nobody has enough
|
||||
"locationid": 20, "locationname": "Hopes", "distance_km": 3.8, "recommended": true, "available": true,
|
||||
"options": [ { "productid": 200, "stock": 6, "price": 12, "…": "…" } ]
|
||||
},
|
||||
"message": "Out of stock at Peelamedu. Hopes has it (3.8 km away)."
|
||||
}
|
||||
```
|
||||
|
||||
`ok: true` → proceed to the basket. `ok: false` → show `message`; if
|
||||
`alternative` is present offer it as a one-tap switch (it is the **same
|
||||
product**, not another size — the customer chose a size and we do not
|
||||
substitute). These are HTTP 200s: they are answers, not errors.
|
||||
|
||||
## `GET /stores?customerid=5123&latitude=11.029&longitude=77.029`
|
||||
|
||||
The customer's registered stores, nearest first, `distance_km: -1` last.
|
||||
Same `ScanStore` shape as inside `stores[]` above, without options.
|
||||
|
||||
## Errors (HTTP status ≠ 200)
|
||||
|
||||
| Status | When |
|
||||
|---|---|
|
||||
| 400 | Missing `customerid`/`label`/ids, or a body that is not JSON. `message` says which. |
|
||||
| 404 | `customerid` does not exist. |
|
||||
| 503 | The catalogue database is not reachable. Retry later; the rest of the app is unaffected. |
|
||||
| 500 | Anything else. Logged server-side. |
|
||||
|
||||
## Behind the curtain (for whoever operates it)
|
||||
|
||||
- **Recognition** = pgvector cosine search over every `brand_*` table in the
|
||||
catalogue (each with its own index, merged), plus a word match on
|
||||
`product_name`/`title`/`search_query` that settles near-ties and works on
|
||||
its own when no embedding model is configured. The model is set by
|
||||
`EMBEDDING_PROVIDER/MODEL/API_KEY` and **must** be the one that indexed
|
||||
the catalogue — the first search checks the vector width and refuses a
|
||||
mismatch by name.
|
||||
- **The word match asks for most of the label, not all of it**
|
||||
(`minTokenHits`: two thirds, rounded up, and both of a two-word label).
|
||||
Requiring every word meant one word the catalogue does not use took the
|
||||
right product out of the running entirely — "Dettol bottle pack" retrieved
|
||||
no Dettol, "Parle G biscuit pack" retrieved no Parle-G — and the vector
|
||||
search then answered alone, confidently and wrongly, at a score the floor
|
||||
could not catch. Each brand's rows are ordered by how much of the label
|
||||
they carry (the whole label as a substring outranks any number of loose
|
||||
words) so that the per-brand `LIMIT` keeps the best rows and not merely the
|
||||
first ones the planner reached. Packaging words — "pack", "bottle", "jar",
|
||||
"sachet" and friends, see `utils.isPackaging` — are dropped before any of
|
||||
this, like pack sizes, unless the label is nothing else.
|
||||
- **The catalogue's model** (verified 2026-09-15 by cosine against a stored
|
||||
row: 1.0000): `all-MiniLM-L6-v2`, 384-d, unit-normalised, embedding the
|
||||
`search_query` column (brand + name + category + blurb + price range).
|
||||
Ollama ships it as `all-minilm`; the cluster's `ollama.krow` service serves
|
||||
it, so production is:
|
||||
```
|
||||
EMBEDDING_PROVIDER=openai
|
||||
EMBEDDING_BASE_URL=http://ollama.krow.svc.cluster.local:11434/v1
|
||||
EMBEDDING_MODEL=all-minilm
|
||||
EMBEDDING_API_KEY=ollama # any non-empty value; Ollama ignores it
|
||||
EMBEDDING_DIMENSIONS=384
|
||||
```
|
||||
A bare label ("Milk Bikis") scores ~0.92 against its product's stored
|
||||
vector and ~0.23 against an unrelated one, which is what the 0.50 floor in
|
||||
`scanService.go` is set against — the middle of that split, not the edge of
|
||||
the noise. It was 0.30 until a near-miss got through in production
|
||||
("Paracetamol" → "Paneer Makhni 500ml", 0.304). If the catalogue team ever
|
||||
re-embeds with another model, change `EMBEDDING_MODEL`/`DIMENSIONS` here
|
||||
and nothing else.
|
||||
- **Speed**: the label's vector (7 days) and the ranked catalogue hits
|
||||
(30 min) are cached in Redis and in-process, so a popular product costs
|
||||
one model call platform-wide. Customer, stores and catalogue are read
|
||||
concurrently; the whole lookup is capped at 5 s and a slow model degrades
|
||||
to a text answer instead of a spinner. Live stock is one indexed query and
|
||||
is never cached.
|
||||
- **Availability** is the same rule the app's catalogue screen uses:
|
||||
`products.approve = 1`, `productlocations.publishedat IS NOT NULL`, stock =
|
||||
live `SUM(in) − SUM(out)` of `productstocks` at that outlet, price = the
|
||||
outlet's own price else the tenant's retail price.
|
||||
- **No reservation.** Confirm re-reads the ledger; a hold would give the
|
||||
same answer with a timer to babysit. If contention becomes real, a
|
||||
Redis-backed short hold slots in at `Confirm` without changing the API.
|
||||
- **Identity** is the `customerid` in the body, like every other mobile
|
||||
endpoint here — there is no auth layer yet (see `SECURITY_HANDOFF.md`).
|
||||
|
||||
## Two decisions, and why
|
||||
|
||||
Both come from a proposal (2026-09-23) to have the app send vectors it
|
||||
computed on the phone. Recorded here because the next person will ask.
|
||||
|
||||
### The app does not send `textvector`
|
||||
|
||||
An on-device MiniLM vector is only comparable to the catalogue's if the app
|
||||
ships the identical model *and* tokenizer *and* pooling *and* normalisation;
|
||||
a quantised tflite build usually drifts, and the failure is silent — the
|
||||
ranking just gets worse. There is also nothing to gain: the server-side
|
||||
embed is ~30 ms warm and the result is cached in Redis by label, so one
|
||||
model call serves every customer who scans that product. A client-supplied
|
||||
vector *defeats* that cache (the key would have to be the vector, not the
|
||||
label), and 384 floats is ~5 KB of upload against ~12 bytes for
|
||||
`"Milk Bikis"`. If the field ever arrives it can be accepted and validated,
|
||||
but the app should not be asked to compute it.
|
||||
|
||||
**Send the full OCR text instead** if you want to give the server more to
|
||||
work with — ~100 bytes, no model coupling, strictly more information than a
|
||||
single label.
|
||||
|
||||
### The app does not send `imagevector` — yet
|
||||
|
||||
The catalogue *does* carry image vectors: every `brand_*` table has
|
||||
`img_vector vector(1024)`, filled on 1885 of 2124 rows (empty in
|
||||
`brand_haldirams`, `brand_kaleesuwari`, `brand_mdh`, `brand_zzsmoketest`).
|
||||
That matches the proposed MobileNetV3-Small embedder, so the idea is
|
||||
coherent and half-built — this flow simply does not read that column.
|
||||
|
||||
It stays unread for now because **Google Lens is already the image
|
||||
recogniser, and a far better one**: photo → Lens → label is Google's product
|
||||
recognition, trained on billions of images. Putting a 137M-parameter
|
||||
ImageNet backbone searching 1885 vectors *behind* that adds little where
|
||||
Lens succeeds, and MobileNetV3-Small — which struggles to tell one blue
|
||||
biscuit wrapper from another — is unlikely to rescue the cases where Lens
|
||||
fails. There is also an unverified dependency: the preprocessing the app
|
||||
would use (BGR → centre crop → 224×224 INTER_AREA → RGB → `/255.0`) has to
|
||||
match whatever the catalogue pipeline actually ran, or the search returns
|
||||
confidently-ranked noise.
|
||||
|
||||
**What would change this:** the field data. Once live, count how often
|
||||
`/lookup` returns `ambiguous: true` or nothing recognised. If Lens labels are
|
||||
reliable, image search is polish; if that number is high, it becomes the
|
||||
priority — and the first task is the cosine check (embed a known catalogue
|
||||
product's image through the app's exact pipeline, compare with its stored
|
||||
`img_vector`; ≈0.99 means the contract holds), not writing the query.
|
||||
|
||||
There is one non-recognition argument for it worth remembering: on-device
|
||||
inference is free and needs no Google dependency, which matters if Cloud
|
||||
Vision costs start to bite at volume. That is a business reason, not a
|
||||
quality one.
|
||||
|
||||
## For backend developers
|
||||
|
||||
### Where the code is
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `models/scan.go` | request/response shapes (`ScanLookupRequest`, `ScanStoreOffer`, `ScanOption`, …) |
|
||||
| `repositories/scanRepository.go` | all SQL: registered stores, live options, vector + text search, the two-tier cache |
|
||||
| `services/scanService.go` | the pipeline: parallel reads, scoring, family grouping, ranking, confirm fallback |
|
||||
| `controllers/scanController.go` | the three handlers and the error → status mapping |
|
||||
| `routes/scanroutes.go` | `/v1/mob/scan/*` |
|
||||
| `utils/embedding.go` | `Embedder` interface, OpenAI-compatible and Gemini clients |
|
||||
| `utils/geo.go` | coordinate parsing, haversine, opening hours, label tokenising |
|
||||
| `config/config.go` | `EmbeddingConfig` and its validation |
|
||||
| `scratch/cataloguedims` | read-only check of every catalogue vector column's width and fill |
|
||||
|
||||
### Try it locally
|
||||
|
||||
```sh
|
||||
go run . # with the local compose stack; EMBEDDING_* unset → text-only, still works
|
||||
curl -s localhost:1122/live/api/v1/mob/scan/lookup -H 'Content-Type: application/json' \
|
||||
-d '{"customerid":1,"label":"Milk Bikis","latitude":11.03,"longitude":77.03}' | jq .details
|
||||
```
|
||||
|
||||
To exercise the vector path locally, run Ollama on your Mac
|
||||
(`ollama pull all-minilm`) and set `EMBEDDING_PROVIDER=openai`,
|
||||
`EMBEDDING_BASE_URL=http://localhost:11434/v1`, `EMBEDDING_MODEL=all-minilm`,
|
||||
`EMBEDDING_API_KEY=ollama`, `EMBEDDING_DIMENSIONS=384` in `.env.local`. The
|
||||
local catalogue must carry vectors from the same model for results to mean
|
||||
anything; a schema-only dump does not.
|
||||
|
||||
### Tests
|
||||
|
||||
`go test ./services -run 'Lookup|Confirm|Stores|Brand|Ambiguous|Specific|TextScore|Distinct|Naming'`
|
||||
drives the whole pipeline through a fake repository
|
||||
(`services/scan_test.go`); no database. `go test ./utils` covers both HTTP
|
||||
clients against `httptest` servers, and the geo helpers. Add a case to
|
||||
`scan_test.go`'s fixture when you change ranking — it is the spec, and
|
||||
`newBrandLabelFixture` in particular is the regression guard for the
|
||||
brand-name bug described under Scoring.
|
||||
|
||||
### Knobs (constants in `scanService.go`)
|
||||
|
||||
| Constant | Default | Effect |
|
||||
|---|---|---|
|
||||
| `scanLookupTimeout` | 5 s | whole lookup, including the model call |
|
||||
| `scanCatalogueTopK` | 15 | rows taken from each brand table and from the merge |
|
||||
| `scanMinScore` | 0.50 | below this the best hit is not shown as a match |
|
||||
| `scanAmbiguityMargin` | 0.06 | how close the runner-up may be before the answer becomes a question |
|
||||
| `scanMaxCandidates` | 10 | longest "did you mean?" list |
|
||||
| `embedTimeout` (`utils/embedding.go`) | 4 s | one model call |
|
||||
| `scanVectorTTL` / `scanHitsTTL` (`scanRepository.go`) | 7 d / 30 min | cache lifetimes |
|
||||
|
||||
Scores: vector = `1 − cosine distance`; text = 0.95 for the whole label
|
||||
inside the name, else `0.8 × (label words found / label words)`; combined =
|
||||
`max(vector, text) + 0.10` when both hit, capped at 1. Ties are broken by
|
||||
cosine distance — nearest first, a text-only row last — and only then by
|
||||
name.
|
||||
|
||||
The label and the product name are both separator-folded before that
|
||||
substring test (`utils.FoldSeparators`), and compared again with separators
|
||||
removed (`utils.TightenLabel`, labels of 4+ characters), so the brand's own
|
||||
punctuation does not decide the match: "Parle G", "Parle-G" and "ParleG" all
|
||||
reach *Parle-G Original Glucose Biscuits*. A single-character token survives
|
||||
tokenising when it follows a word, because it is often the whole name — the
|
||||
"G" of Parle-G, the "K" of Special K. It is still dropped when it stands
|
||||
alone or is a pack multiplier.
|
||||
|
||||
All three mattered at once: before this, "Parle G" tied with *Parle Monaco
|
||||
Classic* at 0.9 (the "G" was dropped, so only "parle" matched either row),
|
||||
and the name tie-break handed it to Monaco because a space precedes a hyphen
|
||||
in ASCII. A confident, wrong answer — the kind no score floor can catch.
|
||||
|
||||
**When the substring rule ties, that tie is the answer.** A bare brand name
|
||||
is a substring of every one of that brand's names, so all of them score 0.95
|
||||
— identically, at a high score no floor would ever catch. Rather than
|
||||
scoring around it, `isAmbiguous` reads it: if the runner-up is within
|
||||
`scanAmbiguityMargin` of the leader, the reply becomes `ambiguous: true`
|
||||
with `candidates` instead of a match (see Response B). Erring towards asking
|
||||
is deliberate — one tap on a picture against the wrong biscuit. A label that
|
||||
names one product leaves the runner-up far behind, so the common case is
|
||||
untouched, and `services/scan_test.go`'s
|
||||
`TestABrandNameScoresItsProductsIdentically` guards the tie itself: a
|
||||
formula that broke it on name length or word count would bring the bug
|
||||
back.
|
||||
|
||||
### Changing the embedding model
|
||||
|
||||
1. The catalogue team re-embeds `search_query` with the new model.
|
||||
2. Serve it (Ollama pull, or a hosted key).
|
||||
3. Change `EMBEDDING_MODEL` / `EMBEDDING_DIMENSIONS` (and provider/URL if
|
||||
needed) in the cluster; roll the pods.
|
||||
4. Flush the hit cache if you cannot wait 30 min: keys are
|
||||
`scan:hits:v1:*` and `scan:emb:v1:*` in Redis (they are also keyed by
|
||||
model name, so old entries simply stop being read).
|
||||
|
||||
Nothing in Go changes. A width mismatch fails the first search with an error
|
||||
naming both numbers.
|
||||
|
||||
### Adding a provider
|
||||
|
||||
Implement `utils.Embedder` (`Embed(ctx, text) ([]float32, error)` and
|
||||
`Model() string`), add a case to `NewEmbedder`, and add the provider name to
|
||||
the allow-list in `config.validate`. Keep the HTTP client timeout: the
|
||||
customer is holding a phone.
|
||||
@@ -1,9 +1,14 @@
|
||||
package facade
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"nearle/config"
|
||||
"nearle/controllers"
|
||||
"nearle/repositories"
|
||||
"nearle/services"
|
||||
"nearle/services/tools"
|
||||
"nearle/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -22,6 +27,18 @@ type Facade struct {
|
||||
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
|
||||
// HTTP. Unexported: everything else should use the controller.
|
||||
@@ -32,11 +49,30 @@ type Facade struct {
|
||||
// catalogueDB is a separate connection to the pgvector catalogue database;
|
||||
// it may be nil if catalogue env vars are not configured, in which case
|
||||
// catalogue endpoints will error at query time rather than at startup.
|
||||
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||
// embedder may be nil too: scan-to-order then matches on words alone.
|
||||
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, 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
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
userService := services.NewUserService(userRepo)
|
||||
userService := services.NewUserService(userRepo, inviteService)
|
||||
userController := controllers.NewUserController(userService)
|
||||
|
||||
// Catalogue Module (separate pgvector DB — never the main `db`). Built
|
||||
@@ -47,14 +83,29 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||
catalogueController := controllers.NewCatalogueController(catalogueService)
|
||||
|
||||
// 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)
|
||||
productService := services.NewProductService(productRepo, catalogueService)
|
||||
productService := services.NewProductService(
|
||||
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
|
||||
productController := controllers.NewProductController(productService)
|
||||
|
||||
// When each branch delivers.
|
||||
//
|
||||
// BEFORE the order controller, which takes it: order creation re-checks a
|
||||
// chosen window against the same rule the app was shown, so the two cannot
|
||||
// drift. No dependency the other way — this service knows nothing of orders.
|
||||
deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
|
||||
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
|
||||
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService)
|
||||
|
||||
// Order Module
|
||||
orderRepo := repositories.NewOrderRepository(db)
|
||||
orderService := services.NewOrderService(orderRepo)
|
||||
orderController := controllers.NewOrderController(orderService)
|
||||
orderController := controllers.NewOrderController(orderService, deliverySlotService)
|
||||
|
||||
// Deliveries Module
|
||||
deliveriesRepo := repositories.NewDeliveriesRepository(db)
|
||||
@@ -67,8 +118,11 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||
utilsController := controllers.NewUtilsController(utilsService)
|
||||
|
||||
//Tenant Module
|
||||
tenantRepo := repositories.NewTenantRepository(db)
|
||||
tenantService := services.NewTenantService(tenantRepo)
|
||||
//
|
||||
// Onboarding, adding a person and commissioning a branch all create an
|
||||
// account with no password, so all three send an invitation. `tenantRepo` and
|
||||
// `inviteService` are built above, where the reasoning is.
|
||||
tenantService := services.NewTenantService(tenantRepo, inviteService)
|
||||
tenantController := controllers.NewTenantController(tenantService)
|
||||
|
||||
//Partner Module
|
||||
@@ -109,6 +163,79 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||
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{
|
||||
UserController: userController,
|
||||
ProductController: productController,
|
||||
@@ -123,6 +250,12 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
|
||||
PosController: posController,
|
||||
LiveController: liveController,
|
||||
CatalogueUploadController: catalogueUploadController,
|
||||
ScanController: scanController,
|
||||
DeliverySlotController: deliverySlotController,
|
||||
AssistantController: assistantController,
|
||||
HealthController: healthController,
|
||||
MCPController: mcpController,
|
||||
Tools: toolRegistry,
|
||||
posService: posService,
|
||||
}
|
||||
}
|
||||
|
||||
24
go.mod
24
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
github.com/gofiber/fiber v1.14.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/valyala/fasthttp v1.50.0
|
||||
golang.org/x/oauth2 v0.12.0
|
||||
google.golang.org/api v0.143.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
@@ -42,8 +43,8 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||
github.com/aws/smithy-go v1.27.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gofiber/utils v0.0.10 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
@@ -54,7 +55,6 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/gorilla/schema v1.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -62,28 +62,17 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.3.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.10.0 // indirect
|
||||
github.com/spf13/cast v1.5.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/stretchr/testify v1.8.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.50.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
@@ -94,15 +83,12 @@ require (
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect
|
||||
google.golang.org/grpc v1.58.2 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.50.0
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/spf13/viper v1.17.0
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
389
go.sum
389
go.sum
@@ -1,59 +1,21 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
||||
cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
|
||||
cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
|
||||
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.13.0 h1:/3S4RssUV4GO/kvgJZB+tayjhOfyAHs+KcpJgRVu/Qk=
|
||||
cloud.google.com/go/firestore v1.13.0/go.mod h1:QojqqOh8IntInDUSTAh0c8ZsPYAr68Ma8c5DWOy8xb8=
|
||||
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
|
||||
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
|
||||
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
|
||||
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
@@ -100,13 +62,8 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
@@ -118,16 +75,7 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
|
||||
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/gofiber/fiber v1.14.6 h1:QRUPvPmr8ijQuGo1MgupHBn8E+wW0IKqiOvIZPtV70o=
|
||||
github.com/gofiber/fiber v1.14.6/go.mod h1:Yw2ekF1YDPreO9V6TMYjynu94xRxZBdaa8X5HhHsjCM=
|
||||
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
|
||||
@@ -135,67 +83,34 @@ github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmh
|
||||
github.com/gofiber/utils v0.0.10 h1:3Mr7X7JdCUo7CWf/i5sajSaDmArEDtti8bM1JUVso2U=
|
||||
github.com/gofiber/utils v0.0.10/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -203,21 +118,12 @@ github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -234,24 +140,11 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -261,12 +154,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -276,37 +163,16 @@ github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQ
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
||||
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
|
||||
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
|
||||
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
|
||||
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
|
||||
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
|
||||
@@ -315,302 +181,74 @@ github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE
|
||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
|
||||
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.143.0 h1:o8cekTkqhywkbZT6p1UHJPZ9+9uuCAJs/KYomxZB8fA=
|
||||
google.golang.org/api v0.143.0/go.mod h1:FoX9DO9hT7DLNn97OuoZAGSDuNAXdJRuGK98rSUgurk=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb h1:XFBgcDwm7irdHTbz4Zk2h7Mh+eis4nfJEFQFYzJzuIA=
|
||||
google.golang.org/genproto v0.0.0-20230913181813-007df8e322eb/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb h1:lK0oleSc7IQsUxO3U5TjL9DWlsxpEBemh+zpB7IqhWI=
|
||||
@@ -618,21 +256,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230913181813-007df8e322eb/go.
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 h1:N3bU/SQDCDyD6R528GJ/PwW9KjYcJA3dgyH+MovAkIM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:KSqppvjFjtoCI+KGd4PELB0qLNxdJHRGqRI09mB6pQA=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I=
|
||||
google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
@@ -643,20 +270,12 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -665,12 +284,4 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
|
||||
@@ -39,11 +39,41 @@ inside a git repository. `.gitignore` excludes `*.sql` here for that reason.
|
||||
|
||||
## Getting something to test against
|
||||
|
||||
An empty schema boots but has no tenants, so there is nothing to sign in as.
|
||||
Two options:
|
||||
`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.
|
||||
|
||||
- **Onboard a tenant through the console** once it is pointed at localhost.
|
||||
That exercises the real path and is usually what you want.
|
||||
- **Copy a few rows** you actually need — a tenant, its locations, its
|
||||
app_users — with `pg_dump --data-only --table=...`. Check what you are
|
||||
copying: `app_users.password` is stored in clear.
|
||||
| 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');
|
||||
@@ -161,4 +161,72 @@ INSERT INTO productstocks (
|
||||
(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;
|
||||
|
||||
341
main.go
341
main.go
@@ -1,14 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/config"
|
||||
"nearle/db"
|
||||
"nearle/facade"
|
||||
"nearle/messaging"
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
"nearle/routes"
|
||||
"nearle/utils"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -18,33 +22,77 @@ import (
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
godotenv.Load()
|
||||
// corsSettings is a function so it can be tested.
|
||||
//
|
||||
// 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() {
|
||||
// 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.Use(cors.New(cors.Config{
|
||||
AllowHeaders: "Origin,Content-Type,Accept,Content-Length,Accept-Language,Accept-Encoding,Connection,Access-Control-Allow-Origin",
|
||||
AllowOrigins: "*",
|
||||
AllowCredentials: true,
|
||||
AllowMethods: "GET,POST,HEAD,PUT,DELETE,PATCH,OPTIONS",
|
||||
}))
|
||||
// Cross-origin access.
|
||||
//
|
||||
// The console is served from app.nearledaily.com and calls this host
|
||||
// directly, so every request it makes is cross-origin and the browser
|
||||
// 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...")
|
||||
db.Connect()
|
||||
db.Connect(cfg)
|
||||
fmt.Println("✅ Database connections established!")
|
||||
|
||||
// Shared with the express backend. POS terminal presence lives here under a
|
||||
// TTL; optional, because losing the health board is an inconvenience and
|
||||
// losing a sale is not.
|
||||
db.InitRedis()
|
||||
db.InitRedis(cfg.Redis)
|
||||
|
||||
// Ensure schema is updated
|
||||
db.DB.AutoMigrate(&models.StockRequest{})
|
||||
@@ -56,6 +104,24 @@ func main() {
|
||||
log.Fatal("POS schema migration failed:", err)
|
||||
}
|
||||
|
||||
// What Nearle Buddy did, and on whose behalf. Its own table: these rows are
|
||||
// written on a different schedule from anything else and are the only record
|
||||
// of an assistant acting for a merchant.
|
||||
//
|
||||
// Logged and carried on rather than fatal, unlike the migrations around it,
|
||||
// and the difference is deliberate. Those create tables the product cannot
|
||||
// trade without — a POS order has nowhere to land if its table is missing.
|
||||
// This one serves an assistant that may not even be switched on, and taking
|
||||
// the whole backend down over it would stop every shop taking orders to
|
||||
// protect a log.
|
||||
//
|
||||
// The degradation is already built: `DBAudit` writes to the log as well as
|
||||
// the table, and reports each failed insert as AUDIT ROW LOST. So a missing
|
||||
// table costs the queryable trail and nothing else, loudly.
|
||||
if err := db.DB.AutoMigrate(&models.AssistantAudit{}); err != nil {
|
||||
log.Printf("assistant: audit table unavailable, the trail is log-only: %v", err)
|
||||
}
|
||||
|
||||
// Shift windows for till staff. Additive — `app_users.shiftid` already
|
||||
// existed and pointed at the rider table, so an account with no shift is
|
||||
// simply unassigned rather than broken.
|
||||
@@ -78,6 +144,113 @@ func main() {
|
||||
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
||||
}
|
||||
|
||||
// What the global catalogue knew about this product, kept.
|
||||
//
|
||||
// The import copies eight of the catalogue's eighteen fields onto the
|
||||
// tenant's product and left the other ten behind — among them the FSSAI
|
||||
// licence, the nutrition lines, the highlights, the provider list, the
|
||||
// price range and the variant key. The console needs exactly those to
|
||||
// decide what to charge, so `ProductDrawer` went back to the catalogue for
|
||||
// them on every open.
|
||||
//
|
||||
// That lookup is not a substitute for storing them. A tenant's product is a
|
||||
// SNAPSHOT and outlives its source row: the catalogue is re-scraped, a
|
||||
// variant is retired, and the licence number and the nutrition panel for a
|
||||
// product the shop is still selling are gone with no way back. Measured
|
||||
// locally by retiring one row — the product survived, everything the drawer
|
||||
// shows about it did not.
|
||||
//
|
||||
// One jsonb column rather than six typed ones, and rather than the
|
||||
// `productspecs` table that has sat unused since the schema was written.
|
||||
// The value is a snapshot of somebody else's record, read as a whole and
|
||||
// displayed as a whole — it is never joined, aggregated or filtered — and
|
||||
// the catalogue grows fields faster than this side can add migrations.
|
||||
// Postgres can still reach inside it (`cataloguefacts->>'fssai_license'`)
|
||||
// on the day somebody needs to. `productimages` beside it made the same
|
||||
// call for the same reason.
|
||||
//
|
||||
// Not fatal on failure, exactly like the column above: a product without
|
||||
// its catalogue facts is the product we have today.
|
||||
if err := db.DB.Exec(
|
||||
`ALTER TABLE products ADD COLUMN IF NOT EXISTS cataloguefacts jsonb`).Error; err != nil {
|
||||
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
|
||||
}
|
||||
|
||||
// Whether this shop shows a health score for this product.
|
||||
//
|
||||
// The shopkeeper's call, not ours. The score comes from a third party that
|
||||
// matches a reference product by name — often at under 60% confidence — so a
|
||||
// merchant who knows the packet in front of them may reasonably decide the
|
||||
// rating does not describe what they are selling, and should be able to take
|
||||
// it off their own shelf without taking it off everybody's.
|
||||
//
|
||||
// DEFAULT TRUE, so every product already imported keeps showing exactly what
|
||||
// it shows today. A new column defaulting to false would silently strip the
|
||||
// health score from every shelf on the platform, which is a change nobody
|
||||
// asked for dressed up as a migration.
|
||||
//
|
||||
// Only the score. `nutrition` is unaffected and always sent: the figures are
|
||||
// what the packet says, while the score is somebody's judgement of them.
|
||||
if err := db.DB.Exec(
|
||||
`ALTER TABLE products ADD COLUMN IF NOT EXISTS showhealthscore boolean NOT NULL DEFAULT true`).Error; err != nil {
|
||||
log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err)
|
||||
}
|
||||
|
||||
// When a shop delivers, and which window an order chose.
|
||||
//
|
||||
// Three named windows a day per BRANCH — see models/deliveryslot.go for why
|
||||
// the scope is the branch and not the company.
|
||||
//
|
||||
// ── A branch with no rows here still trades ─────────────────────────────
|
||||
//
|
||||
// Every tenant on the platform the day this ships has no slots, and all of
|
||||
// them must keep taking orders exactly as before. No backfill, no defaults
|
||||
// written here: absence means "order without a slot", and the app is
|
||||
// required to treat an empty list as ordinary rather than as a closed shop.
|
||||
// Seeding every existing branch with invented timings would have each one
|
||||
// promising hours nobody agreed to.
|
||||
if err := db.DB.Exec(`CREATE TABLE IF NOT EXISTS deliveryslots (
|
||||
slotid SERIAL PRIMARY KEY,
|
||||
tenantid INTEGER NOT NULL,
|
||||
locationid INTEGER NOT NULL DEFAULT 0,
|
||||
slotkey TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
starttime TEXT NOT NULL,
|
||||
endtime TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`).Error; err != nil {
|
||||
log.Println("⚠️ could not create deliveryslots, delivery windows will be unavailable:", err)
|
||||
}
|
||||
|
||||
// One row per key per branch. A shop has ONE morning, and a duplicate would
|
||||
// show the shopper the same window twice with different hours — so the
|
||||
// upsert that writes these leans on this constraint rather than on a
|
||||
// read-then-write that two requests could interleave.
|
||||
if err := db.DB.Exec(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS deliveryslots_branch_key
|
||||
ON deliveryslots (tenantid, locationid, slotkey)`).Error; err != nil {
|
||||
log.Println("⚠️ could not add the deliveryslots uniqueness index, a branch may end up with duplicate windows:", err)
|
||||
}
|
||||
|
||||
// The window an order chose, and the day it falls on.
|
||||
//
|
||||
// Both nullable, and both stay empty for every order placed without a slot —
|
||||
// which is every order today and every order from a branch that never sets
|
||||
// timings. Nothing downstream may require them.
|
||||
//
|
||||
// The DATE is not redundant. "evening" cannot say tonight or tomorrow night,
|
||||
// and an order placed after the last window closes is for the next day.
|
||||
if err := db.DB.Exec(
|
||||
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotid INTEGER`).Error; err != nil {
|
||||
log.Println("⚠️ could not add orders.deliveryslotid, orders will not record a delivery window:", err)
|
||||
}
|
||||
if err := db.DB.Exec(
|
||||
`ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotdate DATE`).Error; err != nil {
|
||||
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
|
||||
}
|
||||
|
||||
// When a product became visible to a store, and the only thing that decides
|
||||
// whether it is.
|
||||
//
|
||||
@@ -172,6 +345,60 @@ func main() {
|
||||
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
|
||||
@@ -263,7 +490,74 @@ func main() {
|
||||
log.Fatal("could not add catalogueuploads.sheetrows:", err)
|
||||
}
|
||||
|
||||
f := facade.NewFacade(db.DB, db.CatalogueDB)
|
||||
// The model behind scan-to-order. Optional: without EMBEDDING_PROVIDER the
|
||||
// search matches on words, which works but ranks less well.
|
||||
embedder, err := utils.NewEmbedder(cfg.Embedding)
|
||||
if err != nil {
|
||||
log.Fatal("embedding provider:", err)
|
||||
}
|
||||
if embedder == nil {
|
||||
log.Println("scan: EMBEDDING_PROVIDER not set, product search is text-only")
|
||||
} else {
|
||||
log.Printf("scan: product search uses %s/%s", cfg.Embedding.Provider, cfg.Embedding.Model)
|
||||
}
|
||||
|
||||
// The model behind Nearle Buddy. Optional in the same way: without
|
||||
// ASSISTANT_PROVIDER the tools still work and the panel says the assistant
|
||||
// is not switched on, rather than the console showing a field that accepts
|
||||
// text and swallows it.
|
||||
chat, err := utils.NewChat(cfg.Assistant)
|
||||
if err != nil {
|
||||
log.Fatal("assistant provider:", err)
|
||||
}
|
||||
if chat == nil {
|
||||
log.Printf("assistant: OFF — %s", cfg.Assistant.Why())
|
||||
} else {
|
||||
log.Printf("assistant: %s, balanced tier is %s", cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
|
||||
}
|
||||
|
||||
// ASSISTANT_AGENTS_DIR replaces the compiled-in agent definitions wholesale.
|
||||
// Empty uses the embedded ones, so a deployment cannot be broken by a missing
|
||||
// directory.
|
||||
// Mail, for the invitation a newly onboarded merchant is sent.
|
||||
//
|
||||
// Optional in the same way as the model and the embedder: without it the
|
||||
// server still boots and still onboards tenants, and the create response
|
||||
// says the invitation was not sent and which variable is missing. Refusing
|
||||
// to start would make a mail relay a hard dependency of creating a shop,
|
||||
// which it is not.
|
||||
mailer, err := utils.NewMailer(cfg.Mail)
|
||||
if err != nil {
|
||||
// A configured-but-invalid sender, as opposed to no mail at all. That
|
||||
// fails every message, so it is worth stopping for rather than
|
||||
// discovering one silent invitation at a time.
|
||||
log.Fatal("mail:", err)
|
||||
}
|
||||
if mailer == nil {
|
||||
log.Printf("mail: OFF — %s", cfg.Mail.Why())
|
||||
} else {
|
||||
log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address())
|
||||
}
|
||||
|
||||
// NUTRITION_BASE is the catalogue-intelligence host — the same service the
|
||||
// console reads its health score card from. Unset means product screens
|
||||
// carry no nutrition panel, and nothing else changes.
|
||||
//
|
||||
// Logged for the same reason mail is, and learned the same way: with it
|
||||
// unset, `getproductbyvariant` simply omits `nutrition` and `healthscore`,
|
||||
// which is indistinguishable from a product the service has not scored.
|
||||
// A deploy that silently does nothing is one somebody has to reverse
|
||||
// engineer from the outside, and this line is the difference.
|
||||
nutritionBase := strings.TrimSpace(os.Getenv("NUTRITION_BASE"))
|
||||
if nutritionBase == "" {
|
||||
log.Printf("nutrition: OFF — NUTRITION_BASE is not set, so no product carries a nutrition panel or health score")
|
||||
} else {
|
||||
log.Printf("nutrition: reading panels and scores from %s", nutritionBase)
|
||||
}
|
||||
|
||||
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat,
|
||||
os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail,
|
||||
nutritionBase)
|
||||
|
||||
routes.RegisterRoutes(app, f)
|
||||
|
||||
@@ -293,17 +587,20 @@ func main() {
|
||||
repositories.SetCatalogueNotifier(posMqtt)
|
||||
}
|
||||
|
||||
// Start server
|
||||
// How much of the till fleet is carrying a session token, in the log every
|
||||
// half hour.
|
||||
//
|
||||
// The port comes from APP_PORT, defaulting to the 1122 this has always
|
||||
// served on. It was hardcoded, which left `config.Load()`'s Port field
|
||||
// dead and the Dockerfile's `EXPOSE 1009` describing a port nothing
|
||||
// listened on — and made running a second copy locally, on a free port,
|
||||
// impossible without editing this line.
|
||||
port := os.Getenv("APP_PORT")
|
||||
if port == "" {
|
||||
port = "1122"
|
||||
}
|
||||
// `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() {
|
||||
log.Printf("🚀 listening on :%s", port)
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
|
||||
113
main_test.go
Normal file
113
main_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
)
|
||||
|
||||
// Cross-origin access, checked the way a browser checks it.
|
||||
//
|
||||
// These exist because this went wrong in production and nothing caught it.
|
||||
// Adding the session token to the console made every request non-simple, so
|
||||
// browsers began asking permission first — and the answer did not name the
|
||||
// `Authorization` header, so every call was blocked.
|
||||
//
|
||||
// The reason it reached production is worth keeping in mind while reading
|
||||
// these: the preflight returns 204 and looks perfectly healthy from a terminal,
|
||||
// the server logs nothing unusual, and `curl` cannot reproduce it because curl
|
||||
// does not enforce CORS. The only thing that noticed was a browser.
|
||||
|
||||
// preflight asks the question a browser asks before a cross-origin request.
|
||||
func preflight(t *testing.T, requestHeaders string) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(cors.New(corsSettings()))
|
||||
app.Get("/probe", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/probe", nil)
|
||||
req.Header.Set("Origin", "https://app.nearledaily.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
if requestHeaders != "" {
|
||||
req.Header.Set("Access-Control-Request-Headers", requestHeaders)
|
||||
}
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("preflight: %v", err)
|
||||
}
|
||||
|
||||
out := map[string]string{}
|
||||
for _, name := range []string{
|
||||
"Access-Control-Allow-Origin",
|
||||
"Access-Control-Allow-Headers",
|
||||
"Access-Control-Allow-Methods",
|
||||
"Access-Control-Allow-Credentials",
|
||||
} {
|
||||
out[name] = resp.Header.Get(name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTheBrowserIsAllowedToSendTheSessionToken(t *testing.T) {
|
||||
// The bug itself. Without `Authorization` in this list the console cannot
|
||||
// make a single authenticated call, and the error surfaces only in a
|
||||
// browser console as a CORS failure.
|
||||
headers := preflight(t, "authorization")["Access-Control-Allow-Headers"]
|
||||
|
||||
if !strings.Contains(strings.ToLower(headers), "authorization") {
|
||||
t.Fatalf("the console may not send its session token: %q", headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHeadersTheConsoleAlreadySentStillWork(t *testing.T) {
|
||||
// Adding one header must not quietly drop the others.
|
||||
headers := strings.ToLower(preflight(t, "content-type")["Access-Control-Allow-Headers"])
|
||||
|
||||
for _, needed := range []string{"content-type", "accept", "origin"} {
|
||||
if !strings.Contains(headers, needed) {
|
||||
t.Fatalf("%q is no longer allowed: %q", needed, headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWildcardOriginIsNotPairedWithCredentials(t *testing.T) {
|
||||
// Not a valid combination: a browser rejects a credentialed response
|
||||
// carrying a wildcard origin. It was here already and was harmless only
|
||||
// because nothing used credentials — it would have become a second bug
|
||||
// that looked exactly like the first, the day anything did.
|
||||
got := preflight(t, "authorization")
|
||||
|
||||
if got["Access-Control-Allow-Origin"] == "*" &&
|
||||
strings.EqualFold(got["Access-Control-Allow-Credentials"], "true") {
|
||||
t.Fatal("wildcard origin with credentials allowed — browsers reject this pair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryMethodTheConsoleUsesIsAllowed(t *testing.T) {
|
||||
// The console writes with POST, PUT and DELETE. A missing one fails only
|
||||
// on the screens that use it, which is the kind of gap that ships.
|
||||
methods := strings.ToUpper(preflight(t, "authorization")["Access-Control-Allow-Methods"])
|
||||
|
||||
for _, method := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
|
||||
if !strings.Contains(methods, method) {
|
||||
t.Fatalf("%s is not allowed cross-origin: %q", method, methods)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResponseHeaderIsNotListedAsAnAllowedRequestHeader(t *testing.T) {
|
||||
// `Access-Control-Allow-Origin` was in the allowed REQUEST headers, which is
|
||||
// a category error: it is something the server sends back, never something a
|
||||
// browser asks to send. Harmless, but it reads as though somebody added
|
||||
// names until the error went away.
|
||||
headers := strings.ToLower(preflight(t, "authorization")["Access-Control-Allow-Headers"])
|
||||
|
||||
if strings.Contains(headers, "access-control-allow-origin") {
|
||||
t.Fatalf("a response header is listed as an allowed request header: %q", headers)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,15 @@ func StartLiveHub() *LiveHub {
|
||||
SetCleanSession(true). // No queued backlog on reconnect; stale nudges are noise.
|
||||
SetOrderMatters(false)
|
||||
|
||||
if user := strings.TrimSpace(os.Getenv("MQTT_USERNAME")); user != "" {
|
||||
// MQTT_USER is the name the ingest consumer and the env files use. This
|
||||
// read MQTT_USERNAME for a while, so the live stream connected to the
|
||||
// production broker with no credentials at all; MQTT_USERNAME is still
|
||||
// honoured for any deployment that set it.
|
||||
user := strings.TrimSpace(os.Getenv("MQTT_USER"))
|
||||
if user == "" {
|
||||
user = strings.TrimSpace(os.Getenv("MQTT_USERNAME"))
|
||||
}
|
||||
if user != "" {
|
||||
opts.SetUsername(user)
|
||||
opts.SetPassword(os.Getenv("MQTT_PASSWORD"))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ func PosAuth(pos services.PosService) fiber.Handler {
|
||||
token := bearerToken(c)
|
||||
|
||||
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() {
|
||||
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)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
249
middleware/posauthadoption.go
Normal file
249
middleware/posauthadoption.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
How much of the till fleet is carrying a session token.
|
||||
|
||||
── Why this exists ─────────────────────────────────────────────────────────
|
||||
|
||||
`POS_AUTH_REQUIRED` is off, and the only thing standing between here and
|
||||
switching it on is a number nobody has: how many terminals still call the POS
|
||||
routes with no token. Flipping the flag blind is the one action on this surface
|
||||
that can stop a shop trading mid-queue — a cashier at a counter unable to ring a
|
||||
bill is not a reversible inconvenience.
|
||||
|
||||
So this counts, and names the outlets that are still untokened, so the flag gets
|
||||
flipped on evidence rather than on hope.
|
||||
|
||||
── What it deliberately is not ─────────────────────────────────────────────
|
||||
|
||||
Not persisted. It lives in memory and resets on restart, which is honest about
|
||||
what it measures: adoption since this process started, not all time. A restart
|
||||
mid-observation means starting the week again, and that is a smaller cost than a
|
||||
migration and a table for a number that stops mattering the day the flag is on.
|
||||
|
||||
Not a rate limiter and not a gate. It records; it never refuses. Nothing in
|
||||
here can change whether a request is served.
|
||||
|
||||
Capped. `store_id` comes off the wire, so an untokened caller could otherwise
|
||||
name ten thousand outlets and grow this without bound. Past the cap new outlets
|
||||
are counted in the totals and not listed individually, which keeps the answer
|
||||
useful without making it a way to spend the server's memory.
|
||||
*/
|
||||
|
||||
// posAdoptionCap is how many distinct untokened outlets are named individually.
|
||||
// The real fleet is dozens; anything beyond this is noise or somebody probing.
|
||||
const posAdoptionCap = 200
|
||||
|
||||
type posOutletSeen struct {
|
||||
Locationid int
|
||||
Requests int64
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
var posAdoption = struct {
|
||||
sync.Mutex
|
||||
since time.Time
|
||||
tokened int64
|
||||
untokened int64
|
||||
// Untokened requests by the outlet they named, and by the route they hit.
|
||||
outlets map[int]*posOutletSeen
|
||||
paths map[string]int64
|
||||
// True once the cap was reached, so the report can say it is partial
|
||||
// rather than quietly under-reporting.
|
||||
truncated bool
|
||||
}{
|
||||
since: time.Now(),
|
||||
outlets: map[int]*posOutletSeen{},
|
||||
paths: map[string]int64{},
|
||||
}
|
||||
|
||||
// recordPosToken notes one request that arrived with a usable token.
|
||||
func recordPosToken() {
|
||||
posAdoption.Lock()
|
||||
posAdoption.tokened++
|
||||
posAdoption.Unlock()
|
||||
}
|
||||
|
||||
// recordPosUntokened notes one request that arrived with none, and where it
|
||||
// claimed to be. `locationid` is 0 when the route named no outlet.
|
||||
func recordPosUntokened(locationid int, path string) {
|
||||
now := time.Now()
|
||||
|
||||
posAdoption.Lock()
|
||||
defer posAdoption.Unlock()
|
||||
|
||||
posAdoption.untokened++
|
||||
posAdoption.paths[path]++
|
||||
|
||||
if locationid <= 0 {
|
||||
return
|
||||
}
|
||||
if seen, ok := posAdoption.outlets[locationid]; ok {
|
||||
seen.Requests++
|
||||
seen.LastSeen = now
|
||||
return
|
||||
}
|
||||
if len(posAdoption.outlets) >= posAdoptionCap {
|
||||
posAdoption.truncated = true
|
||||
return
|
||||
}
|
||||
posAdoption.outlets[locationid] = &posOutletSeen{
|
||||
Locationid: locationid, Requests: 1, FirstSeen: now, LastSeen: now,
|
||||
}
|
||||
}
|
||||
|
||||
// PosAdoptionOutlet is one outlet still calling without a token.
|
||||
type PosAdoptionOutlet struct {
|
||||
Locationid int `json:"locationid"`
|
||||
Requests int64 `json:"requests"`
|
||||
FirstSeen string `json:"firstseen"`
|
||||
LastSeen string `json:"lastseen"`
|
||||
}
|
||||
|
||||
// PosAdoptionPath is one route, and how often it was reached untokened.
|
||||
type PosAdoptionPath struct {
|
||||
Path string `json:"path"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
// PosAdoption is the answer to "is it safe to switch enforcement on yet".
|
||||
type PosAdoption struct {
|
||||
// Whether an untokened request is currently refused.
|
||||
Enforced bool `json:"enforced"`
|
||||
// When counting started — process start, not all time.
|
||||
Since string `json:"since"`
|
||||
// Requests seen on the POS surface since then.
|
||||
Tokened int64 `json:"tokened"`
|
||||
Untokened int64 `json:"untokened"`
|
||||
// 0–100. 100 means every request in this window carried a token, which is
|
||||
// the condition for flipping the flag.
|
||||
AdoptedPercent float64 `json:"adoptedpercent"`
|
||||
// The outlets still calling without one, busiest first. These are the tills
|
||||
// that would stop working the moment enforcement is switched on.
|
||||
Outlets []PosAdoptionOutlet `json:"outlets"`
|
||||
// Which routes they are reaching, busiest first.
|
||||
Paths []PosAdoptionPath `json:"paths"`
|
||||
// True when more outlets were seen than are listed — see posAdoptionCap.
|
||||
Truncated bool `json:"truncated"`
|
||||
// Plain-language reading of the above, for whoever has to make the call.
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
// PosAdoptionReport is the snapshot, safe to call at any time.
|
||||
func PosAdoptionReport() PosAdoption {
|
||||
posAdoption.Lock()
|
||||
defer posAdoption.Unlock()
|
||||
|
||||
report := PosAdoption{
|
||||
Enforced: posAuthRequired(),
|
||||
Since: posAdoption.since.Format(time.RFC3339),
|
||||
Tokened: posAdoption.tokened,
|
||||
Untokened: posAdoption.untokened,
|
||||
Truncated: posAdoption.truncated,
|
||||
Outlets: make([]PosAdoptionOutlet, 0, len(posAdoption.outlets)),
|
||||
Paths: make([]PosAdoptionPath, 0, len(posAdoption.paths)),
|
||||
}
|
||||
|
||||
total := posAdoption.tokened + posAdoption.untokened
|
||||
if total > 0 {
|
||||
report.AdoptedPercent = float64(posAdoption.tokened) * 100 / float64(total)
|
||||
}
|
||||
|
||||
for _, seen := range posAdoption.outlets {
|
||||
report.Outlets = append(report.Outlets, PosAdoptionOutlet{
|
||||
Locationid: seen.Locationid,
|
||||
Requests: seen.Requests,
|
||||
FirstSeen: seen.FirstSeen.Format(time.RFC3339),
|
||||
LastSeen: seen.LastSeen.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
// Busiest first: the outlet ringing the most bills is the one that hurts
|
||||
// most if enforcement switches on before it has adopted.
|
||||
sort.Slice(report.Outlets, func(i, j int) bool {
|
||||
return report.Outlets[i].Requests > report.Outlets[j].Requests
|
||||
})
|
||||
|
||||
for path, count := range posAdoption.paths {
|
||||
report.Paths = append(report.Paths, PosAdoptionPath{Path: path, Requests: count})
|
||||
}
|
||||
sort.Slice(report.Paths, func(i, j int) bool {
|
||||
return report.Paths[i].Requests > report.Paths[j].Requests
|
||||
})
|
||||
|
||||
report.Verdict = posAdoptionVerdict(report)
|
||||
return report
|
||||
}
|
||||
|
||||
// posAdoptionVerdict says what the numbers mean, because the number on its own
|
||||
// invites the wrong reading in both directions: a clean window that is only an
|
||||
// hour long proves nothing, and one stubborn outlet is not a reason to leave
|
||||
// the whole surface open.
|
||||
func posAdoptionVerdict(r PosAdoption) string {
|
||||
switch {
|
||||
case r.Enforced:
|
||||
return "Enforcement is already on: an untokened request is refused."
|
||||
case r.Tokened+r.Untokened == 0:
|
||||
return "No POS traffic seen since this process started, so there is nothing to conclude yet."
|
||||
case r.Untokened == 0:
|
||||
return "Every POS request in this window carried a token. Watch for a few trading days — a quiet window is not the same as an adopted fleet — then set POS_AUTH_REQUIRED=true."
|
||||
case len(r.Outlets) == 0:
|
||||
return "Untokened requests are arriving but none names an outlet, so they cannot be traced to a till. Check the paths below before switching enforcement on."
|
||||
default:
|
||||
return "Terminals are still calling without a token. The outlets listed below would stop being able to trade the moment POS_AUTH_REQUIRED=true is set. Update those tills first."
|
||||
}
|
||||
}
|
||||
|
||||
// posAdoptionLogEvery is how often the summary reaches the log.
|
||||
//
|
||||
// Long, because this is a slow-moving fact — a fleet adopts over days, not
|
||||
// minutes — and a log line nobody needs every minute is a log line people learn
|
||||
// to scroll past.
|
||||
const posAdoptionLogEvery = 30 * time.Minute
|
||||
|
||||
// LogPosAdoption prints the summary on a timer until ctx is done.
|
||||
//
|
||||
// In the log as well as on the endpoint because the two get used by different
|
||||
// people at different moments: somebody already reading Dokploy's log because a
|
||||
// till is misbehaving should not have to know an endpoint exists.
|
||||
//
|
||||
// Outlet ids only, never names or counts of takings — a log is the one place
|
||||
// this data ends up somewhere nobody chose to put it.
|
||||
func LogPosAdoption(ctx context.Context) {
|
||||
ticker := time.NewTicker(posAdoptionLogEvery)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
report := PosAdoptionReport()
|
||||
if report.Tokened+report.Untokened == 0 {
|
||||
continue // nothing happened; saying so every half hour is noise
|
||||
}
|
||||
if report.Untokened == 0 {
|
||||
log.Printf("pos auth: %.0f%% of %d requests carried a token; no untokened terminals seen since %s",
|
||||
report.AdoptedPercent, report.Tokened+report.Untokened, report.Since)
|
||||
continue
|
||||
}
|
||||
outlets := make([]string, 0, len(report.Outlets))
|
||||
for _, o := range report.Outlets {
|
||||
outlets = append(outlets, strconv.Itoa(o.Locationid))
|
||||
}
|
||||
log.Printf("pos auth: %.0f%% of %d requests carried a token; %d untokened, from outlet(s) %s — these would stop trading if POS_AUTH_REQUIRED were set",
|
||||
report.AdoptedPercent, report.Tokened+report.Untokened,
|
||||
report.Untokened, strings.Join(outlets, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
297
middleware/posauthadoption_test.go
Normal file
297
middleware/posauthadoption_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
/*
|
||||
Counting the till fleet's adoption of the session token.
|
||||
|
||||
This exists to answer one question — is it safe to set POS_AUTH_REQUIRED=true —
|
||||
and the cost of answering it wrong is a cashier at a counter who cannot ring a
|
||||
bill. So these are mostly about the figure being honest: not flattering, not
|
||||
alarmist, and never able to change whether a request is served.
|
||||
*/
|
||||
|
||||
// resetAdoption puts the counters back, since they are process-wide.
|
||||
func resetAdoption(t *testing.T) {
|
||||
t.Helper()
|
||||
posAdoption.Lock()
|
||||
posAdoption.since = time.Now()
|
||||
posAdoption.tokened = 0
|
||||
posAdoption.untokened = 0
|
||||
posAdoption.outlets = map[int]*posOutletSeen{}
|
||||
posAdoption.paths = map[string]int64{}
|
||||
posAdoption.truncated = false
|
||||
posAdoption.Unlock()
|
||||
}
|
||||
|
||||
func TestAnUntokenedTillIsNamedByItsOutlet(t *testing.T) {
|
||||
// The whole point. Without this list, switching enforcement on is a guess
|
||||
// about which shops stop trading.
|
||||
resetAdoption(t)
|
||||
|
||||
recordPosUntokened(1185, "/live/api/v1/pos/orders")
|
||||
recordPosUntokened(1185, "/live/api/v1/pos/orders")
|
||||
recordPosUntokened(1170, "/live/api/v1/pos/catalogue")
|
||||
recordPosToken()
|
||||
|
||||
report := PosAdoptionReport()
|
||||
if report.Untokened != 3 || report.Tokened != 1 {
|
||||
t.Fatalf("counts wrong: %+v", report)
|
||||
}
|
||||
if len(report.Outlets) != 2 {
|
||||
t.Fatalf("outlets: %+v", report.Outlets)
|
||||
}
|
||||
// Busiest first — the outlet ringing the most bills is the one that hurts
|
||||
// most if enforcement goes on before it has adopted.
|
||||
if report.Outlets[0].Locationid != 1185 || report.Outlets[0].Requests != 2 {
|
||||
t.Errorf("not ordered by traffic: %+v", report.Outlets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAdoptedPercentageIsOfEverything(t *testing.T) {
|
||||
resetAdoption(t)
|
||||
for i := 0; i < 3; i++ {
|
||||
recordPosToken()
|
||||
}
|
||||
recordPosUntokened(1185, "/pos/orders")
|
||||
|
||||
if got := PosAdoptionReport().AdoptedPercent; got != 75 {
|
||||
t.Fatalf("adopted = %v%%, want 75", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoTrafficIsNotAHundredPercent(t *testing.T) {
|
||||
// A fleet nobody has used is not a fleet that has adopted. Reporting 100%
|
||||
// here is the single most dangerous rounding this file could do — it would
|
||||
// green-light the flag on an empty window.
|
||||
resetAdoption(t)
|
||||
|
||||
report := PosAdoptionReport()
|
||||
if report.AdoptedPercent != 0 {
|
||||
t.Fatalf("empty window reported as %v%%", report.AdoptedPercent)
|
||||
}
|
||||
if !strings.Contains(report.Verdict, "nothing to conclude") {
|
||||
t.Errorf("verdict oversells an empty window: %q", report.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACleanWindowStillSaysToKeepWatching(t *testing.T) {
|
||||
// Zero untokened requests in one hour is not an adopted fleet — a shop that
|
||||
// is shut has no traffic either. The verdict has to say so, because the
|
||||
// number on its own reads as permission.
|
||||
resetAdoption(t)
|
||||
recordPosToken()
|
||||
|
||||
verdict := PosAdoptionReport().Verdict
|
||||
if !strings.Contains(verdict, "trading days") {
|
||||
t.Errorf("a one-request window was treated as proof: %q", verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUntokenedFleetIsNotDescribedAsReady(t *testing.T) {
|
||||
resetAdoption(t)
|
||||
recordPosToken()
|
||||
recordPosUntokened(1185, "/pos/orders")
|
||||
|
||||
verdict := PosAdoptionReport().Verdict
|
||||
if !strings.Contains(verdict, "stop being able to trade") {
|
||||
t.Errorf("the consequence is not stated: %q", verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequestThatNamesNoOutletIsStillCounted(t *testing.T) {
|
||||
// `/pos/staff` deliberately takes no location parameter. Such a request is
|
||||
// still an untokened till, and dropping it would understate the problem.
|
||||
resetAdoption(t)
|
||||
recordPosUntokened(0, "/live/api/v1/pos/staff")
|
||||
|
||||
report := PosAdoptionReport()
|
||||
if report.Untokened != 1 {
|
||||
t.Fatalf("not counted: %+v", report)
|
||||
}
|
||||
if len(report.Outlets) != 0 {
|
||||
t.Errorf("invented an outlet for a request that named none: %+v", report.Outlets)
|
||||
}
|
||||
if len(report.Paths) != 1 || report.Paths[0].Path != "/live/api/v1/pos/staff" {
|
||||
t.Errorf("the route was lost: %+v", report.Paths)
|
||||
}
|
||||
if !strings.Contains(report.Verdict, "cannot be traced") {
|
||||
t.Errorf("verdict does not explain the blind spot: %q", report.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutletsCannotGrowWithoutBound(t *testing.T) {
|
||||
// `store_id` comes off the wire. Without a cap an untokened caller could
|
||||
// name ten thousand outlets and spend the server's memory doing it.
|
||||
resetAdoption(t)
|
||||
for i := 1; i <= posAdoptionCap+50; i++ {
|
||||
recordPosUntokened(i, "/pos/orders")
|
||||
}
|
||||
|
||||
report := PosAdoptionReport()
|
||||
if len(report.Outlets) > posAdoptionCap {
|
||||
t.Fatalf("listed %d outlets, cap is %d", len(report.Outlets), posAdoptionCap)
|
||||
}
|
||||
if report.Untokened != int64(posAdoptionCap+50) {
|
||||
// The total must stay true even when the list is trimmed.
|
||||
t.Errorf("total under-reported: %d", report.Untokened)
|
||||
}
|
||||
if !report.Truncated {
|
||||
t.Error("a trimmed list was presented as complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstAndLastSeenAreBothKept(t *testing.T) {
|
||||
// "This till stopped calling untokened three days ago" and "it did so a
|
||||
// minute ago" are different facts, and only one of them means it has been
|
||||
// updated. So the first sighting must stick and the last must move.
|
||||
//
|
||||
// The clock is wound back rather than slept through: the report formats to
|
||||
// RFC3339, which is second-precision, and a test that waits a second to
|
||||
// prove an assignment is a second every run forever.
|
||||
resetAdoption(t)
|
||||
recordPosUntokened(1185, "/pos/orders")
|
||||
|
||||
posAdoption.Lock()
|
||||
seen := posAdoption.outlets[1185]
|
||||
seen.FirstSeen = seen.FirstSeen.Add(-48 * time.Hour)
|
||||
seen.LastSeen = seen.LastSeen.Add(-48 * time.Hour)
|
||||
posAdoption.Unlock()
|
||||
|
||||
recordPosUntokened(1185, "/pos/orders")
|
||||
|
||||
out := PosAdoptionReport().Outlets[0]
|
||||
if out.FirstSeen == "" || out.LastSeen == "" {
|
||||
t.Fatalf("timestamps missing: %+v", out)
|
||||
}
|
||||
if out.Requests != 2 {
|
||||
t.Errorf("requests = %d, want 2", out.Requests)
|
||||
}
|
||||
if out.FirstSeen == out.LastSeen {
|
||||
t.Errorf("last seen never moved: %+v", out)
|
||||
}
|
||||
if out.FirstSeen > out.LastSeen {
|
||||
// RFC3339 sorts lexically, so this comparison is meaningful.
|
||||
t.Errorf("first seen is after last seen: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReportSaysWhetherEnforcementIsAlreadyOn(t *testing.T) {
|
||||
resetAdoption(t)
|
||||
t.Setenv("POS_AUTH_REQUIRED", "true")
|
||||
|
||||
report := PosAdoptionReport()
|
||||
if !report.Enforced {
|
||||
t.Fatal("enforcement is on and the report says otherwise")
|
||||
}
|
||||
if !strings.Contains(report.Verdict, "already on") {
|
||||
t.Errorf("verdict ignores that the work is done: %q", report.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The guard still behaves exactly as it did ───────────────────────────── */
|
||||
|
||||
func TestCountingNeverChangesWhetherARequestIsServed(t *testing.T) {
|
||||
// This whole file is instrumentation. If it can refuse a request, or let
|
||||
// one through that should have been refused, it has become the thing it was
|
||||
// built to make safe.
|
||||
//
|
||||
// Both sides of the flag, against the real middleware.
|
||||
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
required string
|
||||
want int
|
||||
}{
|
||||
{"off: an untokened till still trades", "", 200},
|
||||
{"on: an untokened till is refused", "true", 401},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resetAdoption(t)
|
||||
t.Setenv("POS_AUTH_REQUIRED", tc.required)
|
||||
|
||||
got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=1185", "")
|
||||
if got != tc.want {
|
||||
t.Fatalf("status %d, want %d", got, tc.want)
|
||||
}
|
||||
// Counted either way: the figure is about what the fleet is doing,
|
||||
// not about what the flag currently allows.
|
||||
if report := PosAdoptionReport(); report.Untokened != 1 {
|
||||
t.Errorf("untokened = %d, want 1", report.Untokened)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyARequestThatWouldSurviveEnforcementCountsAsAdopted(t *testing.T) {
|
||||
// A token that verifies but names somebody else's outlet is refused, and
|
||||
// must NOT be counted as adopted — otherwise a misconfigured till inflates
|
||||
// the very number used to decide the flag is safe to set.
|
||||
t.Setenv("POS_TOKEN_SECRET", posTestSecret)
|
||||
t.Setenv("POS_AUTH_REQUIRED", "")
|
||||
resetAdoption(t)
|
||||
|
||||
token := posTokenFor(t, 1147, 1185)
|
||||
if got := callPos(t, "GET", "/live/api/v1/pos/catalogue?store_id=9999", token); got != 403 {
|
||||
t.Fatalf("a token was allowed to name another tenant's outlet: %d", got)
|
||||
}
|
||||
if report := PosAdoptionReport(); report.Tokened != 0 {
|
||||
t.Errorf("a refused request was counted as adopted: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Harness ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const posTestSecret = "a-pos-signing-secret-of-ample-length"
|
||||
|
||||
// posLocations answers the tenant-owns-outlet question without a database.
|
||||
// Only LocationAllowed is real; anything else the guard touched would panic,
|
||||
// which is the signal wanted.
|
||||
type posLocations struct {
|
||||
services.PosService
|
||||
}
|
||||
|
||||
func (posLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
|
||||
// Tenant 1147 owns 1185 and nothing else, which is all these tests need.
|
||||
return tenantID == 1147 && locationID == 1185, nil
|
||||
}
|
||||
|
||||
func posTokenFor(t *testing.T, tenantID, locationID int) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintPosToken(utils.PosClaims{
|
||||
Tenantid: tenantID, Locationid: locationID, Configid: 1,
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("minting a terminal session: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func callPos(t *testing.T, method, target, token string) int {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use("/live/api/v1/pos", PosAuth(posLocations{}))
|
||||
app.All("/live/api/v1/pos/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("calling: %v", err)
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
332
middleware/webauth.go
Normal file
332
middleware/webauth.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Authorisation for the console.
|
||||
//
|
||||
// The `/web` surface has never had any. The console keeps its login record in
|
||||
// per-tab `sessionStorage` and sends no `Authorization` header, so every
|
||||
// endpoint under `/v1/web` reads `tenantid` off the query string and believes
|
||||
// it. Changing one number in a URL reads another merchant's orders, stock,
|
||||
// staff and takings.
|
||||
//
|
||||
// This is the same hole `posauth.go` was written to close on the POS surface,
|
||||
// and it is closed the same way, in the same order:
|
||||
//
|
||||
// 1. the caller holds a token this server signed, and
|
||||
// 2. the tenant they are naming is the tenant inside that token.
|
||||
//
|
||||
// The second is the one that matters. A valid session is not a licence to name
|
||||
// any tenant — it is a licence to name *your* tenant.
|
||||
//
|
||||
// ── Why this could not wait for the assistant ───────────────────────────────
|
||||
//
|
||||
// Nearle Buddy answers questions over this same data. Behind REST, reading
|
||||
// another merchant's books takes knowing the endpoints, knowing the fields and
|
||||
// iterating. Behind an assistant it is one sentence — "summarise the top ten
|
||||
// tenants by revenue" — and the model assembles the cross-tenant answer itself,
|
||||
// accurately and helpfully, because the data was in scope. The permission rules
|
||||
// the assistant needs have nothing to stand on until this exists.
|
||||
//
|
||||
// ── What this does NOT yet do ───────────────────────────────────────────────
|
||||
//
|
||||
// It verifies what a request NAMES: the tenant, and the branch. It does not yet
|
||||
// make handlers derive their scope from the session rather than from the wire.
|
||||
//
|
||||
// It also does not validate `partnerid`, `customerid` or `appuserid`, and that
|
||||
// one is not an oversight — it is blocked. A delivery partner serves several
|
||||
// merchants at once (`insights.ts` records partner 60 answering with deliveries
|
||||
// spanning twelve shops), so scoping a read by partner is a cross-tenant read by
|
||||
// design. Refusing the parameter outright would be wrong: `RiderDrawer` and
|
||||
// `AssignBar` are merchant screens and both send it legitimately, for a partner
|
||||
// assigned to that merchant.
|
||||
//
|
||||
// Closing it properly needs a check this codebase does not have — "is this
|
||||
// partner assigned to this tenant?" — in the shape of `LocationAllowed`, which
|
||||
// answers the same question for branches. Until that exists, a handler scoping
|
||||
// on one of these three is trusting the caller, and the assistant is kept away
|
||||
// from them entirely: no tool accepts any of these as an argument, and the
|
||||
// registry refuses to register one that tries.
|
||||
|
||||
// WebLocalsKey names where the verified claims are parked for handlers.
|
||||
const WebLocalsKey = "webclaims"
|
||||
|
||||
// webAuthRequired reports whether a request without a valid token is refused.
|
||||
//
|
||||
// Defaults to ON. It did not always: this shipped defaulting to off, because
|
||||
// the console was live and its sign-in did not yet hand back a token, so
|
||||
// enforcing first would have locked every merchant out of a working product.
|
||||
//
|
||||
// That rollout is finished. Sign-in mints a token, the console sends it on
|
||||
// every call, and it expires cleanly. Leaving the default off after that point
|
||||
// was not caution, it was an open door nobody had got round to shutting — and
|
||||
// it was measured wide open: a `getorders` with no credential at all returned a
|
||||
// real merchant's orders to anyone on the internet.
|
||||
//
|
||||
// ── The way out, if this goes wrong ─────────────────────────────────────────
|
||||
//
|
||||
// `WEB_AUTH_REQUIRED=false` restores the old behaviour, immediately and without
|
||||
// a deploy. That is the escape hatch, and it exists because flipping a default
|
||||
// that can lock people out should always be reversible by one person in one
|
||||
// minute. A token that is SENT is still always verified either way — the flag
|
||||
// only decides what happens to a request carrying none.
|
||||
func webAuthRequired() bool {
|
||||
setting := strings.TrimSpace(os.Getenv("WEB_AUTH_REQUIRED"))
|
||||
if setting == "" {
|
||||
return true
|
||||
}
|
||||
return !strings.EqualFold(setting, "false")
|
||||
}
|
||||
|
||||
// publicWebPaths are the endpoints that must work before anybody has a token.
|
||||
//
|
||||
// Sign-in, chiefly: guarding the login route with a session token means nobody
|
||||
// can ever obtain one. Kept as suffixes rather than full paths so the group
|
||||
// prefix can move without silently locking the door.
|
||||
var publicWebPaths = []string{
|
||||
"/users/applogin",
|
||||
"/users/weblogin",
|
||||
"/tenant/weblogin",
|
||||
// First-password-set runs before a session exists, from a link in the
|
||||
// invitation mail.
|
||||
"/users/setpassword",
|
||||
}
|
||||
|
||||
func isPublicWebPath(path string) bool {
|
||||
lower := strings.ToLower(path)
|
||||
for _, suffix := range publicWebPaths {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// webLocationChecker is the only question this middleware asks of the database:
|
||||
// does this tenant own this branch? Narrowed to one method so the guard can be
|
||||
// tested without a database, and so it cannot quietly grow a second dependency.
|
||||
type webLocationChecker interface {
|
||||
LocationAllowed(tenantID, locationID int) (bool, error)
|
||||
}
|
||||
|
||||
// WebAuth verifies the console session and pins the request to its tenant.
|
||||
func WebAuth(pos services.PosService) fiber.Handler { return webAuthWith(pos) }
|
||||
|
||||
func webAuthWith(locations webLocationChecker) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if isPublicWebPath(c.Path()) {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
token := webBearerToken(c)
|
||||
|
||||
if token == "" {
|
||||
if webAuthRequired() {
|
||||
return webUnauthorized(c, "a session token is required; sign in again")
|
||||
}
|
||||
// A console that predates tokens. Allowed through unpinned, which is
|
||||
// exactly the state this middleware exists to end — see
|
||||
// webAuthRequired.
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
claims, err := utils.ParseWebToken(token, time.Now())
|
||||
if err != nil {
|
||||
// Always refused, flag or no flag. A token that does not verify is a
|
||||
// stronger signal than no token at all: nothing sends a broken one by
|
||||
// accident.
|
||||
return webUnauthorized(c, err.Error())
|
||||
}
|
||||
|
||||
// Nearle's own staff work across every tenant and legitimately name any
|
||||
// of them. Checked once, here, rather than at each test below, so the
|
||||
// exemption is a single visible branch instead of three.
|
||||
if !claims.IsPlatformAccount() {
|
||||
if requested := requestedTenant(c); requested > 0 && requested != claims.Tenantid {
|
||||
return webForbidden(c, "this session cannot reach tenant "+strconv.Itoa(requested))
|
||||
}
|
||||
|
||||
// A request can also scope by branch alone, naming no tenant at all,
|
||||
// so pinning the tenant is not enough on its own.
|
||||
if requested := requestedWebLocation(c); requested > 0 && requested != claims.Locationid {
|
||||
allowed, err := locations.LocationAllowed(claims.Tenantid, requested)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
||||
"code": http.StatusServiceUnavailable, "status": false,
|
||||
"message": "could not verify branch access",
|
||||
})
|
||||
}
|
||||
if !allowed {
|
||||
return webForbidden(c, "this session cannot reach branch "+strconv.Itoa(requested))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Locals(WebLocalsKey, claims)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// webBearerToken reads the session out of the request.
|
||||
//
|
||||
// `Authorization: Bearer …` only. The POS reader next door also accepts
|
||||
// `X-Pos-Token`, because shop routers between a till and this server strip
|
||||
// Authorization headers on plain HTTP and a terminal that cannot authenticate
|
||||
// is a shop that cannot trade. The console has no such problem — it is a
|
||||
// browser on HTTPS — so it gets the one form, and a second accepted header is
|
||||
// a second thing to get wrong.
|
||||
func webBearerToken(c *fiber.Ctx) string {
|
||||
header := strings.TrimSpace(c.Get("Authorization"))
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
if after, found := strings.CutPrefix(header, "Bearer "); found {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
if !strings.Contains(header, " ") {
|
||||
return header
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// requestedTenant reads the tenant a request is naming, from wherever it put it.
|
||||
//
|
||||
// Query first, because that is where every `/web` list endpoint carries it, then
|
||||
// the body, because the writes do not: `createdeliveries`, `publishproduct` and
|
||||
// the rest post JSON. Checking only the query would leave every call that
|
||||
// CHANGES another tenant's data unguarded, which is the wrong half to skip.
|
||||
func requestedTenant(c *fiber.Ctx) int {
|
||||
for _, key := range []string{"tenantid", "tenant_id"} {
|
||||
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
||||
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return bodyScopeID(c, "tenantid", "tenant_id")
|
||||
}
|
||||
|
||||
// requestedWebLocation reads the branch a request is naming.
|
||||
//
|
||||
// Separate from the POS reader's `requestedLocation` because the two surfaces
|
||||
// spell it differently: POS routes use `store_id`, the console uses
|
||||
// `locationid`. Both spellings are read here anyway — a shared endpoint is
|
||||
// cheaper to allow for than to discover.
|
||||
func requestedWebLocation(c *fiber.Ctx) int {
|
||||
for _, key := range []string{"locationid", "location_id", "store_id"} {
|
||||
if raw := strings.TrimSpace(c.Query(key)); raw != "" {
|
||||
if id, err := strconv.Atoi(raw); err == nil && id > 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return bodyScopeID(c, "locationid", "location_id", "store_id")
|
||||
}
|
||||
|
||||
// bodyScopeID pulls a scoping id out of a JSON request body.
|
||||
//
|
||||
// Decoded loosely rather than into a request type, on purpose: this runs before
|
||||
// the handler and must not refuse anything the handler would have accepted. A
|
||||
// body that will not parse here is left for the handler to reject with its own
|
||||
// message, and a request shape that changes later must not silently stop being
|
||||
// authorised.
|
||||
//
|
||||
// `c.Body()` returns buffered bytes, so reading here does not consume the
|
||||
// stream the handler goes on to parse.
|
||||
//
|
||||
// An ARRAY body — `createdeliveries` posts one — is walked too. A batch naming
|
||||
// another tenant in its elements is precisely the call worth guarding, and a
|
||||
// probe that only understood objects would wave it through.
|
||||
func bodyScopeID(c *fiber.Ctx, keys ...string) int {
|
||||
body := c.Body()
|
||||
if len(body) == 0 || len(body) > 8<<20 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var raw json.RawMessage = body
|
||||
trimmed := strings.TrimLeft(string(body), " \t\r\n")
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var elements []json.RawMessage
|
||||
if err := json.Unmarshal(body, &elements); err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, element := range elements {
|
||||
if id := scopeIDFromObject(element, keys); id > 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return scopeIDFromObject(raw, keys)
|
||||
}
|
||||
|
||||
func scopeIDFromObject(raw json.RawMessage, keys []string) int {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, key := range keys {
|
||||
if id := asScopeID(fields[key]); id > 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// asScopeID reads an id that may have been sent as a number or as a string.
|
||||
//
|
||||
// Both spellings are on the wire today — the console sends numbers, some app
|
||||
// callers send strings — and a probe that understood only one would return 0
|
||||
// for the other, which reads as "named no tenant" and waves the request past
|
||||
// the check.
|
||||
func asScopeID(raw json.RawMessage) int {
|
||||
if len(raw) == 0 {
|
||||
return 0
|
||||
}
|
||||
var number int
|
||||
if err := json.Unmarshal(raw, &number); err == nil {
|
||||
return number
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
if id, err := strconv.Atoi(strings.TrimSpace(text)); err == nil {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func webUnauthorized(c *fiber.Ctx, message string) error {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
||||
"code": http.StatusUnauthorized, "status": false, "message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func webForbidden(c *fiber.Ctx, message string) error {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"code": http.StatusForbidden, "status": false, "message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// WebClaimsFrom returns the verified session on a request, if it carried one.
|
||||
//
|
||||
// The second return distinguishes "no token" from "a token claiming tenant 0",
|
||||
// which is a platform account and a real answer. A handler that treated the two
|
||||
// alike would give an unauthenticated caller the one session that reads
|
||||
// everything.
|
||||
func WebClaimsFrom(c *fiber.Ctx) (utils.WebClaims, bool) {
|
||||
claims, ok := c.Locals(WebLocalsKey).(utils.WebClaims)
|
||||
return claims, ok
|
||||
}
|
||||
334
middleware/webauth_test.go
Normal file
334
middleware/webauth_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const webTestSecret = "a-test-signing-key-long-enough"
|
||||
|
||||
// fakeLocations answers the tenant-owns-branch question without a database.
|
||||
//
|
||||
// `owned` is the branch the tenant genuinely has; anything else is refused, and
|
||||
// `fails` makes the lookup itself error so the unavailable path can be reached.
|
||||
type fakeLocations struct {
|
||||
tenant int
|
||||
owned int
|
||||
fails bool
|
||||
}
|
||||
|
||||
func (f fakeLocations) LocationAllowed(tenantID, locationID int) (bool, error) {
|
||||
if f.fails {
|
||||
return false, errFakeLookup
|
||||
}
|
||||
return tenantID == f.tenant && locationID == f.owned, nil
|
||||
}
|
||||
|
||||
type fakeErr struct{}
|
||||
|
||||
func (fakeErr) Error() string { return "lookup unavailable" }
|
||||
|
||||
var errFakeLookup = fakeErr{}
|
||||
|
||||
// call runs one request through the middleware and reports the status.
|
||||
//
|
||||
// The handler behind it always succeeds, so any non-200 came from the guard.
|
||||
func call(t *testing.T, locations webLocationChecker, token, method, target, body string) int {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New()
|
||||
app.Use("/live/api/v1/web", webAuthWith(locations))
|
||||
app.All("/live/api/v1/web/*", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
|
||||
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("calling: %v", err)
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func tokenFor(t *testing.T, claims utils.WebClaims) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintWebToken(claims, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("minting: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/* ── The hole this exists to close ─────────────────────────────────────── */
|
||||
|
||||
func TestASessionCannotNameAnotherTenant(t *testing.T) {
|
||||
// One number in a URL. Before this middleware it read another merchant's
|
||||
// orders, stock, staff and takings.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||
|
||||
own := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||
if own != fiber.StatusOK {
|
||||
t.Fatalf("a session was refused its own tenant: %d", own)
|
||||
}
|
||||
|
||||
other := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||
if other != fiber.StatusForbidden {
|
||||
t.Fatalf("tenant 916 was readable with a tenant 1147 session: %d", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWriteCannotNameAnotherTenantInItsBody(t *testing.T) {
|
||||
// The half that would be easy to skip. Reads carry `tenantid` in the query;
|
||||
// the calls that CHANGE things post JSON, so a query-only check leaves every
|
||||
// write unguarded.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||
|
||||
body := `{"tenantid":916,"productname":"Milk Bikis"}`
|
||||
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", body)
|
||||
if got != fiber.StatusForbidden {
|
||||
t.Fatalf("a write into tenant 916 was allowed: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABatchCannotSmuggleAnotherTenantInAnArray(t *testing.T) {
|
||||
// `createdeliveries` posts an array. A probe that only understood objects
|
||||
// would wave through exactly the call that creates work in another
|
||||
// merchant's shop.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||
|
||||
body := `[{"orderheaderid":1,"tenantid":916}]`
|
||||
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/deliveries/createdeliveries", body)
|
||||
if got != fiber.StatusForbidden {
|
||||
t.Fatalf("a batch naming tenant 916 was allowed: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATenantSentAsAStringIsStillChecked(t *testing.T) {
|
||||
// Both spellings are on the wire. A probe that understood only numbers
|
||||
// returns 0 for `"916"`, which reads as "named no tenant" and passes.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147})
|
||||
|
||||
got := call(t, fakeLocations{}, session, "POST", "/live/api/v1/web/products/create", `{"tenantid":"916"}`)
|
||||
if got != fiber.StatusForbidden {
|
||||
t.Fatalf("a string tenant id slipped past: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scoping by branch alone ───────────────────────────────────────────── */
|
||||
|
||||
func TestABranchMustBelongToTheSessionsTenant(t *testing.T) {
|
||||
// A request can scope by branch and name no tenant at all, so pinning the
|
||||
// tenant is not sufficient on its own.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||
locations := fakeLocations{tenant: 1147, owned: 1173}
|
||||
|
||||
mine := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1173", "")
|
||||
if mine != fiber.StatusOK {
|
||||
t.Fatalf("a second branch of my own tenant was refused: %d", mine)
|
||||
}
|
||||
|
||||
theirs := call(t, locations, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
||||
if theirs != fiber.StatusForbidden {
|
||||
t.Fatalf("another tenant's branch was readable: %d", theirs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionsOwnBranchNeedsNoLookup(t *testing.T) {
|
||||
// `fails: true` errors on any lookup, so reaching OK proves the home branch
|
||||
// short-circuits before asking.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||
|
||||
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1172", "")
|
||||
if got != fiber.StatusOK {
|
||||
t.Fatalf("the session's own branch was refused: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedBranchLookupIsNotAPass(t *testing.T) {
|
||||
// If the check cannot run, the answer is "cannot verify", never "allowed".
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 904, Tenantid: 1147, Locationid: 1172})
|
||||
|
||||
got := call(t, fakeLocations{fails: true}, session, "GET", "/live/api/v1/web/products/get?locationid=1185", "")
|
||||
if got != fiber.StatusServiceUnavailable {
|
||||
t.Fatalf("a broken lookup did not refuse: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tokens ────────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestABrokenTokenIsAlwaysRefused(t *testing.T) {
|
||||
// Refused whatever the flag says. Nothing sends a broken token by accident.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||
|
||||
got := call(t, fakeLocations{}, "w1.rubbish.signature", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||
if got != fiber.StatusUnauthorized {
|
||||
t.Fatalf("a forged token was not refused: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATillsTokenIsNotAConsoleSessionHere(t *testing.T) {
|
||||
// A POS token is the same shape signed with the same key. If it verified
|
||||
// here its `Locationid` would land where `Tenantid` is read.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
pos, _, err := utils.MintPosToken(utils.PosClaims{Userid: 7, Tenantid: 916, Locationid: 1185, Roleid: 8}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("minting a POS token: %v", err)
|
||||
}
|
||||
|
||||
got := call(t, fakeLocations{}, pos, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=1147", "")
|
||||
if got != fiber.StatusUnauthorized {
|
||||
t.Fatalf("a cashier's token was accepted on the console: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The staged rollout ────────────────────────────────────────────────── */
|
||||
|
||||
func TestWithoutTheFlagAnUntokenedRequestStillWorks(t *testing.T) {
|
||||
// The console in production sends no token yet. Locking it out before
|
||||
// sign-in issues one would break a working product.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||
|
||||
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||
if got != fiber.StatusOK {
|
||||
t.Fatalf("an untokened request was refused while enforcement is off: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTheFlagAnUntokenedRequestIsRefused(t *testing.T) {
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
||||
|
||||
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||
if got != fiber.StatusUnauthorized {
|
||||
t.Fatalf("enforcement is on and an untokened request passed: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInStillWorksWithEnforcementOn(t *testing.T) {
|
||||
// Guarding the login route with a session token means nobody can ever get
|
||||
// one. This is the test that catches a locked-out deployment.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
t.Setenv("WEB_AUTH_REQUIRED", "true")
|
||||
|
||||
for _, path := range []string{
|
||||
"/live/api/v1/web/users/applogin",
|
||||
"/live/api/v1/web/tenant/weblogin",
|
||||
} {
|
||||
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
|
||||
t.Fatalf("%s was locked behind a session: %d", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The platform account ──────────────────────────────────────────────── */
|
||||
|
||||
func TestPlatformStaffMayNameAnyTenant(t *testing.T) {
|
||||
// Nearle's own staff work across tenants and the console's /nearle pages
|
||||
// depend on it.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
session := tokenFor(t, utils.WebClaims{Userid: 12, Superadmin: true, Roleid: 1})
|
||||
|
||||
got := call(t, fakeLocations{}, session, "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||
if got != fiber.StatusOK {
|
||||
t.Fatalf("a platform session was refused tenant 916: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoTokenIsNotAPlatformAccount(t *testing.T) {
|
||||
// Tenant 0 is the session that reads everything, and Go's zero value is 0.
|
||||
// A handler reading claims off a request that carried none would hand an
|
||||
// anonymous caller exactly that session.
|
||||
app := fiber.New()
|
||||
var found bool
|
||||
app.Get("/probe", func(c *fiber.Ctx) error {
|
||||
_, found = WebClaimsFrom(c)
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
if _, err := app.Test(httptest.NewRequest("GET", "/probe", nil)); err != nil {
|
||||
t.Fatalf("probing: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("claims were reported present on a request that carried none")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The default, after the rollout ────────────────────────────────────── */
|
||||
|
||||
func TestEnforcementIsOnByDefault(t *testing.T) {
|
||||
// It shipped defaulting to off so a live console could adopt tokens without
|
||||
// its users being locked out. That finished, and the default was measured
|
||||
// still open: a getorders with no credential returned a real merchant's
|
||||
// orders to anyone.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
t.Setenv("WEB_AUTH_REQUIRED", "")
|
||||
|
||||
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||
if got != fiber.StatusUnauthorized {
|
||||
t.Fatalf("an untokened request was served with no setting present: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforcementCanBeTurnedOffWithoutADeploy(t *testing.T) {
|
||||
// The escape hatch. Flipping a default that can lock people out has to be
|
||||
// reversible by one person in one minute.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
t.Setenv("WEB_AUTH_REQUIRED", "false")
|
||||
|
||||
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||
if got != fiber.StatusOK {
|
||||
t.Fatalf("the escape hatch does not work: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheWordFalseOpensTheDoor(t *testing.T) {
|
||||
// A typo must fail closed. "no", "0" and "off" all look like they might
|
||||
// disable it, and a deployment that meant to disable it and did not is far
|
||||
// safer than one that meant to enable it and did not.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
for _, setting := range []string{"no", "0", "off", "FALSE ", "nope"} {
|
||||
t.Setenv("WEB_AUTH_REQUIRED", setting)
|
||||
got := call(t, fakeLocations{}, "", "GET", "/live/api/v1/web/orders/tenant/getorders?tenantid=916", "")
|
||||
if setting == "FALSE " && got != fiber.StatusOK {
|
||||
t.Fatalf("a trimmed, case-insensitive false was not honoured: %d", got)
|
||||
}
|
||||
if setting != "FALSE " && got != fiber.StatusUnauthorized {
|
||||
t.Fatalf("%q opened the door: %d", setting, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInStillWorksWithTheNewDefault(t *testing.T) {
|
||||
// The test that catches a locked-out deployment. Guarding the login route
|
||||
// means nobody can ever obtain a token.
|
||||
t.Setenv("POS_TOKEN_SECRET", webTestSecret)
|
||||
t.Setenv("WEB_AUTH_REQUIRED", "")
|
||||
|
||||
for _, path := range []string{
|
||||
"/live/api/v1/web/users/applogin",
|
||||
"/live/api/v1/web/tenant/weblogin",
|
||||
} {
|
||||
if got := call(t, fakeLocations{}, "", "POST", path, `{"authname":"a@b.c"}`); got != fiber.StatusOK {
|
||||
t.Fatalf("%s was locked behind a session: %d", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
models/assistantaudit.go
Normal file
52
models/assistantaudit.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// AssistantAudit is one attempt to use an assistant tool.
|
||||
//
|
||||
// A new table rather than a column on anything: these rows are written on a
|
||||
// different schedule, read by different people, and are the only record of what
|
||||
// an assistant did on a merchant's behalf. Nothing else in the schema has that
|
||||
// job.
|
||||
//
|
||||
// ── Refusals are the interesting rows ───────────────────────────────────────
|
||||
//
|
||||
// Every call is recorded, including the ones the registry said no to. A trail of
|
||||
// successes answers "did anything try to read another tenant?" with silence,
|
||||
// which reads exactly like "no".
|
||||
//
|
||||
// ── What is NOT stored ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Not the question, and not the answer. The question is a shopkeeper's own words
|
||||
// and can carry anything they typed; the answer contains rows about their
|
||||
// business. Neither is needed to review what the assistant DID — the tool, the
|
||||
// arguments and the outcome are the act — and storing them would make this table
|
||||
// a second copy of the data it exists to police.
|
||||
type AssistantAudit struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement;column:id"`
|
||||
At time.Time `json:"at" gorm:"column:at;index"`
|
||||
Agent string `json:"agent" gorm:"column:agent;size:64"`
|
||||
Tool string `json:"tool" gorm:"column:tool;size:64;index"`
|
||||
Scope string `json:"scope" gorm:"column:scope;size:16"`
|
||||
Userid int `json:"userid" gorm:"column:userid;index"`
|
||||
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||
// The arguments the handler actually received — validated and defaulted,
|
||||
// not as the model sent them. What ran is what is worth being able to read
|
||||
// back; what was asked for is only interesting when it differs, and the
|
||||
// refusal row records that.
|
||||
Args string `json:"args" gorm:"column:args;type:jsonb"`
|
||||
// ok | refused | failed | proposed | approved.
|
||||
//
|
||||
// `refused` is the guard saying no and `failed` is the handler breaking;
|
||||
// collapsing them would hide a broken tool inside a count of things working
|
||||
// as designed. `proposed` and `approved` are the two halves of a write, and
|
||||
// a `proposed` with no matching `approved` is somebody deciding not to.
|
||||
Outcome string `json:"outcome" gorm:"column:outcome;size:16;index"`
|
||||
Detail string `json:"detail" gorm:"column:detail"`
|
||||
Rows int `json:"rows" gorm:"column:rows"`
|
||||
// Milliseconds. Integer rather than an interval type so it can be averaged
|
||||
// and sorted without anybody having to know the database's duration syntax.
|
||||
Tookms int64 `json:"tookms" gorm:"column:tookms"`
|
||||
}
|
||||
|
||||
func (AssistantAudit) TableName() string { return "assistantaudit" }
|
||||
@@ -166,6 +166,18 @@ type Ridersummary struct {
|
||||
}
|
||||
|
||||
type Deliveryinfo struct {
|
||||
// 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"`
|
||||
Orderheaderid int `json:"orderheaderid"`
|
||||
Applocationid int `json:"applocationid"`
|
||||
|
||||
111
models/deliveryslot.go
Normal file
111
models/deliveryslot.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
When a shop delivers.
|
||||
|
||||
A branch offers at most three windows a day — morning, afternoon, evening — and
|
||||
the shopper picks one at checkout. The window is a PREFERENCE, not a promise:
|
||||
every order is accepted, there is no capacity limit, and a slot never fills up.
|
||||
It tells the shop when to group a drop, and it tells the shopper roughly when to
|
||||
expect one.
|
||||
|
||||
── Per branch, not per tenant ──────────────────────────────────────────────
|
||||
|
||||
Timings belong to a shop floor, not a company. A tenant with an outlet in a
|
||||
market and another in an office park will run different hours, and discovering
|
||||
that after the fact would mean migrating live rows. `locationid` is on the table
|
||||
from the start for that reason, and onboarding simply fills it with the primary
|
||||
branch it just created.
|
||||
|
||||
── A branch with no slots is not broken ────────────────────────────────────
|
||||
|
||||
Every tenant trading today has no slots at all, and must keep taking orders.
|
||||
Absence means "order without a slot", exactly as before — never "this shop is
|
||||
closed". The whole rollout rests on that, so nothing here may treat an empty
|
||||
list as an error.
|
||||
*/
|
||||
type DeliverySlots struct {
|
||||
Slotid int `json:"deliveryslotid" gorm:"primaryKey;autoIncrement;column:slotid"`
|
||||
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
|
||||
Locationid int `json:"locationid" gorm:"column:locationid;index"`
|
||||
|
||||
// Which of the three this is. Fixed rather than free-form: the app shows
|
||||
// them in a known order and may want an icon per slot, and a shop inventing
|
||||
// a fourth would have nowhere to appear.
|
||||
Slotkey string `json:"slotkey" gorm:"column:slotkey"`
|
||||
|
||||
// What the shopper reads. Separate from `slotkey` so a shop can say
|
||||
// "Before work" without breaking the app's ordering.
|
||||
Name string `json:"name" gorm:"column:name"`
|
||||
|
||||
// "HH:MM", 24-hour, in the shop's local time.
|
||||
//
|
||||
// Stored as text, not as a timestamp, because this is a time of DAY that
|
||||
// recurs — it has no date until an order attaches one. A timestamp column
|
||||
// would invite a timezone conversion on every read, which is the one thing
|
||||
// this must not do: the shopkeeper typed 08:00 meaning eight in the morning
|
||||
// where they are standing.
|
||||
Starttime string `json:"starttime" gorm:"column:starttime"`
|
||||
|
||||
// Also the CUT-OFF. There is deliberately no separate cutoff column: a slot
|
||||
// accepts orders right up to the moment it ends, and then stops being
|
||||
// offered. Morning 08:00–10:00 takes an order at 09:59 and not at 10:01.
|
||||
Endtime string `json:"endtime" gorm:"column:endtime"`
|
||||
|
||||
// "active" or "inactive". A shop that stops doing evenings turns the slot
|
||||
// off rather than deleting it, so orders already placed against it still
|
||||
// resolve to something with a name.
|
||||
Status string `json:"status" gorm:"column:status"`
|
||||
|
||||
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
|
||||
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (DeliverySlots) TableName() string {
|
||||
return "deliveryslots"
|
||||
}
|
||||
|
||||
// The three keys, in the order a shopper reads them.
|
||||
const (
|
||||
SlotMorning = "morning"
|
||||
SlotAfternoon = "afternoon"
|
||||
SlotEvening = "evening"
|
||||
)
|
||||
|
||||
// SlotKeys is the whole set, in display order. Used to validate input and to
|
||||
// seed a new branch.
|
||||
var SlotKeys = []string{SlotMorning, SlotAfternoon, SlotEvening}
|
||||
|
||||
// IsSlotKey reports whether a key is one of the three.
|
||||
func IsSlotKey(key string) bool {
|
||||
return slices.Contains(SlotKeys, key)
|
||||
}
|
||||
|
||||
/*
|
||||
A slot offered to a shopper, with the day it falls on.
|
||||
|
||||
`DeliverySlots` describes a window that recurs; this is one concrete occurrence
|
||||
of it. The app needs the date because "evening" alone cannot distinguish tonight
|
||||
from tomorrow night, and once today's last window closes the next thing on offer
|
||||
is tomorrow morning.
|
||||
|
||||
The app does NO time arithmetic. It renders what this list contains, and the
|
||||
list already excludes anything that has closed.
|
||||
*/
|
||||
type AvailableDeliverySlot struct {
|
||||
Slotid int `json:"deliveryslotid"`
|
||||
Slotkey string `json:"slotkey"`
|
||||
Name string `json:"name"`
|
||||
Starttime string `json:"starttime"`
|
||||
Endtime string `json:"endtime"`
|
||||
// "YYYY-MM-DD", the day this window falls on.
|
||||
Slotdate string `json:"slotdate"`
|
||||
// True when `Slotdate` is not today. Saves the app comparing dates to
|
||||
// decide whether to write "Tomorrow" beside the name.
|
||||
IsTomorrow bool `json:"istomorrow"`
|
||||
}
|
||||
164
models/healthscore.go
Normal file
164
models/healthscore.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
/*
|
||||
The health score, as the customer app renders it.
|
||||
|
||||
The figures come from the catalogue-intelligence service — the same record the
|
||||
nutrition panel comes from, and the same one behind the health score card in the
|
||||
console. See services/nutritionService.go.
|
||||
|
||||
── Why the judgement is made here and not in the app ───────────────────────
|
||||
|
||||
The service returns a raw number and, from this endpoint, no band. Deciding what
|
||||
that number means — which band, whether the match is sure enough to state
|
||||
plainly, whether the product is even food — is a set of rules that already exists
|
||||
in the console. Sending the raw number and letting the app re-derive them would
|
||||
mean two implementations of the same judgement, drifting apart, disagreeing about
|
||||
the same product on two screens. The rules travel with the answer instead.
|
||||
*/
|
||||
type HealthScore struct {
|
||||
// 0–100, rounded. The service sends one decimal and nobody reads it.
|
||||
Score int `json:"score"`
|
||||
// "excellent" | "good" | "fair" | "poor" — for styling.
|
||||
Band string `json:"band"`
|
||||
// What a shopper reads, rather than what a nutritionist would call it.
|
||||
Label string `json:"label"`
|
||||
|
||||
// Sentences the service already wrote for a person. Rendered as given.
|
||||
Positives []string `json:"positives,omitempty"`
|
||||
Cautions []string `json:"cautions,omitempty"`
|
||||
Diettags []string `json:"diettags,omitempty"`
|
||||
|
||||
// Declared allergens. A false positive sends somebody to read the packet; a
|
||||
// false negative sends them to hospital, so a declared one is always shown.
|
||||
Allergens []string `json:"allergens,omitempty"`
|
||||
// True when an EMPTY allergen list must NOT be read as "contains none".
|
||||
//
|
||||
// The service accepts a source match down to 0.32 confidence, and
|
||||
// `data_status: "verified"` speaks to the numbers being real, not to the
|
||||
// record being this product. What must never happen is silence standing in
|
||||
// for "none" — which is exactly what an empty list rendered as nothing looks
|
||||
// like. An app MUST say "not confirmed" rather than draw nothing here.
|
||||
Allergensunconfirmed bool `json:"allergensunconfirmed,omitempty"`
|
||||
|
||||
// Set when the match is not sure enough to state plainly. When present the
|
||||
// app must show it: a nutrition table presented as fact on a 61% match is a
|
||||
// claim the data does not support.
|
||||
Caveat string `json:"caveat,omitempty"`
|
||||
|
||||
// Where the figures came from, for a shopper who wants to check.
|
||||
Source *HealthSource `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type HealthSource struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// LowConfidence is the line below which a match is a guide, not a fact.
|
||||
//
|
||||
// The same 0.7 the console uses. Sampling 40 scored products: 37 matched below
|
||||
// 0.7 and 26 below 0.5, so this fires often — which is the point.
|
||||
const LowConfidence = 0.7
|
||||
|
||||
/*
|
||||
BandFor turns a score into a band.
|
||||
|
||||
The service's own `health_band` wins when it sends one. It does NOT send one
|
||||
from the per-product endpoint — only from the list — so for this response the
|
||||
fallback is not an edge case, it is the only path, which makes these thresholds
|
||||
load-bearing rather than cosmetic.
|
||||
|
||||
They are the SERVICE'S thresholds, not ours. Derived from its output and since
|
||||
confirmed by that team in writing:
|
||||
|
||||
excellent >= 80
|
||||
good 60 – 79.9
|
||||
fair 40 – 59.9 confirmed 40, not 50
|
||||
poor < 40
|
||||
|
||||
Delete this fallback once `health_band` is on the per-product response — it is
|
||||
on their list. Until then, picking our own numbers would be one API disagreeing
|
||||
with itself depending which endpoint a screen called.
|
||||
*/
|
||||
func BandFor(score float64, sent string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(sent)) {
|
||||
case "excellent", "good", "fair", "poor":
|
||||
return strings.ToLower(strings.TrimSpace(sent))
|
||||
}
|
||||
switch {
|
||||
case score >= 80:
|
||||
return "excellent"
|
||||
case score >= 60:
|
||||
return "good"
|
||||
case score >= 40:
|
||||
return "fair"
|
||||
default:
|
||||
return "poor"
|
||||
}
|
||||
}
|
||||
|
||||
// BandLabel is what a shopper reads.
|
||||
func BandLabel(band string) string {
|
||||
switch band {
|
||||
case "excellent":
|
||||
return "Very healthy"
|
||||
case "good":
|
||||
return "Healthy"
|
||||
case "fair":
|
||||
return "Okay"
|
||||
default:
|
||||
return "Less healthy"
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
foodCategoryWords mean "this is food or drink".
|
||||
|
||||
An ALLOWLIST, and the asymmetry of the two failure modes is why. Withholding a
|
||||
score on real food costs a shopper a badge they never had. Showing one on
|
||||
something inedible is a different order of mistake, and the service has made it:
|
||||
measured 4 Sep 2026, `GET /nutrition/Godrej/godrej_hit_spray_1101d017` returned
|
||||
`health_score: 80.0, data_status: "verified"` — an "excellent" rating for
|
||||
insecticide. Palmolive soap and Pantene shampoo both scored 37.5 the same way.
|
||||
|
||||
Re-measured 29 Sep 2026: those records now answer `unavailable`, so the purge
|
||||
their team described has run. The guard stays anyway. It costs nothing when the
|
||||
data is clean, and the tenant this was built for stocks soap, shampoo and
|
||||
toothpaste alongside its food.
|
||||
*/
|
||||
var foodCategoryWords = []string{
|
||||
"beverage", "drink", "juice", "water", "tea", "coffee",
|
||||
"chocolate", "candy", "confection", "sweet", "dessert",
|
||||
"dairy", "milk", "cheese", "butter", "ghee", "curd", "yogurt",
|
||||
"snack", "biscuit", "cookie", "wafer", "chips", "namkeen",
|
||||
"atta", "staple", "flour", "rice", "dal", "pulse", "grain", "cereal",
|
||||
"pasta", "noodle", "bread", "bakery",
|
||||
"oil", "masala", "spice", "sauce", "pickle", "jam", "honey",
|
||||
"food", "nutrition", "breakfast", "fruit", "vegetable", "egg", "meat",
|
||||
}
|
||||
|
||||
// IsEdible reports whether a score is attached to something a person eats.
|
||||
//
|
||||
// An unrecognised category is treated as NOT food. On screen that reads as "not
|
||||
// scored yet", which is honest — we genuinely do not know — and is what most
|
||||
// products show anyway.
|
||||
func IsEdible(category string) bool {
|
||||
value := strings.ToLower(strings.TrimSpace(category))
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
// "General" carries soap and household goods alongside anything else the
|
||||
// scraper could not place. Ambiguous is not good enough for this decision.
|
||||
if value == "general" {
|
||||
return false
|
||||
}
|
||||
for _, word := range foodCategoryWords {
|
||||
if strings.Contains(value, word) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
52
models/nutrition.go
Normal file
52
models/nutrition.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
/*
|
||||
The nutrition panel, as the customer app renders it.
|
||||
|
||||
The figures come from the catalogue-intelligence service — the same one behind
|
||||
the health score card in the console — and this is the shape they reach the app
|
||||
in. See services/nutritionService.go for the fetch and the mapping.
|
||||
|
||||
── Why the field names are ugly ────────────────────────────────────────────
|
||||
|
||||
`servingsize`, not `serving_size` or `servingSize`. This is the shape the app
|
||||
developer asked for, and an API is a promise to a client already written against
|
||||
it. Consistency with the rest of Fiesta — itself inconsistent, `productid`
|
||||
beside `image_id` beside `sku_source` — is worth less than not breaking the
|
||||
caller.
|
||||
*/
|
||||
type NutritionPanel struct {
|
||||
// What the figures are measured against. "100g" for everything the service
|
||||
// returns today: its top-level values are per 100g, which is what the
|
||||
// console's own panel prints beneath them.
|
||||
Per string `json:"per,omitempty"`
|
||||
// What the pack calls one serving — "1 mini (11 g)". Absent when the
|
||||
// service did not state one, rather than defaulted: a serving size is a
|
||||
// claim about the food, and a guessed one is a false claim.
|
||||
Servingsize string `json:"servingsize,omitempty"`
|
||||
// Never nil when this panel exists — see HasValues. An app receiving
|
||||
// `items: null` has to branch; one receiving `[]` does not, and a panel with
|
||||
// no rows should not have been sent at all.
|
||||
Items []NutritionItem `json:"items"`
|
||||
}
|
||||
|
||||
// NutritionItem is one line of the panel.
|
||||
type NutritionItem struct {
|
||||
Name string `json:"name"`
|
||||
// The figure. A float because saturated fat is 18.7g as often as it is 19g,
|
||||
// and rounding it to please a type would be editing a label.
|
||||
Value float64 `json:"value"`
|
||||
// "kcal", "g", "mg". Free text on purpose: `extended_nutrients` carries its
|
||||
// own units from the source, and a closed list here would mean refusing to
|
||||
// carry whatever the label actually says.
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// HasValues reports whether this panel is worth sending.
|
||||
//
|
||||
// A panel with no rows is not a panel — it is an empty box on a product page,
|
||||
// which a shopper reads as "this food has no nutrition" rather than "we do not
|
||||
// know yet". The endpoint omits it instead.
|
||||
func (p *NutritionPanel) HasValues() bool {
|
||||
return p != nil && len(p.Items) > 0
|
||||
}
|
||||
@@ -130,23 +130,39 @@ type OrderInfo struct {
|
||||
Deliverylat FlexibleString `json:"deliverylat"`
|
||||
Deliverylong FlexibleString `json:"deliverylong"`
|
||||
Deliverytype string `json:"deliverytype"`
|
||||
Paymenttype int `json:"paymenttype"`
|
||||
Tenantname string `json:"tenantname"`
|
||||
Tenanttoken string `json:"tenanttoken"`
|
||||
Tenantsuburb string `json:"tenantsuburb"`
|
||||
Tenantcity string `json:"tenantcity"`
|
||||
Tenantcontactno string `json:"tenantcontactno"`
|
||||
Tenantpostcode string `json:"tenantpostcode"`
|
||||
Locationname string `json:"locationname"`
|
||||
Locationsuburb string `json:"locationsuburb"`
|
||||
Locationcity string `json:"locationcity"`
|
||||
Locationcontactno string `json:"locationcontactno"`
|
||||
Rider string `json:"rider"`
|
||||
Ridercontactno string `json:"ridercontactno"`
|
||||
Riderkms FlexibleString `json:"riderkms"`
|
||||
Smsdelivery int `json:"smsdelivery"`
|
||||
Customertoken string `json:"customertoken"`
|
||||
Ridertoken string `json:"ridertoken"`
|
||||
// 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"`
|
||||
Tenantname string `json:"tenantname"`
|
||||
Tenanttoken string `json:"tenanttoken"`
|
||||
Tenantsuburb string `json:"tenantsuburb"`
|
||||
Tenantcity string `json:"tenantcity"`
|
||||
Tenantcontactno string `json:"tenantcontactno"`
|
||||
Tenantpostcode string `json:"tenantpostcode"`
|
||||
Locationname string `json:"locationname"`
|
||||
Locationsuburb string `json:"locationsuburb"`
|
||||
Locationcity string `json:"locationcity"`
|
||||
Locationcontactno string `json:"locationcontactno"`
|
||||
Rider string `json:"rider"`
|
||||
Ridercontactno string `json:"ridercontactno"`
|
||||
Riderkms FlexibleString `json:"riderkms"`
|
||||
Smsdelivery int `json:"smsdelivery"`
|
||||
Customertoken string `json:"customertoken"`
|
||||
Ridertoken string `json:"ridertoken"`
|
||||
}
|
||||
|
||||
type DeliveryQuery struct {
|
||||
@@ -233,19 +249,39 @@ type Ordermonths struct {
|
||||
}
|
||||
|
||||
type Orders struct {
|
||||
Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"`
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Applocationid int `json:"applocationid"`
|
||||
Moduleid int `json:"moduleid"`
|
||||
Partnerid int `json:"partnerid"`
|
||||
Configid int `json:"configid"`
|
||||
Categoryid int `json:"categoryid"`
|
||||
Subcategoryid int `json:"subcategoryid"`
|
||||
Orderid string `json:"orderid"`
|
||||
Orderdate string `json:"orderdate,omitempty"`
|
||||
Deliverytime string `json:"deliverytime"`
|
||||
Deliverytype string `json:"deliverytype"`
|
||||
Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"`
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Applocationid int `json:"applocationid"`
|
||||
Moduleid int `json:"moduleid"`
|
||||
Partnerid int `json:"partnerid"`
|
||||
Configid int `json:"configid"`
|
||||
Categoryid int `json:"categoryid"`
|
||||
Subcategoryid int `json:"subcategoryid"`
|
||||
Orderid string `json:"orderid"`
|
||||
Orderdate string `json:"orderdate,omitempty"`
|
||||
Deliverytime string `json:"deliverytime"`
|
||||
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"`
|
||||
Pending string `json:"pending"`
|
||||
Processing string `json:"processing"`
|
||||
|
||||
@@ -73,7 +73,11 @@ type Partnerinfo 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"`
|
||||
Starttime string `json:"starttime"`
|
||||
Endtime string `json:"endtime"`
|
||||
@@ -296,10 +300,20 @@ type NewPartner struct {
|
||||
Where they work — ONE district, not a set.
|
||||
|
||||
`Applocationid` is the home region and goes on the partner row itself,
|
||||
because `GetPartners` filters on it and the rider app reads it.
|
||||
`Applocationids` is every region they cover and goes to
|
||||
`partnerlocations` — one partner routinely serves several cities, and
|
||||
that is the whole reason the link table exists.
|
||||
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"`
|
||||
/*
|
||||
|
||||
@@ -155,6 +155,78 @@ type Products struct {
|
||||
// `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"`
|
||||
Productsku string `json:"productsku,omitempty"`
|
||||
Brandid int `json:"brandid,omitempty"`
|
||||
@@ -209,35 +281,57 @@ type Products struct {
|
||||
}
|
||||
|
||||
type Locationproducts struct {
|
||||
Productid int `json:"productid"`
|
||||
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
|
||||
Productlocationid int `json:"productlocationid" gorm:"->"`
|
||||
Tenantid int `json:"tenantid,omitempty"`
|
||||
Categoryid int `json:"categoryid"`
|
||||
Categoryname string `json:"categoryname" gorm:"->"`
|
||||
Subcategoryid int `json:"subcategoryid,omitempty"`
|
||||
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
|
||||
Catalogueid int `json:"catalogueid,omitempty"`
|
||||
Addonid int `json:"addonid,omitempty"`
|
||||
Discountid int `json:"discountid,omitempty"`
|
||||
Pricingid int `json:"pricingid,omitempty"`
|
||||
Productname string `json:"productname,omitempty"`
|
||||
Productimage string `json:"productimage,omitempty"`
|
||||
Productdesc string `json:"productdesc,omitempty"`
|
||||
Productsku string `json:"productsku,omitempty"`
|
||||
Brandid int `json:"brandid,omitempty"`
|
||||
Productbrand string `json:"productbrand,omitempty"`
|
||||
Productunit string `json:"productunit"`
|
||||
Unitvalue string `json:"unitvalue"`
|
||||
Toppicks string `json:"toppicks,omitempty"`
|
||||
Productcost float64 `json:"productcost,omitempty"`
|
||||
Taxamount float64 `json:"taxamount,omitempty"`
|
||||
Taxpercent float64 `json:"taxpercent,omitempty"`
|
||||
Producttax int `json:"producttax" gorm:"default:0"`
|
||||
Productstock int `json:"productstock" gorm:"default:0"`
|
||||
Productcombo int `json:"productcombo" gorm:"default:0"`
|
||||
Variants int `json:"variants" gorm:"default:0"`
|
||||
Quantity int `json:"quantity"`
|
||||
Productid int `json:"productid"`
|
||||
AppLocationid int `json:"applocationid" gorm:"column:applocationid"`
|
||||
Productlocationid int `json:"productlocationid" gorm:"->"`
|
||||
Tenantid int `json:"tenantid,omitempty"`
|
||||
Categoryid int `json:"categoryid"`
|
||||
Categoryname string `json:"categoryname" gorm:"->"`
|
||||
Subcategoryid int `json:"subcategoryid,omitempty"`
|
||||
Subcategoryname string `json:"Subcategoryname" gorm:"->"`
|
||||
Catalogueid int `json:"catalogueid,omitempty"`
|
||||
Addonid int `json:"addonid,omitempty"`
|
||||
Discountid int `json:"discountid,omitempty"`
|
||||
Pricingid int `json:"pricingid,omitempty"`
|
||||
Productname string `json:"productname,omitempty"`
|
||||
Productimage string `json:"productimage,omitempty"`
|
||||
Productdesc string `json:"productdesc,omitempty"`
|
||||
Productsku string `json:"productsku,omitempty"`
|
||||
|
||||
// Three columns this read used to leave in the table.
|
||||
//
|
||||
// All three are stored on `products` and none of them reached the store
|
||||
// catalogue screen, because this struct had no field to scan them into —
|
||||
// so the console could not use what the import had gone to the trouble of
|
||||
// saving:
|
||||
//
|
||||
// Imageid the catalogue's durable key, and what HealthScorePanel
|
||||
// joins on. Absent, the panel reads it as "this product
|
||||
// never came from the catalogue" and renders nothing — for
|
||||
// EVERY product, including ones that plainly did.
|
||||
// Productimages the rest of a product's photos. `imagesOf()` parses this
|
||||
// and always got undefined, so the gallery fell back to
|
||||
// the single `productimage` and the extra images — 90 of
|
||||
// nestle's 123 products have them — were never shown.
|
||||
// Cataloguefacts the licence, nutrition, highlights, providers and price
|
||||
// range kept at import so they survive a re-scrape.
|
||||
Imageid string `json:"imageid,omitempty"`
|
||||
Productimages string `json:"productimages,omitempty"`
|
||||
Cataloguefacts string `json:"cataloguefacts,omitempty"`
|
||||
|
||||
Brandid int `json:"brandid,omitempty"`
|
||||
Productbrand string `json:"productbrand,omitempty"`
|
||||
Productunit string `json:"productunit"`
|
||||
Unitvalue string `json:"unitvalue"`
|
||||
Toppicks string `json:"toppicks,omitempty"`
|
||||
Productcost float64 `json:"productcost,omitempty"`
|
||||
Taxamount float64 `json:"taxamount,omitempty"`
|
||||
Taxpercent float64 `json:"taxpercent,omitempty"`
|
||||
Producttax int `json:"producttax" gorm:"default:0"`
|
||||
Productstock int `json:"productstock" gorm:"default:0"`
|
||||
Productcombo int `json:"productcombo" gorm:"default:0"`
|
||||
Variants int `json:"variants" gorm:"default:0"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Price is the per-store selling price from productlocations.price — the one
|
||||
// CreateProductLocation upserts. Read-only here: it comes from the joined
|
||||
// productlocations row, not from products. Without it a store could set a
|
||||
@@ -248,6 +342,19 @@ type Locationproducts struct {
|
||||
Diffpercent float64 `json:"diffpercent,omitempty"`
|
||||
Othercost float64 `json:"othercost,omitempty"`
|
||||
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"`
|
||||
Status string `json:"status" gorm:"default:outofstock"`
|
||||
|
||||
@@ -428,6 +535,14 @@ type ImportCatalogueProductRequest struct {
|
||||
Retailprice float64 `json:"retailprice"`
|
||||
Productcost float64 `json:"productcost"`
|
||||
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 {
|
||||
|
||||
206
models/scan.go
Normal file
206
models/scan.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package models
|
||||
|
||||
// Scan-to-order.
|
||||
//
|
||||
// A customer points the app at a packet, Google Lens (on the phone) reads a
|
||||
// label off it, and the app asks: "which of MY shops has this, in what sizes,
|
||||
// and which one should I buy from?" These are the shapes on both sides of
|
||||
// that conversation.
|
||||
|
||||
// ScanLookupRequest is what the app sends once Lens has produced a label.
|
||||
type ScanLookupRequest struct {
|
||||
Customerid int `json:"customerid"`
|
||||
// What Lens read: "Milk Bikis", "Dabur Honey 500g". Free text, trimmed
|
||||
// and capped by the service. Not required when Brand and Catalogueid
|
||||
// name a product outright.
|
||||
Label string `json:"label"`
|
||||
// A product the customer has already chosen, by its catalogue key —
|
||||
// which is how the app resolves a `candidates` list from an earlier
|
||||
// ambiguous lookup, and how a deep link or a re-order skips recognition
|
||||
// altogether. When both are set the label is ignored and no catalogue
|
||||
// search runs.
|
||||
Brand string `json:"brand"`
|
||||
Catalogueid int64 `json:"catalogueid"`
|
||||
// Where the customer is right now. Optional: without it the customer's
|
||||
// saved primary address is used, and without that stores are listed in
|
||||
// registration order with no distance.
|
||||
Latitude FlexibleString `json:"latitude"`
|
||||
Longitude FlexibleString `json:"longitude"`
|
||||
// The tenants the app believes the customer has scanned into. Optional and
|
||||
// never trusted on its own: the server intersects it with the
|
||||
// tenantcustomers table and reports anything it dropped.
|
||||
Tenantids []int `json:"tenantids"`
|
||||
// How many stores to return. 0 = all registered stores that stock it.
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ScanStore is one of the customer's registered outlets.
|
||||
type ScanStore struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Tenantname string `json:"tenantname"`
|
||||
Locationid int `json:"locationid"`
|
||||
Locationname string `json:"locationname"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
// Kilometres from the customer, or -1 when either side has no usable
|
||||
// coordinates. Never omitted: a missing number is easy to misread as 0.
|
||||
DistanceKm float64 `json:"distance_km"`
|
||||
// Delivery reach in the outlet's own units, straight from tenantlocations.
|
||||
Deliveryradius int `json:"deliveryradius"`
|
||||
Deliverymins int `json:"deliverymins"`
|
||||
Open bool `json:"open"`
|
||||
}
|
||||
|
||||
// ScanOption is one thing the customer can actually put in the basket at one
|
||||
// store: the matched product itself, or one of its sizes. Each is a real
|
||||
// product row with its own price and stock, which is why they are flat.
|
||||
type ScanOption struct {
|
||||
// Where this is sold.
|
||||
//
|
||||
// On the option and not only on the enclosing store, because an order line
|
||||
// carries both and the app would otherwise have to reach back up the
|
||||
// response to build one. `Locationid` is the real outlet and never 0.
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
|
||||
Productid int `json:"productid"`
|
||||
Productname string `json:"productname"`
|
||||
|
||||
// The pack size, both ways round.
|
||||
//
|
||||
// `Size` is the printable "500 g" the app has always shown. The two parts
|
||||
// are sent beside it because an order line needs the unit on its own, and
|
||||
// pulling it back out of the label means parsing a string a shop typed.
|
||||
Size string `json:"size"`
|
||||
Unitvalue string `json:"unitvalue"`
|
||||
Productunit string `json:"productunit"`
|
||||
|
||||
// What the customer pays: the outlet's own price, or the product's retail
|
||||
// price where the outlet has not set one.
|
||||
Price float64 `json:"price"`
|
||||
// What the shop paid. NOT a price to charge — it is `products.productcost`,
|
||||
// the same field the product screens return, and billing against it would
|
||||
// sell at cost.
|
||||
Productcost float64 `json:"productcost"`
|
||||
|
||||
// Carried on the order header, so the app has them without a second read.
|
||||
Categoryid int `json:"categoryid"`
|
||||
Subcategoryid int `json:"subcategoryid"`
|
||||
|
||||
Stock int `json:"stock"`
|
||||
Available bool `json:"available"`
|
||||
|
||||
// The same URL under both names: `image` is what this endpoint has always
|
||||
// sent, `productimage` is what every other product response calls it and
|
||||
// what an order line is built from.
|
||||
Image string `json:"image,omitempty"`
|
||||
Productimage string `json:"productimage,omitempty"`
|
||||
|
||||
// Is this the product that matched, or a size hanging under it?
|
||||
IsVariant bool `json:"is_variant"`
|
||||
Variantname string `json:"variantname,omitempty"`
|
||||
// How the row was tied back to the catalogue: "imageid",
|
||||
// "brand+catalogueid", "name" or "variant-of:<productid>".
|
||||
MatchedBy string `json:"matched_by"`
|
||||
}
|
||||
|
||||
// ScanStoreOffer is one store and what it can sell.
|
||||
type ScanStoreOffer struct {
|
||||
ScanStore
|
||||
// Nearest store with at least one option in stock. Exactly one offer
|
||||
// carries this, and only when something is in stock somewhere.
|
||||
Recommended bool `json:"recommended"`
|
||||
// Any option in stock here.
|
||||
Available bool `json:"available"`
|
||||
Options []ScanOption `json:"options"`
|
||||
}
|
||||
|
||||
// ScanCatalogueMatch is what the catalogue search settled on.
|
||||
type ScanCatalogueMatch struct {
|
||||
Brand string `json:"brand"`
|
||||
Catalogueid int64 `json:"catalogueid"`
|
||||
Imageid string `json:"imageid,omitempty"`
|
||||
ProductName string `json:"product_name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
VariantKey string `json:"variant_key,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
// "vector+text", "text" or "direct" — how the score was produced. The app
|
||||
// can be more cautious with a text-only match; "direct" means the caller
|
||||
// named the product by its catalogue key and nothing was recognised.
|
||||
Method string `json:"method"`
|
||||
// Set only on entries of `candidates`: at least one of the customer's
|
||||
// registered stores has this product in stock right now. Candidates are
|
||||
// ordered with the available ones first, so a "did you mean?" list can
|
||||
// show what is actually buyable before what is not.
|
||||
Available bool `json:"available,omitempty"`
|
||||
}
|
||||
|
||||
// ScanLookupResponse is the answer to a scan.
|
||||
type ScanLookupResponse struct {
|
||||
Label string `json:"label"`
|
||||
// The best catalogue product for the label, and the sizes of it the
|
||||
// catalogue knows about (each a separate catalogue row).
|
||||
//
|
||||
// Match is nil when nothing was recognised, and also when several
|
||||
// products matched equally well — see Ambiguous.
|
||||
Match *ScanCatalogueMatch `json:"match"`
|
||||
Variants []ScanCatalogueMatch `json:"catalogue_variants"`
|
||||
// Several products fit the label and no one of them is a clear winner —
|
||||
// which is what a bare brand name ("britannia") or a generic word
|
||||
// ("biscuits") produces, and Lens returns those often because a
|
||||
// wordmark is the most legible thing on a packet.
|
||||
//
|
||||
// When true: Match is nil, Stores is empty, and Candidates holds the
|
||||
// products to offer as "did you mean?". Picking one means calling
|
||||
// /lookup again with that candidate's `brand` and `catalogueid`.
|
||||
//
|
||||
// Guessing instead would mean showing a confident price for a product
|
||||
// the customer did not photograph.
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
Candidates []ScanCatalogueMatch `json:"candidates"`
|
||||
// 0..1. Below ~0.5 the app should confirm with the customer before
|
||||
// showing prices. With Ambiguous set this is the leader's score, which
|
||||
// by definition the runner-up nearly equals.
|
||||
Confidence float64 `json:"confidence"`
|
||||
// Registered stores that stock the product, nearest first, in-stock
|
||||
// first. Empty with Available=false when none does.
|
||||
Stores []ScanStoreOffer `json:"stores"`
|
||||
Available bool `json:"available"`
|
||||
// The locationid of the store marked Recommended, or 0.
|
||||
RecommendedLocationid int `json:"recommended_locationid"`
|
||||
// Tenant ids the app sent that the customer is not actually registered
|
||||
// with. Empty normally; non-empty means the app's local list is stale.
|
||||
UnregisteredTenantids []int `json:"unregistered_tenantids,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ScanConfirmRequest is sent when the customer taps a store and a size.
|
||||
type ScanConfirmRequest struct {
|
||||
Customerid int `json:"customerid"`
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Productid int `json:"productid"`
|
||||
Quantity int `json:"quantity"`
|
||||
Latitude FlexibleString `json:"latitude"`
|
||||
Longitude FlexibleString `json:"longitude"`
|
||||
}
|
||||
|
||||
// ScanConfirmResponse says whether the pick still holds, and where to go if
|
||||
// it does not.
|
||||
type ScanConfirmResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
// "in_stock", "insufficient_stock", "out_of_stock", "not_sold_here",
|
||||
// "store_not_registered".
|
||||
Reason string `json:"reason"`
|
||||
Store *ScanStore `json:"store,omitempty"`
|
||||
Option *ScanOption `json:"option,omitempty"`
|
||||
Requested int `json:"requested"`
|
||||
// The next-nearest registered store with enough of the same product, when
|
||||
// the chosen one has run out. Nil when there is none.
|
||||
Alternative *ScanStoreOffer `json:"alternative,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
64
models/showHealthScoreTag_test.go
Normal file
64
models/showHealthScoreTag_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
`showhealthscore` must not carry a GORM default.
|
||||
|
||||
GORM skips a zero-value field whose tag names a default — the documented
|
||||
behaviour is that a `false`, `0` or `""` is left out of the INSERT so the
|
||||
database default applies. For a boolean whose whole purpose is being set to
|
||||
false, that means the one value anybody would set it to is the one that cannot
|
||||
be written.
|
||||
|
||||
It shipped that way. A product imported with the health score switched off came
|
||||
back switched on, and it took importing one and reading it back to find out,
|
||||
because every layer above reported success: the console sent `false`, the
|
||||
request carried `false`, the handler read `false`, GORM dropped it, and the
|
||||
column default wrote `true`.
|
||||
|
||||
The DEFAULT belongs on the column, set by the migration in main.go. That covers
|
||||
rows predating the column and any INSERT that genuinely omits it, without
|
||||
teaching the ORM to discard a deliberate false on the way past.
|
||||
*/
|
||||
func TestShowHealthScoreCarriesNoGormDefault(t *testing.T) {
|
||||
field, ok := reflect.TypeFor[Products]().FieldByName("Showhealthscore")
|
||||
if !ok {
|
||||
t.Fatal("Products.Showhealthscore has moved or been renamed")
|
||||
}
|
||||
|
||||
tag := field.Tag.Get("gorm")
|
||||
if strings.Contains(strings.ToLower(tag), "default") {
|
||||
t.Fatalf(
|
||||
"gorm tag %q names a default. GORM then skips this field when it is false, "+
|
||||
"so a product whose health score is switched off is written as switched on. "+
|
||||
"The column default is set by the migration in main.go instead.",
|
||||
tag,
|
||||
)
|
||||
}
|
||||
|
||||
// The column still has to be named, since the Go field is one word and the
|
||||
// column is too but GORM's default naming would make it `show_health_score`.
|
||||
if !strings.Contains(tag, "column:showhealthscore") {
|
||||
t.Errorf("gorm tag %q no longer names the column", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowHealthScoreIsAPlainBoolOnTheWire(t *testing.T) {
|
||||
// Not a pointer, and not `omitempty`. Every product says what it is: the
|
||||
// console reads it to set the switch, and an absent key would be
|
||||
// indistinguishable from false on a screen that has to show one or the
|
||||
// other.
|
||||
field, _ := reflect.TypeFor[Products]().FieldByName("Showhealthscore")
|
||||
|
||||
if field.Type.Kind() != reflect.Bool {
|
||||
t.Errorf("Showhealthscore is %s, want bool", field.Type.Kind())
|
||||
}
|
||||
if tag := field.Tag.Get("json"); tag != "showhealthscore" {
|
||||
t.Errorf("json tag is %q — an omitempty here would hide every `false`", tag)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,14 @@ type Tenantinfo struct {
|
||||
Allocationid int `json:"allocationid"`
|
||||
Allocationtype string `json:"allocationtype"`
|
||||
Allocationmode int `json:"allocationmode"`
|
||||
|
||||
// How many outlets this merchant has.
|
||||
//
|
||||
// Only `GetAllTenants` fills this; it is 0 everywhere else, which is why it
|
||||
// is last and optional rather than part of the record proper. The console's
|
||||
// store list previously derived it by counting duplicate rows, and this
|
||||
// endpoint has never returned duplicates — see the note on the query.
|
||||
Branchcount int `json:"branchcount"`
|
||||
}
|
||||
|
||||
type Tenantlocations struct {
|
||||
@@ -190,6 +198,22 @@ type StaffInfo struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Locationid int `json:"locationid"`
|
||||
Locationname string `json:"locationname"`
|
||||
// Whether this login still works, straight off `app_users.status`.
|
||||
// Without it every row on the console's Users & access screen read
|
||||
// "Unknown", because the field was never selected or sent.
|
||||
Status string `json:"status"`
|
||||
// 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 {
|
||||
|
||||
99
repositories/assistantAuditRepository.go
Normal file
99
repositories/assistantAuditRepository.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Where the assistant's audit rows land.
|
||||
//
|
||||
// Deliberately thin: one insert and one read. The registry decides what an entry
|
||||
// means; this only has to keep it.
|
||||
type AssistantAuditRepository interface {
|
||||
Record(entry models.AssistantAudit) error
|
||||
// Recent reads the trail back for one tenant, newest first.
|
||||
//
|
||||
// Scoped by tenant even though this is a review surface, because "who looked
|
||||
// at what" is itself a merchant's data — a trail readable across tenants
|
||||
// would be a nicer version of the hole the trail exists to detect.
|
||||
Recent(tenantID, limit int) ([]models.AssistantAudit, error)
|
||||
}
|
||||
|
||||
type assistantAuditRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewAssistantAuditRepository(db *gorm.DB) AssistantAuditRepository {
|
||||
return &assistantAuditRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *assistantAuditRepository) Record(entry models.AssistantAudit) error {
|
||||
if r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(&entry).Error
|
||||
}
|
||||
|
||||
func (r *assistantAuditRepository) Recent(tenantID, limit int) ([]models.AssistantAudit, error) {
|
||||
if r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []models.AssistantAudit
|
||||
err := r.db.Where("tenantid = ?", tenantID).
|
||||
Order("at DESC").Limit(limit).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// EncodeAuditArgs renders arguments for storage.
|
||||
//
|
||||
// Keys sorted, so two identical calls store identical JSON and a query looking
|
||||
// for one of them finds both. Go randomises map iteration, and without this the
|
||||
// same call would be unsearchable across rows.
|
||||
//
|
||||
// A value that will not encode becomes a string rather than failing the write:
|
||||
// losing the audit row entirely to save one unencodable argument is the wrong
|
||||
// trade, and the row is still the record that the call happened.
|
||||
func EncodeAuditArgs(args map[string]any) string {
|
||||
if len(args) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
ordered := make(map[string]json.RawMessage, len(args))
|
||||
keys := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
raw, err := json.Marshal(args[key])
|
||||
if err != nil {
|
||||
raw, _ = json.Marshal("<unencodable>")
|
||||
}
|
||||
ordered[key] = raw
|
||||
}
|
||||
|
||||
// Re-marshalled through an ordered slice of pairs so the output is stable;
|
||||
// a map would be re-randomised on the way out.
|
||||
var out []byte
|
||||
out = append(out, '{')
|
||||
for i, key := range keys {
|
||||
if i > 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
name, _ := json.Marshal(key)
|
||||
out = append(out, name...)
|
||||
out = append(out, ':')
|
||||
out = append(out, ordered[key]...)
|
||||
}
|
||||
out = append(out, '}')
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// AuditDuration converts a duration for storage, rounding to the millisecond.
|
||||
func AuditDuration(d time.Duration) int64 { return d.Round(time.Millisecond).Milliseconds() }
|
||||
@@ -208,3 +208,35 @@ func TestNormaliseBrandKeyRefusesToInventAKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every word of the label was once required, so one word the catalogue does
|
||||
// not use ("Parle G biscuit pack") kept the right product out of the result
|
||||
// altogether and left the vector search to answer alone.
|
||||
func TestMinTokenHitsAsksForMostWordsNotAllOfThem(t *testing.T) {
|
||||
for _, tc := range []struct{ tokens, want int }{
|
||||
{1, 1}, // one word: it has to be there
|
||||
{2, 2}, // "Parle G" — both, and both are in Parle-G
|
||||
{3, 2}, // "Milk Bikis pack" — the pack is allowed to be missing
|
||||
{4, 3}, // "Parle G biscuit pack"
|
||||
{5, 4},
|
||||
{6, 4},
|
||||
} {
|
||||
if got := minTokenHits(tc.tokens); got != tc.want {
|
||||
t.Errorf("%d tokens: need %d, want %d", tc.tokens, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A threshold that could fall to 1 would let any single common word drag in
|
||||
// whole brand tables; one that stayed at n would be the bug all over again.
|
||||
func TestMinTokenHitsStaysBetweenTwoAndAll(t *testing.T) {
|
||||
for n := 3; n <= 30; n++ {
|
||||
got := minTokenHits(n)
|
||||
if got < 2 {
|
||||
t.Fatalf("%d tokens: %d is too loose", n, got)
|
||||
}
|
||||
if got >= n {
|
||||
t.Fatalf("%d tokens: %d still demands every word", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured
|
||||
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
||||
// scan-into-struct silently drops slice-kind destination fields, so they
|
||||
// are read as text here and parsed into []string in scanProductRow.
|
||||
|
||||
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,
|
||||
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
|
||||
|
||||
@@ -120,13 +120,26 @@ const (
|
||||
a.riderslat,a.riderslon,a.deliveryamt,a.kms,a.actualkms,a.riderkms,a.deliverycharges,a.deliverytype,a.paymenttype,a.smsdelivery,
|
||||
a.expecteddeliverytime,a.profit,a.transitminutes,a.calculationdistancekm,
|
||||
a.notes,a.ordernotes,b.tenantname,b.primarycontact as tenantcontactno,b.tenanttoken,b.suburb as tenantsuburb,b.city as tenantcity,
|
||||
c.firstname AS ridername,c.userfcmtoken,e.locationname,e.suburb AS locationsuburb,e.contactno AS locationcontactno
|
||||
c.firstname AS ridername,c.userfcmtoken,e.locationname,e.suburb AS locationsuburb,e.contactno AS locationcontactno,
|
||||
-- The delivery window, read from the ORDER.
|
||||
--
|
||||
-- The deliveries table has no window column and deliberately gets none:
|
||||
-- the window is a fact about what the customer asked for, and copying it
|
||||
-- here would be a second truth that can drift from the first. The
|
||||
-- dispatch board is exactly where drift would be noticed and exactly where
|
||||
-- it would cost the most, so it is joined.
|
||||
o.deliveryslotid, o.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
|
||||
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
|
||||
FROM deliveries a
|
||||
INNER JOIN tenants b ON a.tenantid=b.tenantid
|
||||
INNER JOIN app_users c ON a.userid=c.userid
|
||||
INNER JOIN tenantlocations e ON a.locationid=e.locationid
|
||||
INNER JOIN app_location f ON a.applocationid = f.applocationid
|
||||
INNER JOIN app_locationconfig g ON f.applocationid = g.applocationid`
|
||||
INNER JOIN app_locationconfig g ON f.applocationid = g.applocationid
|
||||
-- Both LEFT. Most deliveries have no window, and every one of them must
|
||||
-- still appear on the board — an INNER JOIN here would empty dispatch.
|
||||
LEFT JOIN orders o ON a.orderheaderid = o.orderheaderid
|
||||
LEFT JOIN deliveryslots s ON o.deliveryslotid = s.slotid`
|
||||
)
|
||||
|
||||
func (r *deliveriesRepository) CreateDeliveries(data []models.Deliveries) error {
|
||||
|
||||
97
repositories/deliverySlotRepository.go
Normal file
97
repositories/deliverySlotRepository.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
type DeliverySlotRepository interface {
|
||||
ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error)
|
||||
FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error)
|
||||
Save(slots []models.DeliverySlots) error
|
||||
}
|
||||
|
||||
type deliverySlotRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDeliverySlotRepository(db *gorm.DB) DeliverySlotRepository {
|
||||
return &deliverySlotRepository{db: db}
|
||||
}
|
||||
|
||||
// Ordered by start time so every caller — the console's editor and the app's
|
||||
// list alike — sees morning before evening without sorting it again.
|
||||
func (r *deliverySlotRepository) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) {
|
||||
slots := make([]models.DeliverySlots, 0, len(models.SlotKeys))
|
||||
|
||||
err := r.db.Table("deliveryslots").
|
||||
Where("tenantid = ? AND locationid = ?", tenantID, locationID).
|
||||
Order("starttime ASC").
|
||||
Find(&slots).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slots, nil
|
||||
}
|
||||
|
||||
/*
|
||||
One window, scoped to the branch that claims it.
|
||||
|
||||
The tenant and location are in the WHERE and not checked afterwards: a slot id
|
||||
arrives from the app, which carries no session, so it is a claim about which
|
||||
shop it belongs to. Looking it up by id alone and trusting the row would let one
|
||||
shop's checkout name another shop's window.
|
||||
*/
|
||||
func (r *deliverySlotRepository) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) {
|
||||
var slot models.DeliverySlots
|
||||
|
||||
err := r.db.Table("deliveryslots").
|
||||
Where("slotid = ? AND tenantid = ? AND locationid = ?", slotID, tenantID, locationID).
|
||||
First(&slot).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Not an error: "this shop does not offer that" is an answer, and the
|
||||
// service turns it into something a shopper can read.
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &slot, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Write a branch's windows, all of them or none.
|
||||
|
||||
── Upsert, not delete-then-insert ──────────────────────────────────────────
|
||||
|
||||
Slot ids are referenced by `orders.deliveryslotid`. Replacing the rows would
|
||||
renumber them, and every order already placed would point at a window that no
|
||||
longer means what it did — or at nothing. The unique index on
|
||||
(tenantid, locationid, slotkey) is what makes the conflict target work, so a
|
||||
branch keeps one morning however many times it is edited.
|
||||
|
||||
── One transaction ─────────────────────────────────────────────────────────
|
||||
|
||||
A shop editing all three and getting two is worse than getting none: the two
|
||||
that took are live and serving shoppers, and nothing on screen says which.
|
||||
*/
|
||||
func (r *deliverySlotRepository) Save(slots []models.DeliverySlots) error {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Table("deliveryslots").
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "tenantid"}, {Name: "locationid"}, {Name: "slotkey"},
|
||||
},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"name", "starttime", "endtime", "status", "updated"}),
|
||||
}).
|
||||
Create(&slots).Error
|
||||
})
|
||||
}
|
||||
@@ -102,14 +102,30 @@ const (
|
||||
a.deliveryid AS deliverycustomerid, a.deliveryid, a.deliveryaddress, a.deliverylat, a.deliverylong, a.deliverytype,
|
||||
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity, a.paymenttype, a.smsdelivery, b.customertoken,
|
||||
c.tenantname, c.tenanttoken, c.primarycontact AS tenantcontactno, c.postcode AS tenantpostcode, c.suburb AS tenantsuburb, c.city AS tenantcity,
|
||||
d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity
|
||||
d.locationname, d.contactno AS locationcontactno, d.postcode AS locationpostcode, d.suburb AS locationsuburb, d.city AS locationcity,
|
||||
-- The delivery window the customer asked for.
|
||||
--
|
||||
-- Selected EXPLICITLY, like everything else here: this select names its
|
||||
-- columns, so a field added to the Go struct and not added to this line
|
||||
-- is simply absent from every row, with nothing anywhere saying so. That
|
||||
-- exact gap shipped once on products.showhealthscore and made every
|
||||
-- reading taken from the list meaningless.
|
||||
--
|
||||
-- The NAME is joined rather than stored on the order, so a shop that
|
||||
-- renames "Evening" to "After work" sees the new name on old orders --
|
||||
-- the window they chose has not changed, only what it is called.
|
||||
a.deliveryslotid, a.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
|
||||
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
|
||||
FROM orders a
|
||||
LEFT JOIN customers b ON a.customerid = b.customerid
|
||||
LEFT JOIN tenants c ON a.tenantid = c.tenantid
|
||||
LEFT JOIN tenantlocations d ON a.locationid = d.locationid
|
||||
|
||||
LEFT JOIN app_location h ON a.applocationid = h.applocationid
|
||||
LEFT JOIN app_locationconfig i ON a.applocationid = i.applocationid`
|
||||
LEFT JOIN app_locationconfig i ON a.applocationid = i.applocationid
|
||||
-- LEFT, because the overwhelming majority of orders have no window and
|
||||
-- must still appear. An INNER JOIN here would silently empty the list.
|
||||
LEFT JOIN deliveryslots s ON a.deliveryslotid = s.slotid`
|
||||
|
||||
orderdetails = `SELECT DISTINCT a.orderheaderid, a.applocationid,
|
||||
a.tenantid, a.locationid, a.partnerid, a.configid, a.categoryid, a.subcategoryid, a.moduleid,
|
||||
@@ -122,12 +138,22 @@ const (
|
||||
a.deliverycustomer,a.deliverycontactno,a.deliverylocation as deliverysuburb, a.deliverycity,a.paymenttype, a.smsdelivery, a.orderamount,
|
||||
b.tenantname, b.tenanttoken, b.primarycontact AS tenantcontactno, b.postcode AS tenantpostcode, b.suburb AS tenantsuburb,b.city AS tenantcity,
|
||||
c.locationname, c.contactno AS locationcontactno, c.postcode AS locationpostcode, c.suburb AS locationsuburb, c.city AS locationcity,
|
||||
d.locationname AS applocation
|
||||
d.locationname AS applocation,
|
||||
-- The delivery window, same as the 'base' select above.
|
||||
--
|
||||
-- BOTH selects need it. 'base' backs the console's list; this one backs the
|
||||
-- partner, customer, user and admin reads -- including the customer app's
|
||||
-- own order history, which is where a shopper expects to see the window
|
||||
-- they picked. Fixing one and not the other is how a field ends up present
|
||||
-- on some screens and silently absent on others.
|
||||
a.deliveryslotid, a.deliveryslotdate, s.slotkey, s.name AS deliveryslotname,
|
||||
s.starttime AS deliveryslotstart, s.endtime AS deliveryslotend
|
||||
FROM orders a
|
||||
LEFT JOIN tenants b ON a.tenantid = b.tenantid
|
||||
LEFT JOIN tenantlocations c ON a.locationid = c.locationid
|
||||
LEFT JOIN app_location d ON a.applocationid = d.applocationid
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid`
|
||||
LEFT JOIN app_locationconfig e ON d.applocationid = e.applocationid
|
||||
LEFT JOIN deliveryslots s ON a.deliveryslotid = s.slotid`
|
||||
)
|
||||
|
||||
func (r *orderRepository) GetTenantOrders(input models.DeliveryQuery) ([]models.OrderInfo, error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ type PartnerRepository interface {
|
||||
GetActiveRiders(partnerid, aid, uid, tid int) ([]models.RiderInfo, error)
|
||||
GetPartners(aid, pid, uid int) ([]models.Partnerinfo, error)
|
||||
GetRiderShifts(aid int) ([]models.Ridershifts, error)
|
||||
CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error)
|
||||
GetLocationConfig(uid, cid int) ([]models.Locationconfigs, error)
|
||||
GetRiderLogs(pid, aid int, fdate, tdate string) ([]models.RiderlogDetails, error)
|
||||
GetRiderInfo(userid int) (models.RiderInfo, error)
|
||||
@@ -87,30 +88,67 @@ func (r *partnerRepository) GetPartners(aid, pid, uid int) ([]models.Partnerinfo
|
||||
var q1 string
|
||||
var args []interface{}
|
||||
|
||||
// Every variant joins partnerlocations, and that join is the whole point.
|
||||
//
|
||||
// ── It is what separates our partners from somebody else's ──────────────
|
||||
//
|
||||
// `partnerinfo` is shared. It has no column saying which product a row
|
||||
// belongs to — no configid, no appid — so a partner created by another app
|
||||
// on this database is indistinguishable from ours by its own fields, and
|
||||
// this read used to return every Active row on the platform. The console
|
||||
// made that worse rather than better: it asks `getapplocations` for EVERY
|
||||
// region and then fetches partners region by region, so the applocationid
|
||||
// filter below never narrowed anything.
|
||||
//
|
||||
// `partnerlocations` is the difference. Only `CreatePartner` writes it —
|
||||
// one row per region, in the same transaction as the partner — so a row in
|
||||
// that table means "registered through this console". The partners that
|
||||
// predate it were inserted by hand and have none, which is why two of them
|
||||
// are called "Test".
|
||||
//
|
||||
// ── The region filter reads the link table, not the home region ─────────
|
||||
//
|
||||
// `partnerinfo.applocationid` is the HOME region — CreatePartner writes
|
||||
// `regions[0]` there — while partnerlocations holds every region covered.
|
||||
// Those are not the same thing, and not only in theory: partner 44,
|
||||
// Xpress-Cbe-Main, has a home region of 1 and link rows for 1 AND 2, so
|
||||
// filtering on the partner row hid them from every Madurai query. That is
|
||||
// the case the link table exists for.
|
||||
//
|
||||
// DISTINCT because such a partner has one row per region in the join and is
|
||||
// still one partner. Only partnerinfo columns are selected, so there is
|
||||
// nothing per-region for it to fail to collapse.
|
||||
//
|
||||
// A caller fanning out over regions and concatenating the answers still has
|
||||
// to dedupe — the same partner is legitimately in two of them. The console's
|
||||
// `useAllPartners` does; it listed Xpress-Cbe-Main twice until it did.
|
||||
const columns = `select distinct p.partnerid,p.applocationid,p.partnertypeid,p.partnername,
|
||||
p.primarycontact,p.primaryemail,p.contactno,p.address,p.suburb,p.state,p.city,p.partnerimage
|
||||
from partnerinfo p
|
||||
inner join partnerlocations l on l.partnerid = p.partnerid
|
||||
where p.status='Active'`
|
||||
|
||||
if pid != 0 {
|
||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
||||
contactno,address,suburb,state,city,partnerimage
|
||||
from partnerinfo where status='Active' and partnerid=?`
|
||||
// Scoped the same way on purpose: asking for a partner by id must not
|
||||
// be a way round the separation above.
|
||||
q1 = columns + ` and p.partnerid=?`
|
||||
args = append(args, pid)
|
||||
|
||||
} else if aid != 0 {
|
||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
||||
contactno,address,suburb,state,city,partnerimage
|
||||
from partnerinfo where status='Active' and applocationid=?`
|
||||
q1 = columns + ` and l.applocationid=?`
|
||||
args = append(args, aid)
|
||||
|
||||
} else {
|
||||
q1 = `select partnerid,applocationid,partnertypeid,partnername,primarycontact,primaryemail,
|
||||
contactno,address,suburb,state,city,partnerimage
|
||||
from partnerinfo where status='Active'`
|
||||
q1 = columns
|
||||
}
|
||||
|
||||
q1 += ` order by p.partnername, p.partnerid`
|
||||
|
||||
err := r.db.Raw(q1, args...).Find(&data).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
print(q1)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -615,13 +653,17 @@ them are named "Test".
|
||||
|
||||
Where a partner works is recorded twice, on purpose and not by accident:
|
||||
|
||||
partnerinfo.applocationid their home region — `GetPartners` filters on it
|
||||
and the rider app reads it
|
||||
partnerinfo.applocationid their home region — the rider app reads it
|
||||
partnerlocations every region they cover
|
||||
|
||||
Both are kept in step here. Writing only the first would confine a partner to
|
||||
one city, and writing only the second would hide them from every existing
|
||||
query. */
|
||||
one city, and writing only the second would hide them from the rider app.
|
||||
|
||||
`GetPartners` reads the SECOND: it joins partnerlocations, which both scopes a
|
||||
region query to every city a partner actually covers and — because only this
|
||||
function writes that table — separates partners registered here from the ones
|
||||
another product put in the shared `partnerinfo`. So the link rows are not
|
||||
bookkeeping; they are what makes a partner ours. */
|
||||
|
||||
// CreatePartner onboards a delivery partner and records the regions they cover.
|
||||
func (r *partnerRepository) CreatePartner(input models.NewPartner) (int, error) {
|
||||
@@ -986,3 +1028,178 @@ func (r *partnerRepository) regionByName(db *gorm.DB, name string) int {
|
||||
WHERE LOWER(TRIM(locationname)) = LOWER(TRIM(?)) LIMIT 1`, name).Scan(&id)
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Rider shifts ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A shift is the window a rider works, and `CreateRider` refuses a rider
|
||||
// without one — `getriders` joins `ridershifts` through `ridersettings.shiftid`,
|
||||
// so a rider on a shift that does not exist is a rider nobody can see.
|
||||
//
|
||||
// Until now the table could only be READ. There was no endpoint, no service
|
||||
// method and not even a field for `applocationid` on the model, so a region
|
||||
// that shipped without shift rows could never have a rider added to it at all:
|
||||
// the console showed "No shifts set up for this region" and there was nothing
|
||||
// anybody could do from the product to change that. Till staff had
|
||||
// `createstaffshift` from the beginning; riders were simply missed.
|
||||
|
||||
// riderShiftClock is a start or end time as the column stores it.
|
||||
//
|
||||
// Accepts `9:00`, `09:00` and `09:00:00` and normalises to `HH:MM`. The rows
|
||||
// inserted by hand over the years use all three spellings, and `GetRiderShifts`
|
||||
// builds its label by concatenating the two columns raw — so `9:00-17:00` and
|
||||
// `09:00-17:00` are two different labels for one window in the same dropdown.
|
||||
func riderShiftClock(raw string) (string, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return "", errors.New("a shift needs a start and an end time")
|
||||
}
|
||||
|
||||
parts := strings.Split(text, ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return "", fmt.Errorf("%q is not a time — write it as HH:MM", raw)
|
||||
}
|
||||
|
||||
hour, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return "", fmt.Errorf("%q is not a time — the hour must be 0 to 23", raw)
|
||||
}
|
||||
minute, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return "", fmt.Errorf("%q is not a time — the minutes must be 0 to 59", raw)
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", hour, minute), nil
|
||||
}
|
||||
|
||||
// riderShiftHours is how long the window runs, in hours.
|
||||
//
|
||||
// Computed rather than asked for, because it is the one field a person gets
|
||||
// wrong and nothing downstream checks: `shifthours` feeds rider pay, and a
|
||||
// window of 09:00–17:00 recorded as 4 hours underpays every rider on it.
|
||||
//
|
||||
// A window that ends before it starts crosses midnight and is measured that
|
||||
// way — a 22:00–06:00 night shift is eight hours, not minus sixteen.
|
||||
func riderShiftHours(start, end string) float32 {
|
||||
toMinutes := func(clock string) int {
|
||||
parts := strings.Split(clock, ":")
|
||||
hour, _ := strconv.Atoi(parts[0])
|
||||
minute, _ := strconv.Atoi(parts[1])
|
||||
return hour*60 + minute
|
||||
}
|
||||
|
||||
span := toMinutes(end) - toMinutes(start)
|
||||
if span <= 0 {
|
||||
span += 24 * 60
|
||||
}
|
||||
return float32(span) / 60
|
||||
}
|
||||
|
||||
// validateRiderShift checks everything that does not need the database.
|
||||
//
|
||||
// Split out so the rules are testable without one, and returns the shift with
|
||||
// its times normalised and its hours worked out rather than reporting on a copy
|
||||
// the caller then has to rebuild.
|
||||
func validateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
|
||||
if shift.Applocationid == 0 {
|
||||
return shift, errors.New("a shift needs a delivery region")
|
||||
}
|
||||
|
||||
start, err := riderShiftClock(shift.Starttime)
|
||||
if err != nil {
|
||||
return shift, err
|
||||
}
|
||||
end, err := riderShiftClock(shift.Endtime)
|
||||
if err != nil {
|
||||
return shift, err
|
||||
}
|
||||
if start == end {
|
||||
return shift, errors.New("a shift cannot start and end at the same time")
|
||||
}
|
||||
|
||||
shift.Starttime = start
|
||||
shift.Endtime = end
|
||||
// Always recomputed, never taken from the request. A caller that sends its
|
||||
// own number is a caller that can disagree with the window it just sent.
|
||||
shift.Shifthours = riderShiftHours(start, end)
|
||||
|
||||
if shift.Basefare < 0 || shift.Additionalcharges < 0 || shift.Fuelcharge < 0 {
|
||||
return shift, errors.New("pay cannot be negative")
|
||||
}
|
||||
return shift, nil
|
||||
}
|
||||
|
||||
// CreateRiderShift opens a shift window in one region.
|
||||
func (r *partnerRepository) CreateRiderShift(shift models.Ridershifts) (models.Ridershifts, error) {
|
||||
shift, err := validateRiderShift(shift)
|
||||
if err != nil {
|
||||
return models.Ridershifts{}, err
|
||||
}
|
||||
|
||||
// Same guard `CreateRider` applies to a rider's region, for the same reason:
|
||||
// `getriders` joins app_locationconfig, so a shift in a region with no
|
||||
// config row would be offered in the dropdown and then hide every rider put
|
||||
// on it.
|
||||
var configs int64
|
||||
if err := r.db.Table("app_locationconfig").
|
||||
Where("applocationid = ?", shift.Applocationid).Count(&configs).Error; err != nil {
|
||||
return models.Ridershifts{}, err
|
||||
}
|
||||
if configs == 0 {
|
||||
return models.Ridershifts{}, fmt.Errorf(
|
||||
"delivery region %d is not configured, so a shift there would hide every rider on it", shift.Applocationid)
|
||||
}
|
||||
|
||||
// The dropdown labels a shift by its times alone, so a duplicate window is
|
||||
// two identical-looking choices and no way to tell which one a rider is on.
|
||||
var clash int64
|
||||
if err := r.db.Table("ridershifts").
|
||||
Where("applocationid = ? AND starttime = ? AND endtime = ?",
|
||||
shift.Applocationid, shift.Starttime, shift.Endtime).
|
||||
Count(&clash).Error; err != nil {
|
||||
return models.Ridershifts{}, err
|
||||
}
|
||||
if clash > 0 {
|
||||
return models.Ridershifts{}, fmt.Errorf(
|
||||
"a %s-%s shift already exists in this region", shift.Starttime, shift.Endtime)
|
||||
}
|
||||
|
||||
// A column map with RETURNING, the same way CreatePartner writes its row,
|
||||
// rather than inserting the struct.
|
||||
//
|
||||
// Inserting the struct would carry `shiftid` at zero into the statement and
|
||||
// leave whether the sequence is used to how GORM reads a `Primary_Key` tag
|
||||
// written in the v1 spelling — which is exactly the kind of thing that works
|
||||
// on one driver and writes a row with id 0 on another. Naming the columns
|
||||
// removes the question: the id is the database's to assign.
|
||||
row := map[string]any{
|
||||
"applocationid": shift.Applocationid,
|
||||
"shiftdate": shift.Shiftdate,
|
||||
"starttime": shift.Starttime,
|
||||
"endtime": shift.Endtime,
|
||||
"shifthours": shift.Shifthours,
|
||||
"basefare": shift.Basefare,
|
||||
"additionalkm": shift.Additionalkm,
|
||||
"additionalcharges": shift.Additionalcharges,
|
||||
"orders": shift.Orders,
|
||||
"fuelcharge": shift.Fuelcharge,
|
||||
}
|
||||
|
||||
if err := r.db.Table("ridershifts").
|
||||
Clauses(clause.Returning{Columns: []clause.Column{{Name: "shiftid"}}}).
|
||||
Create(&row).Error; err != nil {
|
||||
return models.Ridershifts{}, err
|
||||
}
|
||||
|
||||
id, ok := row["shiftid"]
|
||||
if !ok || toInt(id) == 0 {
|
||||
// The rider form selects the new shift by id the moment this returns. A
|
||||
// shift written without one would leave the drawer selecting nothing and
|
||||
// reading as a failed save.
|
||||
return models.Ridershifts{}, errors.New("the shift was written without an id")
|
||||
}
|
||||
shift.Shiftid = toInt(id)
|
||||
|
||||
// The label the dropdown shows, built the same way GetRiderShifts builds it
|
||||
// so a shift reads identically the moment it is created and after a reload.
|
||||
shift.Shiftname = shift.Starttime + "-" + shift.Endtime
|
||||
return shift, nil
|
||||
}
|
||||
|
||||
@@ -36,21 +36,42 @@ func normaliseShiftTime(raw string) (string, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ListStaffShifts returns an outlet's shifts, newest last so a picker reads in
|
||||
// the order they were created rather than alphabetically by name.
|
||||
// ListStaffShifts returns the shifts a tenant's staff can be put on, newest
|
||||
// last so a picker reads in the order they were created rather than
|
||||
// alphabetically by name.
|
||||
//
|
||||
// ── Why the outlet is optional ──────────────────────────────────────────────
|
||||
//
|
||||
// A shift is a fact about how a BUSINESS runs, not about one shop: a tenant
|
||||
// that works 07:00–15:00 and 15:00–23:00 works those hours at every branch it
|
||||
// owns, and making somebody re-enter them per outlet guarantees the third
|
||||
// branch gets 07:00–15:30 and nobody notices. `locationid = 0` is a shift that
|
||||
// belongs to the whole tenant.
|
||||
//
|
||||
// Branch-specific rows are still honoured, because they already exist and a
|
||||
// tenant may genuinely run one outlet differently. Asking for an outlet returns
|
||||
// the tenant's shifts AND that outlet's own; asking for none returns everything
|
||||
// the tenant has.
|
||||
func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
|
||||
if tenantID <= 0 || locationID <= 0 {
|
||||
return nil, fmt.Errorf("tenantid and locationid are required")
|
||||
if tenantID <= 0 {
|
||||
return nil, fmt.Errorf("tenantid is required")
|
||||
}
|
||||
|
||||
shifts := make([]models.StaffShifts, 0)
|
||||
query := `SELECT * FROM staffshifts WHERE tenantid = ? AND locationid = ?`
|
||||
args := []any{tenantID}
|
||||
|
||||
query := `SELECT * FROM staffshifts WHERE tenantid = ?`
|
||||
if locationID > 0 {
|
||||
// The tenant-wide ones and this outlet's, never another outlet's.
|
||||
query += ` AND (COALESCE(locationid, 0) = 0 OR locationid = ?)`
|
||||
args = append(args, locationID)
|
||||
}
|
||||
if !includeInactive {
|
||||
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||
}
|
||||
query += ` ORDER BY staffshiftid`
|
||||
|
||||
if err := r.db.Raw(query, tenantID, locationID).Scan(&shifts).Error; err != nil {
|
||||
if err := r.db.Raw(query, args...).Scan(&shifts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shifts, nil
|
||||
@@ -58,8 +79,14 @@ func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactiv
|
||||
|
||||
// CreateStaffShift adds a window at one outlet.
|
||||
func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
|
||||
if tenantID <= 0 || locationID <= 0 {
|
||||
return nil, fmt.Errorf("tenantid and locationid are required")
|
||||
if tenantID <= 0 {
|
||||
return nil, fmt.Errorf("tenantid is required")
|
||||
}
|
||||
// `locationid = 0` is deliberate and is now the ordinary case: the shift
|
||||
// belongs to the tenant and every branch it owns can use it. An outlet is
|
||||
// only named when one shop really does run different hours.
|
||||
if locationID < 0 {
|
||||
locationID = 0
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
|
||||
54
repositories/posShift_test.go
Normal file
54
repositories/posShift_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
A shift belongs to a business, not to one of its shops.
|
||||
|
||||
Both halves of this used to demand an outlet, so the same two windows had to be
|
||||
re-entered at every branch a tenant owns — which is how the third branch quietly
|
||||
gets 07:00–15:30 and nobody notices until a cashier is filed under hours that do
|
||||
not exist. A tenant that works 07:00–15:00 works those hours everywhere.
|
||||
|
||||
`locationid = 0` is now the ordinary case. A named outlet still works, because
|
||||
one shop may genuinely run differently and those rows already exist.
|
||||
*/
|
||||
|
||||
func TestATimeIsAcceptedInBothFormsTheClientsSend(t *testing.T) {
|
||||
// `<input type="time">` gives "07:00"; some browsers and every hand-typed
|
||||
// value give "07:00:00"; `ridershifts` stores the seconds form already.
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"07:00", "07:00"},
|
||||
{"07:00:00", "07:00"},
|
||||
{" 23:59 ", "23:59"},
|
||||
{"00:00", "00:00"},
|
||||
} {
|
||||
got, err := normaliseShiftTime(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("%q became %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotATimeOfDayIsRefused(t *testing.T) {
|
||||
for _, bad := range []string{"", " ", "7:00", "24:00", "07:60", "morning", "07", "7pm"} {
|
||||
if _, err := normaliseShiftTime(bad); err == nil {
|
||||
t.Fatalf("%q was accepted as a time of day", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTimeErrorSaysWhatShapeIsWanted(t *testing.T) {
|
||||
// "invalid" tells somebody nothing. The message names the format, because
|
||||
// the most common wrong answer is a valid time in the wrong notation.
|
||||
_, err := normaliseShiftTime("7pm")
|
||||
if err == nil || !strings.Contains(err.Error(), "HH:MM") {
|
||||
t.Fatalf("the refusal does not say what to type: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,10 @@ type ProductRepository interface {
|
||||
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
|
||||
CreateProductStock(stocks []models.Productstock) error
|
||||
UpdateProductStatus(productIDs []int, status string) error
|
||||
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||
SetShowHealthScore(tenantID, productID int, show bool) error
|
||||
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
||||
EnsureProductLocation(refs []models.ProductLocationRef) error
|
||||
CreateProduct(product models.Products) error
|
||||
UpdateProduct(product models.Products) error
|
||||
DeleteProduct(productID int) error
|
||||
GetStockStatement(tenantID, locationID, subcategoryID, pageno, pagesize int, keyword string) ([]models.Productstockstatement, error)
|
||||
@@ -393,19 +394,32 @@ func (r *productRepository) UpdateProductStatus(productIDs []int, status string)
|
||||
Update("productstatus", status).Error
|
||||
}
|
||||
|
||||
func (r *productRepository) CreateProduct(product models.Products) error {
|
||||
tx := r.db.Begin()
|
||||
|
||||
if err := tx.Create(&product).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
// normaliseProductJSON makes a product safe to INSERT.
|
||||
//
|
||||
// `products.productimages` is jsonb and `models.Products.Productimages` is a
|
||||
// plain string, so a caller that never set it hands GORM the zero value — and
|
||||
// GORM puts that empty string in the INSERT rather than omitting the column.
|
||||
// Postgres answers "invalid input syntax for type json (SQLSTATE 22P02)" and
|
||||
// the whole row is rejected, over a field nobody asked for.
|
||||
//
|
||||
// That was not a corner case: the console's sheet importer sends no
|
||||
// productimages at all, so EVERY product it created failed with a 500, and
|
||||
// ImportCatalogueProduct leaves the field empty for any catalogue product that
|
||||
// has no photos. An empty ARRAY is the honest value — there are no extra
|
||||
// images — and it is what `catalogueUploadService` already does for its own
|
||||
// jsonb column, for the same reason.
|
||||
//
|
||||
// Applied at the one create path, which is the last point before the SQL, and
|
||||
// the constraint being satisfied is the database's.
|
||||
func normaliseProductJSON(product *models.Products) {
|
||||
if strings.TrimSpace(product.Productimages) == "" {
|
||||
product.Productimages = "[]"
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
// An OBJECT, not an array: this one holds named catalogue fields, and `{}`
|
||||
// is what a reader parsing it expects to find when there are none.
|
||||
if strings.TrimSpace(product.Cataloguefacts) == "" {
|
||||
product.Cataloguefacts = "{}"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *productRepository) UpdateProduct(product models.Products) error {
|
||||
@@ -1316,10 +1330,22 @@ func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
// CreateProductReturningID inserts a new product snapshot and returns its
|
||||
// generated productid. Kept separate from CreateProduct so existing callers
|
||||
// of CreateProduct are unaffected.
|
||||
// CreateProductReturningID inserts a product and returns its generated
|
||||
// productid.
|
||||
//
|
||||
// This is now the only way to create one. There used to be a second method,
|
||||
// `CreateProduct`, that did the same INSERT and threw the id away — it took
|
||||
// the struct by value, so GORM wrote the generated id onto a copy that went
|
||||
// out of scope, and `POST /products/create` answered `productid: 0` for every
|
||||
// product it had just created. The console worked around it by creating, then
|
||||
// re-reading the whole tenant catalogue, then matching back by SKU.
|
||||
//
|
||||
// The two were kept apart so that "existing callers are unaffected", but the
|
||||
// only caller of the id-less one was the endpoint that needed the id most.
|
||||
// One create path also means the jsonb guard above has one place to live.
|
||||
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
||||
normaliseProductJSON(&product)
|
||||
|
||||
if err := r.db.Create(&product).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -1701,3 +1727,30 @@ func (r *productRepository) UpdateProductPricing(productid int, retailprice, pro
|
||||
"taxpercent": taxpercent,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||
//
|
||||
// `Update` with a single column, deliberately, and not `Updates` with a struct.
|
||||
// GORM's struct update SKIPS zero values, so `showhealthscore: false` would be
|
||||
// silently dropped — the flag could be switched on and never off again, which is
|
||||
// the exact failure a merchant would report as "it does not save".
|
||||
//
|
||||
// Scoped by tenant as well as product. `middleware.WebAuth` already refuses a
|
||||
// request naming a tenant the session does not own, so this is the second lock
|
||||
// rather than the first — but a write that changes what a shopper sees should
|
||||
// not rest on one check being correctly mounted.
|
||||
func (r *productRepository) SetShowHealthScore(tenantID, productID int, show bool) error {
|
||||
result := r.db.Table("products").
|
||||
Where("productid = ? AND tenantid = ?", productID, tenantID).
|
||||
Update("showhealthscore", show)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
// Either no such product, or one belonging to another business. Both
|
||||
// are the same answer to the caller, and neither should look like it
|
||||
// worked.
|
||||
return fmt.Errorf("product %d was not found for this business", productID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
126
repositories/riderShift_test.go
Normal file
126
repositories/riderShift_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
/*
|
||||
Shift windows, which riders cannot be hired without.
|
||||
|
||||
`CreateRider` refuses a rider with no shift — correctly, because `getriders`
|
||||
joins `ridershifts` and a rider on a shift that does not exist is a rider nobody
|
||||
can see. But the table could only be READ: no endpoint, no service method, and
|
||||
no `applocationid` field on the model to write one with. A region that shipped
|
||||
without shift rows was a region no rider could ever be added to, and the console
|
||||
said "No shifts set up for this region" with nothing anybody could do about it.
|
||||
|
||||
These cover the rules that do not need a database. The two that do — the region
|
||||
must be configured, and the window must not already exist — are enforced in
|
||||
CreateRiderShift against real tables.
|
||||
*/
|
||||
|
||||
func TestATimeIsNormalisedSoOneWindowHasOneLabel(t *testing.T) {
|
||||
// The dropdown labels a shift by concatenating its two columns raw, so
|
||||
// `9:00-17:00` and `09:00-17:00` are two different labels for one window.
|
||||
// Rows inserted by hand over the years use every spelling.
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"9:00", "09:00"},
|
||||
{"09:00", "09:00"},
|
||||
{"09:00:00", "09:00"},
|
||||
{" 9:5 ", "09:05"},
|
||||
{"23:59", "23:59"},
|
||||
{"0:00", "00:00"},
|
||||
} {
|
||||
got, err := riderShiftClock(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("%q became %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotATimeIsRefused(t *testing.T) {
|
||||
for _, bad := range []string{"", " ", "morning", "25:00", "09:60", "9", "9:00:00:00", "-1:00"} {
|
||||
if _, err := riderShiftClock(bad); err == nil {
|
||||
t.Fatalf("%q was accepted as a time", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoursAreWorkedOutRatherThanAskedFor(t *testing.T) {
|
||||
// `shifthours` feeds rider pay. It is the one field a person gets wrong and
|
||||
// nothing downstream checks, so it is computed and the request's own number
|
||||
// is discarded.
|
||||
shift, err := validateRiderShift(models.Ridershifts{
|
||||
Applocationid: 2, Starttime: "09:00", Endtime: "17:00",
|
||||
Shifthours: 4, // wrong, and sent anyway
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a good shift was refused: %v", err)
|
||||
}
|
||||
if shift.Shifthours != 8 {
|
||||
t.Fatalf("09:00-17:00 came out as %v hours, want 8", shift.Shifthours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANightShiftCrossesMidnightRatherThanGoingNegative(t *testing.T) {
|
||||
// 22:00-06:00 is eight hours. Subtracting the clocks gives minus sixteen,
|
||||
// which would pay a night rider for a negative shift.
|
||||
if got := riderShiftHours("22:00", "06:00"); got != 8 {
|
||||
t.Fatalf("22:00-06:00 came out as %v hours, want 8", got)
|
||||
}
|
||||
if got := riderShiftHours("09:30", "17:00"); got != 7.5 {
|
||||
t.Fatalf("09:30-17:00 came out as %v hours, want 7.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShiftNeedsARegion(t *testing.T) {
|
||||
// Region 0 arrives from a form field nobody filled in. A shift there would
|
||||
// be offered to nobody and joined to nothing.
|
||||
_, err := validateRiderShift(models.Ridershifts{Starttime: "09:00", Endtime: "17:00"})
|
||||
if err == nil || !strings.Contains(err.Error(), "region") {
|
||||
t.Fatalf("a shift with no region was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShiftCannotStartAndEndAtTheSameTime(t *testing.T) {
|
||||
// Would compute as a 24-hour window under the midnight rule, which is not
|
||||
// what anybody who typed the same time twice meant.
|
||||
_, err := validateRiderShift(models.Ridershifts{
|
||||
Applocationid: 2, Starttime: "09:00", Endtime: "9:00",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a zero-length window was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayCannotBeNegative(t *testing.T) {
|
||||
for _, shift := range []models.Ridershifts{
|
||||
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Basefare: -1},
|
||||
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Additionalcharges: -5},
|
||||
{Applocationid: 2, Starttime: "09:00", Endtime: "17:00", Fuelcharge: -0.5},
|
||||
} {
|
||||
if _, err := validateRiderShift(shift); err == nil {
|
||||
t.Fatalf("negative pay was accepted: %+v", shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNormalisedTimesComeBackOnTheShift(t *testing.T) {
|
||||
// The caller inserts what validation returned, not what it was handed —
|
||||
// otherwise the normalising is computed and then thrown away.
|
||||
shift, err := validateRiderShift(models.Ridershifts{
|
||||
Applocationid: 2, Starttime: "9:0", Endtime: "17:00:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
if shift.Starttime != "09:00" || shift.Endtime != "17:00" {
|
||||
t.Fatalf("times were not normalised on the way out: %q-%q", shift.Starttime, shift.Endtime)
|
||||
}
|
||||
}
|
||||
737
repositories/scanRepository.go
Normal file
737
repositories/scanRepository.go
Normal file
@@ -0,0 +1,737 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/db"
|
||||
"nearle/models"
|
||||
"nearle/utils"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
/*
|
||||
Scan-to-order reads from three places and this file is the only one that
|
||||
knows which is which:
|
||||
|
||||
- the catalogue database (pgvector, one table per brand) — to turn a label
|
||||
into a catalogue product;
|
||||
- nearledb — who the customer is, which outlets they scanned into, and what
|
||||
those outlets have on the shelf right now;
|
||||
- Redis — a cache for the expensive and stable half (the label's vector and
|
||||
its catalogue hits). Live stock is never cached.
|
||||
|
||||
Two connections are held rather than one because the catalogue must never be
|
||||
reachable through the nearledb handle: the comment on db.CatalogueDB is
|
||||
explicit about that and every catalogue reader in this package honours it.
|
||||
*/
|
||||
|
||||
// CatalogueKey is how a tenant's product row points back at the catalogue.
|
||||
// Imageid is the stable one; brand+catalogueid is kept for rows imported
|
||||
// before imageid existed (see models.Products.Imageid).
|
||||
type CatalogueKey struct {
|
||||
Brand string
|
||||
Catalogueid int64
|
||||
Imageid string
|
||||
}
|
||||
|
||||
// CatalogueHit is one catalogue row the search considered.
|
||||
type CatalogueHit struct {
|
||||
Brand string
|
||||
ID int64
|
||||
ProductName string
|
||||
Title string
|
||||
Category string
|
||||
Size string
|
||||
VariantKey string
|
||||
ImageID string
|
||||
ImageURL string
|
||||
// Cosine distance from pgvector (0 = identical); -1 for a text-only hit.
|
||||
Distance float64
|
||||
}
|
||||
|
||||
// StoreOptionRow is one sellable product at one outlet, with its live stock.
|
||||
type StoreOptionRow struct {
|
||||
Tenantid int
|
||||
Locationid int
|
||||
Productid int
|
||||
Productname string
|
||||
Productbrand string
|
||||
Catalogueid int64
|
||||
Imageid string
|
||||
Productimage string
|
||||
Productunit string
|
||||
Unitvalue string
|
||||
Price float64
|
||||
// The shop's own cost, distinct from Price. Selected because the product
|
||||
// screens already return it and the app asked for it by name.
|
||||
Productcost float64
|
||||
Categoryid int
|
||||
Subcategoryid int
|
||||
Stock int
|
||||
// For a size row: the product it hangs under and the label given to it.
|
||||
Parentid int
|
||||
Variantname string
|
||||
}
|
||||
|
||||
type ScanRepository interface {
|
||||
// nearledb
|
||||
CustomerExists(ctx context.Context, customerid int) (bool, error)
|
||||
CustomerHome(ctx context.Context, customerid int) (lat, lng float64, ok bool, err error)
|
||||
RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error)
|
||||
// StoreOptions finds, at the given outlets, every published product tied
|
||||
// to one of the catalogue keys (or, for hand-made products, one of the
|
||||
// names) — and every size hanging under those products.
|
||||
StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error)
|
||||
// ProductAt is one product at one outlet with its live stock, or nil.
|
||||
ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error)
|
||||
|
||||
// catalogue
|
||||
VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error)
|
||||
TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error)
|
||||
VectorSearchAvailable() bool
|
||||
// CatalogueRef is one product named by its catalogue key, with its other
|
||||
// pack sizes after it. Nothing is recognised or scored.
|
||||
CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error)
|
||||
|
||||
// cache
|
||||
CachedVector(ctx context.Context, model, label string) ([]float32, bool)
|
||||
CacheVector(ctx context.Context, model, label string, v []float32)
|
||||
CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool)
|
||||
CacheHits(ctx context.Context, method, label string, hits []CatalogueHit)
|
||||
}
|
||||
|
||||
type scanRepository struct {
|
||||
db *gorm.DB
|
||||
catalogue *gorm.DB
|
||||
|
||||
// Catalogue tables and their columns, discovered once and refreshed on a
|
||||
// timer — the catalogue pipeline adds brands without telling anyone.
|
||||
tablesMu sync.Mutex
|
||||
tables map[string]map[string]bool // table -> column set
|
||||
tablesAt time.Time
|
||||
embeddingDim int
|
||||
|
||||
// Process-local cache in front of Redis, bounded, so a hot label costs
|
||||
// nothing even when Redis is not configured.
|
||||
memMu sync.Mutex
|
||||
memVecs map[string][]float32
|
||||
memHits map[string][]CatalogueHit
|
||||
}
|
||||
|
||||
const (
|
||||
scanTablesTTL = 10 * time.Minute
|
||||
scanVectorTTL = 7 * 24 * time.Hour // a label's vector never changes for a given model
|
||||
scanHitsTTL = 30 * time.Minute // the catalogue is rebuilt by scrape; not for long
|
||||
scanMemCacheMax = 2000
|
||||
)
|
||||
|
||||
func NewScanRepository(nearle, catalogue *gorm.DB) ScanRepository {
|
||||
return &scanRepository{
|
||||
db: nearle,
|
||||
catalogue: catalogue,
|
||||
memVecs: make(map[string][]float32),
|
||||
memHits: make(map[string][]CatalogueHit),
|
||||
}
|
||||
}
|
||||
|
||||
// ── nearledb ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (r *scanRepository) CustomerExists(ctx context.Context, customerid int) (bool, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Raw(
|
||||
`SELECT COUNT(1) FROM customers WHERE customerid = ?`, customerid).Scan(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// CustomerHome is the saved primary address, falling back to the customers
|
||||
// row itself. Either may be blank or unparsable — a customer created from a
|
||||
// phone number alone has neither — and that is reported as ok=false rather
|
||||
// than as (0, 0), which is a real place in the Gulf of Guinea.
|
||||
func (r *scanRepository) CustomerHome(ctx context.Context, customerid int) (float64, float64, bool, error) {
|
||||
var row struct {
|
||||
Lat string
|
||||
Lng string
|
||||
}
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(NULLIF(l.latitude, ''), c.latitude, '') AS lat,
|
||||
COALESCE(NULLIF(l.longitude, ''), c.longitude, '') AS lng
|
||||
FROM customers c
|
||||
LEFT JOIN customerlocations l ON l.customerid = c.customerid AND l.primaryaddress = 1
|
||||
WHERE c.customerid = ?
|
||||
LIMIT 1`, customerid).Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
lat, lng, ok := utils.ParseLatLng(row.Lat, row.Lng)
|
||||
return lat, lng, ok, nil
|
||||
}
|
||||
|
||||
// RegisteredStores is every active outlet of every tenant the customer has
|
||||
// scanned into. A tenantcustomers row with locationid 0 means "the tenant",
|
||||
// i.e. all of its outlets; a non-zero one pins a single outlet.
|
||||
func (r *scanRepository) RegisteredStores(ctx context.Context, customerid int) ([]models.ScanStore, error) {
|
||||
var rows []struct {
|
||||
Tenantid int
|
||||
Tenantname string
|
||||
Locationid int
|
||||
Locationname string
|
||||
Address string
|
||||
Latitude string
|
||||
Longitude string
|
||||
Deliveryradius int
|
||||
Deliverymins int
|
||||
Opentime string
|
||||
Closetime string
|
||||
}
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT DISTINCT
|
||||
tl.tenantid, COALESCE(t.tenantname, '') AS tenantname,
|
||||
tl.locationid, COALESCE(tl.locationname, '') AS locationname,
|
||||
COALESCE(tl.address, '') AS address,
|
||||
COALESCE(tl.latitude, '') AS latitude, COALESCE(tl.longitude, '') AS longitude,
|
||||
COALESCE(tl.deliveryradius, 0) AS deliveryradius, COALESCE(tl.deliverymins, 0) AS deliverymins,
|
||||
COALESCE(tl.opentime, '') AS opentime, COALESCE(tl.closetime, '') AS closetime
|
||||
FROM tenantcustomers tc
|
||||
INNER JOIN tenantlocations tl
|
||||
ON tl.tenantid = tc.tenantid
|
||||
AND (COALESCE(tc.locationid, 0) = 0 OR tc.locationid = tl.locationid)
|
||||
LEFT JOIN tenants t ON t.tenantid = tl.tenantid
|
||||
WHERE tc.customerid = ?
|
||||
AND LOWER(COALESCE(tl.status, 'active')) <> 'inactive'
|
||||
ORDER BY tl.tenantid, tl.locationid`, customerid).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
stores := make([]models.ScanStore, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
lat, lng, _ := utils.ParseLatLng(row.Latitude, row.Longitude)
|
||||
stores = append(stores, models.ScanStore{
|
||||
Tenantid: row.Tenantid,
|
||||
Tenantname: row.Tenantname,
|
||||
Locationid: row.Locationid,
|
||||
Locationname: row.Locationname,
|
||||
Address: row.Address,
|
||||
Latitude: lat,
|
||||
Longitude: lng,
|
||||
DistanceKm: -1,
|
||||
Deliveryradius: row.Deliveryradius,
|
||||
Deliverymins: row.Deliverymins,
|
||||
Open: utils.OpenNow(row.Opentime, row.Closetime, now),
|
||||
})
|
||||
}
|
||||
return stores, nil
|
||||
}
|
||||
|
||||
// storeOptionSelect is the projection every outlet read shares, so the
|
||||
// price and stock rules cannot differ between the lookup and the confirm.
|
||||
//
|
||||
// Price: the outlet's own price when it set one, else the tenant's retail
|
||||
// price — the same rule GetProducts applies. Stock: the live IN−OUT balance
|
||||
// of the ledger at that outlet, the same expression the app displays, so a
|
||||
// product can never be offered here and show 0 on the next screen.
|
||||
const storeOptionSelect = `
|
||||
SELECT a.tenantid, b.locationid, a.productid,
|
||||
COALESCE(a.productname, '') AS productname,
|
||||
LOWER(COALESCE(a.productbrand, '')) AS productbrand,
|
||||
COALESCE(a.catalogueid, 0) AS catalogueid,
|
||||
COALESCE(a.imageid, '') AS imageid,
|
||||
COALESCE(a.productimage, '') AS productimage,
|
||||
COALESCE(a.productunit, '') AS productunit,
|
||||
COALESCE(a.unitvalue, '') AS unitvalue,
|
||||
CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
|
||||
COALESCE(a.productcost, 0) AS productcost,
|
||||
COALESCE(a.categoryid, 0) AS categoryid,
|
||||
COALESCE(a.subcategoryid, 0) AS subcategoryid,
|
||||
COALESCE((
|
||||
SELECT SUM(CASE WHEN LOWER(c.stocktype) = 'in' THEN c.quantity
|
||||
WHEN LOWER(c.stocktype) = 'out' THEN -c.quantity
|
||||
ELSE 0 END)
|
||||
FROM productstocks c
|
||||
WHERE c.productid = a.productid AND c.locationid = b.locationid AND c.tenantid = a.tenantid
|
||||
), 0) AS stock,
|
||||
COALESCE(v.productid, 0) AS parentid,
|
||||
COALESCE(v.variantname, '') AS variantname
|
||||
FROM products a
|
||||
INNER JOIN productlocations b ON b.productid = a.productid AND b.tenantid = a.tenantid
|
||||
LEFT JOIN productvariants v ON v.variantproductid = a.productid AND v.tenantid = a.tenantid
|
||||
AND LOWER(COALESCE(v.status, 'active')) <> 'inactive'`
|
||||
|
||||
func (r *scanRepository) StoreOptions(ctx context.Context, locationids []int, keys []CatalogueKey, names []string) ([]StoreOptionRow, error) {
|
||||
if len(locationids) == 0 || (len(keys) == 0 && len(names) == 0) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The products that ARE the catalogue match, at these outlets.
|
||||
var matchConds []string
|
||||
var args []interface{}
|
||||
args = append(args, locationids)
|
||||
for _, k := range keys {
|
||||
if k.Imageid != "" {
|
||||
matchConds = append(matchConds, "a.imageid = ?")
|
||||
args = append(args, k.Imageid)
|
||||
}
|
||||
if k.Brand != "" && k.Catalogueid > 0 {
|
||||
matchConds = append(matchConds, "(LOWER(a.productbrand) = ? AND a.catalogueid = ?)")
|
||||
args = append(args, strings.ToLower(k.Brand), k.Catalogueid)
|
||||
}
|
||||
}
|
||||
for _, n := range names {
|
||||
if n = strings.ToLower(strings.TrimSpace(n)); n != "" {
|
||||
matchConds = append(matchConds, "LOWER(a.productname) = ?")
|
||||
args = append(args, n)
|
||||
}
|
||||
}
|
||||
if len(matchConds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Two reads rather than one recursive query: the second is keyed on the
|
||||
// first's product ids, and a variant of a variant is not a thing here.
|
||||
query := storeOptionSelect + `
|
||||
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
|
||||
AND b.locationid IN (?)
|
||||
AND (` + strings.Join(matchConds, " OR ") + `)`
|
||||
|
||||
var parents []StoreOptionRow
|
||||
if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&parents).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parents) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parentIDs := make([]int, 0, len(parents))
|
||||
for _, p := range parents {
|
||||
parentIDs = append(parentIDs, p.Productid)
|
||||
}
|
||||
|
||||
// The sizes hanging under those products, at the same outlets. Only the
|
||||
// rows whose parent is one of ours — the LEFT JOIN in the select can
|
||||
// attach any parent, so it is pinned here.
|
||||
var sizes []StoreOptionRow
|
||||
err := r.db.WithContext(ctx).Raw(storeOptionSelect+`
|
||||
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
|
||||
AND b.locationid IN (?)
|
||||
AND v.productid IN (?)`, locationids, parentIDs).Scan(&sizes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(parents, sizes...), nil
|
||||
}
|
||||
|
||||
func (r *scanRepository) ProductAt(ctx context.Context, tenantid, locationid, productid int) (*StoreOptionRow, error) {
|
||||
var rows []StoreOptionRow
|
||||
err := r.db.WithContext(ctx).Raw(storeOptionSelect+`
|
||||
WHERE a.approve = 1 AND b.publishedat IS NOT NULL
|
||||
AND a.tenantid = ? AND b.locationid = ? AND a.productid = ?
|
||||
LIMIT 1`, tenantid, locationid, productid).Scan(&rows).Error
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// ── catalogue ───────────────────────────────────────────────────────────────
|
||||
|
||||
// brandTables is every `brand_*` table and its columns, cached briefly.
|
||||
func (r *scanRepository) brandTables(ctx context.Context) (map[string]map[string]bool, error) {
|
||||
if r.catalogue == nil {
|
||||
return nil, ErrCatalogueDBUnavailable
|
||||
}
|
||||
r.tablesMu.Lock()
|
||||
defer r.tablesMu.Unlock()
|
||||
if r.tables != nil && time.Since(r.tablesAt) < scanTablesTTL {
|
||||
return r.tables, nil
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
TableName string
|
||||
ColumnName string
|
||||
}
|
||||
err := r.catalogue.WithContext(ctx).Raw(`
|
||||
SELECT c.table_name, c.column_name
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = 'public' AND c.table_name LIKE 'brand\_%'`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables := make(map[string]map[string]bool)
|
||||
for _, row := range rows {
|
||||
if tables[row.TableName] == nil {
|
||||
tables[row.TableName] = make(map[string]bool)
|
||||
}
|
||||
tables[row.TableName][row.ColumnName] = true
|
||||
}
|
||||
for name, cols := range tables {
|
||||
if !cols["id"] || !cols["product_name"] {
|
||||
delete(tables, name)
|
||||
}
|
||||
}
|
||||
|
||||
// The vector width, read from the first embedding column found. pgvector
|
||||
// stores it as the type modifier, so a mismatch with the model can be
|
||||
// named in the error instead of surfacing as a bare "different vector
|
||||
// dimensions" from the driver.
|
||||
if r.embeddingDim == 0 {
|
||||
for name, cols := range tables {
|
||||
if !cols["embedding"] {
|
||||
continue
|
||||
}
|
||||
var dim int
|
||||
r.catalogue.WithContext(ctx).Raw(`
|
||||
SELECT a.atttypmod FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
WHERE c.relname = ? AND a.attname = 'embedding'`, name).Scan(&dim)
|
||||
if dim > 0 {
|
||||
r.embeddingDim = dim
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
r.tables, r.tablesAt = tables, time.Now()
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// VectorSearchAvailable is whether any catalogue table carries a vector.
|
||||
func (r *scanRepository) VectorSearchAvailable() bool {
|
||||
tables, err := r.brandTables(context.Background())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, cols := range tables {
|
||||
if cols["embedding"] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hitColumns is the projection each search returns, with NULL stand-ins for
|
||||
// columns a particular brand table lacks — the same tolerance
|
||||
// catalogueRepository applies, for the same reason: a newer table missing
|
||||
// one enrichment column is still a perfectly good catalogue of products.
|
||||
func hitColumns(brand string, cols map[string]bool) string {
|
||||
opt := func(name string) string {
|
||||
if cols[name] {
|
||||
return "COALESCE(" + name + ", '') AS " + name
|
||||
}
|
||||
return "'' AS " + name
|
||||
}
|
||||
return fmt.Sprintf(`'%s' AS brand, id, COALESCE(product_name, '') AS product_name, %s, %s, %s, %s, %s, %s`,
|
||||
brand, opt("title"), opt("category"), opt("size"), opt("variant_key"), opt("image_id"), opt("image_url"))
|
||||
}
|
||||
|
||||
// VectorSearch ranks every brand table by cosine distance to the label's
|
||||
// vector and merges the top of each.
|
||||
//
|
||||
// One branch per table, each with its own ORDER BY and LIMIT inside
|
||||
// parentheses, so Postgres can use the per-table vector index instead of
|
||||
// scanning the union. The literal is bound as a parameter and cast — never
|
||||
// concatenated — and table names come from information_schema, never from
|
||||
// the request.
|
||||
func (r *scanRepository) VectorSearch(ctx context.Context, vector []float32, limit int) ([]CatalogueHit, error) {
|
||||
tables, err := r.brandTables(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.embeddingDim > 0 && len(vector) != r.embeddingDim {
|
||||
return nil, fmt.Errorf("embedding is %d wide but the catalogue's embedding column is %d: EMBEDDING_MODEL/EMBEDDING_DIMENSIONS do not match the model that indexed the catalogue", len(vector), r.embeddingDim)
|
||||
}
|
||||
|
||||
literal := utils.VectorLiteral(vector)
|
||||
var branches []string
|
||||
var args []interface{}
|
||||
for _, table := range sortedKeys(tables) {
|
||||
cols := tables[table]
|
||||
if !cols["embedding"] {
|
||||
continue
|
||||
}
|
||||
brand := strings.TrimPrefix(table, "brand_")
|
||||
branches = append(branches, fmt.Sprintf(
|
||||
`(SELECT %s, (embedding <=> ?::vector) AS distance FROM %s WHERE embedding IS NOT NULL ORDER BY embedding <=> ?::vector LIMIT %d)`,
|
||||
hitColumns(brand, cols), table, limit))
|
||||
args = append(args, literal, literal)
|
||||
}
|
||||
if len(branches) == 0 {
|
||||
return nil, errors.New("no catalogue table has an embedding column")
|
||||
}
|
||||
|
||||
query := strings.Join(branches, " UNION ALL ") + fmt.Sprintf(" ORDER BY distance LIMIT %d", limit)
|
||||
var hits []CatalogueHit
|
||||
if err := r.catalogue.WithContext(ctx).Raw(query, args...).Scan(&hits).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// minTokenHits is how many of the label's words a row must carry to be worth
|
||||
// looking at. Every word was once required, which meant a single word the
|
||||
// catalogue does not use — "Parle G biscuit pack", "Milk Bikis pack" — kept
|
||||
// the right product out of the result entirely, leaving the vector search to
|
||||
// answer alone and confidently wrong. Most of them is enough; scoring sorts
|
||||
// out the rest.
|
||||
func minTokenHits(n int) int {
|
||||
if n <= 2 {
|
||||
return n
|
||||
}
|
||||
return (n*2 + 2) / 3 // two thirds, rounded up; never below 2 for n >= 3
|
||||
}
|
||||
|
||||
// TextSearch is the fallback when there is no embedder, and the tie-breaker
|
||||
// beside it when there is: rows whose name or title contains the label, or
|
||||
// carry most of its words.
|
||||
func (r *scanRepository) TextSearch(ctx context.Context, label string, limit int) ([]CatalogueHit, error) {
|
||||
tables, err := r.brandTables(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
tokens := utils.SearchTokens(label)
|
||||
if label == "" || len(tokens) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var branches []string
|
||||
var args []interface{}
|
||||
for _, table := range sortedKeys(tables) {
|
||||
cols := tables[table]
|
||||
brand := strings.TrimPrefix(table, "brand_")
|
||||
|
||||
hay := "LOWER(COALESCE(product_name, ''))"
|
||||
if cols["title"] {
|
||||
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, ''))"
|
||||
}
|
||||
if cols["search_query"] {
|
||||
hay = "LOWER(COALESCE(product_name, '') || ' ' || COALESCE(title, '') || ' ' || COALESCE(search_query, ''))"
|
||||
}
|
||||
|
||||
// How well a row matches, as a number: the whole label as a substring
|
||||
// outweighs any number of loose words, then one point per word found.
|
||||
hits := make([]string, 0, len(tokens)+1)
|
||||
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 100 ELSE 0 END)")
|
||||
for range tokens {
|
||||
hits = append(hits, "(CASE WHEN "+hay+" LIKE ? THEN 1 ELSE 0 END)")
|
||||
}
|
||||
rank := strings.Join(hits, " + ")
|
||||
|
||||
// The expression appears twice in the SQL — once to filter, once to
|
||||
// order — so its arguments are bound twice, in that order.
|
||||
bind := func() {
|
||||
args = append(args, "%"+label+"%")
|
||||
for _, tok := range tokens {
|
||||
args = append(args, "%"+tok+"%")
|
||||
}
|
||||
}
|
||||
bind()
|
||||
bind()
|
||||
|
||||
// Ordering matters as much as the threshold: a looser WHERE lets more
|
||||
// rows qualify, and an unordered LIMIT would then be free to return
|
||||
// the wrong ones. Best match per brand first, id to keep it stable.
|
||||
branches = append(branches, fmt.Sprintf(
|
||||
`(SELECT %s, -1::float8 AS distance FROM %s WHERE (%s) >= %d ORDER BY (%s) DESC, id LIMIT %d)`,
|
||||
hitColumns(brand, cols), table, rank, minTokenHits(len(tokens)), rank, limit))
|
||||
}
|
||||
if len(branches) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var hits []CatalogueHit
|
||||
if err := r.catalogue.WithContext(ctx).Raw(strings.Join(branches, " UNION ALL "), args...).Scan(&hits).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// tableFor resolves a brand the caller named to a real catalogue table.
|
||||
//
|
||||
// The lookup is against the tables discovered from information_schema, never
|
||||
// a string built from the request: table names cannot be parameterised in
|
||||
// SQL, so the discovered map is what keeps this from being an injection
|
||||
// point. Both the table suffix ("britannia") and a display name ("24 Mantra"
|
||||
// → brand_24_mantra) resolve.
|
||||
func (r *scanRepository) tableFor(ctx context.Context, brand string) (string, map[string]bool, error) {
|
||||
tables, err := r.brandTables(ctx)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
"brand_" + strings.ToLower(strings.TrimSpace(brand)),
|
||||
"brand_" + normaliseBrandKey(brand),
|
||||
} {
|
||||
if cols, ok := tables[candidate]; ok {
|
||||
return candidate, cols, nil
|
||||
}
|
||||
}
|
||||
return "", nil, ErrUnknownBrand
|
||||
}
|
||||
|
||||
// CatalogueRef reads one product by (brand, id) and appends its other pack
|
||||
// sizes — same variant_key where the catalogue assigned one, same name
|
||||
// otherwise, matching how the search groups a family.
|
||||
//
|
||||
// Distance is 0 on every row: nothing here was ranked, the caller said which
|
||||
// product they meant.
|
||||
func (r *scanRepository) CatalogueRef(ctx context.Context, brand string, id int64) ([]CatalogueHit, error) {
|
||||
table, cols, err := r.tableFor(ctx, brand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
suffix := strings.TrimPrefix(table, "brand_")
|
||||
columns := hitColumns(suffix, cols)
|
||||
|
||||
var self []CatalogueHit
|
||||
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||
`SELECT %s, 0::float8 AS distance FROM %s WHERE id = ?`, columns, table), id).Scan(&self).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(self) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var siblings []CatalogueHit
|
||||
if cols["variant_key"] && strings.TrimSpace(self[0].VariantKey) != "" {
|
||||
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||
`SELECT %s, 0::float8 AS distance FROM %s WHERE variant_key = ? AND id <> ? ORDER BY id`,
|
||||
columns, table), self[0].VariantKey, id).Scan(&siblings).Error
|
||||
} else {
|
||||
err = r.catalogue.WithContext(ctx).Raw(fmt.Sprintf(
|
||||
`SELECT %s, 0::float8 AS distance FROM %s WHERE LOWER(product_name) = LOWER(?) AND id <> ? ORDER BY id`,
|
||||
columns, table), self[0].ProductName, id).Scan(&siblings).Error
|
||||
}
|
||||
if err != nil {
|
||||
// The product itself was found; losing its other sizes is the smaller
|
||||
// failure and the caller asked for this one.
|
||||
log.Printf("scan: could not read pack sizes of %s#%d: %v", brand, id, err)
|
||||
return self, nil
|
||||
}
|
||||
return append(self, siblings...), nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]map[string]bool) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// ── cache ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two tiers. Redis is shared across replicas and survives a restart; the
|
||||
// in-process map is there so the request after a cache hit costs no network
|
||||
// round trip at all, and so a deployment without Redis still gets the
|
||||
// benefit within one process. Neither tier ever holds stock.
|
||||
|
||||
func scanCacheKey(kind, scope, label string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(label))))
|
||||
return "scan:" + kind + ":v1:" + scope + ":" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func (r *scanRepository) CachedVector(ctx context.Context, model, label string) ([]float32, bool) {
|
||||
key := scanCacheKey("emb", model, label)
|
||||
|
||||
r.memMu.Lock()
|
||||
v, ok := r.memVecs[key]
|
||||
r.memMu.Unlock()
|
||||
if ok {
|
||||
return v, true
|
||||
}
|
||||
|
||||
if db.Rdb == nil {
|
||||
return nil, false
|
||||
}
|
||||
raw, err := db.Rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if json.Unmarshal(raw, &v) != nil || len(v) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
r.remember(key, v, nil)
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (r *scanRepository) CacheVector(ctx context.Context, model, label string, v []float32) {
|
||||
key := scanCacheKey("emb", model, label)
|
||||
r.remember(key, v, nil)
|
||||
if db.Rdb == nil {
|
||||
return
|
||||
}
|
||||
if raw, err := json.Marshal(v); err == nil {
|
||||
if err := db.Rdb.Set(ctx, key, raw, scanVectorTTL).Err(); err != nil {
|
||||
log.Printf("scan: could not cache vector: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *scanRepository) CachedHits(ctx context.Context, method, label string) ([]CatalogueHit, bool) {
|
||||
key := scanCacheKey("hits", method, label)
|
||||
|
||||
r.memMu.Lock()
|
||||
h, ok := r.memHits[key]
|
||||
r.memMu.Unlock()
|
||||
if ok {
|
||||
return h, true
|
||||
}
|
||||
|
||||
if db.Rdb == nil {
|
||||
return nil, false
|
||||
}
|
||||
raw, err := db.Rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if json.Unmarshal(raw, &h) != nil {
|
||||
return nil, false
|
||||
}
|
||||
r.remember(key, nil, h)
|
||||
return h, true
|
||||
}
|
||||
|
||||
func (r *scanRepository) CacheHits(ctx context.Context, method, label string, hits []CatalogueHit) {
|
||||
key := scanCacheKey("hits", method, label)
|
||||
r.remember(key, nil, hits)
|
||||
if db.Rdb == nil {
|
||||
return
|
||||
}
|
||||
if raw, err := json.Marshal(hits); err == nil {
|
||||
if err := db.Rdb.Set(ctx, key, raw, scanHitsTTL).Err(); err != nil {
|
||||
log.Printf("scan: could not cache hits: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remember writes one entry into the process-local tier. Eviction is the
|
||||
// simplest thing that bounds memory: when full, drop everything. Labels are
|
||||
// short-lived popularity, not a working set worth an LRU.
|
||||
func (r *scanRepository) remember(key string, v []float32, h []CatalogueHit) {
|
||||
r.memMu.Lock()
|
||||
defer r.memMu.Unlock()
|
||||
if len(r.memVecs)+len(r.memHits) >= scanMemCacheMax {
|
||||
r.memVecs = make(map[string][]float32)
|
||||
r.memHits = make(map[string][]CatalogueHit)
|
||||
}
|
||||
if v != nil {
|
||||
r.memVecs[key] = v
|
||||
}
|
||||
if h != nil {
|
||||
r.memHits[key] = h
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,20 @@ type TenantRepository interface {
|
||||
UpdateTenantProfile(tenantID int, fields map[string]any) error
|
||||
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
|
||||
GetStaffs(tid int) ([]models.StaffInfo, error)
|
||||
CreateStaff(user models.User) error
|
||||
// Returns the new userid: the account has no password and has to be invited.
|
||||
CreateStaff(user models.User) (int, error)
|
||||
AssignStaffToBranch(tenantID, userID, locationID int) error
|
||||
UpdateStaff(user models.User) error
|
||||
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error)
|
||||
// Second return is the userid of the login this spawned for the branch, or 0
|
||||
// when an existing person was named and no account was created.
|
||||
CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error)
|
||||
UpdateTenantLocation(data models.Tenantlocations) error
|
||||
CheckTenantByNo(cno string) int
|
||||
CreateTenantUser(data models.Tenants) (bool, error)
|
||||
GetUserByNo(cno string) models.UserInfo
|
||||
PrimaryAdminForTenant(tenantID int) (InviteTarget, error)
|
||||
InviteTargetForUser(userID int) (InviteTarget, error)
|
||||
TenantNameByID(tenantID int) (string, error)
|
||||
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
|
||||
AssignPartner(tenantID, partnerID int) error
|
||||
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
|
||||
@@ -85,7 +91,22 @@ func (r *tenantRepository) GetAllTenants(pageno, pagesize, aid int, status, tena
|
||||
|
||||
var data []models.Tenantinfo
|
||||
|
||||
base := `SELECT * FROM tenants a WHERE 1 = 1`
|
||||
// `branchcount` is selected here because there is nowhere else to get it.
|
||||
//
|
||||
// This returns one row per TENANT — there is no join to tenantlocations at
|
||||
// all — but the console's store list read it as one row per
|
||||
// tenant-location pair and counted the duplicates, so every merchant on the
|
||||
// platform showed exactly one branch, and the "Branches" and "Avg branches"
|
||||
// tiles above the list were the tenant count wearing another name. The
|
||||
// tenant's own detail page, which reads gettenantlocations, disagreed with
|
||||
// the list it was opened from.
|
||||
//
|
||||
// A correlated subquery rather than a LEFT JOIN + GROUP BY: the row shape
|
||||
// stays exactly as it was, so nothing else that reads this endpoint has to
|
||||
// change, and every filter below still applies to `a` alone.
|
||||
base := `SELECT a.*,
|
||||
(SELECT COUNT(*) FROM tenantlocations tl WHERE tl.tenantid = a.tenantid) AS branchcount
|
||||
FROM tenants a WHERE 1 = 1`
|
||||
|
||||
var (
|
||||
conds []string
|
||||
@@ -337,7 +358,25 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
||||
a.state,a.postcode,a.userfcmtoken,a.pin,a.applocationid,
|
||||
a.roleid,a.partnerid,a.tenantid,a.locationid,
|
||||
b.locationname,
|
||||
COALESCE(c.rolename,'') AS rolename
|
||||
COALESCE(c.rolename,'') AS rolename,
|
||||
-- Whether the account still works. Absent from this SELECT
|
||||
-- until now, so Users & access had nothing to read and showed
|
||||
-- every person on the platform as "Unknown" — an admin could not
|
||||
-- tell a working login from one that had been switched off.
|
||||
COALESCE(a.status,'') AS status,
|
||||
-- Whether they have ever signed in — or can.
|
||||
--
|
||||
-- Every back-office account is created with an empty password and
|
||||
-- is emailed a link to choose one. Until they use it they are in
|
||||
-- this list, in every branch picker, and cannot sign in at all.
|
||||
-- Without this column the directory cannot tell that person from
|
||||
-- anybody else, so a lost invitation is invisible until they say
|
||||
-- so — and the screen has no way to offer them a new one.
|
||||
--
|
||||
-- Computed here rather than by returning the password: there is no
|
||||
-- reason for a cleartext password to travel up through a service
|
||||
-- and a controller to answer a yes/no question.
|
||||
(COALESCE(TRIM(a.password), '') <> '') AS issetup
|
||||
FROM app_users a
|
||||
LEFT JOIN tenantlocations b ON a.locationid = b.locationid
|
||||
LEFT JOIN app_roles c ON c.roleid = a.roleid
|
||||
@@ -363,27 +402,32 @@ func (r *tenantRepository) GetStaffs(tid int) ([]models.StaffInfo, error) {
|
||||
// `userid` is deliberately not set: it is a `GENERATED BY DEFAULT AS IDENTITY`
|
||||
// column and Postgres allocates it. Computing one here would leave the sequence
|
||||
// unadvanced and two allocators racing each other.
|
||||
func (r *tenantRepository) CreateStaff(user models.User) error {
|
||||
// The userid is returned because the account is created with NO password and the
|
||||
// caller has to invite it. Postgres allocates the id and GORM writes it back
|
||||
// onto `user`, so this costs nothing — and without it the service would have to
|
||||
// look the row up again by authname, which is the one field a concurrent create
|
||||
// could collide on.
|
||||
func (r *tenantRepository) CreateStaff(user models.User) (int, error) {
|
||||
pin, err := ValidateStaffUser(&user)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
user.Pin = int(pin)
|
||||
|
||||
if pin > 0 && user.Tenantid > 0 && user.Locationid > 0 {
|
||||
taken, err := posPinTaken(r.db, user.Tenantid, user.Locationid, pin, user.Userid)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
if taken {
|
||||
return fmt.Errorf("another person at this outlet already uses that PIN")
|
||||
return 0, fmt.Errorf("another person at this outlet already uses that PIN")
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.db.Table("app_users").Create(&user).Error; err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
return nil
|
||||
return user.Userid, nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) UpdateStaff(user models.User) error {
|
||||
@@ -393,7 +437,7 @@ func (r *tenantRepository) UpdateStaff(user models.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, error) {
|
||||
func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) {
|
||||
var user models.Tenantuser
|
||||
tx := r.db.Begin()
|
||||
|
||||
@@ -418,7 +462,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
||||
// authenticate.
|
||||
if data.Operatorid <= 0 && strings.TrimSpace(data.Email) == "" {
|
||||
tx.Rollback()
|
||||
return models.Tenantlocations{}, errors.New(
|
||||
return models.Tenantlocations{}, 0, errors.New(
|
||||
"a branch needs somebody to run it: name an existing user in operatorid, or give an email to create a login from")
|
||||
}
|
||||
|
||||
@@ -427,7 +471,7 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
||||
// QR code (payload is just {tenantid, locationid}) right after onboarding.
|
||||
if err := tx.Create(&data).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return models.Tenantlocations{}, err
|
||||
return models.Tenantlocations{}, 0, err
|
||||
}
|
||||
|
||||
// Step 2a: bind an existing person, when one was named.
|
||||
@@ -444,21 +488,25 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
||||
Updates(map[string]any{"locationid": data.Locationid})
|
||||
if res.Error != nil {
|
||||
tx.Rollback()
|
||||
return models.Tenantlocations{}, res.Error
|
||||
return models.Tenantlocations{}, 0, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// Either the person does not exist, belongs to another merchant, or
|
||||
// is a till account. All three are the same answer to the caller,
|
||||
// and none of them should leave a branch standing.
|
||||
tx.Rollback()
|
||||
return models.Tenantlocations{}, fmt.Errorf(
|
||||
return models.Tenantlocations{}, 0, fmt.Errorf(
|
||||
"user %d cannot run this branch — they belong to another business, do not exist, or are a till account",
|
||||
data.Operatorid)
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return models.Tenantlocations{}, err
|
||||
return models.Tenantlocations{}, 0, err
|
||||
}
|
||||
return data, nil
|
||||
// No userid: nothing was created. The named person already had an account
|
||||
// before this branch existed, so there is nothing here to invite — if
|
||||
// THEY have never set a password, it is their own creation that owes them
|
||||
// an invitation, not this one.
|
||||
return data, 0, nil
|
||||
}
|
||||
|
||||
// Step 2b: no person named — spawn a login, as before.
|
||||
@@ -488,15 +536,19 @@ func (r *tenantRepository) CreateTenantLocation(data models.Tenantlocations) (mo
|
||||
|
||||
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return models.Tenantlocations{}, err
|
||||
return models.Tenantlocations{}, 0, err
|
||||
}
|
||||
|
||||
// Commit
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return models.Tenantlocations{}, err
|
||||
return models.Tenantlocations{}, 0, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
// The spawned login's userid, so the service can invite it. This account is
|
||||
// created with `Password = ""` a few lines above, and the invitation is now
|
||||
// the only way to fill that in — the sign-in screen no longer offers a form.
|
||||
// Without this the branch would be commissioned with a login nobody can use.
|
||||
return data, user.Userid, nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) UpdateTenantLocation(input models.Tenantlocations) error {
|
||||
@@ -625,6 +677,51 @@ func (r *tenantRepository) CreateTenantUser(data models.Tenants) (bool, error) {
|
||||
var custloc models.Customerlocations
|
||||
var tcust models.Tenantcustomers
|
||||
|
||||
// A tenant with configid 0 is unreachable, and it takes its customer row
|
||||
// with it.
|
||||
//
|
||||
// Step 3 below already forces `user.Configid = 1`, with a comment
|
||||
// explaining that AppLogin only ever queries configid 1 and a zero makes
|
||||
// the account permanently unfindable. The same zero was left to flow into
|
||||
// `tenants` itself and into the `customers` row copied from it at step 4,
|
||||
// where nothing corrected it — so a caller that omits configid (the console
|
||||
// sends it; the mobile route and anything else need not) created a business
|
||||
// and a customer that no scoped read can see.
|
||||
//
|
||||
// Defaulted rather than rejected: 1 is the only value any caller has ever
|
||||
// meant here, and refusing the create would break callers that work today.
|
||||
if data.Configid == 0 {
|
||||
data.Configid = 1
|
||||
}
|
||||
|
||||
// Give the primary outlet the scaffolding the tenant already has.
|
||||
//
|
||||
// The outlet itself is created by GORM, as the `Tenantlocations`
|
||||
// association on the struct below — the console nests a full object in the
|
||||
// request and step 1 saves it with the tenant. What it does NOT do is fill
|
||||
// anything the caller left out, and two of those columns matter:
|
||||
//
|
||||
// applocationid — `orderRepository.go` calls it "authoritative" and has
|
||||
// no fallback anywhere for a 0.
|
||||
// moduleid — same file: "tenantlocations carries 0 for
|
||||
// moduleid/partnerid at outlets whose live orders
|
||||
// nonetheless use non-zero values", worked around there
|
||||
// by copying scaffolding off the most recent real order.
|
||||
// A shop commissioned a minute ago has no such order.
|
||||
//
|
||||
// Neither column has a database default, and no onboarding form asks for
|
||||
// them — they describe the platform, not the shop. The tenant's own values
|
||||
// are the right answer and are already right here.
|
||||
//
|
||||
// Filled before the insert rather than corrected after it, so there is one
|
||||
// write and no window where the row exists with a zero in it.
|
||||
if data.Tenantlocations.Applocationid == 0 {
|
||||
data.Tenantlocations.Applocationid = data.Applocationid
|
||||
}
|
||||
if data.Tenantlocations.Moduleid == 0 {
|
||||
data.Tenantlocations.Moduleid = data.Moduleid
|
||||
}
|
||||
|
||||
tx := r.db.Begin()
|
||||
|
||||
// Step 1: Insert into tenants
|
||||
@@ -997,3 +1094,119 @@ func (r *tenantRepository) AssignPartner(tenantID, partnerID int) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InviteTarget is the account a tenant's invitation is addressed to.
|
||||
type InviteTarget struct {
|
||||
Userid int
|
||||
Email string
|
||||
Tenantname string
|
||||
// True when the account already has a password, which means the merchant is
|
||||
// set up and there is nothing to invite them to.
|
||||
IsSetUp bool
|
||||
}
|
||||
|
||||
// PrimaryAdminForTenant finds the account an invitation should go to.
|
||||
//
|
||||
// ── Which of a tenant's users is "the" admin ────────────────────────────────
|
||||
//
|
||||
// A business can have several roleid-3 accounts — staff added later are the
|
||||
// same role. The one onboarding created is identified by its authname matching
|
||||
// the tenant's own `primaryemail`, which is how `CreateTenantUser` writes it,
|
||||
// and that is the account the invitation belongs to. Picking any roleid-3 row
|
||||
// would email whichever staff member happened to sort first.
|
||||
//
|
||||
// `IsSetUp` is computed in the query rather than by returning the password.
|
||||
// There is no reason for a hash — or on this backend, a cleartext password — to
|
||||
// travel up through a service and a controller to answer a yes/no question.
|
||||
func (r *tenantRepository) PrimaryAdminForTenant(tenantID int) (InviteTarget, error) {
|
||||
if tenantID <= 0 {
|
||||
return InviteTarget{}, errors.New("tenantid is required")
|
||||
}
|
||||
|
||||
var row InviteTarget
|
||||
query := `
|
||||
SELECT u.userid AS userid,
|
||||
COALESCE(NULLIF(TRIM(u.email), ''), t.primaryemail) AS email,
|
||||
t.tenantname AS tenantname,
|
||||
(COALESCE(TRIM(u.password), '') <> '') AS issetup
|
||||
FROM tenants t
|
||||
JOIN app_users u
|
||||
ON u.tenantid = t.tenantid
|
||||
AND LOWER(TRIM(u.authname)) = LOWER(TRIM(t.primaryemail))
|
||||
WHERE t.tenantid = ?
|
||||
LIMIT 1`
|
||||
|
||||
if err := r.db.Raw(query, tenantID).Scan(&row).Error; err != nil {
|
||||
return InviteTarget{}, err
|
||||
}
|
||||
if row.Userid == 0 {
|
||||
// Either no such tenant, or one whose primary email matches no account.
|
||||
// The second happens when the address was changed on the tenant after
|
||||
// onboarding without the login being changed with it — worth saying,
|
||||
// because the fix is to correct one of the two rather than to resend.
|
||||
return InviteTarget{}, fmt.Errorf(
|
||||
"tenant %d has no account matching its primary email address", tenantID)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// InviteTargetForUser finds one account by its userid.
|
||||
//
|
||||
// The other half of resend. `PrimaryAdminForTenant` answers "the owner of this
|
||||
// business", which is the only account a tenant HAS at onboarding — but staff
|
||||
// added later and the login every branch spawns are created with no password
|
||||
// too, and there is exactly one of the owner, so they cannot be reached that
|
||||
// way. An operator chasing a branch manager who never got their mail needs to
|
||||
// name the person.
|
||||
//
|
||||
// The tenant is joined for its name only, and joined LEFT: a back-office account
|
||||
// with no tenant is a Nearle staff row, and one exists — the platform agent's.
|
||||
// Failing the lookup on that would be refusing to answer a question that has a
|
||||
// perfectly good answer.
|
||||
func (r *tenantRepository) InviteTargetForUser(userID int) (InviteTarget, error) {
|
||||
if userID <= 0 {
|
||||
return InviteTarget{}, errors.New("userid is required")
|
||||
}
|
||||
|
||||
var row InviteTarget
|
||||
query := `
|
||||
SELECT u.userid AS userid,
|
||||
COALESCE(NULLIF(TRIM(u.email), ''), TRIM(u.authname)) AS email,
|
||||
COALESCE(t.tenantname, '') AS tenantname,
|
||||
(COALESCE(TRIM(u.password), '') <> '') AS issetup
|
||||
FROM app_users u
|
||||
LEFT JOIN tenants t ON t.tenantid = u.tenantid
|
||||
WHERE u.userid = ?
|
||||
AND COALESCE(u.roleid, 0) NOT IN (7, 8)
|
||||
LIMIT 1`
|
||||
|
||||
if err := r.db.Raw(query, userID).Scan(&row).Error; err != nil {
|
||||
return InviteTarget{}, err
|
||||
}
|
||||
if row.Userid == 0 {
|
||||
// No such account, or a till one. Roles 7 and 8 are excluded because a
|
||||
// cashier does not sign in to the console at all — they authenticate at
|
||||
// the terminal with a PIN, and an invitation would send them to a screen
|
||||
// that cannot help them.
|
||||
return InviteTarget{}, fmt.Errorf(
|
||||
"user %d is not a back-office account on this platform", userID)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// TenantNameByID is the business's name, for an invitation's first line.
|
||||
//
|
||||
// Its own tiny read rather than a field threaded through the create paths: a
|
||||
// staff account arrives carrying a tenantid and nothing else about the business,
|
||||
// and the alternative was every caller passing a name it would have had to look
|
||||
// up anyway. An empty name is not an error — `inviteMessage` says "your
|
||||
// business" instead, which is worse copy and a working email.
|
||||
func (r *tenantRepository) TenantNameByID(tenantID int) (string, error) {
|
||||
if tenantID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
var name string
|
||||
err := r.db.Raw(`SELECT COALESCE(tenantname, '') FROM tenants WHERE tenantid = ? LIMIT 1`,
|
||||
tenantID).Scan(&name).Error
|
||||
return name, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -12,16 +14,15 @@ import (
|
||||
type UserRepository interface {
|
||||
GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error)
|
||||
GetUserByID(uid int) (models.UserInfo, error)
|
||||
SetInitialPassword(userid int, password string) error
|
||||
Login(user models.User) (models.UserInfo, error)
|
||||
FindUserID(authname, contactno string, configid int) (int, error)
|
||||
UpdateStaff(user models.User) error
|
||||
GetUserByAuthname(authname string, configid int) (int, string, string)
|
||||
GetUserByContactNo(contactno string, configid int) (int, string, string)
|
||||
UpdateFCMToken(userid int, token string) error
|
||||
GetTenantUserById(userid int) models.TenantUserInfo
|
||||
CreateUser(user models.User) (int, error)
|
||||
GetUserById(uid int) (models.UserInfo, error)
|
||||
GetUserLogin(field, value string, configid int) (int, string, string, int)
|
||||
GetUserLogin(field, value string, configid int) (int, string, string, int, error)
|
||||
UpdateUserFcmToken(uid int, token string) error
|
||||
GetLocationStatus(locationid int) string
|
||||
DeleteUser(userid int) error
|
||||
@@ -164,7 +165,6 @@ func (r *userRepository) Login(user models.User) (models.UserInfo, error) {
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *userRepository) FindUserID(authname, contactno string, configid int) (int, error) {
|
||||
var uid int
|
||||
var query string
|
||||
@@ -187,39 +187,10 @@ func (r *userRepository) FindUserID(authname, contactno string, configid int) (i
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (r *userRepository) UpdateStaff(user models.User) error {
|
||||
return r.db.Table("app_users").Where("userid = ?", user.Userid).Updates(&user).Error
|
||||
}
|
||||
|
||||
// A till account is not a Nearle Daily user. The two products share this table
|
||||
// and nothing else, so every way into the application excludes roles 7 and 8 in
|
||||
// the lookup itself: a cashier is not "refused", they are simply not found.
|
||||
//
|
||||
// Doing it in the query rather than after it is deliberate. A check bolted on
|
||||
// afterwards has to be repeated at each of these call sites and is one edit away
|
||||
// from being forgotten at one of them, and that one would be the hole.
|
||||
func (r *userRepository) GetUserByAuthname(authname string, configid int) (int, string, string) {
|
||||
var uid int
|
||||
var password, status string
|
||||
query := `SELECT userid, password, status FROM app_users
|
||||
WHERE authname = ? AND configid = ?
|
||||
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
||||
r.db.Raw(query, authname, configid).Row().Scan(&uid, &password, &status)
|
||||
return uid, password, status
|
||||
}
|
||||
|
||||
func (r *userRepository) GetUserByContactNo(contactno string, configid int) (int, string, string) {
|
||||
var uid int
|
||||
var password, status string
|
||||
query := `SELECT userid, password, status FROM app_users
|
||||
WHERE contactno = ? AND configid = ?
|
||||
AND COALESCE(roleid, 0) NOT IN (7, 8)`
|
||||
r.db.Raw(query, contactno, configid).Row().Scan(&uid, &password, &status)
|
||||
return uid, password, status
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateFCMToken(userid int, token string) error {
|
||||
query := `UPDATE app_users SET userfcmtoken = ? WHERE userid = ?`
|
||||
return r.db.Exec(query, token, userid).Error
|
||||
@@ -285,6 +256,30 @@ func (r *userRepository) GetTenantUserById(userid int) models.TenantUserInfo {
|
||||
}
|
||||
|
||||
func (r *userRepository) CreateUser(user models.User) (int, error) {
|
||||
// Inherit the delivery region from the tenant when the caller did not name
|
||||
// one.
|
||||
//
|
||||
// `app_users.applocationid` has no column default, and no console form
|
||||
// collects it — it is a platform region, not something a merchant picks
|
||||
// per person. So every back-office account created through this path landed
|
||||
// with 0, which is not a region: `orderRepository.go` calls the equivalent
|
||||
// column on tenantlocations "authoritative" and has no fallback for a zero,
|
||||
// and 43 of 75 live branches are already in that state.
|
||||
//
|
||||
// A lookup rather than a default value, because the right answer is
|
||||
// whichever region the business trades in. Failure is not fatal: the
|
||||
// account is still worth creating, and a 0 here is exactly what would have
|
||||
// been written anyway.
|
||||
if user.Applocationid == 0 && user.Tenantid > 0 {
|
||||
var inherited int
|
||||
if err := r.db.Raw(
|
||||
`SELECT COALESCE(applocationid, 0) FROM tenants WHERE tenantid = ?`,
|
||||
user.Tenantid,
|
||||
).Scan(&inherited).Error; err == nil && inherited > 0 {
|
||||
user.Applocationid = inherited
|
||||
}
|
||||
}
|
||||
|
||||
tx := r.db.Begin()
|
||||
|
||||
if err := tx.Table("app_users").Create(&user).Error; err != nil {
|
||||
@@ -329,9 +324,40 @@ func (r *userRepository) GetUserById(uid int) (models.UserInfo, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int) {
|
||||
var uid, roleid int
|
||||
var password, status string
|
||||
// GetUserLogin is the one sign-in lookup, for the app and the console alike.
|
||||
//
|
||||
// `field` is the column matched — "authname" or "contactno", nothing else is
|
||||
// accepted — and it is interpolated, so the whitelist is what keeps this from
|
||||
// being an injection point.
|
||||
//
|
||||
// A till account is not a Nearle Daily user. The two products share this table
|
||||
// and nothing else, so the lookup itself excludes roles 7 and 8: a cashier is
|
||||
// not "refused", they are simply not found. Doing it in the query rather than
|
||||
// after it is deliberate — a check bolted on afterwards has to be repeated at
|
||||
// every call site and is one edit away from being forgotten at one of them.
|
||||
//
|
||||
// Three outcomes, and the caller must tell them apart:
|
||||
//
|
||||
// - found: uid > 0, err == nil
|
||||
// - not found: uid == 0, err == nil
|
||||
// - failed: err != nil — the database could not answer at all
|
||||
//
|
||||
// The third used to be invisible. `Row().Scan`'s error was discarded, so a
|
||||
// database that was down, a connection pool that was exhausted or a
|
||||
// misconfigured `configid` all came back as uid 0 — which the service then
|
||||
// reported as "Invalid Email". On 2026-07-20 the deployment lost its
|
||||
// ConfigMaps/Secrets and every user on the platform was told their email was
|
||||
// wrong, and nothing in the logs said otherwise.
|
||||
func (r *userRepository) GetUserLogin(field, value string, configid int) (int, string, string, int, error) {
|
||||
switch field {
|
||||
case "authname", "contactno":
|
||||
default:
|
||||
return 0, "", "", 0, fmt.Errorf("login: %q is not a sign-in field", field)
|
||||
}
|
||||
|
||||
var uid int
|
||||
var password, status sql.NullString
|
||||
var roleid sql.NullInt64
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT userid, password, status, roleid
|
||||
@@ -339,9 +365,16 @@ func (r *userRepository) GetUserLogin(field, value string, configid int) (int, s
|
||||
WHERE %s = ? AND configid = ?
|
||||
AND COALESCE(roleid, 0) NOT IN (7, 8)`, field)
|
||||
|
||||
r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
||||
|
||||
return uid, password, status, roleid
|
||||
err := r.db.Raw(query, value, configid).Row().Scan(&uid, &password, &status, &roleid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, "", "", 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, "", "", 0, err
|
||||
}
|
||||
// Nullable columns scanned through sql.Null* so that a NULL password or
|
||||
// role — both exist on real rows — does not itself read as a failed query.
|
||||
return uid, password.String, status.String, int(roleid.Int64), nil
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateUserFcmToken(userid int, fcmToken string) error {
|
||||
@@ -360,4 +393,45 @@ func (r *userRepository) DeleteUser(userid int) error {
|
||||
return r.db.Table("app_users").Where("userid = ?", userid).Delete(&models.User{}).Error
|
||||
}
|
||||
|
||||
// SetInitialPassword writes the first password on an account that has none.
|
||||
//
|
||||
// ── Why this is a separate call and not `UpdateStaff` ───────────────────────
|
||||
//
|
||||
// It is the one write that MUST work without a session, and that is the whole
|
||||
// difficulty. A brand-new account — `createtenantlocation` spawns branch logins
|
||||
// with an empty password — signs in, is told to set one, and at that moment has
|
||||
// no token and no way to get one. The console was doing this through
|
||||
// `PUT /users/update`, which sits behind the session guard, so the call came
|
||||
// back "a session token is required; sign in again" and the account could never
|
||||
// be used. Sign-in needs a password; setting the password needed a sign-in.
|
||||
//
|
||||
// `/users/update` could not simply be opened up: it writes whatever struct it
|
||||
// is handed, so an unauthenticated caller could edit any field of any user.
|
||||
// This can do exactly one thing, to exactly one kind of account.
|
||||
//
|
||||
// ── What makes it safe to expose ────────────────────────────────────────────
|
||||
//
|
||||
// The empty-password check IS the authorisation. An account with a password set
|
||||
// is refused, so this can never overwrite a credential — it is a setup call,
|
||||
// never a reset. There is no "forgot password" flow on this backend and this
|
||||
// must not become one by accident: a reset needs proof of identity, and nothing
|
||||
// here has any.
|
||||
//
|
||||
// The check and the write are one statement, so two callers racing cannot both
|
||||
// see an empty password and both set one. Postgres decides, not this process.
|
||||
func (r *userRepository) SetInitialPassword(userid int, password string) error {
|
||||
result := r.db.Table("app_users").
|
||||
Where("userid = ? AND (password IS NULL OR TRIM(password) = '')", userid).
|
||||
Update("password", password)
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
// One message for "no such user" and "already has a password". They
|
||||
// must not be distinguishable, or this becomes a way to ask whether a
|
||||
// given userid exists and whether it has been set up.
|
||||
return errors.New("that account cannot have its password set here — it may already have one")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
25
routes/assistantroutes.go
Normal file
25
routes/assistantroutes.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"nearle/facade"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Nearle Buddy. See controllers/assistantController.go for the two calls and
|
||||
// services/assistantService.go for the loop behind them.
|
||||
//
|
||||
// Under `/v1/web`, so it inherits `middleware.WebAuth` along with every other
|
||||
// console route — which is the point. The assistant reads the same data the
|
||||
// console does, and it must read it as the same person.
|
||||
func RegisterAssistantRoutes(api fiber.Router, f *facade.Facade) {
|
||||
assistant := api.Group("/v1/web/assistant")
|
||||
|
||||
assistant.Get("/status", f.AssistantController.Status)
|
||||
assistant.Post("/ask", f.AssistantController.Ask)
|
||||
assistant.Post("/approve", f.AssistantController.Approve)
|
||||
|
||||
// The MCP door, under the same group so it inherits the same session guard.
|
||||
// One endpoint: JSON-RPC carries the method in the body.
|
||||
assistant.Post("/mcp", f.MCPController.Handle)
|
||||
}
|
||||
33
routes/deliveryslotroutes.go
Normal file
33
routes/deliveryslotroutes.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"nearle/facade"
|
||||
)
|
||||
|
||||
/*
|
||||
Delivery windows.
|
||||
|
||||
── The web half is guarded, the app half is read-only ──────────────────────
|
||||
|
||||
Setting a branch's hours is a tenant-wide decision with money behind it, so it
|
||||
sits on /v1/web where WebAuth checks the session and the tenant scope. Reading
|
||||
what is on offer is what a shopper's app does before it has any identity at all,
|
||||
so that one endpoint — and only that one — lives on the unauthenticated /v1/mob
|
||||
group.
|
||||
|
||||
There is deliberately NO write route on the mob group. The /v1/mob/* group has
|
||||
no session at all, and a shop's trading hours are not something an anonymous
|
||||
caller gets to set.
|
||||
*/
|
||||
func RegisterDeliverySlotRoutes(api fiber.Router, f *facade.Facade) {
|
||||
// ── Console: read and edit a branch's windows ───────────────────────────
|
||||
web := api.Group("/v1/web/deliveryslots")
|
||||
web.Get("/", f.DeliverySlotController.ListDeliverySlots)
|
||||
web.Put("/", f.DeliverySlotController.SaveDeliverySlots)
|
||||
|
||||
// ── App: what a shopper may pick, already filtered ──────────────────────
|
||||
mob := api.Group("/v1/mob/deliveryslots")
|
||||
mob.Get("/available", f.DeliverySlotController.AvailableDeliverySlots)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ func RegisterPartnerRoutes(api fiber.Router, f *facade.Facade) {
|
||||
partner.Get("/getriders", f.PartnerController.GetActiveRiders)
|
||||
partner.Get("/getpartners", f.PartnerController.GetPartners)
|
||||
partner.Get("/getridershifts", f.PartnerController.GetRiderShifts)
|
||||
// Opening a shift window. Riders cannot be hired without one, and this table
|
||||
// was read-only until now — see partnerController.CreateRiderShift.
|
||||
partner.Post("/createridershift", f.PartnerController.CreateRiderShift)
|
||||
partner.Get("/getlocations", f.PartnerController.GetLocationConfig)
|
||||
partner.Get("/getriderlogs", f.PartnerController.GetRiderLogs)
|
||||
partner.Get("/getfleetsummary", f.PartnerController.GetFleetSummary)
|
||||
|
||||
@@ -77,6 +77,28 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
||||
registerPosStaffConsoleRoutes(api, f)
|
||||
registerPosReadConsoleRoutes(api, f)
|
||||
registerLiveRoutes(api, f)
|
||||
registerPosAdoptionRoute(api, f)
|
||||
}
|
||||
|
||||
// How much of the till fleet has adopted the session token.
|
||||
//
|
||||
// The number that decides when `POS_AUTH_REQUIRED` can be switched on. Nothing
|
||||
// was recording it — an untokened request was waved through in silence — so the
|
||||
// only way to judge the risk of flipping the flag was to flip it and watch.
|
||||
//
|
||||
// ── Why it is on /v1/web and only /v1/web ───────────────────────────────────
|
||||
//
|
||||
// It names the outlets still calling without a token, which is a list of the
|
||||
// shops that would stop trading if enforcement went on today. That is exactly
|
||||
// the list an attacker would want, so it sits behind `middleware.WebAuth` and
|
||||
// NOT on the unauthenticated health endpoint, where the rest of "is this
|
||||
// deployment wired up" lives.
|
||||
//
|
||||
// Registered on its own rather than inside registerPosReadConsoleRoutes,
|
||||
// because that function deliberately mirrors every route onto `/v1/mob/pos`
|
||||
// as well — which has no guard at all.
|
||||
func registerPosAdoptionRoute(api fiber.Router, f *facade.Facade) {
|
||||
api.Group("/v1/web/pos").Get("/authadoption", f.PosController.AuthAdoption)
|
||||
}
|
||||
|
||||
// The same counter-sales reads, for callers that are not a terminal.
|
||||
|
||||
@@ -28,6 +28,14 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
||||
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
||||
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
||||
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
||||
|
||||
// Whether this shop shows a product's health score.
|
||||
//
|
||||
// On `/v1/web` only. The merchant decides for their own shelf, so it needs
|
||||
// the session that says which shelf is theirs — and the mobile group has no
|
||||
// guard at all, which would make this "anyone can turn any shop's health
|
||||
// scores off".
|
||||
products.Put("/showhealthscore", f.ProductController.SetShowHealthScore)
|
||||
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
||||
|
||||
// Repairing catalogue links. A dry run unless `apply=true` — see the handler,
|
||||
|
||||
@@ -2,6 +2,7 @@ package routes
|
||||
|
||||
import (
|
||||
"nearle/facade"
|
||||
"nearle/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -10,6 +11,22 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
||||
|
||||
api := app.Group("/live/api")
|
||||
|
||||
// Console sessions.
|
||||
//
|
||||
// Mounted by PATH rather than on a group object, because the `/v1/web`
|
||||
// routes are not one group — a dozen files each create their own
|
||||
// (`/v1/web/users`, `/v1/web/orders`, `/v1/web/products`, …). Registered
|
||||
// here, ahead of all of them, so a route added later is guarded by default
|
||||
// rather than by somebody remembering to.
|
||||
//
|
||||
// `/v1/pos` is deliberately NOT covered: that is the terminal surface, it
|
||||
// carries a different kind of token, and it has its own guard. But
|
||||
// `/v1/web/pos` and `/v1/web/tenants` ARE, despite their names — both are
|
||||
// console callers, and `createposuser` on the second mints till credentials,
|
||||
// which until now it did on the strength of an unauthenticated request. The
|
||||
// note above registerPosStaffConsoleRoutes asked for exactly this.
|
||||
api.Use("/v1/web", middleware.WebAuth(f.PosService()))
|
||||
|
||||
RegisterUserRoutes(api, f)
|
||||
RegisterProductRoutes(api, f)
|
||||
RegisterOrderRoutes(api, f)
|
||||
@@ -21,4 +38,15 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
|
||||
RegisterCatalogueRoutes(api, f)
|
||||
RegisterPosRoutes(api, f)
|
||||
RegisterUploadRoutes(api, f)
|
||||
RegisterScanRoutes(api, f)
|
||||
RegisterAssistantRoutes(api, f)
|
||||
RegisterDeliverySlotRoutes(api, f)
|
||||
|
||||
// What is running here.
|
||||
//
|
||||
// Registered on `api` and NOT under `/v1/web`, so it answers without a
|
||||
// session — which is the whole point. The question it exists for is "why
|
||||
// does nothing work", and a health check that needs a working credential
|
||||
// cannot answer that. It returns booleans and a build id, never values.
|
||||
api.Get("/v1/health", f.HealthController.Health)
|
||||
}
|
||||
|
||||
17
routes/scanroutes.go
Normal file
17
routes/scanroutes.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"nearle/facade"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Scan-to-order, customer app only. See controllers/scanController.go for
|
||||
// the three calls and services/scanService.go for the pipeline behind them.
|
||||
func RegisterScanRoutes(api fiber.Router, f *facade.Facade) {
|
||||
scan := api.Group("/v1/mob/scan")
|
||||
|
||||
scan.Post("/lookup", f.ScanController.Lookup)
|
||||
scan.Post("/confirm", f.ScanController.Confirm)
|
||||
scan.Get("/stores", f.ScanController.Stores)
|
||||
}
|
||||
135
routes/startup_test.go
Normal file
135
routes/startup_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/config"
|
||||
"nearle/facade"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Can this server be built and can its routes be reached?
|
||||
//
|
||||
// Everything else in this repository tests a function. This tests the thing
|
||||
// that actually happens on deploy: the whole object graph is constructed and
|
||||
// every route is registered. Nothing here needs a database — the repositories
|
||||
// hold their handle without touching it — so it runs in CI beside the unit
|
||||
// tests rather than in an environment somebody has to provision.
|
||||
//
|
||||
// ── Why it is worth its own file ────────────────────────────────────────────
|
||||
//
|
||||
// Three things in `NewFacade` PANIC rather than return an error: a tool that
|
||||
// fails to register, a help corpus that will not load, and an agent naming a
|
||||
// tool that does not exist. Each is a programming mistake that should stop a
|
||||
// deploy, and each was previously reachable only by starting the server against
|
||||
// a real database — which meant, in practice, by deploying.
|
||||
//
|
||||
// The agent one is not hypothetical: a typo in `agents/orders.yaml` is a file
|
||||
// edit away, and it takes a working assistant down at boot.
|
||||
|
||||
func testFacade(t *testing.T) *facade.Facade {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Rendered as a failure rather than a panicking test, because the
|
||||
// message IS the point — "agent orders lists a tool that does not
|
||||
// exist" is the whole diagnosis.
|
||||
t.Fatalf("the server cannot start: %v", r)
|
||||
}
|
||||
}()
|
||||
// No database, no catalogue, no embedder, no model. A deployment with none
|
||||
// of those must still boot and say what it is missing, rather than failing
|
||||
// somewhere the operator cannot see.
|
||||
return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests", nil, config.MailConfig{}, "")
|
||||
}
|
||||
|
||||
func TestTheServerCanBeBuilt(t *testing.T) {
|
||||
f := testFacade(t)
|
||||
if f == nil {
|
||||
t.Fatal("no facade")
|
||||
}
|
||||
// The two doors onto the assistant. Absent means a route registered below
|
||||
// would nil-panic on its first request rather than at boot.
|
||||
if f.AssistantController == nil {
|
||||
t.Fatal("no assistant controller")
|
||||
}
|
||||
if f.MCPController == nil {
|
||||
t.Fatal("no MCP controller")
|
||||
}
|
||||
if f.Tools == nil {
|
||||
t.Fatal("no tool registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryAssistantRouteIsReachable(t *testing.T) {
|
||||
// Registered, not merely written down. A route added to a file that nothing
|
||||
// calls is invisible until somebody reports the feature missing.
|
||||
app := fiber.New()
|
||||
RegisterRoutes(app, testFacade(t))
|
||||
|
||||
for _, route := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"GET", "/live/api/v1/web/assistant/status"},
|
||||
{"POST", "/live/api/v1/web/assistant/ask"},
|
||||
{"POST", "/live/api/v1/web/assistant/approve"},
|
||||
{"POST", "/live/api/v1/web/assistant/mcp"},
|
||||
} {
|
||||
req := httptest.NewRequest(route.method, route.path, strings.NewReader("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", route.method, route.path, err)
|
||||
}
|
||||
if resp.StatusCode == fiber.StatusNotFound {
|
||||
t.Fatalf("%s %s is not registered", route.method, route.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthAnswersThroughTheRealRouteTableWithoutASession(t *testing.T) {
|
||||
// Registered on `api` rather than under `/v1/web`, which is what keeps it
|
||||
// outside the session guard. Asserted here rather than trusted, because the
|
||||
// difference is one path segment and getting it wrong makes the endpoint
|
||||
// useless for the only situation it exists for: nothing else works.
|
||||
//
|
||||
// It also has to survive a facade built with no database, no model and no
|
||||
// embedder — the state somebody is most likely to be asking from.
|
||||
app := fiber.New()
|
||||
RegisterRoutes(app, testFacade(t))
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/live/api/v1/health", nil), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("calling health: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("health needs a session or is unregistered: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAssistantSurfaceSitsBehindTheSessionGuard(t *testing.T) {
|
||||
// The assistant reads the same data the console does and must read it as
|
||||
// the same person. Being under `/v1/web` is what puts it behind WebAuth —
|
||||
// a route registered one path segment to the left would answer anybody.
|
||||
app := fiber.New()
|
||||
RegisterRoutes(app, testFacade(t))
|
||||
|
||||
// WEB_AUTH_REQUIRED is off by default, so an untokened request reaches the
|
||||
// handler; the assistant's own controller then refuses it. Either way it
|
||||
// must not answer with data.
|
||||
req := httptest.NewRequest("POST", "/live/api/v1/web/assistant/ask",
|
||||
strings.NewReader(`{"question":"what is stuck?"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("calling: %v", err)
|
||||
}
|
||||
if resp.StatusCode == fiber.StatusOK {
|
||||
t.Fatal("an untokened question was answered")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,14 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
|
||||
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
|
||||
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)
|
||||
|
||||
// Re-issuing a merchant's first-password link, for the one who never got
|
||||
// the mail or whose invitation expired.
|
||||
//
|
||||
// Web group only, and the handler additionally requires a platform account:
|
||||
// this mints a credential, and the merchant it would invite is by
|
||||
// definition somebody who cannot sign in to ask for it themselves.
|
||||
tenant.Post("/resendinvite", f.TenantController.ResendInvite)
|
||||
|
||||
// One business, by id.
|
||||
//
|
||||
// Also /mob-only until now, so the console's only way to read its own
|
||||
|
||||
@@ -14,6 +14,10 @@ func RegisterUserRoutes(api fiber.Router, f *facade.Facade) {
|
||||
users.Post("/applogin", f.UserController.AppLogin)
|
||||
users.Post("/create", f.UserController.CreateUser)
|
||||
users.Post("/tenant/weblogin", f.UserController.TenantWebLogin)
|
||||
|
||||
// First password, before a session can exist. Public by necessity and safe
|
||||
// because of what it refuses — see userController.SetPassword.
|
||||
users.Post("/setpassword", f.UserController.SetPassword)
|
||||
users.Put("/update", f.UserController.UpdateStaff)
|
||||
users.Delete("/delete", f.UserController.DeleteUser)
|
||||
|
||||
|
||||
49
scratch/buddyconfig/main.go
Normal file
49
scratch/buddyconfig/main.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Would this server switch Nearle Buddy on?
|
||||
//
|
||||
// APP_ENV=production go run ./scratch/buddyconfig
|
||||
//
|
||||
// Reads the configuration exactly as `main.go` does — same files, same order,
|
||||
// same defaults — and reports whether the assistant would be built. Prints a
|
||||
// verdict and, when the answer is no, the name of the variable that is missing.
|
||||
// Never a value: this exists to be run against production configuration.
|
||||
//
|
||||
// Connects to nothing. `config.Load` reads and validates; the database, the
|
||||
// model and the queue are all somebody else's job.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"nearle/config"
|
||||
"nearle/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Println("configuration is not valid:")
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("APP_ENV %s\n", cfg.AppEnv)
|
||||
fmt.Printf("sessions can issue %t\n", utils.WebTokenConfigured())
|
||||
|
||||
if !cfg.Assistant.Enabled() {
|
||||
fmt.Printf("assistant OFF — %s\n", cfg.Assistant.Why())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// The gateway itself, built the way the facade builds it. Enabled() passing
|
||||
// and NewChat returning nil would be a disagreement worth catching here
|
||||
// rather than at the first question somebody asks.
|
||||
chat, err := utils.NewChat(cfg.Assistant)
|
||||
if err != nil || chat == nil {
|
||||
fmt.Printf("assistant OFF — gateway not built: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("assistant ON — provider %s, balanced tier %s\n",
|
||||
cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced))
|
||||
}
|
||||
81
scratch/buddystatus/main.go
Normal file
81
scratch/buddystatus/main.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Asks the deployed server whether Nearle Buddy has a model.
|
||||
//
|
||||
// go run ./scratch/buddystatus # production
|
||||
// go run ./scratch/buddystatus http://localhost:1122
|
||||
//
|
||||
// `/assistant/status` sits behind the session guard, so this mints one. That it
|
||||
// CAN mint one, from a secret sitting in a tracked file, is itself the finding
|
||||
// recorded in middleware/webauth.go: anybody with repository access can issue a
|
||||
// session for any tenant. Rotating POS_TOKEN_SECRET out of `.env.local` is the
|
||||
// fix, and this tool stops working the day that happens — which is correct.
|
||||
//
|
||||
// Read-only. It asks one question and prints the answer.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// The secret lives in the env files, not in this program.
|
||||
_ = godotenv.Load(".env.local")
|
||||
_ = godotenv.Load(".env")
|
||||
|
||||
host := "https://fiesta.nearle.app"
|
||||
if len(os.Args) > 1 {
|
||||
host = strings.TrimRight(os.Args[1], "/")
|
||||
}
|
||||
|
||||
token, _, err := utils.MintWebToken(utils.WebClaims{Userid: 904, Tenantid: 1147}, time.Now())
|
||||
if err != nil {
|
||||
fmt.Println("cannot mint a session:", err)
|
||||
fmt.Println("POS_TOKEN_SECRET is not set here, or is shorter than 16 characters.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
url := host + "/live/api/v1/web/assistant/status"
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
fmt.Println("could not reach", url, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("%s\nHTTP %d\n%s\n\n", url, resp.StatusCode, body)
|
||||
|
||||
var envelope struct {
|
||||
Details struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if json.Unmarshal(body, &envelope) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
fmt.Println("The session was refused — this deployment signs with a different secret.")
|
||||
case envelope.Details.Available:
|
||||
fmt.Println("Buddy has a model. The composer should accept a question on Console, Sales and Inventory.")
|
||||
case envelope.Details.Reason != "":
|
||||
fmt.Println("Buddy is off:", envelope.Details.Reason)
|
||||
default:
|
||||
fmt.Println("Buddy is off. This build does not say why — ASSISTANT_BASE_URL, ASSISTANT_MODEL")
|
||||
fmt.Println("and ASSISTANT_API_KEY are what it needs, set on the platform and redeployed.")
|
||||
}
|
||||
}
|
||||
90
scratch/cataloguedims/main.go
Normal file
90
scratch/cataloguedims/main.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Reports how the catalogue's `embedding` columns are shaped — width, how
|
||||
// many rows are filled, and a sample norm — so the embedding model Fiesta
|
||||
// calls can be matched to the one that indexed the catalogue. Metadata and
|
||||
// counts only, on a read-only transaction; it never writes.
|
||||
//
|
||||
// go run ./scratch/cataloguedims # reads CATALOGUE_DB_* from .env.production
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
sample := flag.String("sample", "", "print one row's texts and stored vector from this table, to check which model produced it")
|
||||
flag.Parse()
|
||||
_ = godotenv.Load(".env.production")
|
||||
dsn := url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(os.Getenv("CATALOGUE_DB_USER"), os.Getenv("CATALOGUE_DB_PASSWORD")),
|
||||
Host: os.Getenv("CATALOGUE_DB_HOST") + ":" + os.Getenv("CATALOGUE_DB_PORT"),
|
||||
Path: "/" + os.Getenv("CATALOGUE_DB_NAME"),
|
||||
}
|
||||
q := dsn.Query()
|
||||
q.Set("sslmode", "disable")
|
||||
q.Set("default_transaction_read_only", "on")
|
||||
dsn.RawQuery = q.Encode()
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn.String()), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if *sample != "" {
|
||||
var row struct {
|
||||
ProductName string
|
||||
Title string
|
||||
SearchQuery string
|
||||
Embedding string
|
||||
}
|
||||
db.Raw(fmt.Sprintf(`SELECT product_name, COALESCE(title, '') AS title, COALESCE(search_query, '') AS search_query,
|
||||
embedding::text AS embedding FROM %s WHERE embedding IS NOT NULL ORDER BY id LIMIT 1`, *sample)).Scan(&row)
|
||||
fmt.Printf("product_name: %s\ntitle: %s\nsearch_query: %s\nembedding: %s\n", row.ProductName, row.Title, row.SearchQuery, row.Embedding)
|
||||
return
|
||||
}
|
||||
|
||||
var cols []struct {
|
||||
Relname string
|
||||
Attname string
|
||||
Typname string
|
||||
Atttypmod int
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT c.relname, a.attname, t.typname, a.atttypmod
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
JOIN pg_type t ON t.oid = a.atttypid
|
||||
WHERE t.typname = 'vector' AND a.attnum > 0 AND c.relname LIKE 'brand\_%'
|
||||
ORDER BY c.relname, a.attname`).Scan(&cols).Error; err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
fmt.Println("no brand_* table has a vector column")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-24s %-16s %-8s %5s %5s %5s\n", "table", "column", "type", "dims", "rows", "filled")
|
||||
for _, c := range cols {
|
||||
var total, filled int64
|
||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s`, c.Relname)).Scan(&total)
|
||||
db.Raw(fmt.Sprintf(`SELECT COUNT(1) FROM %s WHERE %s IS NOT NULL`, c.Relname, c.Attname)).Scan(&filled)
|
||||
fmt.Printf("%-24s %-16s %-8s %5d %5d %5d\n", c.Relname, c.Attname, c.Typname, c.Atttypmod, total, filled)
|
||||
}
|
||||
|
||||
// nomic/bge emit unit vectors; a norm far from 1 means another pipeline.
|
||||
for _, c := range cols {
|
||||
var norm float64
|
||||
db.Raw(fmt.Sprintf(`SELECT vector_norm(embedding) FROM %s WHERE embedding IS NOT NULL LIMIT 1`, c.Relname)).Scan(&norm)
|
||||
if norm > 0 {
|
||||
fmt.Printf("sample vector norm (%s): %.4f\n", c.Relname, norm)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
402
scratch/cataloguefactsbackfill/main.go
Normal file
402
scratch/cataloguefactsbackfill/main.go
Normal file
@@ -0,0 +1,402 @@
|
||||
// Backfills products.cataloguefacts for products imported before the column existed.
|
||||
//
|
||||
// The catalogue import copied eight of the catalogue's eighteen fields onto a
|
||||
// tenant's product and left the other ten behind — the FSSAI licence, nutrients,
|
||||
// highlights, providers, the typical price range, the variant key. The console
|
||||
// covered for it by asking the catalogue again on every drawer open, and that
|
||||
// stops working the moment a re-scrape retires the source row: a tenant's
|
||||
// product is a SNAPSHOT and outlives it, so a licence number came off a product
|
||||
// the shop was still selling with no way back.
|
||||
//
|
||||
// The import keeps them now. Every product imported BEFORE that does not have
|
||||
// them, and no amount of new code fixes a row that was written last month — so
|
||||
// this reads each one's catalogue entry while it is still there and stores it.
|
||||
//
|
||||
// go run ./scratch/cataloguefactsbackfill # dry run — shows every change
|
||||
// go run ./scratch/cataloguefactsbackfill apply # writes, then prints the undo
|
||||
//
|
||||
// ── What it will and will not touch ─────────────────────────────────────────
|
||||
//
|
||||
// Only products with an `imageid` and a NULL `cataloguefacts`. That is the
|
||||
// whole safety story:
|
||||
//
|
||||
// - NULL means nothing was ever written. A product whose facts are already
|
||||
// stored — including one stored as `{}` because the catalogue genuinely had
|
||||
// nothing to say — is never overwritten, so re-running this is a no-op
|
||||
// rather than a second opinion.
|
||||
// - No `imageid` means it never came from the catalogue. Sheet-imported
|
||||
// products have no entry to read and are left alone.
|
||||
// - A catalogue row that has already been retired cannot be recovered by
|
||||
// anything, here or later. Those are counted and named rather than written
|
||||
// as empty, because `{}` would claim the catalogue said nothing when the
|
||||
// truth is that nobody asked in time.
|
||||
//
|
||||
// Brand tables are discovered rather than assumed, and their columns are
|
||||
// checked one by one before being selected: the catalogue is another team's
|
||||
// scrape, brands appear between runs, and a table missing `nutrients` is a
|
||||
// perfectly good catalogue of products. Demanding the full column set is the
|
||||
// exact mistake that once made 16 of 35 live brands invisible to this side.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
// The columns worth keeping, in the order the drawer reads them. Scalars and
|
||||
// arrays are separated because an array comes back as a Postgres text[] literal
|
||||
// and has to be parsed before it can be re-encoded as JSON.
|
||||
var scalarFacts = []string{
|
||||
"title", "category", "variant_key", "sku_source",
|
||||
"price_range", "fssai_license", "search_query",
|
||||
}
|
||||
|
||||
var arrayFacts = []string{"providers", "highlights", "nutrients"}
|
||||
|
||||
type product struct {
|
||||
Productid int
|
||||
Productbrand string
|
||||
Imageid string
|
||||
Productname string
|
||||
Tenantid int
|
||||
}
|
||||
|
||||
func main() {
|
||||
apply := len(os.Args) > 1 && os.Args[1] == "apply"
|
||||
|
||||
_ = godotenv.Load()
|
||||
|
||||
main, err := open("DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME")
|
||||
if err != nil {
|
||||
log.Fatal("nearledb: ", err)
|
||||
}
|
||||
cat, err := open("CATALOGUE_DB_HOST", "CATALOGUE_DB_PORT", "CATALOGUE_DB_USER",
|
||||
"CATALOGUE_DB_PASSWORD", "CATALOGUE_DB_NAME")
|
||||
if err != nil {
|
||||
log.Fatal("cataloguedb: ", err)
|
||||
}
|
||||
|
||||
// The column has to exist before there is anything to fill. Checked rather
|
||||
// than assumed so this says so plainly instead of failing inside a query.
|
||||
var hasColumn int
|
||||
main.Raw(`SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_name = 'products' AND column_name = 'cataloguefacts'`).Scan(&hasColumn)
|
||||
if hasColumn == 0 {
|
||||
log.Fatal("products.cataloguefacts does not exist — start the API once to run the migration, then re-run this")
|
||||
}
|
||||
|
||||
var candidates []product
|
||||
main.Raw(`SELECT productid, tenantid, COALESCE(productbrand,'') AS productbrand,
|
||||
COALESCE(imageid,'') AS imageid, COALESCE(productname,'') AS productname
|
||||
FROM products
|
||||
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL
|
||||
ORDER BY productbrand, productid`).Scan(&candidates)
|
||||
|
||||
var (
|
||||
total int
|
||||
alreadyDone int
|
||||
noImageid int
|
||||
)
|
||||
main.Raw(`SELECT COUNT(*) FROM products`).Scan(&total)
|
||||
main.Raw(`SELECT COUNT(*) FROM products WHERE cataloguefacts IS NOT NULL`).Scan(&alreadyDone)
|
||||
main.Raw(`SELECT COUNT(*) FROM products WHERE COALESCE(imageid,'') = ''`).Scan(&noImageid)
|
||||
|
||||
fmt.Printf("products on the platform : %d\n", total)
|
||||
fmt.Printf(" never came from the catalogue : %d (no imageid — left alone)\n", noImageid)
|
||||
fmt.Printf(" facts already stored : %d (never overwritten)\n", alreadyDone)
|
||||
fmt.Printf(" to backfill : %d\n\n", len(candidates))
|
||||
|
||||
if len(candidates) == 0 {
|
||||
fmt.Println("nothing to do.")
|
||||
return
|
||||
}
|
||||
|
||||
// One column check per brand table, not per product: the shape is a
|
||||
// property of the table and a per-row check would be thousands of
|
||||
// information_schema reads to learn the same thing.
|
||||
columnsByTable := map[string][]string{}
|
||||
missingTable := map[string]bool{}
|
||||
|
||||
type update struct {
|
||||
product product
|
||||
facts string
|
||||
}
|
||||
var (
|
||||
updates []update
|
||||
retired []product
|
||||
unknown []product
|
||||
emptyOnly []product
|
||||
)
|
||||
|
||||
for _, p := range candidates {
|
||||
table := brandTable(p.Productbrand)
|
||||
if table == "" {
|
||||
unknown = append(unknown, p)
|
||||
continue
|
||||
}
|
||||
if missingTable[table] {
|
||||
retired = append(retired, p)
|
||||
continue
|
||||
}
|
||||
|
||||
cols, known := columnsByTable[table]
|
||||
if !known {
|
||||
cols = factColumnsOf(cat, table)
|
||||
if cols == nil {
|
||||
missingTable[table] = true
|
||||
retired = append(retired, p)
|
||||
continue
|
||||
}
|
||||
columnsByTable[table] = cols
|
||||
}
|
||||
|
||||
facts, found := factsFor(cat, table, cols, p.Imageid)
|
||||
if !found {
|
||||
retired = append(retired, p)
|
||||
continue
|
||||
}
|
||||
if len(facts) == 0 {
|
||||
// The row is there and had nothing in these columns. Worth writing
|
||||
// `{}` — it is the true answer and it stops the console asking the
|
||||
// catalogue again on every open.
|
||||
emptyOnly = append(emptyOnly, p)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(facts)
|
||||
if err != nil {
|
||||
log.Printf("could not encode facts for product %d: %v", p.Productid, err)
|
||||
continue
|
||||
}
|
||||
updates = append(updates, update{product: p, facts: string(encoded)})
|
||||
}
|
||||
|
||||
fmt.Printf("%-9s %-14s %-22s %-34s %s\n", "product", "brand", "imageid", "name", "facts recovered")
|
||||
for _, u := range updates {
|
||||
var keys []string
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal([]byte(u.facts), &got)
|
||||
for k := range got {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
summary := strings.Join(keys, ",")
|
||||
if summary == "" {
|
||||
summary = "(catalogue row has none)"
|
||||
}
|
||||
fmt.Printf("%-9d %-14s %-22s %-34s %s\n",
|
||||
u.product.Productid, trim(u.product.Productbrand, 14), trim(u.product.Imageid, 22),
|
||||
trim(u.product.Productname, 34), summary)
|
||||
}
|
||||
|
||||
if len(retired) > 0 {
|
||||
fmt.Printf("\n!! %d product(s) cannot be recovered — their catalogue row is gone:\n", len(retired))
|
||||
for _, p := range retired {
|
||||
fmt.Printf(" %-9d %-14s %-22s %s\n", p.Productid, trim(p.Productbrand, 14),
|
||||
trim(p.Imageid, 22), trim(p.Productname, 40))
|
||||
}
|
||||
fmt.Println(" These are left NULL. The console falls back to the live lookup for them,")
|
||||
fmt.Println(" which will also find nothing — the detail was lost before this ran.")
|
||||
}
|
||||
|
||||
if len(unknown) > 0 {
|
||||
fmt.Printf("\n!! %d product(s) carry a brand with no table in the catalogue:\n", len(unknown))
|
||||
for _, p := range unknown {
|
||||
fmt.Printf(" %-9d %-14s %s\n", p.Productid, trim(p.Productbrand, 14), trim(p.Productname, 40))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nwill write %d product(s)", len(updates))
|
||||
if len(emptyOnly) > 0 {
|
||||
fmt.Printf(", %d of them as `{}` because the catalogue row carries none of these fields", len(emptyOnly))
|
||||
}
|
||||
fmt.Printf("; leaving %d NULL\n", len(retired)+len(unknown))
|
||||
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
if !apply {
|
||||
fmt.Println("\ndry run — nothing written. re-run with `apply` to write.")
|
||||
return
|
||||
}
|
||||
|
||||
// One row at a time, each guarded by `cataloguefacts IS NULL` again.
|
||||
// Between the read above and this write another import could have stored
|
||||
// the real thing, and this must never be the one that overwrites it.
|
||||
written := 0
|
||||
ids := make([]int, 0, len(updates))
|
||||
for _, u := range updates {
|
||||
res := main.Exec(`UPDATE products SET cataloguefacts = ?::jsonb
|
||||
WHERE productid = ? AND cataloguefacts IS NULL`,
|
||||
u.facts, u.product.Productid)
|
||||
if res.Error != nil {
|
||||
log.Printf("product %d: %v", u.product.Productid, res.Error)
|
||||
continue
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
written++
|
||||
ids = append(ids, u.product.Productid)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nwrote %d product(s)\n", written)
|
||||
|
||||
var stillNull int
|
||||
main.Raw(`SELECT COUNT(*) FROM products
|
||||
WHERE COALESCE(imageid,'') <> '' AND cataloguefacts IS NULL`).Scan(&stillNull)
|
||||
fmt.Printf("catalogue-linked products still without facts: %d\n", stillNull)
|
||||
|
||||
if len(ids) > 0 {
|
||||
fmt.Printf("\nundo:\n UPDATE products SET cataloguefacts = NULL WHERE productid IN (%s);\n",
|
||||
joinInts(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func open(hostKey, portKey, userKey, passKey, nameKey string) (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv(hostKey), os.Getenv(portKey), os.Getenv(userKey),
|
||||
os.Getenv(passKey), os.Getenv(nameKey))
|
||||
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
}
|
||||
|
||||
// brandTable mirrors the repository's rule: a brand IS a `brand_<name>` table.
|
||||
//
|
||||
// Lowercased and stripped of anything that is not a letter, digit or
|
||||
// underscore. The table name cannot be parameterized in SQL, so this is the
|
||||
// one place it is built and it refuses to build anything else.
|
||||
func brandTable(brand string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r + 32
|
||||
}
|
||||
return -1
|
||||
}, strings.TrimSpace(brand))
|
||||
|
||||
if cleaned == "" {
|
||||
return ""
|
||||
}
|
||||
return "brand_" + cleaned
|
||||
}
|
||||
|
||||
// factColumnsOf returns which of the fact columns this brand table actually
|
||||
// has, or nil when the table is not there at all.
|
||||
func factColumnsOf(db *gorm.DB, table string) []string {
|
||||
var have []string
|
||||
db.Raw(`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ?`, table).Scan(&have)
|
||||
if len(have) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
present := map[string]bool{}
|
||||
for _, c := range have {
|
||||
present[c] = true
|
||||
}
|
||||
// image_id is how a product is found at all. Without it the table cannot
|
||||
// answer the question, whatever else it holds.
|
||||
if !present["image_id"] {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keep []string
|
||||
for _, c := range append(append([]string{}, scalarFacts...), arrayFacts...) {
|
||||
if present[c] {
|
||||
keep = append(keep, c)
|
||||
}
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
// factsFor reads one catalogue row and returns only what it actually stated.
|
||||
//
|
||||
// An empty field is omitted rather than stored as "" or [], so a reader can
|
||||
// tell "the catalogue did not say" from "the catalogue said none" — the drawer
|
||||
// prints a row per fact and an empty string would print an empty row.
|
||||
func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[string]any, bool) {
|
||||
if len(cols) == 0 {
|
||||
return map[string]any{}, true
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(cols))
|
||||
for _, c := range cols {
|
||||
if isArrayFact(c) {
|
||||
selects = append(selects, c+"::text AS "+c)
|
||||
continue
|
||||
}
|
||||
selects = append(selects, c)
|
||||
}
|
||||
|
||||
row := map[string]any{}
|
||||
res := db.Raw(`SELECT `+strings.Join(selects, ", ")+` FROM `+table+
|
||||
` WHERE image_id = ? LIMIT 1`, imageID).Scan(&row)
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
facts := map[string]any{}
|
||||
for _, c := range cols {
|
||||
raw, ok := row[c]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", raw))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if isArrayFact(c) {
|
||||
values := models.ParsePGArray(text)
|
||||
kept := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
facts[c] = kept
|
||||
}
|
||||
continue
|
||||
}
|
||||
facts[c] = text
|
||||
}
|
||||
return facts, true
|
||||
}
|
||||
|
||||
func isArrayFact(name string) bool {
|
||||
for _, c := range arrayFacts {
|
||||
if c == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trim(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 1 {
|
||||
return s[:n]
|
||||
}
|
||||
return s[:n-1] + "…"
|
||||
}
|
||||
|
||||
func joinInts(ids []int) string {
|
||||
parts := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
parts[i] = fmt.Sprint(id)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
76
scratch/nutritionlive/main.go
Normal file
76
scratch/nutritionlive/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Shows the exact `getproductbyvariant` response once NUTRITION_BASE is set.
|
||||
//
|
||||
// Takes the LIVE Fiesta response for a product, runs the same decoration the
|
||||
// endpoint runs, and prints the result. Nothing here is hand-assembled: the
|
||||
// product row is production's, the panel and score are the live catalogue-
|
||||
// intelligence service's, and the code between them is what is deployed.
|
||||
//
|
||||
// go run ./scratch/nutritionlive # product 7101
|
||||
// go run ./scratch/nutritionlive <tenantid> <productid>
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
)
|
||||
|
||||
const fiesta = "https://fiesta.nearle.app/live/api/v1/mob/products/getproductbyvariant"
|
||||
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Details []models.Products `json:"details"`
|
||||
Message string `json:"message"`
|
||||
Status bool `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
tenant, product := "1147", "7101"
|
||||
if len(os.Args) == 3 {
|
||||
tenant, product = os.Args[1], os.Args[2]
|
||||
}
|
||||
|
||||
base := os.Getenv("NUTRITION_BASE")
|
||||
if base == "" {
|
||||
base = "https://mcp.nearle.ai.in/api"
|
||||
}
|
||||
nutrition := services.NewNutritionService(base)
|
||||
|
||||
url := fmt.Sprintf("%s?tenantid=%s&productid=%s&variantid=0", fiesta, tenant, product)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
response, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Println("fetching the live product:", err)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
fmt.Println("reading the live product:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var out envelope
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
fmt.Println("parsing the live product:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The same call the endpoint makes, on the same rows.
|
||||
for i := range out.Details {
|
||||
found := nutrition.ForProduct(out.Details[i].Productbrand, out.Details[i].Imageid)
|
||||
out.Details[i].Nutrition = found.Panel
|
||||
out.Details[i].Healthscore = found.Health
|
||||
}
|
||||
|
||||
encoded, _ := json.MarshalIndent(out, "", " ")
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
59
scratch/nutritionproof/main.go
Normal file
59
scratch/nutritionproof/main.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Proves the nutrition panel and health score end to end against the LIVE
|
||||
// catalogue-intelligence service — no database, no Fiesta, just the piece that
|
||||
// was built.
|
||||
//
|
||||
// go run ./scratch/nutritionproof # known products
|
||||
// go run ./scratch/nutritionproof <brand> <image_id> # any product
|
||||
//
|
||||
// Prints what `getproductbyvariant` will put on the product once deployed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
base := os.Getenv("NUTRITION_BASE")
|
||||
if base == "" {
|
||||
base = "https://mcp.nearle.ai.in/api"
|
||||
}
|
||||
service := services.NewNutritionService(base)
|
||||
|
||||
// Real products from tenant 1147, checked against the live service on
|
||||
// 29 Sep 2026: one the service has scored, one it has nothing for, and one
|
||||
// that is not food — which must come back with no score whatever the
|
||||
// service says, because that endpoint is not gated for edibility.
|
||||
products := []models.Products{
|
||||
{Productid: 7101, Productbrand: "balaji", Imageid: "balaji_balaji_wafers_simply_salted_135g",
|
||||
Productname: "Balaji Wafers Simply Salted 135g (scored)"},
|
||||
{Productid: 7093, Productbrand: "patanjali", Imageid: "patanjali_patanjali_cow_ghee_500ml",
|
||||
Productname: "Patanjali Cow Ghee 500ml (no data)"},
|
||||
{Productid: 7121, Productbrand: "godrej", Imageid: "godrej_godrej_no1_sandal_and_turmeric_soap_100g",
|
||||
Productname: "Godrej No.1 Soap 100g (not food)"},
|
||||
}
|
||||
if len(os.Args) == 3 {
|
||||
products = []models.Products{{
|
||||
Productbrand: os.Args[1],
|
||||
Imageid: os.Args[2],
|
||||
Productname: os.Args[1] + "/" + os.Args[2],
|
||||
}}
|
||||
}
|
||||
|
||||
for i := range products {
|
||||
found := service.ForProduct(products[i].Productbrand, products[i].Imageid)
|
||||
products[i].Nutrition = found.Panel
|
||||
products[i].Healthscore = found.Health
|
||||
|
||||
fmt.Printf("\n---- %s ----\n", products[i].Productname)
|
||||
encoded, _ := json.MarshalIndent(map[string]any{
|
||||
"nutrition": products[i].Nutrition,
|
||||
"healthscore": products[i].Healthscore,
|
||||
}, "", " ")
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
}
|
||||
267
scratch/poslivecheck/main.go
Normal file
267
scratch/poslivecheck/main.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Does the mobile-number-and-PIN sign-in actually work against the live data?
|
||||
//
|
||||
// Picks a supervisor and a cashier that already have both halves of the
|
||||
// credential, prints them so they can be typed into a terminal, then runs the
|
||||
// real repository and service — the same code path the HTTP handler calls — and
|
||||
// reports what came back.
|
||||
//
|
||||
// Read-only. PosLogin issues SELECTs and mints a token in memory; nothing here
|
||||
// writes, and the token is not persisted anywhere by design.
|
||||
//
|
||||
// Numbers and PINs are masked unless -show is passed. They belong to real
|
||||
// people at real shops, and the default should not be to print them into
|
||||
// whatever is capturing this program's output.
|
||||
//
|
||||
// go run ./scratch/poslivecheck # picks a ready pair, masked
|
||||
// go run ./scratch/poslivecheck -show # prints the credentials
|
||||
// go run ./scratch/poslivecheck -show 1087 1137 # ...at a named outlet
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"nearle/models"
|
||||
"nearle/repositories"
|
||||
"nearle/services"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type account struct {
|
||||
Userid int
|
||||
Tenantid int
|
||||
Locationid int
|
||||
Roleid int
|
||||
Fullname string
|
||||
Contactno string
|
||||
Pin int64
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
if strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) == "" {
|
||||
log.Fatal("POS_TOKEN_SECRET is not set; sign-in mints a token and will fail without it")
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Only accounts holding both halves are candidates. An account missing
|
||||
// either cannot sign in at all, and picking one would prove nothing except
|
||||
// that the rejection works.
|
||||
where := `COALESCE(roleid,0) = ?
|
||||
AND COALESCE(pin,0) BETWEEN 1000 AND 9999
|
||||
AND TRIM(COALESCE(contactno,'')) <> ''
|
||||
AND LOWER(COALESCE(status,'active')) <> 'inactive'`
|
||||
args := []interface{}{}
|
||||
|
||||
// -show opts into printing the credentials themselves.
|
||||
show := false
|
||||
rest := []string{}
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "-show" || arg == "--show" {
|
||||
show = true
|
||||
continue
|
||||
}
|
||||
rest = append(rest, arg)
|
||||
}
|
||||
|
||||
if len(rest) > 1 {
|
||||
tenantID, _ := strconv.Atoi(rest[0])
|
||||
locationID, _ := strconv.Atoi(rest[1])
|
||||
where += ` AND tenantid = ? AND locationid = ?`
|
||||
args = append(args, tenantID, locationID)
|
||||
}
|
||||
|
||||
pick := func(roleID int) *account {
|
||||
var a account
|
||||
params := append([]interface{}{roleID}, args...)
|
||||
err := db.Raw(`
|
||||
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
||||
COALESCE(roleid,0) AS roleid,
|
||||
TRIM(CONCAT(COALESCE(firstname,''),' ',COALESCE(lastname,''))) AS fullname,
|
||||
COALESCE(contactno,'') AS contactno, COALESCE(pin,0) AS pin
|
||||
FROM app_users WHERE `+where+` ORDER BY userid LIMIT 1`, params...).Scan(&a).Error
|
||||
if err != nil || a.Userid == 0 {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
supervisor := pick(models.PosRoleSupervisor)
|
||||
cashier := pick(models.PosRoleCashier)
|
||||
|
||||
fmt.Println("Accounts that can sign in today")
|
||||
fmt.Println(strings.Repeat("-", 78))
|
||||
for _, pair := range []struct {
|
||||
label string
|
||||
a *account
|
||||
}{{"supervisor", supervisor}, {"cashier", cashier}} {
|
||||
a := pair.a
|
||||
if a == nil {
|
||||
fmt.Printf(" %-11s none — no account of this role has both a number and a PIN\n", pair.label)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %-11s userid %-6d tenant %-6d outlet %-6d %s\n",
|
||||
pair.label, a.Userid, a.Tenantid, a.Locationid, a.Fullname)
|
||||
fmt.Printf(" %-11s mobile %s PIN %s\n\n", "",
|
||||
mask(a.Contactno, show), maskPin(a.Pin, show))
|
||||
}
|
||||
if !show {
|
||||
fmt.Println(" (masked — re-run with -show to print them)")
|
||||
}
|
||||
|
||||
if supervisor == nil && cashier == nil {
|
||||
log.Fatal("nothing to test with")
|
||||
}
|
||||
|
||||
repo := repositories.NewPosRepository(db)
|
||||
svc := services.NewPosService(repo, nil)
|
||||
|
||||
fmt.Println("\nSigning in (real service, live data)")
|
||||
fmt.Println(strings.Repeat("-", 78))
|
||||
|
||||
pass, fail := 0, 0
|
||||
check := func(name string, ok bool, detail string) {
|
||||
if ok {
|
||||
pass++
|
||||
fmt.Printf(" PASS %-46s %s\n", name, detail)
|
||||
return
|
||||
}
|
||||
fail++
|
||||
fmt.Printf(" FAIL %-46s %s\n", name, detail)
|
||||
}
|
||||
|
||||
signIn := func(label string, a *account) *models.PosSession {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
session, err := svc.Login(models.PosLoginRequest{
|
||||
Contactno: a.Contactno,
|
||||
Pin: strconv.FormatInt(a.Pin, 10),
|
||||
})
|
||||
if err != nil {
|
||||
check(label+" signs in", false, err.Error())
|
||||
return nil
|
||||
}
|
||||
check(label+" signs in", true, fmt.Sprintf(
|
||||
"%s at %s (outlet %d), can_manage_staff=%v",
|
||||
session.Role, session.Locationname, session.Locationid, session.Canmanagestaff))
|
||||
check(label+" gets a token", session.Token != "",
|
||||
fmt.Sprintf("%d chars, expires %s", len(session.Token), session.Expiresat))
|
||||
return session
|
||||
}
|
||||
|
||||
supSession := signIn("supervisor", supervisor)
|
||||
cashSession := signIn("cashier", cashier)
|
||||
|
||||
if supSession != nil {
|
||||
check("supervisor can manage staff", supSession.Canmanagestaff, "role 7 grants it")
|
||||
}
|
||||
if cashSession != nil {
|
||||
check("cashier cannot manage staff", !cashSession.Canmanagestaff, "role 8 does not")
|
||||
}
|
||||
|
||||
// The response must not carry anyone's PIN — the whole point of the change
|
||||
// that removed staff[].pin.
|
||||
//
|
||||
// Checked against the serialised JSON, not the Go struct. PosStaffMember.Pin
|
||||
// is still *populated* — the query needs it to drop two people sharing a PIN
|
||||
// — and is kept off the wire by `json:"-"`. Asserting on the struct field
|
||||
// tests the wrong layer and fails a correct implementation.
|
||||
if supSession != nil {
|
||||
encoded, merr := json.Marshal(supSession)
|
||||
check("session serialises", merr == nil, errText(merr))
|
||||
if merr == nil {
|
||||
check("no PIN travels in the session", !strings.Contains(string(encoded), `"pin"`),
|
||||
fmt.Sprintf("%d staff listed, %d bytes of JSON", len(supSession.Staff), len(encoded)))
|
||||
}
|
||||
}
|
||||
|
||||
// A number typed the way a person actually types it.
|
||||
if supervisor != nil && len(supervisor.Contactno) == 10 {
|
||||
for label, typed := range map[string]string{
|
||||
"+91 with spaces": "+91 " + supervisor.Contactno[:5] + " " + supervisor.Contactno[5:],
|
||||
"leading zero": "0" + supervisor.Contactno,
|
||||
"bare ten digits": supervisor.Contactno,
|
||||
} {
|
||||
_, err := svc.Login(models.PosLoginRequest{
|
||||
Contactno: typed, Pin: strconv.FormatInt(supervisor.Pin, 10),
|
||||
})
|
||||
check("number accepted as typed", err == nil, label)
|
||||
}
|
||||
}
|
||||
|
||||
// And the refusals.
|
||||
if supervisor != nil {
|
||||
wrong := supervisor.Pin + 1
|
||||
if wrong > 9999 {
|
||||
wrong = 1000
|
||||
}
|
||||
_, err := svc.Login(models.PosLoginRequest{
|
||||
Contactno: supervisor.Contactno, Pin: strconv.FormatInt(wrong, 10),
|
||||
})
|
||||
check("a wrong PIN is refused", err != nil, errText(err))
|
||||
}
|
||||
|
||||
_, err = svc.Login(models.PosLoginRequest{Contactno: "9999999999", Pin: "4821"})
|
||||
check("an unknown number is refused", err != nil, errText(err))
|
||||
|
||||
// Switching operator at an open terminal, which is what the till does when
|
||||
// a colleague takes over.
|
||||
if supSession != nil && cashier != nil && cashier.Locationid == supSession.Locationid {
|
||||
switched, err := repo.PosLoginByPin(supSession.Tenantid, supSession.Locationid,
|
||||
strconv.FormatInt(cashier.Pin, 10))
|
||||
if err != nil {
|
||||
check("operator switch by PIN", false, err.Error())
|
||||
} else {
|
||||
check("operator switch by PIN", true,
|
||||
fmt.Sprintf("now %s, can_manage_staff=%v", switched.Role, switched.Canmanagestaff))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%d passed, %d failed\n", pass, fail)
|
||||
if fail > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// mask shows enough of a number to recognise the account, not enough to sign in
|
||||
// as it.
|
||||
func mask(number string, show bool) string {
|
||||
if show {
|
||||
return number
|
||||
}
|
||||
if len(number) != 10 {
|
||||
return strings.Repeat("*", len(number))
|
||||
}
|
||||
return number[:2] + "******" + number[8:]
|
||||
}
|
||||
|
||||
func maskPin(pin int64, show bool) string {
|
||||
if show {
|
||||
return strconv.FormatInt(pin, 10)
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return "no error"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
166
scratch/posloginready/main.go
Normal file
166
scratch/posloginready/main.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Can the accounts that may open a till actually complete the new sign-in?
|
||||
//
|
||||
// Read-only, and deliberately prints no numbers and no PINs — only whether each
|
||||
// account has one and whether the login would find it.
|
||||
//
|
||||
// The question this answers is narrower than "does it have a number". The login
|
||||
// matches `LOWER(TRIM(contactno)) = LOWER(TRIM($1))` where $1 has already been
|
||||
// reduced to ten digits, so the comparison is exact: a row holding
|
||||
// "+91 98765 43210" is invisible to somebody typing the same number, because
|
||||
// only one side of the comparison gets normalised.
|
||||
//
|
||||
// go run ./scratch/posloginready
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"nearle/models"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
Userid int
|
||||
Tenantid int
|
||||
Locationid int
|
||||
Roleid int
|
||||
Contactno string
|
||||
Pin int64
|
||||
Status string
|
||||
}
|
||||
|
||||
// normalise mirrors repositories.normalisePosPhone, which is unexported.
|
||||
func normalise(raw string) string {
|
||||
digits := strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, raw)
|
||||
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
|
||||
digits = digits[2:]
|
||||
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
|
||||
digits = digits[1:]
|
||||
}
|
||||
if len(digits) != 10 {
|
||||
return ""
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var rows []row
|
||||
if err := db.Raw(`
|
||||
SELECT userid, COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
|
||||
COALESCE(roleid,0) AS roleid, COALESCE(contactno,'') AS contactno,
|
||||
COALESCE(pin,0) AS pin, COALESCE(status,'') AS status
|
||||
FROM app_users
|
||||
WHERE COALESCE(roleid,0) IN (?, ?)
|
||||
ORDER BY tenantid, locationid, userid`,
|
||||
models.PosRoleSupervisor, models.PosRoleCashier).Scan(&rows).Error; err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// What the login would see. Only active till accounts are candidates, and a
|
||||
// number matching more than one of them is refused outright.
|
||||
byPhone := map[string][]int{}
|
||||
for _, r := range rows {
|
||||
if strings.EqualFold(strings.TrimSpace(r.Status), "inactive") {
|
||||
continue
|
||||
}
|
||||
if stored := strings.TrimSpace(r.Contactno); stored != "" {
|
||||
byPhone[strings.ToLower(stored)] = append(byPhone[strings.ToLower(stored)], r.Userid)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("till accounts (roleid %d/%d): %d\n\n", models.PosRoleSupervisor, models.PosRoleCashier, len(rows))
|
||||
fmt.Printf("%-8s %-8s %-9s %-5s %-9s %-8s %-7s %s\n",
|
||||
"userid", "tenant", "location", "role", "status", "number", "pin", "can sign in?")
|
||||
fmt.Println(strings.Repeat("-", 86))
|
||||
|
||||
var ready, noPhone, unnormalised, noPin, ambiguous, inactive int
|
||||
|
||||
for _, r := range rows {
|
||||
stored := strings.TrimSpace(r.Contactno)
|
||||
norm := normalise(stored)
|
||||
|
||||
phoneState := "missing"
|
||||
switch {
|
||||
case stored == "":
|
||||
phoneState = "missing"
|
||||
case norm == "":
|
||||
phoneState = "unusable"
|
||||
case norm != stored:
|
||||
phoneState = "STORED RAW"
|
||||
default:
|
||||
phoneState = "ok"
|
||||
}
|
||||
|
||||
pinState := "missing"
|
||||
if r.Pin >= 1000 && r.Pin <= 9999 {
|
||||
pinState = "ok"
|
||||
} else if r.Pin != 0 {
|
||||
pinState = "unusable"
|
||||
}
|
||||
|
||||
verdict := "yes"
|
||||
switch {
|
||||
case strings.EqualFold(r.Status, "inactive"):
|
||||
verdict, inactive = "no — inactive", inactive+1
|
||||
case stored == "":
|
||||
verdict, noPhone = "no — no number", noPhone+1
|
||||
case norm == "":
|
||||
verdict, noPhone = "no — number unusable", noPhone+1
|
||||
case norm != stored:
|
||||
// The one that looks fine in the console and fails at the counter.
|
||||
verdict, unnormalised = "NO — number stored unnormalised", unnormalised+1
|
||||
case pinState != "ok":
|
||||
verdict, noPin = "no — no usable PIN", noPin+1
|
||||
case len(byPhone[strings.ToLower(stored)]) > 1:
|
||||
verdict, ambiguous = "NO — number shared with another till account", ambiguous+1
|
||||
default:
|
||||
ready++
|
||||
}
|
||||
|
||||
fmt.Printf("%-8d %-8d %-9d %-5d %-9s %-8s %-7s %s\n",
|
||||
r.Userid, r.Tenantid, r.Locationid, r.Roleid, r.Status, phoneState, pinState, verdict)
|
||||
}
|
||||
|
||||
fmt.Printf("\nready to sign in with number + PIN : %d of %d\n", ready, len(rows))
|
||||
fmt.Printf(" blocked, no/unusable number : %d\n", noPhone)
|
||||
fmt.Printf(" blocked, number stored raw : %d\n", unnormalised)
|
||||
fmt.Printf(" blocked, no usable PIN : %d\n", noPin)
|
||||
fmt.Printf(" blocked, number not unique : %d\n", ambiguous)
|
||||
fmt.Printf(" inactive : %d\n", inactive)
|
||||
|
||||
// Cross-tenant collisions are the failure creation cannot prevent:
|
||||
// posPhoneTaken scopes uniqueness to one tenant, the login does not scope at
|
||||
// all, so two tenants may each hold a number that neither can then use.
|
||||
fmt.Println("\nnumbers shared by more than one active till account:")
|
||||
found := false
|
||||
for _, users := range byPhone {
|
||||
if len(users) > 1 {
|
||||
found = true
|
||||
fmt.Printf(" userids %v — all refused with \"more than one account uses these sign-in details\"\n", users)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Println(" none")
|
||||
}
|
||||
}
|
||||
@@ -137,15 +137,11 @@ func main() {
|
||||
}
|
||||
|
||||
fmt.Println("\n3. a till account cannot reach the Nearle Daily application")
|
||||
uid, _, _ := users.GetUserByAuthname(sup.Authname, sup.Configid)
|
||||
check("applogin lookup does not find the supervisor",
|
||||
uid == 0,
|
||||
fmt.Sprintf("GetUserByAuthname(%s) -> userid %d", sup.Authname, uid))
|
||||
|
||||
uid2, _, _, _ := users.GetUserLogin("authname", sup.Authname, sup.Configid)
|
||||
check("tenant web login does not find the supervisor",
|
||||
uid2 == 0,
|
||||
fmt.Sprintf("GetUserLogin(%s) -> userid %d", sup.Authname, uid2))
|
||||
// Both the app and the console sign-in now go through GetUserLogin.
|
||||
uid2, _, _, _, lookupErr := users.GetUserLogin("authname", sup.Authname, sup.Configid)
|
||||
check("app and tenant web login do not find the supervisor",
|
||||
uid2 == 0 && lookupErr == nil,
|
||||
fmt.Sprintf("GetUserLogin(%s) -> userid %d err=%v", sup.Authname, uid2, lookupErr))
|
||||
|
||||
uid3, _ := users.FindUserID(sup.Authname, "", sup.Configid)
|
||||
check("password-setup lookup does not find the supervisor",
|
||||
|
||||
@@ -27,9 +27,8 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"nearle/config"
|
||||
"nearle/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
@@ -44,8 +43,7 @@ func main() {
|
||||
tenant := flag.Int("tenant", 0, "restrict to one tenant")
|
||||
flag.Parse()
|
||||
|
||||
_ = godotenv.Load()
|
||||
db.Connect()
|
||||
db.Connect(config.MustLoad())
|
||||
|
||||
if db.ImageStore == nil {
|
||||
log.Fatal("the image store is not configured (USE_S3 / S3_*), so there is nothing to list images from")
|
||||
|
||||
229
services/agents.go
Normal file
229
services/agents.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"nearle/utils"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Agents, as configuration.
|
||||
//
|
||||
// An agent is a document, not a class: a name, a tier, some words about its job,
|
||||
// and a list of tools it may use. Adding one is a file. Changing what an agent
|
||||
// can reach is an edit, not a deploy of new code — and crucially, nothing about
|
||||
// the loop changes when either happens, so five agents cannot drift into five
|
||||
// slightly different behaviours.
|
||||
//
|
||||
// ── Embedded, with a disk override ──────────────────────────────────────────
|
||||
//
|
||||
// The defaults are compiled in, so a binary always has working agents and a
|
||||
// deployment cannot be broken by a missing directory. `ASSISTANT_AGENTS_DIR`
|
||||
// replaces them wholesale — not merges, which would leave a deployment guessing
|
||||
// which half of an agent it was running.
|
||||
//
|
||||
// ── Why the tool names are checked at startup ───────────────────────────────
|
||||
//
|
||||
// A typo in a tool name is invisible at runtime: the agent simply never calls
|
||||
// that tool, the model explains it cannot look something up, and everything
|
||||
// reports healthy. Refusing to start is the loud version of the same fact.
|
||||
|
||||
//go:embed agents/*.yaml
|
||||
var embeddedAgents embed.FS
|
||||
|
||||
// basePrompt is the part every agent shares.
|
||||
//
|
||||
// In Go rather than repeated in each file, because it is about how this system
|
||||
// works rather than about any one domain — and three copies of "say when a
|
||||
// result was truncated" is three chances for one of them to lose it.
|
||||
//
|
||||
// Each agent's own `system` is appended, and says what that agent is for.
|
||||
const basePrompt = `You are Nearle Buddy, helping a shopkeeper run their business from the Nearle console.
|
||||
|
||||
Answer from tool results and nothing else. Every number you state must have come
|
||||
from a tool in this conversation. If no tool can answer, say so plainly and say
|
||||
what you would need — never estimate, and never fill a gap from general knowledge
|
||||
about retail.
|
||||
|
||||
When a tool returns no rows, that is an answer: say there are none. Do not
|
||||
describe an empty result as a problem with the system.
|
||||
|
||||
When a result says it was truncated, say so in your reply. Do not describe a
|
||||
capped list as the full picture.
|
||||
|
||||
When a tool refuses, tell the person what it said. A refusal usually names
|
||||
something they can do — pick a branch, for instance.
|
||||
|
||||
Say what your answer covers — one branch or all of them — using the scope the
|
||||
tool reports.
|
||||
|
||||
Be brief. A shopkeeper reading this is mid-shift: lead with the answer, then the
|
||||
detail that makes it actionable. No preamble, no restating the question.`
|
||||
|
||||
// agentFile is one agent on disk.
|
||||
type agentFile struct {
|
||||
Name string `yaml:"name"`
|
||||
Tier string `yaml:"tier"`
|
||||
System string `yaml:"system"`
|
||||
Tools []string `yaml:"tools"`
|
||||
MaxSteps int `yaml:"max_steps"`
|
||||
MaxToolCalls int `yaml:"max_tool_calls"`
|
||||
}
|
||||
|
||||
// Sensible bounds for an agent that does not name its own.
|
||||
const (
|
||||
defaultMaxSteps = 4
|
||||
defaultMaxToolCalls = 6
|
||||
)
|
||||
|
||||
// LoadAgents reads the agent definitions.
|
||||
//
|
||||
// `dir` empty uses the embedded defaults. `known` reports whether a tool name
|
||||
// exists — passed in rather than imported, so this does not depend on the
|
||||
// registry and can be tested without one.
|
||||
func LoadAgents(dir string, known func(string) bool) (map[string]Agent, error) {
|
||||
files, err := readAgentFiles(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("no agent definitions found in %q", dir)
|
||||
}
|
||||
|
||||
agents := make(map[string]Agent, len(files))
|
||||
for _, file := range files {
|
||||
agent, err := file.build(known)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, taken := agents[agent.Name]; taken {
|
||||
// Two files claiming one name means one of them is being ignored,
|
||||
// and which one depends on directory order.
|
||||
return nil, fmt.Errorf("two agents are called %q", agent.Name)
|
||||
}
|
||||
agents[agent.Name] = agent
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
func readAgentFiles(dir string) ([]agentFile, error) {
|
||||
var (
|
||||
entries []fs.DirEntry
|
||||
read func(string) ([]byte, error)
|
||||
err error
|
||||
where string
|
||||
)
|
||||
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
where = "agents"
|
||||
entries, err = embeddedAgents.ReadDir(where)
|
||||
read = func(name string) ([]byte, error) { return embeddedAgents.ReadFile(path.Join(where, name)) }
|
||||
} else {
|
||||
where = dir
|
||||
entries, err = os.ReadDir(where)
|
||||
read = func(name string) ([]byte, error) { return os.ReadFile(path.Join(where, name)) }
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading agent definitions from %q: %w", where, err)
|
||||
}
|
||||
|
||||
// Sorted, so a duplicate name is reported against the same file every time
|
||||
// rather than whichever the filesystem happened to hand back first.
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
files := make([]agentFile, 0, len(names))
|
||||
for _, name := range names {
|
||||
raw, err := read(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", name, err)
|
||||
}
|
||||
var file agentFile
|
||||
if err := yaml.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("%s is not valid YAML: %w", name, err)
|
||||
}
|
||||
if file.Name == "" {
|
||||
// Named by its contents, not its filename: a file renamed on deploy
|
||||
// would otherwise silently become a different agent.
|
||||
return nil, fmt.Errorf("%s does not name its agent", name)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// build turns a file into an agent, refusing anything that would fail quietly.
|
||||
func (f agentFile) build(known func(string) bool) (Agent, error) {
|
||||
if len(f.Tools) == 0 {
|
||||
// An agent with no tools can only answer from the prompt, which is the
|
||||
// one thing this assistant is built not to do.
|
||||
return Agent{}, fmt.Errorf("agent %q has no tools", f.Name)
|
||||
}
|
||||
|
||||
for _, tool := range f.Tools {
|
||||
if known != nil && !known(tool) {
|
||||
return Agent{}, fmt.Errorf("agent %q lists a tool that does not exist: %q", f.Name, tool)
|
||||
}
|
||||
}
|
||||
|
||||
tier := strings.ToLower(strings.TrimSpace(f.Tier))
|
||||
switch tier {
|
||||
case utils.TierFast, utils.TierBalanced, utils.TierDeep:
|
||||
case "":
|
||||
tier = utils.TierBalanced
|
||||
default:
|
||||
// A tier nobody recognises would silently resolve to the balanced model
|
||||
// via the gateway's fallback, so a deep agent could quietly run on the
|
||||
// cheap one for months.
|
||||
return Agent{}, fmt.Errorf("agent %q names an unknown tier %q", f.Name, f.Tier)
|
||||
}
|
||||
|
||||
steps := f.MaxSteps
|
||||
if steps <= 0 {
|
||||
steps = defaultMaxSteps
|
||||
}
|
||||
calls := f.MaxToolCalls
|
||||
if calls <= 0 {
|
||||
calls = defaultMaxToolCalls
|
||||
}
|
||||
|
||||
system := basePrompt
|
||||
if extra := strings.TrimSpace(f.System); extra != "" {
|
||||
system += "\n\n" + extra
|
||||
}
|
||||
|
||||
return Agent{
|
||||
Name: f.Name,
|
||||
Tier: tier,
|
||||
System: system,
|
||||
Tools: f.Tools,
|
||||
MaxSteps: steps,
|
||||
MaxToolCalls: calls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AgentNames lists what is loaded, for a startup log.
|
||||
//
|
||||
// Sorted, so two deployments running the same config log the same line and a
|
||||
// diff between them means something.
|
||||
func AgentNames(agents map[string]Agent) []string {
|
||||
names := make([]string, 0, len(agents))
|
||||
for name := range agents {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
32
services/agents/console.yaml
Normal file
32
services/agents/console.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# The Console overview.
|
||||
#
|
||||
# Deliberately the widest allow-list here, because the page it serves is a
|
||||
# dashboard: "what needs attention across my business today?" legitimately
|
||||
# spans orders, stock and tills, and an agent narrower than the page would
|
||||
# leave chips on screen that answer "I cannot look that up".
|
||||
#
|
||||
# It does NOT get every tool. `stuck_orders` and `sales_by_channel` belong
|
||||
# to the Sales page, where somebody is already looking at orders — a
|
||||
# dashboard question does not need the individual jobs, it needs the count.
|
||||
name: console
|
||||
tier: balanced
|
||||
|
||||
system: |
|
||||
You answer overview questions about a whole business — every branch, the
|
||||
shelves, and the tills.
|
||||
|
||||
Lead with what needs a person today and leave out what is running normally.
|
||||
This is the first thing somebody reads in the morning, so an answer listing
|
||||
four healthy things and one problem has buried the only part that matters.
|
||||
|
||||
When several things need attention, order them by what costs money soonest:
|
||||
a till that cannot sell, then stock that has run out, then approvals waiting,
|
||||
then deliveries that have stalled.
|
||||
|
||||
tools:
|
||||
- branch_performance
|
||||
- delivery_progress
|
||||
- low_stock
|
||||
- pending_approvals
|
||||
- till_status
|
||||
- help
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user