453 lines
19 KiB
Go
453 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"nearle/config"
|
|
"nearle/db"
|
|
"nearle/facade"
|
|
"nearle/messaging"
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
"nearle/routes"
|
|
"nearle/utils"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
_ "time/tzdata"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
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",
|
|
}))
|
|
|
|
fmt.Println("🌐 Connecting to databases...")
|
|
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(cfg.Redis)
|
|
|
|
// Ensure schema is updated
|
|
db.DB.AutoMigrate(&models.StockRequest{})
|
|
|
|
// Counter sales from the in-store terminals. Separate tables from `orders`
|
|
// because a bill carries a cashier, a terminal, rounding, promos, loyalty
|
|
// and a payment split that `orders` has nowhere to put.
|
|
if err := db.DB.AutoMigrate(&models.PosOrders{}, &models.PosOrderItems{}); err != nil {
|
|
log.Fatal("POS schema migration failed:", err)
|
|
}
|
|
|
|
// Shift windows for till staff. Additive — `app_users.shiftid` already
|
|
// existed and pointed at the rider table, so an account with no shift is
|
|
// simply unassigned rather than broken.
|
|
if err := db.DB.AutoMigrate(&models.StaffShifts{}); err != nil {
|
|
log.Fatal("staff shift schema migration failed:", err)
|
|
}
|
|
|
|
// The rest of a product's photos.
|
|
//
|
|
// An explicit ALTER rather than AutoMigrate on `models.Products`: that model
|
|
// has drifted from the live table over time, and letting GORM reconcile the
|
|
// whole thing to add one column would rewrite far more than anyone asked
|
|
// for. `IF NOT EXISTS` makes it a no-op on every boot after the first.
|
|
//
|
|
// Not fatal on failure. A missing column costs the extra images and nothing
|
|
// else — `productimage` still carries the first — and refusing to start the
|
|
// API over a gallery would be the worse trade.
|
|
if err := db.DB.Exec(
|
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS productimages jsonb`).Error; err != nil {
|
|
log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err)
|
|
}
|
|
|
|
// What the global catalogue knew about this product, kept.
|
|
//
|
|
// The import copies eight of the catalogue's eighteen fields onto the
|
|
// tenant's product and left the other ten behind — among them the FSSAI
|
|
// licence, the nutrition lines, the highlights, the provider list, the
|
|
// price range and the variant key. The console needs exactly those to
|
|
// decide what to charge, so `ProductDrawer` went back to the catalogue for
|
|
// them on every open.
|
|
//
|
|
// That lookup is not a substitute for storing them. A tenant's product is a
|
|
// SNAPSHOT and outlives its source row: the catalogue is re-scraped, a
|
|
// variant is retired, and the licence number and the nutrition panel for a
|
|
// product the shop is still selling are gone with no way back. Measured
|
|
// locally by retiring one row — the product survived, everything the drawer
|
|
// shows about it did not.
|
|
//
|
|
// One jsonb column rather than six typed ones, and rather than the
|
|
// `productspecs` table that has sat unused since the schema was written.
|
|
// The value is a snapshot of somebody else's record, read as a whole and
|
|
// displayed as a whole — it is never joined, aggregated or filtered — and
|
|
// the catalogue grows fields faster than this side can add migrations.
|
|
// Postgres can still reach inside it (`cataloguefacts->>'fssai_license'`)
|
|
// on the day somebody needs to. `productimages` beside it made the same
|
|
// call for the same reason.
|
|
//
|
|
// Not fatal on failure, exactly like the column above: a product without
|
|
// its catalogue facts is the product we have today.
|
|
if err := db.DB.Exec(
|
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS cataloguefacts jsonb`).Error; err != nil {
|
|
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
|
|
}
|
|
|
|
// When a product became visible to a store, and the only thing that decides
|
|
// whether it is.
|
|
//
|
|
// `productlocations.status` cannot do this job and was never able to:
|
|
// syncProductLocationStatus overwrites it with 'available'/'outofstock' on
|
|
// every stock movement, so the 'Draft' the import wrote survived on exactly
|
|
// one row out of 6,755. A separate column is untouched by that, and "when
|
|
// was this published" is worth knowing regardless.
|
|
//
|
|
// The backfill is not optional and must land in the same deploy as the
|
|
// column. Membership of a store catalogue was the *existence* of the row,
|
|
// so reading `publishedat IS NOT NULL` without it empties every shop on the
|
|
// platform at once.
|
|
//
|
|
// The backfill runs ONCE — only on the boot that adds the column.
|
|
//
|
|
// It used to run on every boot, and that quietly cancelled the whole point
|
|
// of the column. `WHERE publishedat IS NULL` matches a legacy row on the
|
|
// first boot and, on every boot after, matches exactly the products
|
|
// somebody is deliberately holding back: imported, not yet priced, waiting
|
|
// for review. A restart published all of them and backdated the release to
|
|
// the row's `created`, so it did not even look recent.
|
|
//
|
|
// Observed 2026-08-31: seventeen products imported at R mart at 15:55 sat
|
|
// correctly unpublished, and the next deploy published all seventeen
|
|
// stamped 15:55. With several deploys a day, the admin-catalogue tier could
|
|
// not survive an afternoon.
|
|
//
|
|
// Detecting "the column was just added" rather than tracking a migration
|
|
// version: the question is answerable from the schema itself, so it needs
|
|
// no new table and cannot drift out of step with one.
|
|
var hadPublishedAt int64
|
|
if err := db.DB.Raw(`
|
|
SELECT COUNT(1) FROM information_schema.columns
|
|
WHERE table_name = 'productlocations' AND column_name = 'publishedat'`).
|
|
Scan(&hadPublishedAt).Error; err != nil {
|
|
log.Fatal("could not check productlocations.publishedat:", err)
|
|
}
|
|
if hadPublishedAt == 0 {
|
|
// First boot with the column. Every existing row predates publication
|
|
// as a concept, and membership of this table WAS publication — so
|
|
// leaving them null would empty every shop on the platform at once.
|
|
if err := db.DB.Exec(
|
|
`ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat timestamp`).Error; err != nil {
|
|
log.Fatal("could not add productlocations.publishedat:", err)
|
|
}
|
|
if err := db.DB.Exec(`
|
|
UPDATE productlocations
|
|
SET publishedat = COALESCE(created, NOW())
|
|
WHERE publishedat IS NULL`).Error; err != nil {
|
|
log.Fatal("could not backfill productlocations.publishedat:", err)
|
|
}
|
|
log.Println("productlocations.publishedat added and backfilled (one time)")
|
|
}
|
|
|
|
// A key generator for productvariants.variantid.
|
|
//
|
|
// The column is NOT NULL with no default and no identity, unlike
|
|
// products.productid next door which is an identity column. So every insert
|
|
// had to supply the id by hand, and GORM does not — it sent nothing and
|
|
// Postgres refused the row with a not-null violation. That is why variants
|
|
// could never be attached to a product: the write could not land at all.
|
|
//
|
|
// Guarded on the absence of a default rather than tracked as a migration
|
|
// version, matching the checks above: the question is answerable from the
|
|
// schema itself. The sequence starts above whatever ids are already there,
|
|
// so the two rows on production keep theirs.
|
|
var variantKeyed int64
|
|
if err := db.DB.Raw(`
|
|
SELECT COUNT(1) FROM information_schema.columns
|
|
WHERE table_name = 'productvariants' AND column_name = 'variantid'
|
|
AND (column_default IS NOT NULL OR is_identity = 'YES')`).
|
|
Scan(&variantKeyed).Error; err != nil {
|
|
log.Fatal("could not check productvariants.variantid:", err)
|
|
}
|
|
if variantKeyed == 0 {
|
|
if err := db.DB.Exec(`
|
|
CREATE SEQUENCE IF NOT EXISTS productvariants_variantid_seq
|
|
START WITH 1 OWNED BY productvariants.variantid`).Error; err != nil {
|
|
log.Fatal("could not create productvariants_variantid_seq:", err)
|
|
}
|
|
if err := db.DB.Exec(`
|
|
SELECT setval('productvariants_variantid_seq',
|
|
COALESCE((SELECT MAX(variantid) FROM productvariants), 0) + 1, false)`).Error; err != nil {
|
|
log.Fatal("could not position productvariants_variantid_seq:", err)
|
|
}
|
|
if err := db.DB.Exec(`
|
|
ALTER TABLE productvariants
|
|
ALTER COLUMN variantid SET DEFAULT nextval('productvariants_variantid_seq')`).Error; err != nil {
|
|
log.Fatal("could not default productvariants.variantid:", err)
|
|
}
|
|
log.Println("productvariants.variantid given a key generator (one time)")
|
|
}
|
|
|
|
// Key generators for the two partner tables, for exactly the reason above.
|
|
//
|
|
// `partnerinfo.partnerid` and `partnerlocations.partnerlocationid` are both
|
|
// NOT NULL with no default and no identity, so GORM — which sends nothing
|
|
// for a key it expects the database to mint — had every insert refused with
|
|
// a not-null violation. `createpartner` therefore could not write a partner
|
|
// OR its regions: the endpoint exists, the form exists, and the row could
|
|
// never land. The five partners on the platform were all inserted by hand,
|
|
// which is the symptom rather than a choice.
|
|
//
|
|
// This matters more than one broken button. `GetPartners` now separates the
|
|
// partners registered through this console from the ones another product
|
|
// left in the shared `partnerinfo` by joining `partnerlocations` — and only
|
|
// a successful create writes that table. Without a key generator no partner
|
|
// can ever be registered, so nothing would ever have a link row and the
|
|
// Rider partners page would be empty forever.
|
|
//
|
|
// Both sequences start above the ids already there, so the hand-inserted
|
|
// rows keep theirs.
|
|
for _, key := range []struct{ table, column string }{
|
|
{"partnerinfo", "partnerid"},
|
|
{"partnerlocations", "partnerlocationid"},
|
|
} {
|
|
var keyed int64
|
|
if err := db.DB.Raw(`
|
|
SELECT COUNT(1) FROM information_schema.columns
|
|
WHERE table_name = ? AND column_name = ?
|
|
AND (column_default IS NOT NULL OR is_identity = 'YES')`,
|
|
key.table, key.column).Scan(&keyed).Error; err != nil {
|
|
log.Fatalf("could not check %s.%s: %v", key.table, key.column, err)
|
|
}
|
|
if keyed > 0 {
|
|
continue
|
|
}
|
|
|
|
seq := key.table + "_" + key.column + "_seq"
|
|
if err := db.DB.Exec(fmt.Sprintf(
|
|
`CREATE SEQUENCE IF NOT EXISTS %s START WITH 1 OWNED BY %s.%s`,
|
|
seq, key.table, key.column)).Error; err != nil {
|
|
log.Fatalf("could not create %s: %v", seq, err)
|
|
}
|
|
if err := db.DB.Exec(fmt.Sprintf(
|
|
`SELECT setval('%s', COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false)`,
|
|
seq, key.column, key.table)).Error; err != nil {
|
|
log.Fatalf("could not position %s: %v", seq, err)
|
|
}
|
|
if err := db.DB.Exec(fmt.Sprintf(
|
|
`ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')`,
|
|
key.table, key.column, seq)).Error; err != nil {
|
|
log.Fatalf("could not default %s.%s: %v", key.table, key.column, err)
|
|
}
|
|
log.Printf("%s.%s given a key generator (one time)", key.table, key.column)
|
|
}
|
|
|
|
// The catalogue's own stable key for an imported product.
|
|
//
|
|
// `catalogueid` was never able to be this. The catalogue is rebuilt by
|
|
// scrape and renumbered every time — pepsico's live ids run 3, 6, 9 … 27,
|
|
// 30 — so a product imported when it was id 26 now points at nothing.
|
|
// Measured 2026-08-31: eleven of the nineteen links on the platform were
|
|
// dangling, which silently breaks three things (the "already imported"
|
|
// ticks, re-importing, and dedupe on the next scrape).
|
|
//
|
|
// Additive and nullable: every existing row keeps working, and a re-import
|
|
// or `/relinkcatalogue` is how one acquires the key.
|
|
if err := db.DB.Exec(
|
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS imageid text`).Error; err != nil {
|
|
log.Fatal("could not add products.imageid:", err)
|
|
}
|
|
// Not unique: two tenants legitimately stock the same catalogue product,
|
|
// and each keeps its own snapshot row. The lookup is always per tenant.
|
|
if err := db.DB.Exec(
|
|
`CREATE INDEX IF NOT EXISTS products_tenant_imageid_idx
|
|
ON products (tenantid, imageid)`).Error; err != nil {
|
|
log.Println("⚠️ could not add products.imageid index:", err)
|
|
}
|
|
|
|
// Receipts for spreadsheets sent to the catalogue ingest service.
|
|
//
|
|
// The ingest service holds a drop on its own terms: an unreviewed one is
|
|
// deleted after seven days, the list of a sender's drops needs a credential
|
|
// production does not issue, and the batch id — which is the ONLY
|
|
// credential for reading a result back — is handed out once, to a browser.
|
|
// Lose it and the upload becomes unfindable even to the person who sent it.
|
|
// This table is where we keep it.
|
|
//
|
|
// Raw SQL rather than AutoMigrate, matching the two ALTERs above: GORM
|
|
// reconciling a model against a live table has rewritten more than was
|
|
// asked for here before, and `IF NOT EXISTS` makes this a no-op after the
|
|
// first boot.
|
|
if err := db.DB.Exec(`
|
|
CREATE TABLE IF NOT EXISTS catalogueuploads (
|
|
uploadid SERIAL PRIMARY KEY,
|
|
tenantid INT NOT NULL,
|
|
locationid INT NOT NULL DEFAULT 0,
|
|
categoryid INT NOT NULL DEFAULT 0,
|
|
batchid VARCHAR(64) NOT NULL,
|
|
runid VARCHAR(64),
|
|
filename TEXT,
|
|
sender TEXT,
|
|
uploadedby INT NOT NULL DEFAULT 0,
|
|
uploadedname TEXT,
|
|
rowcount INT NOT NULL DEFAULT 0,
|
|
laststatus VARCHAR(32) NOT NULL DEFAULT 'pending',
|
|
inserted INT NOT NULL DEFAULT 0,
|
|
backfilled INT NOT NULL DEFAULT 0,
|
|
skipped INT NOT NULL DEFAULT 0,
|
|
rejected INT NOT NULL DEFAULT 0,
|
|
shelvedcount INT NOT NULL DEFAULT 0,
|
|
skippedcount INT NOT NULL DEFAULT 0,
|
|
shelvedat TIMESTAMP,
|
|
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`).Error; err != nil {
|
|
log.Fatal("could not create catalogueuploads:", err)
|
|
}
|
|
// The upload is recorded the instant the drop is accepted and then polled,
|
|
// so a refresh, a retried request or a second tab would each insert the same
|
|
// receipt again. The constraint is what makes `Record` idempotent — without
|
|
// it one upload shows up three times and none of them is wrong.
|
|
if err := db.DB.Exec(
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS catalogueuploads_batchid_key
|
|
ON catalogueuploads (batchid)`).Error; err != nil {
|
|
log.Fatal("could not add catalogueuploads.batchid unique index:", err)
|
|
}
|
|
// Every read is "this shop's uploads, newest first".
|
|
if err := db.DB.Exec(
|
|
`CREATE INDEX IF NOT EXISTS catalogueuploads_scope_idx
|
|
ON catalogueuploads (tenantid, locationid, created DESC)`).Error; err != nil {
|
|
log.Println("⚠️ could not add catalogueuploads scope index:", err)
|
|
}
|
|
// The sheet's own prices and opening stock, kept so shelving can happen
|
|
// after the browser that uploaded it is gone.
|
|
//
|
|
// Added separately from the CREATE above because the table already exists in
|
|
// production without it. The ingest service holds none of this — its
|
|
// catalogue is shared by every merchant and carries no price and no stock —
|
|
// so before this column the join could only be made in the tab that did the
|
|
// upload, and that tab is normally long closed by the time their admin
|
|
// releases the drop and the run finishes.
|
|
if err := db.DB.Exec(
|
|
`ALTER TABLE catalogueuploads ADD COLUMN IF NOT EXISTS sheetrows jsonb`).Error; err != nil {
|
|
log.Fatal("could not add catalogueuploads.sheetrows:", err)
|
|
}
|
|
|
|
// The model behind scan-to-order. Optional: without EMBEDDING_PROVIDER the
|
|
// search matches on words, which works but ranks less well.
|
|
embedder, err := utils.NewEmbedder(cfg.Embedding)
|
|
if err != nil {
|
|
log.Fatal("embedding provider:", err)
|
|
}
|
|
if embedder == nil {
|
|
log.Println("scan: EMBEDDING_PROVIDER not set, product search is text-only")
|
|
} else {
|
|
log.Printf("scan: product search uses %s/%s", cfg.Embedding.Provider, cfg.Embedding.Model)
|
|
}
|
|
|
|
f := facade.NewFacade(db.DB, db.CatalogueDB, embedder)
|
|
|
|
routes.RegisterRoutes(app, f)
|
|
|
|
// POS terminals reach the ingest over MQTT when MQTT_URL is set, and over
|
|
// HTTP otherwise. Both land on the same service, so a bill cannot behave
|
|
// differently depending on how it arrived.
|
|
//
|
|
// A broker that is configured but unreachable is fatal on purpose: coming
|
|
// up healthy while every till quietly queues is the worse failure.
|
|
// The consumer is also the publisher for `nearle/pos/{loc}/catalogue`.
|
|
// Registering it here inverts the dependency: the repositories that move
|
|
// stock cannot import `messaging` — wiring runs this way and the reverse
|
|
// would be a cycle — so they hold an interface and this supplies it. Left
|
|
// unset the notification is simply skipped, which is what happens when the
|
|
// broker is unavailable and must not stop the API booting.
|
|
// The console's live stream listens to the same broker, read-only, and on
|
|
// EVERY replica — unlike the ingest consumer below, which is elected. A
|
|
// console connected to a non-elected replica must still see its tills.
|
|
// Never fatal: no broker simply means the console keeps polling.
|
|
messaging.StartLiveHub()
|
|
|
|
posMqtt, err := messaging.StartPosMqttConsumer(f.PosService())
|
|
if err != nil {
|
|
log.Fatal("POS MQTT consumer failed to start:", err)
|
|
}
|
|
if posMqtt != nil {
|
|
repositories.SetCatalogueNotifier(posMqtt)
|
|
}
|
|
|
|
// 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 {
|
|
log.Fatal("Server failed to start:", err)
|
|
}
|
|
}()
|
|
|
|
gracefulShutdown(posMqtt)
|
|
}
|
|
|
|
func selectDBMiddleware(c *fiber.Ctx) error {
|
|
path := c.Path()
|
|
result := strings.Split(path, "/")
|
|
|
|
var flavour string
|
|
if len(result) > 1 {
|
|
flavour = result[1]
|
|
}
|
|
|
|
var currentDB *gorm.DB
|
|
switch flavour {
|
|
case "dev", "live":
|
|
currentDB = db.DB
|
|
}
|
|
|
|
if currentDB != nil {
|
|
c.Locals("DB", currentDB)
|
|
}
|
|
|
|
return c.Next()
|
|
}
|
|
|
|
func gracefulShutdown(posMqtt *messaging.PosMqttConsumer) {
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
|
|
<-c
|
|
fmt.Println("\nShutting down gracefully...")
|
|
|
|
// Drained before anything else: a bill mid-commit still gets its ack, and
|
|
// without one the terminal would hold it and send it again on restart.
|
|
posMqtt.Close()
|
|
db.CloseRedis()
|
|
|
|
// Normally: close db.DB_DEV and db.DB_LIVE
|
|
// Example:
|
|
// closeDB(db.DB_DEV)
|
|
// closeDB(db.DB_LIVE)
|
|
|
|
time.Sleep(2 * time.Second)
|
|
fmt.Println("Shutdown complete.")
|
|
|
|
os.Exit(0)
|
|
}
|