hss-pos / backend /cmd /server /main.go
sashank1989
feat: hybrid cloud sync + fix table/KOT state after payment
46ae36f
Raw
History Blame Contribute Delete
18.6 kB
package main
import (
"encoding/json"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"restaurant-pos/internal/config"
"restaurant-pos/internal/database"
"restaurant-pos/internal/handlers"
"restaurant-pos/internal/middleware"
"restaurant-pos/internal/models"
"restaurant-pos/internal/services"
ws "restaurant-pos/internal/websocket"
"github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
)
func main() {
log.Println("=== HSS POS System ===")
log.Println("Starting server...")
// Load config
cfg := config.Load()
// Initialize database
db, err := database.Initialize(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Seed default data
database.Seed(db)
// Create WebSocket hub
hub := ws.NewHub()
go hub.Run()
// Create Fiber app
app := fiber.New(fiber.Config{
AppName: "HSS POS",
BodyLimit: 10 * 1024 * 1024, // 10MB
ErrorHandler: customErrorHandler,
})
// Setup middleware
middleware.Setup(app, cfg)
// Health check
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"service": "restaurant-pos",
"ws_clients": hub.ClientCount(),
})
})
// WAL checkpoint — merges WAL data into main DB file for backup
app.Get("/api/v1/internal/checkpoint", func(c *fiber.Ctx) error {
sqlDB, err := db.DB()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "failed to get DB handle"})
}
var busyLog, checkpointed, total int
err = sqlDB.QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busyLog, &checkpointed, &total)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
log.Printf("[DB] WAL checkpoint: busy=%d, checkpointed=%d, total=%d", busyLog, checkpointed, total)
return c.JSON(fiber.Map{
"status": "ok",
"busy": busyLog,
"checkpointed": checkpointed,
"total": total,
})
})
// ================================================================
// INITIALIZE HANDLERS
// ================================================================
authHandler := handlers.NewAuthHandler(db, cfg)
productHandler := handlers.NewProductHandler(db)
orderHandler := handlers.NewOrderHandler(db, hub)
kotHandler := handlers.NewKOTHandler(db, hub)
tableHandler := handlers.NewTableHandler(db, hub)
inventoryHandler := handlers.NewInventoryHandler(db)
vendorHandler := handlers.NewVendorHandler(db)
expenseHandler := handlers.NewExpenseHandler(db)
customerHandler := handlers.NewCustomerHandler(db)
reportHandler := handlers.NewReportHandler(db)
settingsHandler := handlers.NewSettingsHandler(db)
whatsappHandler := handlers.NewWhatsAppHandler(db, orderHandler)
externalHandler := handlers.NewExternalOrderHandler(db, orderHandler)
printerHandler := handlers.NewPrinterHandler(db)
edcHandler := handlers.NewEDCHandler(db)
edcHandler.AutoMigrate()
// ================================================================
// HYBRID SYNC SYSTEM
// ================================================================
syncService := services.NewSyncService(db, cfg)
orderHandler.SyncService = syncService
syncHandler := handlers.NewSyncHandler(db, cfg, syncService)
// Order Relay: local polls cloud for Swiggy/Zomato/WhatsApp orders
relayService := services.NewOrderRelayService(db, cfg)
relayService.OrderProcessor = func(platform string, payload json.RawMessage) error {
// Route to appropriate handler based on platform
// Cloud relays finalized orders in ExternalOrderPayload format for all platforms
return externalHandler.ProcessRelayedOrder(platform, payload)
}
// Start background services
syncService.Start()
relayService.Start()
log.Printf("[MODE] Deployment: %s | Location: %s (%s)", cfg.DeploymentMode, cfg.LocationName, cfg.LocationID)
// ================================================================
// API ROUTES
// ================================================================
api := app.Group("/api/v1")
// Auth (public)
auth := api.Group("/auth")
auth.Post("/login", authHandler.Login)
auth.Post("/pin-login", authHandler.PinLogin)
// Public menu endpoint
api.Get("/menu", productHandler.GetMenu)
// Customer search (public for POS)
api.Get("/customers/search", customerHandler.SearchCustomer)
// WebSocket endpoint
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
userID := c.Query("user_id", "anonymous")
role := c.Query("role", "")
station := c.Query("station", "")
client := &ws.Client{
Conn: c,
UserID: userID,
Role: role,
Station: station,
Channels: make(map[string]bool),
}
// Subscribe to relevant channels based on role
switch role {
case "kitchen":
if station != "" {
client.Channels[station] = true
}
case "captain":
client.Channels["orders"] = true
client.Channels["tables"] = true
case "cashier":
client.Channels["orders"] = true
client.Channels["payments"] = true
}
hub.Register(client)
defer hub.Unregister(client)
// Read loop - handle incoming client messages
for {
_, msg, err := c.ReadMessage()
if err != nil {
break
}
// Handle client pings/subscriptions
_ = msg
}
}))
// ================================================================
// EXTERNAL WEBHOOKS (public - verified by tokens/signatures)
// Must be registered BEFORE protected group to avoid auth middleware
// ================================================================
webhooks := api.Group("/webhooks")
webhooks.Get("/whatsapp", whatsappHandler.VerifyWebhook)
webhooks.Post("/whatsapp", whatsappHandler.ReceiveMessage)
webhooks.Post("/swiggy", externalHandler.SwiggyWebhook)
webhooks.Post("/zomato", externalHandler.ZomatoWebhook)
webhooks.Post("/external-order", externalHandler.ReceiveExternalOrder)
webhooks.Post("/edc/callback", edcHandler.Callback)
// ================================================================
// SYNC & RELAY ENDPOINTS (token-authenticated, not JWT)
// ================================================================
sync := api.Group("/sync")
sync.Get("/status", syncHandler.Status) // Frontend: shows sync indicator
sync.Post("/trigger", syncHandler.TriggerSync) // Admin: force immediate sync
sync.Post("/ingest", syncHandler.Ingest) // Cloud: receives batched data from local
sync.Get("/menu", syncHandler.GetMenu) // Local pulls menu updates from cloud
sync.Get("/locations", syncHandler.ListLocations) // Cloud dashboard: list locations
sync.Get("/dashboard", syncHandler.CloudDashboard) // Cloud dashboard: aggregated reports
// Webhook relay (cloud queues → local polls)
relay := sync.Group("/relay")
relay.Post("/webhook/:platform", syncHandler.RelayWebhook) // External platforms push here
relay.Get("/pending", syncHandler.RelayPending) // Local polls for pending orders
relay.Post("/ack", syncHandler.RelayAck) // Local acknowledges processed orders
relay.Get("/stats", syncHandler.RelayStats) // Admin: relay queue stats
// ================================================================
// PROTECTED ROUTES
// ================================================================
protected := api.Group("", middleware.AuthRequired(cfg, db))
// Profile
protected.Get("/auth/me", authHandler.Me)
protected.Post("/auth/change-password", authHandler.ChangePassword)
// Users (admin only)
users := protected.Group("/users", middleware.RequireRoles(models.RoleAdmin, models.RoleManager))
users.Get("/", authHandler.ListUsers)
users.Post("/", authHandler.CreateUser)
users.Put("/:id", authHandler.UpdateUser)
users.Patch("/:id/toggle", authHandler.ToggleUserStatus)
protected.Get("/roles", authHandler.ListRoles)
// Categories
categories := protected.Group("/categories")
categories.Get("/", productHandler.ListCategories)
categories.Get("/:id", productHandler.GetCategory)
categories.Post("/", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), productHandler.CreateCategory)
categories.Put("/:id", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), productHandler.UpdateCategory)
categories.Delete("/:id", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), productHandler.DeleteCategory)
// Products
products := protected.Group("/products")
products.Get("/", productHandler.ListProducts)
products.Get("/:id", productHandler.GetProduct)
products.Post("/", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), productHandler.CreateProduct)
products.Put("/:id", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), productHandler.UpdateProduct)
products.Patch("/:id/availability", productHandler.ToggleAvailability)
products.Delete("/:id", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), productHandler.DeleteProduct)
// Modifier Groups
modifiers := protected.Group("/modifier-groups")
modifiers.Get("/", productHandler.ListModifierGroups)
modifiers.Post("/", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), productHandler.CreateModifierGroup)
// Orders
orders := protected.Group("/orders")
orders.Get("/", orderHandler.ListOrders)
orders.Get("/active", orderHandler.ActiveOrders)
orders.Get("/:id", orderHandler.GetOrder)
orders.Post("/", orderHandler.CreateOrder)
orders.Post("/:id/items", orderHandler.AddItems)
orders.Post("/:id/discount", orderHandler.ApplyDiscount)
orders.Post("/:id/payment", orderHandler.ProcessPayment)
orders.Post("/:id/void", middleware.RequireRoles(models.RoleAdmin, models.RoleManager, models.RoleCashier), orderHandler.VoidOrder)
orders.Post("/:id/reprint", orderHandler.ReprintBill)
orders.Patch("/:id/status", orderHandler.UpdateOrderStatus)
orders.Patch("/:id/items/:itemId/cancel", orderHandler.CancelOrderItem)
orders.Post("/:id/split", orderHandler.SplitBill)
orders.Post("/merge-tables", orderHandler.MergeTables)
orders.Post("/transfer-table", orderHandler.TransferTable)
// KOTs
kots := protected.Group("/kots")
kots.Get("/", kotHandler.ListKOTs)
kots.Get("/stats", kotHandler.KOTStats)
kots.Get("/kds", kotHandler.KDSView)
kots.Get("/history", kotHandler.KOTHistory)
kots.Patch("/:id/status", kotHandler.UpdateKOTStatus)
kots.Patch("/:id/items/:itemId/ready", kotHandler.MarkItemReady)
kots.Post("/:id/cancel-item", kotHandler.CancelKOTItem)
// Tables
tables := protected.Group("/tables")
tables.Get("/", tableHandler.ListTables)
tables.Get("/overview", tableHandler.TableOverview)
tables.Get("/sections", tableHandler.ListSections)
tables.Post("/sections", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), tableHandler.CreateSection)
tables.Post("/", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), tableHandler.CreateTable)
tables.Put("/:id", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), tableHandler.UpdateTable)
tables.Patch("/:id/status", tableHandler.UpdateTableStatus)
tables.Delete("/:id", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), tableHandler.DeleteTable)
// Inventory
inventory := protected.Group("/inventory")
inventory.Get("/", inventoryHandler.ListItems)
inventory.Get("/low-stock", inventoryHandler.LowStockAlerts)
inventory.Get("/:id", inventoryHandler.GetItem)
inventory.Post("/", inventoryHandler.CreateItem)
inventory.Put("/:id", inventoryHandler.UpdateItem)
inventory.Post("/movements", inventoryHandler.AddStockMovement)
inventory.Get("/:id/movements", inventoryHandler.GetMovements)
// Recipes
recipes := protected.Group("/recipes")
recipes.Get("/", inventoryHandler.ListRecipes)
recipes.Get("/:id", inventoryHandler.GetRecipe)
recipes.Post("/", inventoryHandler.CreateRecipe)
recipes.Put("/:id", inventoryHandler.UpdateRecipe)
recipes.Delete("/:id", inventoryHandler.DeleteRecipe)
// Vendors
vendors := protected.Group("/vendors")
vendors.Get("/", vendorHandler.ListVendors)
vendors.Get("/:id", vendorHandler.GetVendor)
vendors.Post("/", vendorHandler.CreateVendor)
vendors.Put("/:id", vendorHandler.UpdateVendor)
// Purchase Orders
purchases := protected.Group("/purchases")
purchases.Get("/", vendorHandler.ListPurchaseOrders)
purchases.Post("/", vendorHandler.CreatePurchaseOrder)
purchases.Post("/:id/receive", vendorHandler.ReceivePurchaseOrder)
// Expenses
expenses := protected.Group("/expenses")
expenses.Get("/", expenseHandler.ListExpenses)
expenses.Get("/summary", expenseHandler.ExpenseSummary)
expenses.Post("/", expenseHandler.CreateExpense)
expenses.Put("/:id", expenseHandler.UpdateExpense)
expenses.Delete("/:id", middleware.RequireRoles(models.RoleAdmin, models.RoleManager), expenseHandler.DeleteExpense)
// Customers
customers := protected.Group("/customers")
customers.Get("/", customerHandler.ListCustomers)
customers.Get("/:id", customerHandler.GetCustomer)
customers.Post("/", customerHandler.CreateCustomer)
customers.Put("/:id", customerHandler.UpdateCustomer)
customers.Get("/:id/orders", customerHandler.CustomerOrders)
customers.Post("/:id/loyalty", customerHandler.ManageLoyalty)
// CRM
crm := protected.Group("/crm")
crm.Get("/campaigns", customerHandler.ListCampaigns)
crm.Post("/campaigns", customerHandler.CreateCampaign)
// Reports
reports := protected.Group("/reports", middleware.RequireRoles(models.RoleAdmin, models.RoleManager, models.RoleAccountant))
reports.Get("/sales/dashboard", reportHandler.SalesDashboard)
reports.Get("/sales", reportHandler.SalesReport)
reports.Get("/gst", reportHandler.GSTReport)
reports.Get("/items", reportHandler.ItemSalesReport)
reports.Get("/categories", reportHandler.CategorySalesReport)
reports.Get("/captain", reportHandler.CaptainReport)
reports.Get("/kitchen", reportHandler.KitchenReport)
reports.Get("/revenue-leakage", reportHandler.RevenueLeakage)
reports.Get("/kot", reportHandler.KOTReport)
reports.Get("/cancelled-items", reportHandler.CancelledItemsReport)
reports.Get("/expenses", reportHandler.ExpenseReport)
reports.Get("/inventory", reportHandler.InventoryReport)
// Settings (admin only)
settings := protected.Group("/settings", middleware.RequireRoles(models.RoleAdmin))
settings.Get("/config", settingsHandler.GetConfig)
settings.Put("/config", settingsHandler.UpdateConfig)
settings.Get("/audit-logs", settingsHandler.GetAuditLogs)
// Printers & Print Routing (admin only)
printers := protected.Group("/printers", middleware.RequireRoles(models.RoleAdmin, models.RoleManager))
printers.Get("/", printerHandler.ListPrinters)
printers.Post("/", printerHandler.CreatePrinter)
printers.Get("/:id", printerHandler.GetPrinter)
printers.Put("/:id", printerHandler.UpdatePrinter)
printers.Delete("/:id", printerHandler.DeletePrinter)
printers.Post("/:id/test", printerHandler.TestPrinter)
// Print routes (routing rules)
printRoutes := protected.Group("/print-routes", middleware.RequireRoles(models.RoleAdmin, models.RoleManager))
printRoutes.Get("/", printerHandler.ListRoutes)
printRoutes.Post("/", printerHandler.CreateRoute)
printRoutes.Delete("/:id", printerHandler.DeleteRoute)
// Print jobs (history)
protected.Get("/print-jobs", printerHandler.ListJobs)
// Print actions (available to cashier, captain, admin, manager)
protected.Post("/orders/:id/print-bill", printerHandler.PrintBill)
protected.Post("/kots/:id/print", printerHandler.PrintKOT)
// Platform Config (admin only)
platforms := protected.Group("/platforms", middleware.RequireRoles(models.RoleAdmin))
platforms.Get("/", externalHandler.ListPlatforms)
platforms.Post("/", externalHandler.CreatePlatform)
platforms.Get("/:id", externalHandler.GetPlatform)
platforms.Put("/:id", externalHandler.UpdatePlatform)
platforms.Delete("/:id", externalHandler.DeletePlatform)
// Online Order Management (authenticated)
onlineOrders := protected.Group("/online-orders")
onlineOrders.Get("/", externalHandler.ListOnlineOrders)
onlineOrders.Get("/counts", externalHandler.OnlineOrderCounts)
onlineOrders.Post("/manual", externalHandler.CreateManualOnlineOrder)
onlineOrders.Put("/:id/accept", externalHandler.AcceptOrder)
onlineOrders.Put("/:id/reject", externalHandler.RejectOrder)
onlineOrders.Put("/:id/ready", externalHandler.MarkReady)
onlineOrders.Put("/:id/picked-up", externalHandler.MarkPickedUp)
onlineOrders.Put("/:id/delivered", externalHandler.MarkDelivered)
// EDC Payment (card swipe machine)
edcRoutes := protected.Group("/edc")
edcRoutes.Post("/initiate", edcHandler.Initiate)
edcRoutes.Get("/status/:id", edcHandler.Status)
edcRoutes.Post("/cancel/:id", edcHandler.Cancel)
edcRoutes.Get("/config", edcHandler.GetConfig)
// Webhook Logs (admin only)
protected.Get("/webhook-logs", middleware.RequireRoles(models.RoleAdmin), externalHandler.ListWebhookLogs)
// ================================================================
// STATIC FILE SERVING (production mode)
// ================================================================
staticDir := os.Getenv("STATIC_DIR")
if staticDir != "" {
if info, stErr := os.Stat(staticDir); stErr == nil && info.IsDir() {
log.Printf("Serving static frontend from: %s", staticDir)
indexFile := filepath.Join(staticDir, "index.html")
app.Get("/*", func(c *fiber.Ctx) error {
// Try to serve the exact file from the static directory
filePath := filepath.Join(staticDir, c.Path())
if fInfo, fErr := os.Stat(filePath); fErr == nil && !fInfo.IsDir() {
return c.SendFile(filePath)
}
// SPA fallback — serve index.html for client-side routing
return c.SendFile(indexFile)
})
}
}
// ================================================================
// START SERVER
// ================================================================
go func() {
port := ":" + cfg.Port
log.Printf("Server starting on http://localhost%s", port)
log.Printf("API docs: http://localhost%s/api/v1", port)
log.Printf("WebSocket: ws://localhost%s/ws", port)
log.Printf("Environment: %s", cfg.Environment)
if err := app.Listen(port); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}()
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
app.Shutdown()
log.Println("Server stopped")
}
func customErrorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{
"success": false,
"error": err.Error(),
})
}