This commit is contained in:
2026-09-02 10:49:46 +05:30
parent 1e3fe88e87
commit c0d39e550d
8 changed files with 463 additions and 25 deletions

59
.env.example Normal file
View File

@@ -0,0 +1,59 @@
# Fiesta configuration.
#
# 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.
#
# `godotenv.Load()` in main.go reads `.env` from the working directory, so
# `go run .` from this folder picks it up with no flags.
# ── 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.
APP_PORT=1122
ENV=development
# ── The main database (nearledb) ────────────────────────────────────────────
#
# ⚠️ POINTING THIS AT PRODUCTION MAKES LOCAL TESTING WRITE TO PRODUCTION.
#
# There is no "local mode" that protects you: `go run .` against the live host
# creates real tenants, real logins and real stock movements, and main.go runs
# schema migrations on boot. If the point of running locally is to try a change
# before it is deployed, a local Postgres with a dump restored into it is the
# only version that actually does that.
# These match docker-compose.local.yml, so `docker compose -f
# docker-compose.local.yml up -d` and `go run .` work together with no edits.
DB_HOST=localhost
DB_PORT=5433
DB_NAME=nearledb
DB_USER=nearle
DB_PASSWORD=localdev
# ── The catalogue database (pgvector) ───────────────────────────────────────
#
# A separate connection on purpose, so catalogue work never touches nearledb.
# Leave blank to start without it: catalogue endpoints then fail at query time
# rather than at boot, which is fine for testing anything else.
# 5434, not 5432: a developer machine usually has something on 5432 already,
# and a silent connection to the wrong database is worse than a refused one.
CATALOGUE_DB_HOST=localhost
CATALOGUE_DB_PORT=5434
CATALOGUE_DB_NAME=cataloguedb
CATALOGUE_DB_USER=nearle
CATALOGUE_DB_PASSWORD=localdev
# ── Redis — POS terminal presence, under a TTL ──────────────────────────────
#
# Optional. Losing the health board is an inconvenience; losing a sale is not,
# so the API runs without it.
REDIS_PORT=6379
REDIS_USER=
REDIS_DB=0
# ── Auth ────────────────────────────────────────────────────────────────────
JWT_SECRET_KEY=
USER_CONTEXT_KEY=

114
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,114 @@
# A local stack to develop Fiesta against, so a change can be tried before it is
# deployed.
#
# Named `.local` because it is NOT the deployment compose. Nothing here should
# ever run on a server: the passwords are literals, the ports are published to
# the host, and the whole point is that the data is disposable.
#
# docker compose -f docker-compose.local.yml up -d
#
# ── READ THIS FIRST ─────────────────────────────────────────────────────────
#
# An EMPTY database is not enough to boot Fiesta. `main.go` runs migrations on
# startup, and most of them assume tables that nothing in this repository
# creates — `ALTER TABLE products`, `ALTER TABLE productlocations`. AutoMigrate
# covers only stockrequests, the POS order tables and staffshifts. Against a
# blank database the first ALTER fails and `log.Fatal` stops the process.
#
# So load the schema before the first run:
#
# pg_dump --schema-only --no-owner --no-privileges \
# -h <live-host> -p 5433 -U <user> -d nearledb \
# > init/nearledb/01-schema.sql
#
# Anything in ./init/nearledb is applied, in filename order, the first time the
# volume is created. To reload after changing it, drop the volume:
#
# docker compose -f docker-compose.local.yml down -v
#
# ── Why bother ──────────────────────────────────────────────────────────────
#
# Because pointing `DB_HOST` at production is not local testing — it is
# production with a local UI. `go run .` there creates real tenants and real
# logins, and runs those same schema migrations against live data.
services:
# The main database. Port 5433 on the host, matching production's DB_PORT, so
# the only line that changes between the two is DB_HOST.
nearledb:
image: postgres:16-alpine
container_name: nearle-db-local
environment:
POSTGRES_DB: nearledb
POSTGRES_USER: nearle
POSTGRES_PASSWORD: localdev
# Asia/Kolkata to match the DSN Fiesta builds. Timestamps written here
# otherwise differ from production by five and a half hours, which is the
# sort of thing that looks like a bug in the code being tested.
TZ: Asia/Kolkata
PGTZ: Asia/Kolkata
ports:
- '5433:5432'
volumes:
- nearledb-data:/var/lib/postgresql/data
- ./init/nearledb:/docker-entrypoint-initdb.d:ro
healthcheck:
# `go run .` fails hard if the database is not up yet, so the compose
# waits for a real connection rather than for the container to exist.
test: ['CMD-SHELL', 'pg_isready -U nearle -d nearledb']
interval: 5s
timeout: 3s
retries: 20
# The catalogue database, kept separate exactly as it is in production — the
# comment on `db.CatalogueDB` is explicit that catalogue work must never touch
# nearledb, and one container per database is the cheapest way to keep that
# true locally too.
#
# The pgvector image rather than plain postgres: the live catalogue is a
# pgvector database. Nothing in Fiesta's Go code uses a vector column today —
# it reads the per-brand tables — but a schema dump from the real one will
# carry `CREATE EXTENSION vector`, and that fails on a stock image.
#
# 5434 on the host, not 5432: a developer machine usually already has
# something on 5432, and a silent connection to the wrong database is worse
# than a refused one.
cataloguedb:
image: pgvector/pgvector:pg16
container_name: nearle-catalogue-local
environment:
POSTGRES_DB: cataloguedb
POSTGRES_USER: nearle
POSTGRES_PASSWORD: localdev
TZ: Asia/Kolkata
PGTZ: Asia/Kolkata
ports:
- '5434:5432'
volumes:
- cataloguedb-data:/var/lib/postgresql/data
- ./init/cataloguedb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U nearle -d cataloguedb']
interval: 5s
timeout: 3s
retries: 20
# POS terminal presence, under a TTL.
#
# Optional in the same way it is optional in production: `db.InitRedis()`
# failing costs the till health board and nothing else, because losing a sale
# matters and losing a dashboard does not. Included because it is one line.
redis:
image: redis:7-alpine
container_name: nearle-redis-local
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
volumes:
nearledb-data:
cataloguedb-data:

10
init/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Schema and data dumps. A dump taken from production carries real customers,
# real orders and cleartext passwords — none of it belongs in this repository,
# and a local seed is regenerated in one command anyway.
*.sql
# ...except the synthetic fixture. It invents a merchant rather than copying
# one, so there is nothing in it to leak and everybody gets the same shop to
# develop against.
!02-seed.sql
*.dump

49
init/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Local database seed
Anything in `nearledb/` or `cataloguedb/` is applied by Postgres, in filename
order, **the first time the volume is created**. Editing a file later does
nothing on its own — drop the volume to re-apply:
```
docker compose -f ../docker-compose.local.yml down -v
```
## Why this is not optional
Fiesta runs migrations on boot, and most of them assume tables that nothing in
this repository creates:
```
ALTER TABLE products ADD COLUMN IF NOT EXISTS productimages
ALTER TABLE products ADD COLUMN IF NOT EXISTS imageid
ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat
```
`AutoMigrate` covers only `stockrequests`, the two POS order tables and
`staffshifts`. Against an empty database the first `ALTER` fails and
`log.Fatal` stops the process — so a schema is required before the first run.
## Getting the schema
Structure only, no data, no ownership:
```
pg_dump --schema-only --no-owner --no-privileges \
-h <live-host> -p 5433 -U <user> -d nearledb \
> nearledb/01-schema.sql
```
**Take the schema, not the data.** A dump with rows in it puts real customers,
real orders and real (cleartext) passwords on a laptop, and this directory is
inside a git repository. `.gitignore` excludes `*.sql` here for that reason.
## Getting something to test against
An empty schema boots but has no tenants, so there is nothing to sign in as.
Two options:
- **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.

164
init/nearledb/02-seed.sql Normal file
View File

@@ -0,0 +1,164 @@
-- A synthetic shop to develop against.
--
-- INVENTED DATA, and that is the point. Copying rows out of production to get a
-- working local console puts real customers, real orders and real (cleartext)
-- passwords on a laptop — so this file makes up a merchant instead, which means
-- it can be committed, shared, and reset without anybody thinking about what is
-- in it.
--
-- Everything uses ids from 9000 up, well clear of anything real, so a local
-- database that has also had production rows loaded into it will not collide.
--
-- What it gives you:
--
-- * a complete merchant (9001 Testmart) — profile filled in, so the setup
-- walkthrough shows it finished
-- * an INCOMPLETE merchant (9002 Halfmart) with `categoryid = 0` — the exact
-- shape that broke `gettenantinfo` and made the profile step impossible to
-- finish. Worth keeping as a permanent regression fixture.
-- * two branches, so the "All branches" tenant-wide read has something to
-- aggregate and cannot silently show one outlet
-- * one account per role, so every workspace can be signed into
-- * products in each of the three app-visibility states — on sale, no stock,
-- no price — because those three are what most console bugs turn out to be
--
-- Passwords are the literal string below. Fiesta compares passwords in clear,
-- which is a real problem in production and simply a fact here.
BEGIN;
-- ── Masters the tenant joins hang off ───────────────────────────────────────
INSERT INTO app_location (applocationid, locationname, latitude, longitude, radius)
VALUES (9001, 'Testville', '11.0168', '76.9558', 25000)
ON CONFLICT (applocationid) DO NOTHING;
INSERT INTO app_category (categoryid, categoryname)
VALUES (9001, 'Grocery')
ON CONFLICT (categoryid) DO NOTHING;
-- ── Merchant one: complete ──────────────────────────────────────────────────
INSERT INTO tenants (
tenantid, tenantname, companyname, configid, categoryid, applocationid,
primaryemail, primarycontact, address, suburb, city, state, postcode,
latitude, longitude, tenantimage, tenantinfo, licenseno, registrationno,
minorder, approved, status
) VALUES (
9001, 'Testmart', 'Testmart Retail', 1, 9001, 9001,
'owner@testmart.invalid', '9000000001', '1 Test Street', 'Testville', 'Coimbatore',
'Tamil Nadu', '641001', '11.0168', '76.9558',
'https://placehold.co/200x200?text=Testmart', 'Daily needs and fresh produce.',
'12345678901234', 'REG-TESTMART-1',
99, 1, 'Active'
) ON CONFLICT (tenantid) DO NOTHING;
-- ── Merchant two: the regression fixture ────────────────────────────────────
--
-- `categoryid = 0` matches no `app_category` row. Both master joins in
-- GetTenantByID were INNER, so this tenant came back as an all-zero record —
-- the console showed an empty profile form over a real business, and the setup
-- walkthrough's first step could never complete. Four of two hundred live
-- tenants are in this state. Keep it: it is the cheapest possible guard against
-- that join going back.
INSERT INTO tenants (
tenantid, tenantname, configid, categoryid, applocationid,
primaryemail, primarycontact, address, city, state, postcode, approved, status
) VALUES (
9002, 'Halfmart', 1, 0, 9001,
'owner@halfmart.invalid', '9000000002', '2 Test Street', 'Coimbatore',
'Tamil Nadu', '641002', 1, 'Active'
) ON CONFLICT (tenantid) DO NOTHING;
-- ── Two branches, so tenant-wide reads have something to aggregate ──────────
INSERT INTO tenantlocations (
locationid, tenantid, locationname, email, contactno, address, suburb, city,
state, postcode, latitude, longitude, opentime, closetime, applocationid,
deliveryradius, deliverymins, status
) VALUES
(9101, 9001, 'Testmart Main', 'main@testmart.invalid', '9000000011',
'1 Test Street', 'Testville', 'Coimbatore', 'Tamil Nadu', '641001',
'11.0168', '76.9558', '08:00', '22:00', 9001, 5000, 30, 'Active'),
(9102, 9001, 'Testmart North', 'north@testmart.invalid', '9000000012',
'9 North Road', 'Northville', 'Coimbatore', 'Tamil Nadu', '641004',
'11.0500', '76.9600', '09:00', '21:00', 9001, 5000, 30, 'Active'),
(9103, 9002, 'Halfmart Main', 'main@halfmart.invalid', '9000000021',
'2 Test Street', 'Testville', 'Coimbatore', 'Tamil Nadu', '641002',
'11.0170', '76.9560', '08:00', '22:00', 9001, 5000, 30, 'Active')
ON CONFLICT (locationid) DO NOTHING;
-- ── One account per role ────────────────────────────────────────────────────
--
-- `authname` and `configid` are both set on every row. Login is
-- `WHERE authname = ? AND configid = ?` and never looks at the email column, so
-- an account missing either is created, listed, and refused at the sign-in
-- screen. That was a real bug; these rows are what it looks like done right.
--
-- roleid decides the workspace: 1 and 3 reach Store Admin, everything else is a
-- branch user. 7 and 8 are till accounts and are excluded from every
-- back-office query by the backend itself.
INSERT INTO app_users (
userid, authname, firstname, lastname, email, dialcode, contactno,
configid, roleid, password, tenantid, locationid, applocationid,
status, issuperadmin
) VALUES
(9201, 'admin@testmart.invalid', 'Tessa', 'Admin', 'admin@testmart.invalid',
'+91', '9000000101', 1, 3, 'localdev', 9001, 9101, 9001, 'Active', false),
(9202, 'main@testmart.invalid', 'Mani', 'Manager', 'main@testmart.invalid',
'+91', '9000000102', 1, 4, 'localdev', 9001, 9101, 9001, 'Active', false),
-- Hired, not yet placed. `locationid = 0` is the state the people screen
-- exists to resolve, and it was invisible until GetStaffs stopped INNER
-- JOINing tenantlocations.
(9203, 'newhire@testmart.invalid', 'Nila', 'Newhire', 'newhire@testmart.invalid',
'+91', '9000000103', 1, 4, 'localdev', 9001, 0, 9001, 'Active', false),
(9204, 'super@testmart.invalid', 'Sup', 'Ervisor', 'super@testmart.invalid',
'+91', '9000000104', 1, 7, 'localdev', 9001, 9101, 9001, 'Active', false),
(9205, 'cash@testmart.invalid', 'Cash', 'Ier', 'cash@testmart.invalid',
'+91', '9000000105', 1, 8, 'localdev', 9001, 9101, 9001, 'Active', false),
(9206, 'admin@halfmart.invalid', 'Hal', 'Admin', 'admin@halfmart.invalid',
'+91', '9000000201', 1, 3, 'localdev', 9002, 9103, 9001, 'Active', false)
ON CONFLICT (userid) DO NOTHING;
-- ── Products, in each of the three visibility states ────────────────────────
--
-- categoryid 2 is what the customer app browses. A product filed anywhere else
-- is invisible to shoppers however well priced and stocked, which is the single
-- most common cause of "it is not showing in the app".
INSERT INTO products (
productid, tenantid, categoryid, subcategoryid, productname, productbrand,
productsku, productunit, productcost, retailprice, taxpercent, approve,
productimage, productdesc
) VALUES
(9301, 9001, 2, 0, 'Test Rice 5kg', 'Testbrand', 'TM-RICE-5K', '5kg',
320, 395, 5, 1, 'https://placehold.co/120x120?text=Rice', 'Everyday long grain'),
(9302, 9001, 2, 0, 'Test Oil 1L', 'Testbrand', 'TM-OIL-1L', '1L',
150, 198, 5, 1, 'https://placehold.co/120x120?text=Oil', 'Cooking oil'),
(9303, 9001, 2, 0, 'Test Biscuits 100g', 'Testbrand', 'TM-BISC-100', '100g',
18, 25, 12, 1, 'https://placehold.co/120x120?text=Biscuits', 'Sweet biscuits'),
-- Filed under no category: priced, released and stocked below, and still
-- invisible to the app. The state that catches everybody.
(9304, 9001, 0, 0, 'Test Uncategorised', 'Testbrand', 'TM-UNCAT', '1pc',
10, 15, 0, 1, 'https://placehold.co/120x120?text=Uncat', 'No category on purpose')
ON CONFLICT (productid) DO NOTHING;
-- price + publishedat = priced and released. Both are needed: a product with
-- one and not the other looks identical on the shelf and cannot be sold.
INSERT INTO productlocations (
productlocationid, tenantid, locationid, productid, price, status, publishedat
) VALUES
(9401, 9001, 9101, 9301, 395, 'Active', NOW()), -- on sale
(9402, 9001, 9101, 9302, 198, 'Active', NOW()), -- released, no stock below
(9403, 9001, 9101, 9303, 0, 'Active', NULL), -- no price, not released
(9404, 9001, 9101, 9304, 15, 'Active', NOW()), -- everything but a category
(9405, 9001, 9102, 9301, 395, 'Active', NOW()) -- same product, second branch
ON CONFLICT (productlocationid) DO NOTHING;
-- Stock is SUM(in) - SUM(out) per outlet, never a stored figure.
INSERT INTO productstocks (
productstockid, tenantid, locationid, productid, stockdate, stocktype, quantity, status
) VALUES
(9501, 9001, 9101, 9301, NOW(), 'in', 40, 'Active'),
(9502, 9001, 9101, 9301, NOW(), 'out', 5, 'Active'), -- balance 35
(9503, 9001, 9101, 9304, NOW(), 'in', 10, 'Active'),
(9504, 9001, 9102, 9301, NOW(), 'in', 12, 'Active')
ON CONFLICT (productstockid) DO NOTHING;
COMMIT;

13
main.go
View File

@@ -255,8 +255,19 @@ func main() {
}
// Start server
//
// 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"
}
go func() {
if err := app.Listen(":1122"); err != nil {
log.Printf("🚀 listening on :%s", port)
if err := app.Listen(":" + port); err != nil {
log.Fatal("Server failed to start:", err)
}
}()

View File

@@ -25,6 +25,19 @@ type Tenantinfo struct {
Longitude string `json:"longitude"`
Tenantimage string `json:"tenantimage"`
Tenantinfo string `json:"tenantinfo"`
// The FSSAI or trade licence.
//
// The column has always existed and `GetCustomerTenants` already selects it
// for the customer app — it was missing from THIS struct alone, so
// `gettenantinfo` could never return it however well it was stored.
//
// That is not cosmetic. The shop-profile screen reads a merchant's own
// record back through this endpoint, and the setup walkthrough decides
// whether the profile step is finished by looking at exactly this field. A
// merchant could type their licence, save it, see it stored — and the step
// would stay open forever, because the read that judges it never carried
// the value.
Licenseno string `json:"licenseno"`
Paymenttype int `json:"paymenttype"`
Paymode1 int `json:"paymode1"`
Paymode2 int `json:"paymode2"`

View File

@@ -777,6 +777,24 @@ func (r *tenantRepository) GetUserByNo(cno string) models.UserInfo {
return user
}
// GetTenantByID reads one business.
//
// Both master joins are LEFT, and that is the whole fix. They were INNER —
// `app_category` on `a.categoryid` and `app_location` on `a.applocationid` — so
// a tenant whose category or city master row is missing did not come back
// "without a category name", it did not come back AT ALL. The endpoint answered
// 200 with an all-zero record: tenantid 0, every string empty.
//
// Four of two hundred tenants have `categoryid = 0`, which matches no
// `app_category` row, and a newly onboarded shop is the likeliest to be one of
// them. The damage was silent and total: the shop-profile screen seeded an
// empty form over a business that had an address, and the setup walkthrough
// read every field as blank forever — so its first step could never complete
// however many times somebody saved it.
//
// The same INNER-JOIN-as-a-filter mistake has been fixed twice before in this
// file's neighbours, in GetStaffs and GetUserById. A master row is context; its
// absence must never delete the record it decorates.
func (r *tenantRepository) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) {
var data models.Tenantinfo
@@ -792,8 +810,8 @@ func (r *tenantRepository) GetTenantByID(tid int, locationid int, userid int) (m
SELECT a.*,b.categoryname,c.locationname AS applocation, d.allocationid AS allocationmode,e.typename AS allocationtype,e.mapid AS allocationid,f.locationid,
f.locationname, f.contactno as locationcontact
FROM tenants a
INNER JOIN app_category b ON a.categoryid = b.categoryid
INNER JOIN app_location c ON a.applocationid = c.applocationid
LEFT JOIN app_category b ON a.categoryid = b.categoryid
LEFT JOIN app_location c ON a.applocationid = c.applocationid
LEFT JOIN partnerinfo d ON a.partnerid = d.partnerid
LEFT JOIN app_types e ON d.allocationid = e.apptypeid
LEFT JOIN tenantlocations f ON a.tenantid = f.tenantid