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(), }) }