From c0d39e550ddb3ece74a15c814d125b1a9beff0fe Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 2 Sep 2026 10:49:46 +0530 Subject: [PATCH] changes --- .env.example | 59 +++++++++++ docker-compose.local.yml | 114 +++++++++++++++++++++ init/.gitignore | 10 ++ init/README.md | 49 +++++++++ init/nearledb/02-seed.sql | 164 +++++++++++++++++++++++++++++++ main.go | 13 ++- models/tenant.go | 57 ++++++----- repositories/tenantRepository.go | 22 ++++- 8 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.local.yml create mode 100644 init/.gitignore create mode 100644 init/README.md create mode 100644 init/nearledb/02-seed.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f3b6d8 --- /dev/null +++ b/.env.example @@ -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= diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..f2f2107 --- /dev/null +++ b/docker-compose.local.yml @@ -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 -p 5433 -U -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: diff --git a/init/.gitignore b/init/.gitignore new file mode 100644 index 0000000..6f9e2ca --- /dev/null +++ b/init/.gitignore @@ -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 diff --git a/init/README.md b/init/README.md new file mode 100644 index 0000000..356af44 --- /dev/null +++ b/init/README.md @@ -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 -p 5433 -U -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. diff --git a/init/nearledb/02-seed.sql b/init/nearledb/02-seed.sql new file mode 100644 index 0000000..3bc9a25 --- /dev/null +++ b/init/nearledb/02-seed.sql @@ -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; diff --git a/main.go b/main.go index ffa5522..7a00545 100644 --- a/main.go +++ b/main.go @@ -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) } }() diff --git a/models/tenant.go b/models/tenant.go index 1f1ba2b..bbd0873 100644 --- a/models/tenant.go +++ b/models/tenant.go @@ -3,28 +3,41 @@ package models import "time" type Tenantinfo struct { - Tenantid int `json:"tenantid" gorm:"Primary_Key"` - Locationid int `json:"locationid"` - Tenantname string `json:"tenantname"` - Locationname string `json:"locationname"` - Tenanttype string `json:"tenanttype"` - Registrationno string `json:"registrationno"` - Tenanttoken string `json:"tenanttoken"` - Companyname string `json:"companyname"` - Primaryemail string `json:"primaryemail"` - Primarycontact string `json:"primarycontact"` - Locationcatact string `json:"locationcontact"` - Categoryid int `json:"categoryid"` - Subcategoryid int `json:"subcategoryid"` - Address string `json:"address"` - Suburb string `json:"suburb"` - City string `json:"city"` - State string `json:"state"` - Postcode string `json:"postcode"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` - Tenantimage string `json:"tenantimage"` - Tenantinfo string `json:"tenantinfo"` + Tenantid int `json:"tenantid" gorm:"Primary_Key"` + Locationid int `json:"locationid"` + Tenantname string `json:"tenantname"` + Locationname string `json:"locationname"` + Tenanttype string `json:"tenanttype"` + Registrationno string `json:"registrationno"` + Tenanttoken string `json:"tenanttoken"` + Companyname string `json:"companyname"` + Primaryemail string `json:"primaryemail"` + Primarycontact string `json:"primarycontact"` + Locationcatact string `json:"locationcontact"` + Categoryid int `json:"categoryid"` + Subcategoryid int `json:"subcategoryid"` + Address string `json:"address"` + Suburb string `json:"suburb"` + City string `json:"city"` + State string `json:"state"` + Postcode string `json:"postcode"` + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` + Tenantimage string `json:"tenantimage"` + 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"` diff --git a/repositories/tenantRepository.go b/repositories/tenantRepository.go index eb91688..56f6fea 100644 --- a/repositories/tenantRepository.go +++ b/repositories/tenantRepository.go @@ -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