244 lines
8.4 KiB
Go
244 lines
8.4 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|