Let a shop run its own counter: supervisor and cashier, created from the till

A shop had no way to add the people who work in it. The terminal fell back to
three names and three PINs compiled into the app — the same three on every
install — because there was nothing for it to fall back *from*.

Two roles now exist in `app_roles`: Supervisor (7) runs the terminal and creates
staff, Cashier (8) bills. Fixed ids, written by hand, because that table has no
sequence and every id in it was assigned the same way. configid is left NULL
rather than duplicated per portal: a till is a till whichever portal a tenant
uses, and Admin already appears twice in that table for exactly that reason.

`/pos/users` is CRUD over them, and `/pos/login/pin` signs a cashier on at a
terminal a supervisor has already opened.

The rule every one of these follows: **tenant and outlet come from the caller's
token, never from the request.** There is no location field on the create body
to get wrong. A supervisor at Selvapuram cannot create staff at R mart, for the
same reason a till cannot bill into another shop's books — it is the same
inversion applied to people instead of sales.

PIN sign-in is deliberately behind the guard. Four digits is ten thousand
guesses, which is no barrier to an anonymous caller; requiring a session means a
real password opened the terminal first and the guesses are confined to one
outlet's own staff. The session it mints is fresh rather than derived, so a
cashier taking over from a supervisor drops their permissions instead of
inheriting them.

Three things the schema forced:

- A PIN cannot start with zero. `app_users.pin` is a bigint, so "0451" stores as
  451 and reads back as three digits — a cashier would type four and be refused
  for ever. Live data already holds one such account. Rendering refuses to show
  a PIN it cannot represent, rather than showing a short one nobody can type.
- `app_users` has no sequence either, so the next id is read and written inside
  one transaction behind an advisory lock. Two supervisors creating staff at the
  same moment would otherwise compute the same id and one insert would lose.
- 1234, 1111 and friends are refused outright. Live data has 1234 on eleven
  accounts and 1111 on nine.

Proven against outlet 1135, which had zero staff and was the reason the built-in
PINs were still load-bearing:

    created 9188  Store Supervisor  Supervisor  can_manage_staff=true
    created 9189  Counter Cashier   Cashier     can_manage_staff=false
    /pos/staff now returns 2        an unknown PIN is refused

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-06 20:22:16 +05:30
parent c696ec3e79
commit 4b27b84b1f
12 changed files with 1387 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
package controllers
import (
"errors"
"fmt"
"log"
"net/http"
@@ -12,6 +13,7 @@ import (
"nearle/models"
"nearle/repositories"
"nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2"
)
@@ -482,3 +484,195 @@ func (ctl *PosController) Staff(c *fiber.Ctx) error {
},
})
}
// ------------------------------------------------------------- Till staff
//
// A shop runs its own counter. A supervisor creates their cashiers from the
// terminal, and every one of these reads the tenant and outlet from the
// caller's session token rather than from the request — so a supervisor at one
// shop cannot create, edit or list staff at another. That is the same inversion
// that stopped a till naming its own store id, applied to people.
// posManager returns the caller's session, provided they may manage staff.
func posManager(c *fiber.Ctx) (utils.PosClaims, error) {
claims, ok := middleware.PosClaimsFrom(c)
if !ok {
return claims, fiber.NewError(http.StatusUnauthorized,
"a session token is required")
}
if !models.PosRoleCanManageStaff(claims.Roleid) {
// A cashier signing in on the same terminal must not be able to mint
// themselves a supervisor.
return claims, fiber.NewError(http.StatusForbidden,
"only a supervisor can manage till users")
}
return claims, nil
}
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
func (ctl *PosController) CreatePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
var req models.PosUserRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
user, err := ctl.posService.CreateUser(claims.Tenantid, claims.Locationid, claims.Configid, req)
if err != nil {
// Every failure here is something the caller can act on — a bad role, a
// PIN already in use, a name left blank — so it is reported as a 400
// with the reason rather than logged and hidden behind a 500.
return posBadRequest(c, err)
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true,
"message": "User created", "details": user,
})
}
// UpdatePosUser edits one of the caller's own till users.
func (ctl *PosController) UpdatePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
var req models.PosUserRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
user, err := ctl.posService.UpdateUser(claims.Tenantid, claims.Locationid, req)
if err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "User updated", "details": user,
})
}
// ListPosUsers returns the till users at the caller's outlet.
//
// Readable by anyone signed in, not only a supervisor: the terminal needs the
// list to show who is on shift, and a cashier can already see their colleagues
// standing next to them. PINs are the part that matters, and those only go to
// somebody who could set them anyway.
func (ctl *PosController) ListPosUsers(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",
})
}
users, err := ctl.posService.ListUsers(
claims.Tenantid, claims.Locationid,
strings.EqualFold(c.Query("include_inactive"), "true"),
)
if err != nil {
return posServerError(c, "ListPosUsers", err)
}
// A cashier sees who is on shift, not how to sign in as them.
if !models.PosRoleCanManageStaff(claims.Roleid) {
for i := range users {
users[i].Pin = ""
}
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{"location_id": claims.Locationid, "users": users},
})
}
// DeletePosUser retires a till user. Deactivates rather than deletes — bills
// carry the cashier's name and shifts settle against it.
func (ctl *PosController) DeletePosUser(c *fiber.Ctx) error {
claims, err := posManager(c)
if err != nil {
return posClaimError(c, err)
}
userID, convErr := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
if convErr != nil || userID <= 0 {
return posBadRequest(c, fmt.Errorf("user_id is required"))
}
if userID == claims.Userid {
// Otherwise the last supervisor at a shop can lock everybody out with
// one tap, and only we can undo it.
return posBadRequest(c, fmt.Errorf("you cannot deactivate the account you are signed in as"))
}
if err := ctl.posService.DeactivateUser(claims.Tenantid, claims.Locationid, userID); err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true, "message": "User deactivated",
})
}
// PinLogin signs somebody in by PIN at a terminal that is already open.
//
// Requires an existing valid session, and that is the whole security model
// here: four digits is ten thousand guesses, which is no barrier at all to an
// anonymous caller. Tying it to a token means a supervisor has already opened
// the terminal with a real password, and the guesses are confined to one
// outlet's own staff.
//
// The new session is minted fresh rather than derived from the presented one,
// so a cashier taking over from a supervisor drops the supervisor's
// permissions instead of inheriting them.
func (ctl *PosController) PinLogin(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": "sign the terminal in with an email and password before using PIN sign-in",
})
}
var req models.PosLoginRequest
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if strings.TrimSpace(req.Pin) == "" {
return posBadRequest(c, fmt.Errorf("a PIN is required"))
}
session, err := ctl.posService.LoginWithPin(claims.Tenantid, claims.Locationid, req.Pin)
if err != nil {
if repositories.PosLoginRejected(err) {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
"code": http.StatusUnauthorized, "status": false,
"message": "that PIN was not recognised",
})
}
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "Signed in", "details": session,
})
}
// posClaimError renders the fiber.Error that posManager returns.
func posClaimError(c *fiber.Ctx, err error) error {
var fe *fiber.Error
if errors.As(err, &fe) {
return c.Status(fe.Code).JSON(fiber.Map{
"code": fe.Code, "status": false, "message": fe.Message,
})
}
return posServerError(c, "posClaims", err)
}