198 lines
9.2 KiB
Go
198 lines
9.2 KiB
Go
package routes
|
|
|
|
import (
|
|
"nearle/facade"
|
|
"nearle/middleware"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// Routes for the Nearle POS terminal.
|
|
//
|
|
// The paths are fixed by the till, which appends `/orders`, `/customers` and
|
|
// `/catalogue` to whatever base URL a shop enters in Settings. Set that base to
|
|
// this group — `https://your-host/live/api/v1/pos` — and the three line up.
|
|
//
|
|
// Kept in their own group rather than folded into the order routes because a
|
|
// terminal authenticates as a device, not as a signed-in user, and because
|
|
// these answer with a bare ack rather than the web app's response envelope.
|
|
func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
|
|
|
pos := api.Group("/v1/pos")
|
|
|
|
// Sign-in, and the only route on this group that runs before the guard —
|
|
// it is where a session comes from. A till posts the same `app_users`
|
|
// credentials the web console takes, and gets back a token plus the outlet
|
|
// that account is entitled to. The store id it will bill under is decided
|
|
// here, from the user's record, instead of being typed into Settings and
|
|
// taken on trust.
|
|
pos.Post("/login", f.PosController.Login)
|
|
|
|
// Everything past this point carries the session.
|
|
//
|
|
// The guard verifies the token and refuses a request naming an outlet the
|
|
// token's tenant does not own. Until `POS_AUTH_REQUIRED=true` is set it
|
|
// lets an unauthenticated request through, so the terminals already
|
|
// trading do not stop the day this deploys — see middleware.PosAuth.
|
|
pos.Use(middleware.PosAuth(f.PosService()))
|
|
|
|
pos.Get("/session", f.PosController.Session)
|
|
|
|
// Who may ring a bill here. Deliberately takes no location parameter — the
|
|
// answer carries PINs, so the outlet comes from the caller's own token.
|
|
pos.Get("/staff", f.PosController.Staff)
|
|
|
|
// Signing on by PIN, once a supervisor has opened the terminal with a real
|
|
// password. Sits behind the guard on purpose — see PinLogin.
|
|
pos.Post("/login/pin", f.PosController.PinLogin)
|
|
|
|
// The shop's own counter staff. A supervisor creates their cashiers; the
|
|
// outlet is always the caller's own, read from their token.
|
|
pos.Get("/users", f.PosController.ListPosUsers)
|
|
pos.Post("/users", f.PosController.CreatePosUser)
|
|
pos.Put("/users", f.PosController.UpdatePosUser)
|
|
pos.Delete("/users", f.PosController.DeletePosUser)
|
|
|
|
pos.Post("/orders", f.PosController.IngestOrders)
|
|
pos.Post("/customers", f.PosController.IngestCustomers)
|
|
pos.Get("/catalogue", f.PosController.Catalogue)
|
|
|
|
// The 30-second heartbeat, for tills on the HTTP route. The broker carries
|
|
// the same payload for tills on MQTT; both land in the same Redis record,
|
|
// so the fleet board cannot tell them apart and does not need to.
|
|
pos.Post("/health", f.PosController.IngestHealth)
|
|
|
|
// Counter sales, read back out. The ingest above only ever writes; without
|
|
// these a committed bill is unreachable from every screen in the product.
|
|
pos.Get("/sales", f.PosController.GetSales)
|
|
pos.Get("/sales/detail", f.PosController.GetSaleDetail)
|
|
pos.Get("/sales/summary", f.PosController.GetSalesSummary)
|
|
|
|
// Terminal presence, read from Redis. What the rider app's POS board and a
|
|
// support call both hit — the tills themselves publish health over the
|
|
// broker rather than posting it here.
|
|
pos.Get("/health/terminal", f.PosController.TerminalHealth)
|
|
pos.Get("/health/location", f.PosController.LocationHealth)
|
|
|
|
registerPosStaffConsoleRoutes(api, f)
|
|
registerPosReadConsoleRoutes(api, f)
|
|
registerLiveRoutes(api, f)
|
|
registerPosAdoptionRoute(api, f)
|
|
}
|
|
|
|
// How much of the till fleet has adopted the session token.
|
|
//
|
|
// The number that decides when `POS_AUTH_REQUIRED` can be switched on. Nothing
|
|
// was recording it — an untokened request was waved through in silence — so the
|
|
// only way to judge the risk of flipping the flag was to flip it and watch.
|
|
//
|
|
// ── Why it is on /v1/web and only /v1/web ───────────────────────────────────
|
|
//
|
|
// It names the outlets still calling without a token, which is a list of the
|
|
// shops that would stop trading if enforcement went on today. That is exactly
|
|
// the list an attacker would want, so it sits behind `middleware.WebAuth` and
|
|
// NOT on the unauthenticated health endpoint, where the rest of "is this
|
|
// deployment wired up" lives.
|
|
//
|
|
// Registered on its own rather than inside registerPosReadConsoleRoutes,
|
|
// because that function deliberately mirrors every route onto `/v1/mob/pos`
|
|
// as well — which has no guard at all.
|
|
func registerPosAdoptionRoute(api fiber.Router, f *facade.Facade) {
|
|
api.Group("/v1/web/pos").Get("/authadoption", f.PosController.AuthAdoption)
|
|
}
|
|
|
|
// The same counter-sales reads, for callers that are not a terminal.
|
|
//
|
|
// `PosAuth` pins a request to the outlet inside a terminal's token. The web
|
|
// console has no such token and cannot obtain one — `/pos/login` refuses an
|
|
// account that is not a till account, which is the separation working as
|
|
// intended. So the moment `POS_AUTH_REQUIRED=true` is set, every POS screen in
|
|
// the back office goes dark: the two were mutually exclusive.
|
|
//
|
|
// Rather than weaken the terminal guard or hand the console a terminal
|
|
// identity, the reads are offered again outside the group. A browser and a till
|
|
// are different callers and belong on different doors.
|
|
//
|
|
// Only the five the console actually reads, and only reads. Specifically NOT
|
|
// `/health/terminal`: it takes a terminal code and no outlet, so it resolves
|
|
// the shop from the heartbeat and checks that against the caller's token. Off
|
|
// this group there is no token to check, and mirroring it would undo that.
|
|
// Nothing in the console calls it — `/health/location` answers the same
|
|
// question with an outlet to scope by.
|
|
//
|
|
// These inherit the `/web` surface's authentication, which is none. That is not
|
|
// a new hole opened here — `getposusers` on the neighbouring group already
|
|
// answers unauthenticated and returns PINs — but it is the reason this whole
|
|
// surface wants a session guard, which is tracked separately.
|
|
func registerPosReadConsoleRoutes(api fiber.Router, f *facade.Facade) {
|
|
for _, group := range []string{"/v1/web/pos", "/v1/mob/pos"} {
|
|
g := api.Group(group)
|
|
|
|
g.Get("/sales", f.PosController.GetSales)
|
|
g.Get("/sales/detail", f.PosController.GetSaleDetail)
|
|
g.Get("/sales/summary", f.PosController.GetSalesSummary)
|
|
|
|
// The fleet board. Scoped by the outlet named in the query, exactly as
|
|
// on the terminal group — the handler reads nothing from a session.
|
|
g.Get("/health/location", f.PosController.LocationHealth)
|
|
|
|
// What a till at this shop can sell. Read-only and already scoped by
|
|
// store id; the console shows it to explain why a product will not ring.
|
|
g.Get("/catalogue", f.PosController.Catalogue)
|
|
}
|
|
}
|
|
|
|
// Till staff, managed from the web console rather than from a counter.
|
|
//
|
|
// Under `/web` and `/mob` rather than `/pos`, because the callers are the back
|
|
// office and the daily app — neither holds a terminal session, and putting them
|
|
// behind the terminal guard would lock out the very screen an admin uses to set
|
|
// a shop up in the first place.
|
|
//
|
|
// They run the same service calls as `/pos/users`. A supervisor created here is
|
|
// the same row, with the same rules applied, as one created at a till.
|
|
//
|
|
// The outlet is asserted rather than proved, which is the real difference and
|
|
// the weaker half: a terminal signs its outlet, a console just names one. It is
|
|
// checked against the tenant before anything is written, and these should move
|
|
// behind a session guard as soon as the console can hold one — until then, this
|
|
// mints till credentials on the strength of an unauthenticated request, exactly
|
|
// like every other route in this group.
|
|
func registerPosStaffConsoleRoutes(api fiber.Router, f *facade.Facade) {
|
|
for _, group := range []string{"/v1/web/tenants", "/v1/mob/tenants"} {
|
|
g := api.Group(group)
|
|
|
|
// Served rather than hardcoded, so a console offering the choice does
|
|
// not have to know that supervisor is 7.
|
|
g.Get("/posroles", f.PosController.WebPosRoles)
|
|
|
|
g.Get("/getposusers", f.PosController.WebListPosUsers)
|
|
g.Post("/createposuser", f.PosController.WebCreatePosUser)
|
|
g.Put("/updateposuser", f.PosController.WebUpdatePosUser)
|
|
g.Delete("/deleteposuser", f.PosController.WebDeletePosUser)
|
|
|
|
// Working windows a till account can be assigned to. Alongside the
|
|
// staff routes rather than under /pos, for the same reason: the caller
|
|
// is the back office setting a shop up, and it holds no terminal
|
|
// session to be checked against.
|
|
g.Get("/getstaffshifts", f.PosController.WebListStaffShifts)
|
|
g.Post("/createstaffshift", f.PosController.WebCreateStaffShift)
|
|
g.Put("/updatestaffshift", f.PosController.WebUpdateStaffShift)
|
|
}
|
|
}
|
|
|
|
// The console's live event stream.
|
|
//
|
|
// Separate from the POS groups above because the caller is the back office,
|
|
// not a terminal, and because it is the one route here that holds a
|
|
// connection open — worth being obvious about when reading the route table.
|
|
//
|
|
// `/events` is Server-Sent Events and never returns until the client goes
|
|
// away or the age limit fires. `/events/health` is an ordinary JSON read that
|
|
// says how many consoles are attached to THIS replica.
|
|
func registerLiveRoutes(api fiber.Router, f *facade.Facade) {
|
|
g := api.Group("/v1/web/live")
|
|
g.Get("/events", f.LiveController.Stream)
|
|
g.Get("/events/health", f.LiveController.Health)
|
|
}
|