package facade import ( "log" "nearle/config" "nearle/controllers" "nearle/repositories" "nearle/services" "nearle/services/tools" "nearle/utils" "gorm.io/gorm" ) type Facade struct { UserController *controllers.UserController ProductController *controllers.ProductController OrderController *controllers.OrderController DeliveriesController *controllers.DeliveriesController UtilsController *controllers.UtilsController TenantController *controllers.TenantController PartnerController *controllers.PartnerController CustomerController *controllers.CustomerController StockRequestController *controllers.StockRequestController CatalogueController *controllers.CatalogueController PosController *controllers.PosController LiveController *controllers.LiveController CatalogueUploadController *controllers.CatalogueUploadController ScanController *controllers.ScanController AssistantController *controllers.AssistantController HealthController *controllers.HealthController MCPController *controllers.MCPController // Tools is what Nearle Buddy is allowed to do. // // Held on the facade because the assistant is not a module with a // repository of its own — it is a door onto the services already built // here, and every tool handler calls one of them rather than the database. Tools *tools.Registry // Held so the NATS consumer can reach the ingest without going through // HTTP. Unexported: everything else should use the controller. posService services.PosService } // NewFacade wires up modules against the main (nearledb) connection. // catalogueDB is a separate connection to the pgvector catalogue database; // it may be nil if catalogue env vars are not configured, in which case // catalogue endpoints will error at query time rather than at startup. // embedder may be nil too: scan-to-order then matches on words alone. func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig, nutritionBase string) *Facade { // The invitation, built first because two modules need it. // // Every back-office account on this platform is created with NO password — // the onboarded merchant, every person added to the directory, and the login // each branch spawns — and since the sign-in screen stopped offering to set // one, the emailed link is the only way in. So whichever module creates an // account has to be able to send it. // // `mailer` may be nil: a deployment with no mail configured still creates // everything, and each response says the invitation was not sent and names // the variable, rather than failing the create. // // The tenant repository supplies the business name for the mail's first line // (`services.TenantNamer`), which is why it is built here rather than in the // tenant module below. tenantRepo := repositories.NewTenantRepository(db) inviteService := services.NewInviteService(mailer, mailCfg, tenantRepo) // User Module userRepo := repositories.NewUserRepository(db) userService := services.NewUserService(userRepo, inviteService) userController := controllers.NewUserController(userService) // Catalogue Module (separate pgvector DB — never the main `db`). Built // before the Product Module because ProductService depends on it to // bridge catalogue imports into a tenant's own product catalogue. catalogueRepo := repositories.NewCatalogueRepository(catalogueDB) catalogueService := services.NewCatalogueService(catalogueRepo) catalogueController := controllers.NewCatalogueController(catalogueService) // Product Module // // The nutrition service is the catalogue-intelligence host — the same one // behind the health score card in the console — read by the product screen // for its nutrition panel. Nil when NUTRITION_BASE is unset, which serves // every product screen exactly as before, without a panel. productRepo := repositories.NewProductRepository(db) productService := services.NewProductService( productRepo, catalogueService, services.NewNutritionService(nutritionBase)) productController := controllers.NewProductController(productService) // Order Module orderRepo := repositories.NewOrderRepository(db) orderService := services.NewOrderService(orderRepo) orderController := controllers.NewOrderController(orderService) // Deliveries Module deliveriesRepo := repositories.NewDeliveriesRepository(db) deliveriesService := services.NewDeliveriesService(deliveriesRepo) deliveriesController := controllers.NewDeliveriesController(deliveriesService) // Utils Module utilsRepo := repositories.NewUtilsRepository(db) utilsService := services.NewUtilsService(utilsRepo) utilsController := controllers.NewUtilsController(utilsService) //Tenant Module // // Onboarding, adding a person and commissioning a branch all create an // account with no password, so all three send an invitation. `tenantRepo` and // `inviteService` are built above, where the reasoning is. tenantService := services.NewTenantService(tenantRepo, inviteService) tenantController := controllers.NewTenantController(tenantService) //Partner Module partnerRepo := repositories.NewPartnerRepository(db) partnerService := services.NewPartnerService(partnerRepo) partnerController := controllers.NewPartnerController(partnerService) //Customer Module customerRepo := repositories.NewCustomerRepository(db) customerService := services.NewCustomerService(customerRepo) customerController := controllers.NewCustomerController(customerService) // Stock Request Module stockRequestRepo := repositories.NewStockRequestRepository(db) stockRequestService := services.NewStockRequestService(stockRequestRepo, productService) stockRequestController := controllers.NewStockRequestController(stockRequestService) // POS Module — ingest from the in-store terminals. // // Presence has no *gorm.DB: terminal health lives in Redis under a TTL, so // a till that loses power ages out of the board by itself instead of // leaving a Postgres row claiming it is online. posRepo := repositories.NewPosRepository(db) posPresence := repositories.NewPosPresenceRepository() posService := services.NewPosService(posRepo, posPresence) posController := controllers.NewPosController(posService) // Shares the POS service purely for its outlet-ownership check — the // stream itself reads no database and holds no state beyond its // subscribers. liveController := controllers.NewLiveController(posService) // Catalogue Upload Module — our own receipt for every spreadsheet sent to // the ingest service. Their host deletes an unreviewed drop after seven // days and the batch id is the only credential for reading the result // back, so the id has to be kept somewhere that outlives a browser tab. catalogueUploadRepo := repositories.NewCatalogueUploadRepository(db) catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo) catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService) // Scan Module — a label from the customer's camera to "buy it here". // Reads both databases: the catalogue to recognise the product, nearledb // for who the customer is and what their outlets have on the shelf. scanRepo := repositories.NewScanRepository(db, catalogueDB) scanService := services.NewScanService(scanRepo, embedder) scanController := controllers.NewScanController(scanService) // The assistant registry. Built last, because every tool it holds is a thin // wrapper over a service constructed above. // // A registration error panics rather than being logged. A duplicate name or // a tool with no description is a programming mistake, and a server that // starts with a tool silently absent answers real questions with "I cannot // do that" for a reason nobody can see from the outside. // The help corpus, checked before it is registered. A passage carrying one // shop's figures stops the server rather than reaching another shop's screen. helpCorpus, err := tools.LoadHelp() if err != nil { panic("assistant help: " + err.Error()) } // The audit trail goes to the database and to the log. See // services/assistantAudit.go for why both. auditRepo := repositories.NewAssistantAuditRepository(db) toolRegistry := tools.New(services.NewDBAudit(auditRepo)) for _, tool := range []tools.Tool{ tools.StuckOrders(deliveriesService, nil), tools.DeliveryProgress(deliveriesService), tools.BranchPerformance(orderService), tools.PendingApprovals(stockRequestService, nil), tools.LowStock(productService), tools.TillsNotSyncing(posService), tools.SalesByChannel(orderService, posService, nil), tools.Help(helpCorpus), tools.ApproveStockRequest(stockRequestService, stockRequestService), } { if err := toolRegistry.Register(tool); err != nil { panic("assistant tools: " + err.Error()) } } // Nearle Buddy. `chat` may be nil — a deployment with no model configured // still gets the registry and the endpoint, and the endpoint answers "not // switched on here" rather than a 500. The tools themselves are ordinary // Go functions and work either way; only turning a sentence into a tool // call needs a model. // Agent definitions, validated against the registry above. A typo in a tool // name stops the server rather than producing an agent that quietly cannot // do one of the things it claims — which is invisible at runtime, because the // model simply reports it could not look something up. agents, err := services.LoadAgents(agentsDir, toolRegistry.Has) if err != nil { panic("assistant agents: " + err.Error()) } log.Printf("assistant: %d agents loaded %v", len(agents), services.AgentNames(agents)) assistantService := services.NewAssistantService(toolRegistry, chat, agents) // Why there is no model, if there is not. Passed through so /assistant/status // can name the missing variable instead of just saying no. if setter, ok := assistantService.(interface{ SetUnavailableReason(string) }); ok && chat == nil { setter.SetUnavailableReason(assistantWhy) } assistantController := controllers.NewAssistantController(assistantService) // What is running here. Unauthenticated, booleans only — see healthController.go // for why a server that cannot say which build it is costs a day. healthController := controllers.NewHealthController(assistantService, db != nil) // The second door. Same registry, same agents, same session — see // controllers/mcpController.go for why it is a door rather than a service. mcpController := controllers.NewMCPController(toolRegistry, agents) return &Facade{ UserController: userController, ProductController: productController, OrderController: orderController, DeliveriesController: deliveriesController, UtilsController: utilsController, TenantController: tenantController, PartnerController: partnerController, CustomerController: customerController, StockRequestController: stockRequestController, CatalogueController: catalogueController, PosController: posController, LiveController: liveController, CatalogueUploadController: catalogueUploadController, ScanController: scanController, AssistantController: assistantController, HealthController: healthController, MCPController: mcpController, Tools: toolRegistry, posService: posService, } } // PosService exposes the ingest to callers outside the HTTP layer — the NATS // consumer runs the same code path a POST does, so a bill arriving over MQTT // and one arriving over HTTP cannot diverge. func (f *Facade) PosService() services.PosService { return f.posService }