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" ) // 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.` (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() // 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(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) } // What Nearle Buddy did, and on whose behalf. Its own table: these rows are // written on a different schedule from anything else and are the only record // of an assistant acting for a merchant. // // Logged and carried on rather than fatal, unlike the migrations around it, // and the difference is deliberate. Those create tables the product cannot // trade without — a POS order has nowhere to land if its table is missing. // This one serves an assistant that may not even be switched on, and taking // the whole backend down over it would stop every shop taking orders to // protect a log. // // The degradation is already built: `DBAudit` writes to the log as well as // the table, and reports each failed insert as AUDIT ROW LOST. So a missing // table costs the queryable trail and nothing else, loudly. if err := db.DB.AutoMigrate(&models.AssistantAudit{}); err != nil { log.Printf("assistant: audit table unavailable, the trail is log-only: %v", err) } // Shift windows for till staff. Additive — `app_users.shiftid` already // existed and pointed at the rider table, so an account with no shift is // simply unassigned rather than broken. if err := db.DB.AutoMigrate(&models.StaffShifts{}); err != nil { log.Fatal("staff shift schema migration failed:", err) } // The rest of a product's photos. // // An explicit ALTER rather than AutoMigrate on `models.Products`: that model // has drifted from the live table over time, and letting GORM reconcile the // whole thing to add one column would rewrite far more than anyone asked // for. `IF NOT EXISTS` makes it a no-op on every boot after the first. // // Not fatal on failure. A missing column costs the extra images and nothing // else — `productimage` still carries the first — and refusing to start the // API over a gallery would be the worse trade. if err := db.DB.Exec( `ALTER TABLE products ADD COLUMN IF NOT EXISTS productimages jsonb`).Error; err != nil { log.Println("⚠️ could not add products.productimages, extra photos will not be stored:", err) } // What the global catalogue knew about this product, kept. // // The import copies eight of the catalogue's eighteen fields onto the // tenant's product and left the other ten behind — among them the FSSAI // licence, the nutrition lines, the highlights, the provider list, the // price range and the variant key. The console needs exactly those to // decide what to charge, so `ProductDrawer` went back to the catalogue for // them on every open. // // That lookup is not a substitute for storing them. A tenant's product is a // SNAPSHOT and outlives its source row: the catalogue is re-scraped, a // variant is retired, and the licence number and the nutrition panel for a // product the shop is still selling are gone with no way back. Measured // locally by retiring one row — the product survived, everything the drawer // shows about it did not. // // One jsonb column rather than six typed ones, and rather than the // `productspecs` table that has sat unused since the schema was written. // The value is a snapshot of somebody else's record, read as a whole and // displayed as a whole — it is never joined, aggregated or filtered — and // the catalogue grows fields faster than this side can add migrations. // Postgres can still reach inside it (`cataloguefacts->>'fssai_license'`) // on the day somebody needs to. `productimages` beside it made the same // call for the same reason. // // Not fatal on failure, exactly like the column above: a product without // its catalogue facts is the product we have today. if err := db.DB.Exec( `ALTER TABLE products ADD COLUMN IF NOT EXISTS cataloguefacts jsonb`).Error; err != nil { log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err) } // When a product became visible to a store, and the only thing that decides // whether it is. // // `productlocations.status` cannot do this job and was never able to: // syncProductLocationStatus overwrites it with 'available'/'outofstock' on // every stock movement, so the 'Draft' the import wrote survived on exactly // one row out of 6,755. A separate column is untouched by that, and "when // was this published" is worth knowing regardless. // // The backfill is not optional and must land in the same deploy as the // column. Membership of a store catalogue was the *existence* of the row, // so reading `publishedat IS NOT NULL` without it empties every shop on the // platform at once. // // The backfill runs ONCE — only on the boot that adds the column. // // It used to run on every boot, and that quietly cancelled the whole point // of the column. `WHERE publishedat IS NULL` matches a legacy row on the // first boot and, on every boot after, matches exactly the products // somebody is deliberately holding back: imported, not yet priced, waiting // for review. A restart published all of them and backdated the release to // the row's `created`, so it did not even look recent. // // Observed 2026-08-31: seventeen products imported at R mart at 15:55 sat // correctly unpublished, and the next deploy published all seventeen // stamped 15:55. With several deploys a day, the admin-catalogue tier could // not survive an afternoon. // // Detecting "the column was just added" rather than tracking a migration // version: the question is answerable from the schema itself, so it needs // no new table and cannot drift out of step with one. var hadPublishedAt int64 if err := db.DB.Raw(` SELECT COUNT(1) FROM information_schema.columns WHERE table_name = 'productlocations' AND column_name = 'publishedat'`). Scan(&hadPublishedAt).Error; err != nil { log.Fatal("could not check productlocations.publishedat:", err) } if hadPublishedAt == 0 { // First boot with the column. Every existing row predates publication // as a concept, and membership of this table WAS publication — so // leaving them null would empty every shop on the platform at once. if err := db.DB.Exec( `ALTER TABLE productlocations ADD COLUMN IF NOT EXISTS publishedat timestamp`).Error; err != nil { log.Fatal("could not add productlocations.publishedat:", err) } if err := db.DB.Exec(` UPDATE productlocations SET publishedat = COALESCE(created, NOW()) WHERE publishedat IS NULL`).Error; err != nil { log.Fatal("could not backfill productlocations.publishedat:", err) } log.Println("productlocations.publishedat added and backfilled (one time)") } // A key generator for productvariants.variantid. // // The column is NOT NULL with no default and no identity, unlike // products.productid next door which is an identity column. So every insert // had to supply the id by hand, and GORM does not — it sent nothing and // Postgres refused the row with a not-null violation. That is why variants // could never be attached to a product: the write could not land at all. // // Guarded on the absence of a default rather than tracked as a migration // version, matching the checks above: the question is answerable from the // schema itself. The sequence starts above whatever ids are already there, // so the two rows on production keep theirs. var variantKeyed int64 if err := db.DB.Raw(` SELECT COUNT(1) FROM information_schema.columns WHERE table_name = 'productvariants' AND column_name = 'variantid' AND (column_default IS NOT NULL OR is_identity = 'YES')`). Scan(&variantKeyed).Error; err != nil { log.Fatal("could not check productvariants.variantid:", err) } if variantKeyed == 0 { if err := db.DB.Exec(` CREATE SEQUENCE IF NOT EXISTS productvariants_variantid_seq START WITH 1 OWNED BY productvariants.variantid`).Error; err != nil { log.Fatal("could not create productvariants_variantid_seq:", err) } if err := db.DB.Exec(` SELECT setval('productvariants_variantid_seq', COALESCE((SELECT MAX(variantid) FROM productvariants), 0) + 1, false)`).Error; err != nil { log.Fatal("could not position productvariants_variantid_seq:", err) } if err := db.DB.Exec(` ALTER TABLE productvariants ALTER COLUMN variantid SET DEFAULT nextval('productvariants_variantid_seq')`).Error; err != nil { log.Fatal("could not default productvariants.variantid:", err) } log.Println("productvariants.variantid given a key generator (one time)") } // Key generators for the two partner tables, for exactly the reason above. // // `partnerinfo.partnerid` and `partnerlocations.partnerlocationid` are both // NOT NULL with no default and no identity, so GORM — which sends nothing // for a key it expects the database to mint — had every insert refused with // a not-null violation. `createpartner` therefore could not write a partner // OR its regions: the endpoint exists, the form exists, and the row could // never land. The five partners on the platform were all inserted by hand, // which is the symptom rather than a choice. // // This matters more than one broken button. `GetPartners` now separates the // partners registered through this console from the ones another product // left in the shared `partnerinfo` by joining `partnerlocations` — and only // a successful create writes that table. Without a key generator no partner // can ever be registered, so nothing would ever have a link row and the // Rider partners page would be empty forever. // // Both sequences start above the ids already there, so the hand-inserted // rows keep theirs. for _, key := range []struct{ table, column string }{ {"partnerinfo", "partnerid"}, {"partnerlocations", "partnerlocationid"}, } { var keyed int64 if err := db.DB.Raw(` SELECT COUNT(1) FROM information_schema.columns WHERE table_name = ? AND column_name = ? AND (column_default IS NOT NULL OR is_identity = 'YES')`, key.table, key.column).Scan(&keyed).Error; err != nil { log.Fatalf("could not check %s.%s: %v", key.table, key.column, err) } if keyed > 0 { continue } seq := key.table + "_" + key.column + "_seq" if err := db.DB.Exec(fmt.Sprintf( `CREATE SEQUENCE IF NOT EXISTS %s START WITH 1 OWNED BY %s.%s`, seq, key.table, key.column)).Error; err != nil { log.Fatalf("could not create %s: %v", seq, err) } if err := db.DB.Exec(fmt.Sprintf( `SELECT setval('%s', COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false)`, seq, key.column, key.table)).Error; err != nil { log.Fatalf("could not position %s: %v", seq, err) } if err := db.DB.Exec(fmt.Sprintf( `ALTER TABLE %s ALTER COLUMN %s SET DEFAULT nextval('%s')`, key.table, key.column, seq)).Error; err != nil { log.Fatalf("could not default %s.%s: %v", key.table, key.column, err) } log.Printf("%s.%s given a key generator (one time)", key.table, key.column) } // The catalogue's own stable key for an imported product. // // `catalogueid` was never able to be this. The catalogue is rebuilt by // scrape and renumbered every time — pepsico's live ids run 3, 6, 9 … 27, // 30 — so a product imported when it was id 26 now points at nothing. // Measured 2026-08-31: eleven of the nineteen links on the platform were // dangling, which silently breaks three things (the "already imported" // ticks, re-importing, and dedupe on the next scrape). // // Additive and nullable: every existing row keeps working, and a re-import // or `/relinkcatalogue` is how one acquires the key. if err := db.DB.Exec( `ALTER TABLE products ADD COLUMN IF NOT EXISTS imageid text`).Error; err != nil { log.Fatal("could not add products.imageid:", err) } // Not unique: two tenants legitimately stock the same catalogue product, // and each keeps its own snapshot row. The lookup is always per tenant. if err := db.DB.Exec( `CREATE INDEX IF NOT EXISTS products_tenant_imageid_idx ON products (tenantid, imageid)`).Error; err != nil { log.Println("⚠️ could not add products.imageid index:", err) } // Receipts for spreadsheets sent to the catalogue ingest service. // // The ingest service holds a drop on its own terms: an unreviewed one is // deleted after seven days, the list of a sender's drops needs a credential // production does not issue, and the batch id — which is the ONLY // credential for reading a result back — is handed out once, to a browser. // Lose it and the upload becomes unfindable even to the person who sent it. // This table is where we keep it. // // Raw SQL rather than AutoMigrate, matching the two ALTERs above: GORM // reconciling a model against a live table has rewritten more than was // asked for here before, and `IF NOT EXISTS` makes this a no-op after the // first boot. if err := db.DB.Exec(` CREATE TABLE IF NOT EXISTS catalogueuploads ( uploadid SERIAL PRIMARY KEY, tenantid INT NOT NULL, locationid INT NOT NULL DEFAULT 0, categoryid INT NOT NULL DEFAULT 0, batchid VARCHAR(64) NOT NULL, runid VARCHAR(64), filename TEXT, sender TEXT, uploadedby INT NOT NULL DEFAULT 0, uploadedname TEXT, rowcount INT NOT NULL DEFAULT 0, laststatus VARCHAR(32) NOT NULL DEFAULT 'pending', inserted INT NOT NULL DEFAULT 0, backfilled INT NOT NULL DEFAULT 0, skipped INT NOT NULL DEFAULT 0, rejected INT NOT NULL DEFAULT 0, shelvedcount INT NOT NULL DEFAULT 0, skippedcount INT NOT NULL DEFAULT 0, shelvedat TIMESTAMP, created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )`).Error; err != nil { log.Fatal("could not create catalogueuploads:", err) } // The upload is recorded the instant the drop is accepted and then polled, // so a refresh, a retried request or a second tab would each insert the same // receipt again. The constraint is what makes `Record` idempotent — without // it one upload shows up three times and none of them is wrong. if err := db.DB.Exec( `CREATE UNIQUE INDEX IF NOT EXISTS catalogueuploads_batchid_key ON catalogueuploads (batchid)`).Error; err != nil { log.Fatal("could not add catalogueuploads.batchid unique index:", err) } // Every read is "this shop's uploads, newest first". if err := db.DB.Exec( `CREATE INDEX IF NOT EXISTS catalogueuploads_scope_idx ON catalogueuploads (tenantid, locationid, created DESC)`).Error; err != nil { log.Println("⚠️ could not add catalogueuploads scope index:", err) } // The sheet's own prices and opening stock, kept so shelving can happen // after the browser that uploaded it is gone. // // Added separately from the CREATE above because the table already exists in // production without it. The ingest service holds none of this — its // catalogue is shared by every merchant and carries no price and no stock — // so before this column the join could only be made in the tab that did the // upload, and that tab is normally long closed by the time their admin // releases the drop and the run finishes. if err := db.DB.Exec( `ALTER TABLE catalogueuploads ADD COLUMN IF NOT EXISTS sheetrows jsonb`).Error; err != nil { log.Fatal("could not add catalogueuploads.sheetrows:", err) } // The model behind scan-to-order. Optional: without EMBEDDING_PROVIDER the // search matches on words, which works but ranks less well. embedder, err := utils.NewEmbedder(cfg.Embedding) if err != nil { log.Fatal("embedding provider:", err) } if embedder == nil { log.Println("scan: EMBEDDING_PROVIDER not set, product search is text-only") } else { log.Printf("scan: product search uses %s/%s", cfg.Embedding.Provider, cfg.Embedding.Model) } // The model behind Nearle Buddy. Optional in the same way: without // ASSISTANT_PROVIDER the tools still work and the panel says the assistant // is not switched on, rather than the console showing a field that accepts // text and swallows it. chat, err := utils.NewChat(cfg.Assistant) if err != nil { log.Fatal("assistant provider:", err) } if chat == nil { log.Printf("assistant: OFF — %s", cfg.Assistant.Why()) } else { log.Printf("assistant: %s, balanced tier is %s", cfg.Assistant.Provider, cfg.Assistant.ModelFor(utils.TierBalanced)) } // ASSISTANT_AGENTS_DIR replaces the compiled-in agent definitions wholesale. // Empty uses the embedded ones, so a deployment cannot be broken by a missing // directory. // Mail, for the invitation a newly onboarded merchant is sent. // // Optional in the same way as the model and the embedder: without it the // server still boots and still onboards tenants, and the create response // says the invitation was not sent and which variable is missing. Refusing // to start would make a mail relay a hard dependency of creating a shop, // which it is not. mailer, err := utils.NewMailer(cfg.Mail) if err != nil { // A configured-but-invalid sender, as opposed to no mail at all. That // fails every message, so it is worth stopping for rather than // discovering one silent invitation at a time. log.Fatal("mail:", err) } if mailer == nil { log.Printf("mail: OFF — %s", cfg.Mail.Why()) } else { log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address()) } // NUTRITION_BASE is the catalogue-intelligence host — the same service the // console reads its health score card from. Unset means product screens // carry no nutrition panel, and nothing else changes. f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat, os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail, os.Getenv("NUTRITION_BASE")) 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) }