From cd2459dbb658c8674c24d9dd4fe7970d4cdc4788 Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 6 Aug 2026 20:42:27 +0530 Subject: [PATCH] Let an admin create till staff from the console, through the same code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admin sets a shop up from a browser; a supervisor adds a cashier at the counter. Both had to be possible, and only the second one was. So the console gets createposuser / updateposuser / getposusers / deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the same service methods `/pos/users` calls. Not a parallel implementation: a supervisor created from a browser is the same row, with the same PIN rules, the same duplicate check and the same identity-column allocation, as one created at a till. Two paths writing one table is precisely how the two stop matching, and this codebase already had that happen once. configid is inferred rather than asked for. It is a number nobody looks up, it varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting it wrong creates somebody who cannot sign into the portal their colleagues use and is invisible to half the platform's queries. /posroles is served rather than left to the console to hardcode. A console that knew supervisor was 7 would be wrong the day that changed and would have no way to find out. The outlet is the real difference between the two doors. A terminal proves it with a signed token; the console asserts it, and is checked against the tenant before anything is written. That is weaker, and it is worth being plain about: these mint till credentials on an unauthenticated request, exactly like every other route in the /v1/web and /v1/mob groups, because there is no auth middleware on the web API at all. Documented as the weakest point in the design and flagged to move behind a session guard once the console can hold one. The terminal routes are untouched by it. Proven in a rolled-back transaction against live data: the console creates a supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true, the till's /pos/staff sees them alongside the two created at the counter, and 0451, 1234 and a duplicate PIN are each refused with the same message the terminal gives. Co-Authored-By: Claude Opus 5 --- POS_LOGIN.md | 56 +++++++++++ controllers/posController.go | 151 ++++++++++++++++++++++++++++++ messaging/posmqtt_test.go | 2 + models/pos.go | 15 +++ repositories/posRepository.go | 1 + repositories/posUserRepository.go | 19 ++++ routes/posroutes.go | 33 +++++++ services/posService.go | 8 ++ 8 files changed, 285 insertions(+) diff --git a/POS_LOGIN.md b/POS_LOGIN.md index 2263761..fcccaa9 100644 --- a/POS_LOGIN.md +++ b/POS_LOGIN.md @@ -325,6 +325,62 @@ with one tap. --- +## Creating staff from the web console + +The same staff management, for the screen an admin actually uses. Registered +under both `/v1/web/tenants` and `/v1/mob/tenants`. + +``` +GET /v1/web/tenants/posroles +GET /v1/web/tenants/getposusers?tenantid=1087&locationid=1135 +POST /v1/web/tenants/createposuser +PUT /v1/web/tenants/updateposuser +DELETE /v1/web/tenants/deleteposuser?tenantid=1087&locationid=1135&userid=9189 +``` + +`createposuser` takes the same body as `/pos/users`, plus the outlet — the +console has no session token, so it has to name one: + +```json +{ + "tenantid": 1087, + "locationid": 1135, + "full_name": "Asha Kumar", + "role": "cashier", + "pin": "4821" +} +``` + +**These run the same service calls as `/pos/users`.** A supervisor created from +a browser is the same row, with the same rules applied, as one created at a +counter — same PIN validation, same duplicate check, same identity-column +allocation. That is the point of them: two paths writing one table is how the +two stop matching. + +`configid` is never asked for. It is inferred from whichever value the tenant's +existing accounts carry — a number nobody looks up, that varies per tenant (1087 +is spread across 1, 6 and 15), and that silently creates an account nobody can +find if it is wrong. + +`GET /posroles` returns the two roles with their ids and labels, so a console +offering the choice never has to know that supervisor is `7`. + +### :red_circle: These are unauthenticated + +Like every other route in the `/v1/web` and `/v1/mob` groups — there is no auth +middleware anywhere on the web API. The outlet is checked against the tenant +before anything is written, so a caller cannot create staff at a shop that is +not theirs *given a tenant id* — but nothing proves the caller is that tenant. + +So this mints till credentials on an unauthenticated request. It is consistent +with the rest of the platform, and it is still the weakest point in this design. +They should move behind a session guard as soon as the console can hold one. + +The terminal routes are not affected: `/pos/users` proves its outlet with a +signed token. + +--- + ## Using the token ``` diff --git a/controllers/posController.go b/controllers/posController.go index 66d135a..43b6575 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -676,3 +676,154 @@ func posClaimError(c *fiber.Ctx, err error) error { } return posServerError(c, "posClaims", err) } + +// ------------------------------------------------------- Till staff, from the web +// +// The same staff management as `/pos/users`, for the console an admin actually +// uses. Deliberately the same service calls underneath rather than a parallel +// implementation: a supervisor created from a browser must be the same thing as +// one created at a counter, and two code paths writing one table is exactly how +// that stops being true. +// +// The difference is where the outlet comes from. A terminal proves it with a +// signed token; the console asserts it, because it has no session of its own. +// So it is verified against the tenant before anything is written — which is +// weaker than a signature, and is why these should move behind the same guard +// once the console can hold a session. + +// posWebScope reads and checks the tenant and outlet a console request names. +func (ctl *PosController) posWebScope(tenantID, locationID int) error { + if tenantID <= 0 { + return fmt.Errorf("tenantid is required") + } + if locationID <= 0 { + return fmt.Errorf("locationid is required") + } + + allowed, err := ctl.posService.LocationAllowed(tenantID, locationID) + if err != nil { + return fmt.Errorf("could not verify the outlet") + } + if !allowed { + // Not "no such outlet" — that would confirm which ids exist. It did not + // belong to the tenant asking, and that is all the caller needs. + return fmt.Errorf("outlet %d does not belong to tenant %d", locationID, tenantID) + } + return nil +} + +// WebCreatePosUser adds a supervisor or cashier from the console. +func (ctl *PosController) WebCreatePosUser(c *fiber.Ctx) error { + var req models.PosUserWebRequest + if err := c.BodyParser(&req); err != nil { + return posBadRequest(c, fmt.Errorf("invalid request body")) + } + + if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil { + return posBadRequest(c, err) + } + + // The configid the outlet's other people already use, so a new cashier is + // visible to the same portal as their colleagues. Asked for rather than + // derived would mean a console sending a number nobody can look up. + configID := ctl.posService.ConfigidFor(req.Tenantid) + + user, err := ctl.posService.CreateUser(req.Tenantid, req.Locationid, configID, req.PosUserRequest) + if err != nil { + return posBadRequest(c, err) + } + + return c.Status(http.StatusCreated).JSON(fiber.Map{ + "code": http.StatusCreated, "status": true, + "message": "User created", "details": user, + }) +} + +// WebUpdatePosUser edits one of an outlet's till users from the console. +func (ctl *PosController) WebUpdatePosUser(c *fiber.Ctx) error { + var req models.PosUserWebRequest + if err := c.BodyParser(&req); err != nil { + return posBadRequest(c, fmt.Errorf("invalid request body")) + } + + if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil { + return posBadRequest(c, err) + } + + user, err := ctl.posService.UpdateUser(req.Tenantid, req.Locationid, req.PosUserRequest) + if err != nil { + return posBadRequest(c, err) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, + "message": "User updated", "details": user, + }) +} + +// WebListPosUsers lists an outlet's till users for the console. +func (ctl *PosController) WebListPosUsers(c *fiber.Ctx) error { + tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid"))) + locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid"))) + + if err := ctl.posWebScope(tenantID, locationID); err != nil { + return posBadRequest(c, err) + } + + users, err := ctl.posService.ListUsers(tenantID, locationID, + strings.EqualFold(c.Query("include_inactive"), "true")) + if err != nil { + return posServerError(c, "WebListPosUsers", err) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, + "details": fiber.Map{"location_id": locationID, "users": users}, + }) +} + +// WebDeletePosUser retires a till user from the console. +func (ctl *PosController) WebDeletePosUser(c *fiber.Ctx) error { + tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid"))) + locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid"))) + + if err := ctl.posWebScope(tenantID, locationID); err != nil { + return posBadRequest(c, err) + } + + userID, err := strconv.Atoi(strings.TrimSpace(c.Query("userid"))) + if err != nil || userID <= 0 { + return posBadRequest(c, fmt.Errorf("userid is required")) + } + + if err := ctl.posService.DeactivateUser(tenantID, locationID, userID); err != nil { + return posBadRequest(c, err) + } + + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, "message": "User deactivated", + }) +} + +// WebPosRoles lists the roles a console may offer. +// +// Served rather than hardcoded in the console, because the numbers are this +// backend's business. A console that hardcoded 7 and 8 would be wrong the day +// they change, and would have no way to know. +func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "code": http.StatusOK, "status": true, + "details": []fiber.Map{ + { + "role_id": models.PosRoleSupervisor, "role": "supervisor", + "label": models.PosRoleName(models.PosRoleSupervisor), + "description": "Runs the terminal and creates counter staff. Also signs into the app.", + }, + { + "role_id": models.PosRoleCashier, "role": "cashier", + "label": models.PosRoleName(models.PosRoleCashier), + "description": "Billing only.", + }, + }, + }) +} diff --git a/messaging/posmqtt_test.go b/messaging/posmqtt_test.go index 39eb804..71b94cd 100644 --- a/messaging/posmqtt_test.go +++ b/messaging/posmqtt_test.go @@ -105,6 +105,8 @@ func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, err return nil, nil } +func (f *fakePosService) ConfigidFor(int) int { return 0 } + 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 a851763..4f8606e 100644 --- a/models/pos.go +++ b/models/pos.go @@ -442,3 +442,18 @@ type PosUserRequest struct { Contactno string `json:"contactno"` Status string `json:"status"` } + +// PosUserWebRequest is a staff change made from the web console. +// +// Identical to [PosUserRequest] but for the two fields a terminal never needs +// to send: the console has no session token, so it has to name the outlet it is +// working on. That is the one real difference between the two doors into this, +// and it is also the weaker one — the till's outlet is proved by a signature, +// while this is asserted. The handler checks the outlet belongs to the tenant +// before writing anything, which is as far as it can go without the console +// holding a session of its own. +type PosUserWebRequest struct { + PosUserRequest + Tenantid int `json:"tenantid"` + Locationid int `json:"locationid"` +} diff --git a/repositories/posRepository.go b/repositories/posRepository.go index ab0bdfb..df306c3 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -52,6 +52,7 @@ type PosRepository interface { ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) DeactivatePosUser(tenantID, locationID, userID int) error PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error) + PosConfigidFor(tenantID int) int // Reading counter sales back out. Without these a committed bill is // unreachable from every screen in the product. diff --git a/repositories/posUserRepository.go b/repositories/posUserRepository.go index 2d520ef..ca82502 100644 --- a/repositories/posUserRepository.go +++ b/repositories/posUserRepository.go @@ -489,3 +489,22 @@ func (r *posRepository) StaffPinAvailable(tenantID, locationID int, pin int64, e taken, err := posPinTaken(r.db, tenantID, locationID, pin, exceptUser) return !taken, err } + +// PosConfigidFor returns the configid an outlet's people already use. +// +// The console cannot sensibly be asked for this. It is a number nobody looks +// up, it varies per tenant — live data has tenant 1087 spread across 1, 6 and +// 15 — and getting it wrong creates an account that cannot sign into the portal +// its colleagues use and is invisible to half the platform's queries. +// +// So it is inferred from whichever value that tenant's existing accounts most +// commonly carry. Returns 0 for a tenant with no accounts at all, which is +// simply what a fresh tenant looks like. +func (r *posRepository) PosConfigidFor(tenantID int) int { + var configID int + r.db.Raw(`SELECT COALESCE(configid, 0) FROM app_users + WHERE tenantid = ? AND COALESCE(configid, 0) > 0 + GROUP BY configid ORDER BY COUNT(*) DESC, configid LIMIT 1`, + tenantID).Scan(&configID) + return configID +} diff --git a/routes/posroutes.go b/routes/posroutes.go index e5596ba..8f4517c 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -73,4 +73,37 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) { // broker rather than posting it here. pos.Get("/health/terminal", f.PosController.TerminalHealth) pos.Get("/health/location", f.PosController.LocationHealth) + + registerPosStaffConsoleRoutes(api, f) +} + +// 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) + } } diff --git a/services/posService.go b/services/posService.go index c13aa5a..3d492b0 100644 --- a/services/posService.go +++ b/services/posService.go @@ -48,6 +48,10 @@ type PosService interface { // LoginWithPin signs a person in at a terminal that is already open. Never // reachable anonymously — four digits is not a barrier on its own. LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error) + + // ConfigidFor infers which portal a tenant's people belong to, so the console + // is never asked for a number nobody can look up. + ConfigidFor(tenantID int) int } type posService struct { @@ -168,3 +172,7 @@ func (s *posService) LoginWithPin(tenantID, locationID int, pin string) (*models } return s.mint(session, "") } + +func (s *posService) ConfigidFor(tenantID int) int { + return s.repo.PosConfigidFor(tenantID) +}