Files
backend_fiesta/main.go
2026-08-31 15:51:36 +05:30

280 lines
10 KiB
Go

package main
import (
"fmt"
"log"
"nearle/db"
"nearle/facade"
"nearle/messaging"
"nearle/models"
"nearle/repositories"
"nearle/routes"
"os"
"os/signal"
"strings"
"syscall"
"time"
_ "time/tzdata"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/joho/godotenv"
"gorm.io/gorm"
)
func init() {
godotenv.Load()
}
func main() {
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()
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()
// 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)
}
// 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. Membership
// of a store catalogue is currently the *existence* of the row, so switching
// the read to `publishedat IS NOT NULL` without this empties 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)
}
// 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)
}
f := facade.NewFacade(db.DB, db.CatalogueDB)
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
go func() {
if err := app.Listen(":1122"); 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)
}