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) } // 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) } 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) }