Files
backend_fiesta/main.go
2026-08-27 11:17:17 +05:30

189 lines
6.0 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)
}
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)
}