diff --git a/controllers/posController.go b/controllers/posController.go index 1682c47..c217670 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -453,3 +453,32 @@ func (ctl *PosController) Session(c *fiber.Ctx) error { }, }) } + +// Staff lists who may ring a bill at this terminal's outlet. +// +// Scoped by the caller's own session rather than by a query parameter. A till +// asking "who works here" must not be able to ask on behalf of another shop, +// and the answer carries PINs — so the outlet comes from the token, and a +// request without one is refused whatever POS_AUTH_REQUIRED says. +func (ctl *PosController) Staff(c *fiber.Ctx) error { + claims, ok := middleware.PosClaimsFrom(c) + if !ok { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{ + "code": http.StatusUnauthorized, "status": false, + "message": "a session token is required to read staff", + }) + } + + staff, err := ctl.posService.Staff(claims.Tenantid, claims.Locationid) + if err != nil { + return posServerError(c, "Staff", err) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, + "details": models.PosStaffResponse{ + Locationid: claims.Locationid, + Staff: staff, + }, + }) +} diff --git a/messaging/posmqtt_test.go b/messaging/posmqtt_test.go index ed48bc2..24080cf 100644 --- a/messaging/posmqtt_test.go +++ b/messaging/posmqtt_test.go @@ -80,6 +80,10 @@ func (f *fakePosService) LocationAllowed(int, int) (bool, error) { return false, nil } +func (f *fakePosService) Staff(int, int) ([]models.PosStaffMember, error) { + return nil, nil +} + func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) { return nil, nil } diff --git a/models/pos.go b/models/pos.go index 637ee85..806b356 100644 --- a/models/pos.go +++ b/models/pos.go @@ -281,4 +281,46 @@ type PosSession struct { // gets a list of one, so the till has no special case: it shows a picker // when there is a choice and skips it when there is not. Locations []PosLoginLocation `json:"locations"` + + // The people who may ring a bill at the chosen outlet. + // + // Sent with the session so a terminal is ready to trade the moment it signs + // in, rather than needing a second call before the first customer. May be + // empty — most tenants have no staff recorded yet — and the terminal has to + // cope with that rather than treat it as a failure. + Staff []PosStaffMember `json:"staff"` +} + +// PosStaffMember is one person who may ring a bill at an outlet. +// +// Distinct from the account that signs the *terminal* in. The sign-in says +// which shop this till belongs to; this says who is standing at it, and it is +// what gets stamped on a bill as `cashiername` and settled against at the end +// of a shift. +// +// The PIN travels in the clear, over TLS, and that is a considered choice +// rather than an oversight. A four-digit PIN is brute-forceable in microseconds +// whatever it is wrapped in, so hashing it here would buy the appearance of +// strength and not the substance. What it would cost is real: the terminal +// salts every PIN with its own random salt before storing it, so a hash +// computed here could never be verified there without inventing a shared +// scheme and keeping two codebases agreeing about it for ever. +// +// The honest framing is that a PIN is *shift attribution*, not a security +// boundary. The boundary is the session token — which is what stops a till +// reaching another tenant's books at all. The PIN decides which of the people +// already inside a shop gets credited with a sale, and the terminal still +// stores it hashed at rest. +type PosStaffMember struct { + Userid int `json:"user_id"` + Fullname string `json:"full_name"` + Role string `json:"role"` + Pin string `json:"pin,omitempty"` + Status string `json:"status,omitempty"` +} + +// PosStaffResponse answers a request for an outlet's people. +type PosStaffResponse struct { + Locationid int `json:"location_id"` + Staff []PosStaffMember `json:"staff"` } diff --git a/repositories/posAuthRepository.go b/repositories/posAuthRepository.go index 50a4052..e650d70 100644 --- a/repositories/posAuthRepository.go +++ b/repositories/posAuthRepository.go @@ -129,6 +129,15 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession } r.decoratePosSession(session) + + // Staff come down with the session so a till is ready to trade the moment + // it signs in. A failure here is not a failed sign-in: a shop with no staff + // recorded — which is almost all of them today — must still be able to open + // its terminal. + if staff, err := r.PosStaff(session.Tenantid, session.Locationid); err == nil { + session.Staff = staff + } + return session, nil } @@ -297,3 +306,61 @@ func constantTimeEqual(a, b string) bool { } return diff == 0 } + +// PosStaff lists the people who may ring a bill at an outlet. +// +// Two sources, unioned, because the schema has two and neither is complete. +// `tenantstaffs` is the table built for this and holds 12 rows on the entire +// platform; `app_users.locationid` is where staff actually ended up. Reading +// only the purpose-built table would return nothing for almost every shop, and +// reading only `app_users` would miss anyone assigned through the back office's +// staff screen. So both. +// +// Only people with a PIN come back. A row with `pin = 0` cannot ring anything — +// offering it to the till would put a name on screen that no one can sign in +// as, which reads as a broken terminal rather than as an unfinished setup. +func (r *posRepository) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) { + rows := make([]models.PosStaffMember, 0) + + query := ` + SELECT DISTINCT + a.userid, + TRIM(CONCAT(COALESCE(a.firstname,''), ' ', COALESCE(a.lastname,''))) AS fullname, + COALESCE(r.rolename, '') AS role, + CAST(a.pin AS TEXT) AS pin, + COALESCE(a.status, '') AS status + FROM app_users a + LEFT JOIN app_roles r ON r.roleid = a.roleid + WHERE a.tenantid = ? + AND COALESCE(a.pin, 0) > 0 + AND LOWER(COALESCE(a.status, 'active')) <> 'inactive' + AND ( + a.locationid = ? + OR EXISTS (SELECT 1 FROM tenantstaffs s + WHERE s.userid = a.userid + AND s.tenantid = a.tenantid + AND s.locationid = ? + AND LOWER(COALESCE(s.status, 'active')) <> 'inactive') + ) + ORDER BY fullname` + + if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil { + return nil, err + } + + // A PIN shared by two people at one outlet would make the till attribute a + // bill to whichever row it happened to check first — so the second one is + // dropped rather than sent. Live data has 1234 on eleven accounts and 1111 + // on nine, so this is not hypothetical. + seen := make(map[string]bool, len(rows)) + unique := rows[:0] + for _, row := range rows { + if seen[row.Pin] { + continue + } + seen[row.Pin] = true + unique = append(unique, row) + } + + return unique, nil +} diff --git a/repositories/posRepository.go b/repositories/posRepository.go index 3c20328..cbaae3e 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -42,6 +42,7 @@ type PosRepository interface { // own record, rather than being named by the till and believed. PosLogin(req models.PosLoginRequest) (*models.PosSession, error) PosLocationAllowed(tenantID, locationID int) (bool, error) + PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) // Reading counter sales back out. Without these a committed bill is // unreachable from every screen in the product. diff --git a/routes/posroutes.go b/routes/posroutes.go index ecb8955..b84c520 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -38,6 +38,10 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) { 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) + pos.Post("/orders", f.PosController.IngestOrders) pos.Post("/customers", f.PosController.IngestCustomers) pos.Get("/catalogue", f.PosController.Catalogue) diff --git a/services/posService.go b/services/posService.go index ffdcfb3..11a5c3e 100644 --- a/services/posService.go +++ b/services/posService.go @@ -33,6 +33,11 @@ type PosService interface { // LocationAllowed is the authorisation check every other POS call rests on: // does the tenant in the caller's token actually own this outlet. LocationAllowed(tenantID, locationID int) (bool, error) + + // Staff lists who may ring a bill at an outlet. Sent with the session and + // available on its own, so a shop that hires someone mid-shift can pull them + // down without signing the terminal out. + Staff(tenantID, locationID int) ([]models.PosStaffMember, error) } type posService struct { @@ -111,3 +116,7 @@ func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, erro func (s *posService) LocationAllowed(tenantID, locationID int) (bool, error) { return s.repo.PosLocationAllowed(tenantID, locationID) } + +func (s *posService) Staff(tenantID, locationID int) ([]models.PosStaffMember, error) { + return s.repo.PosStaff(tenantID, locationID) +}