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

@@ -208,6 +208,123 @@ door. A request without a token is refused whatever the enforcement setting is.
--- ---
## Roles
Two POS roles, added to `app_roles`:
| roleid | Role | Can |
|---|---|---|
| `7` | **Supervisor** | everything a till does, **plus** creating and editing counter staff |
| `8` | **Cashier** | billing only |
The session carries both, so the terminal never has to map role ids itself:
```json
{ "role_id": 7, "role": "Supervisor", "can_manage_staff": true }
```
Branch on `can_manage_staff`, not on the number. `app_roles` holds six rows for
four back-office roles (Admin is both 3 and 5, Manager both 4 and 6) and most
accounts carry an id that is not in the table at all — any mapping written on
the terminal would be wrong.
Back-office roles 16 also count as supervisors: somebody who already
administers the shop from a browser is not made less privileged by standing at
the counter. **`role_id` 0 is not a role** — it is what an account carries when
nobody set one, and it grants nothing.
---
## `POST /pos/login/pin` — signing on at an open terminal
For a cashier taking over a counter a supervisor has already opened.
**Requires an existing valid token.** That is the security model, not an
oversight: four digits is ten thousand guesses, which is no barrier at all to an
anonymous caller. Tying it to a session means a supervisor has opened the
terminal with a real password first, and the guesses are confined to that one
outlet's staff.
```bash
curl -s -X POST $BASE/login/pin \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"pin":"1602"}'
```
Returns a **new** session, with the same shape as `/login`. New rather than
reused, because the token carries the role — a cashier taking over from a
supervisor must drop their permissions, not inherit them.
`401` if the PIN is not recognised. `400` if two people at the outlet share it,
which creation refuses but older data may contain.
---
## `/pos/users` — the shop's own counter staff
A supervisor creates their own cashiers, from the terminal.
**The outlet is never in the request.** Tenant and location come from the
caller's token, so a supervisor at Selvapuram cannot create staff at R mart by
sending a different number — the same inversion that stopped a till naming its
own store id.
### `POST /pos/users`
```json
{
"full_name": "Asha Kumar",
"role": "cashier",
"pin": "4821",
"authname": "asha@shop.test",
"password": "…"
}
```
| Field | Notes |
|---|---|
| `full_name` | required; split across `firstname`/`lastname` |
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
| `pin` | 4 digits. See the rules below |
| `password` + `authname` | optional; for someone who also signs the terminal in |
**At least one of `pin` or `password` is required.** Creating a person who can
sign in by neither would look like it worked right up until somebody tried.
:warning: **PIN rules, and why**
- **Exactly 4 digits, and cannot start with `0`.** `app_users.pin` is a
`bigint`, so `"0451"` would be stored as `451` and read back as three digits —
a cashier would type four and be refused for ever. One such account already
exists in live data.
- **`1234`, `1111`, `2345`, `4321`, `9999`, `2222`, `3456`, `0000` are refused.**
Live data has `1234` on eleven accounts and `1111` on nine.
- **Unique within the outlet**, not globally. A PIN only distinguishes people at
one counter; making it platform-unique would exhaust the space fast.
Answers `201` with the created user. Every failure is a `400` carrying the
reason, because all of them are things the caller can fix.
### `GET /pos/users`
Readable by anyone signed in — the terminal needs it to show who is on shift.
**A cashier gets the list with `pin` blanked**; only somebody who could set a
PIN gets to see one. `?include_inactive=true` to see leavers.
### `PUT /pos/users`
Same fields plus `user_id`. Send only what changes. Supervisor only.
### `DELETE /pos/users?user_id=9189`
Deactivates — never deletes, because bills carry the cashier's name and shifts
settle against it. Supervisor only, and you cannot deactivate the account you
are signed in as: otherwise the last supervisor at a shop can lock everyone out
with one tap.
---
## Using the token ## Using the token
``` ```

View File

@@ -1,6 +1,7 @@
package controllers package controllers
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -12,6 +13,7 @@ import (
"nearle/models" "nearle/models"
"nearle/repositories" "nearle/repositories"
"nearle/services" "nearle/services"
"nearle/utils"
"github.com/gofiber/fiber/v2" "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)
}

View File

@@ -84,6 +84,27 @@ func (f *fakePosService) Staff(int, int) ([]models.PosStaffMember, error) {
return nil, nil return nil, nil
} }
// Staff management plays no part over the broker — a terminal on MQTT publishes
// bills and nothing else. Denied rather than permitted, so a fake cannot hide a
// regression by waving authorisation through.
func (f *fakePosService) CreateUser(int, int, int, models.PosUserRequest) (*models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) UpdateUser(int, int, models.PosUserRequest) (*models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) ListUsers(int, int, bool) ([]models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) DeactivateUser(int, int, int) error { return nil }
func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) {
return nil, nil
}
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) { func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
return nil, nil return nil, nil
} }

View File

@@ -1,5 +1,7 @@
package models package models
import "strings"
// Wire format for the Nearle POS terminal. // Wire format for the Nearle POS terminal.
// //
// These types mirror what the till actually publishes, field for field. The // These types mirror what the till actually publishes, field for field. The
@@ -237,6 +239,11 @@ type PosLoginRequest struct {
Configid int `json:"configid"` Configid int `json:"configid"`
Locationid int `json:"location_id"` Locationid int `json:"location_id"`
// A PIN, for signing on at a terminal a supervisor has already opened. Only
// honoured by the PIN route, which requires an existing session — four
// digits is no barrier to an anonymous caller.
Pin string `json:"pin"`
// Which physical till is asking. Recorded on the session so a stolen token // Which physical till is asking. Recorded on the session so a stolen token
// can be told apart from the terminal it was issued to. // can be told apart from the terminal it was issued to.
Terminalid string `json:"terminal_id"` Terminalid string `json:"terminal_id"`
@@ -267,6 +274,20 @@ type PosSession struct {
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
Roleid int `json:"role_id"` Roleid int `json:"role_id"`
// What the role is called, and the one thing the terminal actually branches
// on. Sent as a flag rather than leaving the till to map role ids itself:
// `app_roles` has six rows for four roles and most accounts carry an id
// absent from it, so any mapping written on the terminal would be wrong.
Role string `json:"role"`
Canmanagestaff bool `json:"can_manage_staff"`
// Which portal this account belongs to. Carried so a supervisor creating a
// cashier gives them the same configid — an account created under the wrong
// one cannot sign into the web console and is invisible to half the
// platform's queries. Not sent to the terminal: it has no use for it and it
// is one more number to get wrong.
Configid int `json:"-"`
Tenantid int `json:"tenant_id"` Tenantid int `json:"tenant_id"`
Tenantname string `json:"tenant_name"` Tenantname string `json:"tenant_name"`
@@ -324,3 +345,100 @@ type PosStaffResponse struct {
Locationid int `json:"location_id"` Locationid int `json:"location_id"`
Staff []PosStaffMember `json:"staff"` Staff []PosStaffMember `json:"staff"`
} }
// ------------------------------------------------------------ POS staff roles
//
// `app_roles` is keyed by roleid and carries a configid, so the same name
// appears more than once — Admin is both 3 and 5, Manager both 4 and 6, one per
// portal. These two are deliberately not per-portal: a till is a till whichever
// tenant owns it, and a role that had to be duplicated per config would be one
// more thing to remember when a tenant is onboarded.
//
// The ids are fixed rather than allocated, because they are referenced from the
// terminal and from this source. `app_roles.roleid` has no sequence and no
// default — every id in that table was assigned by hand — so nothing is being
// worked around here.
const (
// PosRoleSupervisor runs the terminal: settings, imports, price overrides,
// voids, and creating the people below.
PosRoleSupervisor = 7
// PosRoleCashier bills, and nothing else.
PosRoleCashier = 8
)
// PosRoleName maps a role id to what a person calls it.
func PosRoleName(roleID int) string {
switch roleID {
case PosRoleSupervisor:
return "Supervisor"
case PosRoleCashier:
return "Cashier"
}
return ""
}
// PosRoleFromName reads the role off a request.
//
// Accepts the name rather than the number, so a caller never has to hardcode 7
// or 8 — and returns 0 for anything unrecognised, which every caller treats as
// a refusal rather than as a default.
func PosRoleFromName(name string) int {
switch strings.ToLower(strings.TrimSpace(name)) {
case "supervisor":
return PosRoleSupervisor
case "cashier":
return PosRoleCashier
}
return 0
}
// PosRoleCanManageStaff reports whether a role may create and edit till users.
//
// Supervisors, plus the back office's own admin and manager roles — somebody
// who can already administer the shop from a browser is not made less
// privileged by standing at the counter.
//
// A cashier is never included, and neither is roleid 0. Zero is not a role: it
// is what an account carries when nobody set one, and live data has riders and
// shop accounts sharing it.
func PosRoleCanManageStaff(roleID int) bool {
switch roleID {
case PosRoleSupervisor, 1, 2, 3, 4, 5, 6:
return true
}
return false
}
// PosUser is a person who signs in at a till.
type PosUser struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Firstname string `json:"first_name,omitempty"`
Lastname string `json:"last_name,omitempty"`
Authname string `json:"authname,omitempty"`
Contactno string `json:"contactno,omitempty"`
Roleid int `json:"role_id"`
Role string `json:"role"`
Pin string `json:"pin,omitempty"`
Haspassword bool `json:"has_password"`
Locationid int `json:"location_id"`
Status string `json:"status"`
}
// PosUserRequest creates or edits a till user.
//
// Note what is absent: tenant and location. Both come from the caller's own
// session token. A supervisor creating staff can only ever create them at their
// own outlet, and no field in this struct can say otherwise — which is the same
// inversion that stopped a till naming its own shop.
type PosUserRequest struct {
Userid int `json:"user_id"`
Fullname string `json:"full_name"`
Role string `json:"role"`
Pin string `json:"pin"`
Password string `json:"password"`
Authname string `json:"authname"`
Contactno string `json:"contactno"`
Status string `json:"status"`
}

View File

@@ -87,6 +87,20 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
return nil, errPosLoginRejected return nil, errPosLoginRejected
} }
return r.sessionFor(row, req.Locationid)
}
// sessionFor turns an authenticated account into the session it is entitled to.
//
// Shared by both ways in — an email and password, or a PIN at an already-open
// terminal. Extracted rather than duplicated because everything after the
// credential check is authorisation, and two copies of an authorisation rule
// is one copy too many.
//
// [requestedLocation] is optional and only means anything for an account that
// reaches more than one outlet. It is checked against that set, never trusted
// on its own.
func (r *posRepository) sessionFor(row posLoginRow, requestedLocation int) (*models.PosSession, error) {
if row.Tenantid <= 0 { if row.Tenantid <= 0 {
return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till") return nil, fmt.Errorf("this account is not attached to a tenant and cannot open a till")
} }
@@ -102,30 +116,33 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
// Which outlet this terminal is standing in. A request may ask for one, but // Which outlet this terminal is standing in. A request may ask for one, but
// only from the set the account already reaches. // only from the set the account already reaches.
chosen := locations[0] chosen := locations[0]
if req.Locationid > 0 { if requestedLocation > 0 {
match := false match := false
for _, loc := range locations { for _, loc := range locations {
if loc.Locationid == req.Locationid { if loc.Locationid == requestedLocation {
chosen, match = loc, true chosen, match = loc, true
break break
} }
} }
if !match { if !match {
return nil, fmt.Errorf("this account cannot open a till at outlet %d", req.Locationid) return nil, fmt.Errorf("this account cannot open a till at outlet %d", requestedLocation)
} }
} }
session := &models.PosSession{ session := &models.PosSession{
Userid: row.Userid, Userid: row.Userid,
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname), Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
Email: row.Email, Email: row.Email,
Roleid: row.Roleid, Roleid: row.Roleid,
Tenantid: row.Tenantid, Role: posRoleLabel(row.Roleid),
Storeid: fmt.Sprintf("%d", chosen.Locationid), Configid: row.Configid,
Locationid: chosen.Locationid, Canmanagestaff: models.PosRoleCanManageStaff(row.Roleid),
Locationname: chosen.Locationname, Tenantid: row.Tenantid,
Address: chosen.Address, Storeid: fmt.Sprintf("%d", chosen.Locationid),
Locations: locations, Locationid: chosen.Locationid,
Locationname: chosen.Locationname,
Address: chosen.Address,
Locations: locations,
} }
r.decoratePosSession(session) r.decoratePosSession(session)
@@ -141,6 +158,28 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
return session, nil return session, nil
} }
// posRoleLabel names a role for the terminal.
//
// Prefers the two POS roles this codebase defines, then falls back to whatever
// `app_roles` calls it — which is blank for a great many accounts, because most
// carry a roleid that is not in that table at all.
func posRoleLabel(roleID int) string {
if name := models.PosRoleName(roleID); name != "" {
return name
}
switch roleID {
case 1:
return "Super admin"
case 2:
return "Operations"
case 3, 5:
return "Admin"
case 4, 6:
return "Manager"
}
return ""
}
// posLoginCandidates finds the accounts matching a set of sign-in details. // posLoginCandidates finds the accounts matching a set of sign-in details.
// //
// Returns a list rather than a row because `app_users` does not constrain // Returns a list rather than a row because `app_users` does not constrain

View File

@@ -44,6 +44,15 @@ type PosRepository interface {
PosLocationAllowed(tenantID, locationID int) (bool, error) PosLocationAllowed(tenantID, locationID int) (bool, error)
PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error)
// Till staff, managed by the shop. Tenant and location are always the
// caller's own, taken from their session token — no argument here can name
// somebody else's outlet.
CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
DeactivatePosUser(tenantID, locationID, userID int) error
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
// Reading counter sales back out. Without these a committed bill is // Reading counter sales back out. Without these a committed bill is
// unreachable from every screen in the product. // unreachable from every screen in the product.
Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)

View File

@@ -0,0 +1,422 @@
package repositories
import (
"fmt"
"strconv"
"strings"
"nearle/models"
"gorm.io/gorm"
)
// Till staff, managed by the shop rather than by us.
//
// A supervisor creates their own cashiers, at their own outlet, from the
// terminal. Everything here follows one rule: **the tenant and the outlet come
// from the caller's session token and never from the request body.** A
// supervisor at Selvapuram cannot create a cashier at R mart by sending a
// different number, for the same reason a till cannot bill into another shop.
// PosPinMin and PosPinMax bound an acceptable PIN.
//
// Four digits, and never starting with a zero — because `app_users.pin` is a
// `bigint`. A PIN of "0451" would be stored as 451 and read back as three
// digits, so a cashier would type four and be refused for ever. Live data
// already holds one such account.
//
// Refusing the leading zero costs a shop 1000 of 10000 combinations and buys a
// PIN that means the same thing on the way in and on the way out.
const (
PosPinMin = 1000
PosPinMax = 9999
)
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
roleID := models.PosRoleFromName(req.Role)
if roleID == 0 {
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
}
name := strings.TrimSpace(req.Fullname)
if name == "" {
return nil, fmt.Errorf("a name is required")
}
first, last := splitName(name)
pin, err := validatePosPin(req.Pin)
if err != nil {
return nil, err
}
password := strings.TrimSpace(req.Password)
authname := strings.ToLower(strings.TrimSpace(req.Authname))
// One or the other, at least. A person with neither cannot sign in, and
// creating them would look like it worked right up until somebody tried.
if pin == 0 && password == "" {
return nil, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
}
if password != "" && authname == "" {
return nil, fmt.Errorf("a password needs an email to go with it")
}
var created *models.PosUser
err = r.db.Transaction(func(tx *gorm.DB) error {
// `app_users` has no sequence and no identity — every id in it was
// assigned by hand. So the next one is read and written inside one
// transaction, behind an advisory lock, or two supervisors creating
// staff at the same moment would compute the same id and one insert
// would lose.
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
return err
}
if pin > 0 {
taken, err := posPinTaken(tx, tenantID, locationID, pin, 0)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
if authname != "" {
var clash int64
if err := tx.Raw(`SELECT COUNT(1) FROM app_users WHERE LOWER(TRIM(authname)) = ?`,
authname).Scan(&clash).Error; err != nil {
return err
}
if clash > 0 {
return fmt.Errorf("an account already uses %s", authname)
}
}
var nextID int
if err := tx.Raw(`SELECT COALESCE(MAX(userid), 0) + 1 FROM app_users`).Scan(&nextID).Error; err != nil {
return err
}
if err := tx.Exec(`
INSERT INTO app_users
(userid, firstname, lastname, authname, email, contactno, password,
pin, roleid, configid, tenantid, locationid, status)
VALUES (?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
NULLIF(?, 0), ?, ?, ?, ?, 'Active')`,
nextID, first, last, authname, authname, strings.TrimSpace(req.Contactno),
password, pin, roleID, configID, tenantID, locationID,
).Error; err != nil {
return err
}
created = &models.PosUser{
Userid: nextID,
Fullname: name,
Firstname: first,
Lastname: last,
Authname: authname,
Contactno: strings.TrimSpace(req.Contactno),
Roleid: roleID,
Role: models.PosRoleName(roleID),
Pin: posPinString(pin),
Haspassword: password != "",
Locationid: locationID,
Status: "Active",
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
// UpdatePosUser edits a till user at the caller's outlet.
//
// Scoped by tenant *and* location in the WHERE clause rather than checked
// first: a supervisor sending somebody else's user id updates no rows and is
// told so, instead of quietly editing another shop's staff.
func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
if req.Userid <= 0 {
return nil, fmt.Errorf("user_id is required")
}
sets := []string{}
args := []interface{}{}
if name := strings.TrimSpace(req.Fullname); name != "" {
first, last := splitName(name)
sets = append(sets, "firstname = ?", "lastname = ?")
args = append(args, first, last)
}
if role := strings.TrimSpace(req.Role); role != "" {
roleID := models.PosRoleFromName(role)
if roleID == 0 {
return nil, fmt.Errorf("role must be 'supervisor' or 'cashier'")
}
sets = append(sets, "roleid = ?")
args = append(args, roleID)
}
pin := int64(0)
if strings.TrimSpace(req.Pin) != "" {
p, err := validatePosPin(req.Pin)
if err != nil {
return nil, err
}
pin = p
sets = append(sets, "pin = ?")
args = append(args, pin)
}
if password := strings.TrimSpace(req.Password); password != "" {
sets = append(sets, "password = ?")
args = append(args, password)
}
if status := strings.TrimSpace(req.Status); status != "" {
sets = append(sets, "status = ?")
args = append(args, status)
}
if len(sets) == 0 {
return nil, fmt.Errorf("nothing to change")
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext('app_users'))`).Error; err != nil {
return err
}
if pin > 0 {
taken, err := posPinTaken(tx, tenantID, locationID, pin, req.Userid)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another person at this outlet already uses that PIN")
}
}
query := fmt.Sprintf(
`UPDATE app_users SET %s WHERE userid = ? AND tenantid = ? AND locationid = ?`,
strings.Join(sets, ", "))
args = append(args, req.Userid, tenantID, locationID)
result := tx.Exec(query, args...)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("no user %d at this outlet", req.Userid)
}
return nil
})
if err != nil {
return nil, err
}
users, err := r.ListPosUsers(tenantID, locationID, true)
if err != nil {
return nil, err
}
for i := range users {
if users[i].Userid == req.Userid {
return &users[i], nil
}
}
return nil, nil
}
// ListPosUsers returns the till users at an outlet.
func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
rows := make([]struct {
Userid int
Firstname string
Lastname string
Authname string
Contactno string
Roleid int
Pin int64
Haspassword bool
Status string
}, 0)
query := `
SELECT userid,
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
COALESCE(authname,'') AS authname, COALESCE(contactno,'') AS contactno,
COALESCE(roleid,0) AS roleid, COALESCE(pin,0) AS pin,
(COALESCE(password,'') <> '') AS haspassword,
COALESCE(status,'') AS status
FROM app_users
WHERE tenantid = ? AND locationid = ?
AND COALESCE(roleid,0) IN (?, ?)`
params := []interface{}{tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier}
if !includeInactive {
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
}
query += ` ORDER BY userid`
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
return nil, err
}
users := make([]models.PosUser, 0, len(rows))
for _, row := range rows {
users = append(users, models.PosUser{
Userid: row.Userid,
Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname),
Firstname: row.Firstname,
Lastname: row.Lastname,
Authname: row.Authname,
Contactno: row.Contactno,
Roleid: row.Roleid,
Role: models.PosRoleName(row.Roleid),
Pin: posPinString(row.Pin),
Haspassword: row.Haspassword,
Locationid: locationID,
Status: row.Status,
})
}
return users, nil
}
// DeactivatePosUser retires somebody without deleting them.
//
// Bills carry the cashier's name and shifts settle against it, so a hard delete
// would orphan a day's takings.
func (r *posRepository) DeactivatePosUser(tenantID, locationID, userID int) error {
result := r.db.Exec(`
UPDATE app_users SET status = 'InActive'
WHERE userid = ? AND tenantid = ? AND locationid = ?
AND COALESCE(roleid,0) IN (?, ?)`,
userID, tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// Either no such person, or they belong to another shop, or they are a
// back-office account rather than till staff. One message for all three
// — distinguishing them tells a caller about rows they cannot see.
return fmt.Errorf("no till user %d at this outlet", userID)
}
return nil
}
// PosLoginByPin signs somebody in with a PIN alone, inside an outlet.
//
// A PIN is four digits, so this must never be reachable by an anonymous caller
// — ten thousand guesses is not a barrier. It is only called with a tenant and
// location taken from an *already valid* session token, which means a
// supervisor has opened the terminal with a real password first and the guesses
// are confined to one outlet's own staff.
func (r *posRepository) PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
value, err := validatePosPin(pin)
if err != nil {
return nil, errPosLoginRejected
}
var rows []posLoginRow
err = r.db.Raw(`
SELECT userid, COALESCE(password,'') AS password, COALESCE(status,'') AS status,
COALESCE(roleid,0) AS roleid, COALESCE(configid,0) AS configid,
COALESCE(tenantid,0) AS tenantid, COALESCE(locationid,0) AS locationid,
COALESCE(firstname,'') AS firstname, COALESCE(lastname,'') AS lastname,
COALESCE(email,'') AS email
FROM app_users
WHERE tenantid = ? AND locationid = ? AND pin = ?
AND LOWER(COALESCE(status,'active')) <> 'inactive'
ORDER BY userid`, tenantID, locationID, value).Scan(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, errPosLoginRejected
}
// Two people on one PIN would attribute a bill to whichever row was read
// first. Creation refuses a duplicate, but data predating this endpoint
// need not have, so it is refused here too rather than guessed.
if len(rows) > 1 {
return nil, fmt.Errorf("more than one person at this outlet uses that PIN; ask a supervisor to change one of them")
}
return r.sessionFor(rows[0], locationID)
}
// posPinTaken reports whether a PIN is already in use at an outlet.
//
// Scoped to the outlet rather than globally, because a PIN only ever
// distinguishes people standing at the same counter — making them unique across
// the platform would exhaust nine thousand combinations very quickly.
func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser int) (bool, error) {
var count int64
err := tx.Raw(`
SELECT COUNT(1) FROM app_users
WHERE tenantid = ? AND locationid = ? AND pin = ? AND userid <> ?
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
tenantID, locationID, pin, exceptUser).Scan(&count).Error
return count > 0, err
}
// validatePosPin checks a PIN is one this schema can store faithfully.
func validatePosPin(raw string) (int64, error) {
pin := strings.TrimSpace(raw)
if pin == "" {
return 0, nil
}
if len(pin) != 4 {
return 0, fmt.Errorf("a PIN is exactly 4 digits")
}
value, err := strconv.ParseInt(pin, 10, 64)
if err != nil {
return 0, fmt.Errorf("a PIN is digits only")
}
if value < PosPinMin || value > PosPinMax {
// Which is to say: it started with a zero. Said plainly, because "a PIN
// is 4 digits" would be baffling to somebody who just typed four.
return 0, fmt.Errorf("a PIN cannot start with 0")
}
// The first thing anyone tries, and live data already has 1234 on eleven
// accounts and 1111 on nine.
switch pin {
case "1234", "1111", "0000", "2345", "3456", "4321", "9999", "2222":
return 0, fmt.Errorf("that PIN is too easy to guess; choose another")
}
return value, nil
}
// posPinString renders a stored PIN.
//
// Anything the schema cannot represent as four digits comes back empty rather
// than short: a three-digit PIN on screen is one a cashier cannot type, and
// showing it would send them to a supervisor for a fault they cannot describe.
func posPinString(pin int64) string {
if pin < PosPinMin || pin > PosPinMax {
return ""
}
return strconv.FormatInt(pin, 10)
}
// splitName turns a typed name into the two columns this schema has.
func splitName(full string) (first, last string) {
parts := strings.Fields(strings.TrimSpace(full))
if len(parts) == 0 {
return "", ""
}
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}

View File

@@ -0,0 +1,132 @@
package repositories
import (
"testing"
"nearle/models"
)
// A PIN has to survive a round trip through a `bigint` column, and has to be
// hard enough to guess to be worth having. These cover both, because the schema
// makes the first one non-obvious.
func TestAPinMustSurviveTheColumnItIsStoredIn(t *testing.T) {
// `app_users.pin` is a bigint. "0451" stored there comes back as 451, so a
// cashier would type four digits and be refused for ever. Live data already
// holds one such account.
if _, err := validatePosPin("0451"); err == nil {
t.Fatal("a PIN starting with zero was accepted; it cannot round-trip through a bigint")
}
value, err := validatePosPin("4821")
if err != nil {
t.Fatalf("a good PIN was refused: %v", err)
}
if value != 4821 {
t.Fatalf("PIN parsed to %d, want 4821", value)
}
}
func TestAPinIsExactlyFourDigits(t *testing.T) {
for _, pin := range []string{"123", "12345", "abcd", "12a4", " 12 "} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted", pin)
}
}
}
// The first thing anyone tries. Live data has 1234 on eleven accounts and 1111
// on nine, which is exactly the outcome this prevents repeating.
func TestAnObviousPinIsRefused(t *testing.T) {
for _, pin := range []string{"1234", "1111", "2345", "4321", "9999", "2222"} {
if _, err := validatePosPin(pin); err == nil {
t.Errorf("PIN %q was accepted despite being one of the first guessed", pin)
}
}
}
// An empty PIN is not an error — somebody may be given a password instead. The
// caller decides whether having neither is a problem.
func TestAnAbsentPinIsNotAnError(t *testing.T) {
value, err := validatePosPin("")
if err != nil {
t.Fatalf("an absent PIN was treated as invalid: %v", err)
}
if value != 0 {
t.Fatalf("an absent PIN parsed to %d, want 0", value)
}
}
// A stored PIN the schema cannot represent as four digits comes back empty
// rather than short, because a three-digit PIN on screen is one a cashier
// cannot type — and they would have no way to describe the fault.
func TestAnUnrepresentablePinIsNotShown(t *testing.T) {
if got := posPinString(451); got != "" {
t.Fatalf("a three-digit PIN rendered as %q, want empty", got)
}
if got := posPinString(0); got != "" {
t.Fatalf("an unset PIN rendered as %q, want empty", got)
}
if got := posPinString(4821); got != "4821" {
t.Fatalf("PIN rendered as %q, want 4821", got)
}
}
func TestANameIsSplitAcrossTheTwoColumnsThisSchemaHas(t *testing.T) {
cases := []struct {
in string
first, last string
}{
{"Asha", "Asha", ""},
{"Asha Kumar", "Asha", "Kumar"},
{"Ragul Kannan Selvam", "Ragul", "Kannan Selvam"},
{" Divya R ", "Divya", "R"},
{"", "", ""},
}
for _, tc := range cases {
first, last := splitName(tc.in)
if first != tc.first || last != tc.last {
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)",
tc.in, first, last, tc.first, tc.last)
}
}
}
// Only a role that can actually be checked should grant anything. Zero is the
// one that matters: it is not a role, it is what an account carries when nobody
// set one, and live data has riders and shop accounts sharing it.
func TestOnlyRealRolesCanManageStaff(t *testing.T) {
if models.PosRoleCanManageStaff(0) {
t.Error("roleid 0 was allowed to manage staff; it is unset, not a role")
}
if models.PosRoleCanManageStaff(models.PosRoleCashier) {
t.Error("a cashier was allowed to manage staff, so could promote themselves")
}
if !models.PosRoleCanManageStaff(models.PosRoleSupervisor) {
t.Error("a supervisor was refused staff management, which is their whole purpose")
}
// Somebody who already administers the shop from a browser is not made less
// privileged by standing at the counter.
for _, role := range []int{1, 2, 3, 4, 5, 6} {
if !models.PosRoleCanManageStaff(role) {
t.Errorf("back-office role %d was refused staff management", role)
}
}
}
func TestARoleIsReadFromItsNameNotItsNumber(t *testing.T) {
if got := models.PosRoleFromName("supervisor"); got != models.PosRoleSupervisor {
t.Errorf("supervisor = %d, want %d", got, models.PosRoleSupervisor)
}
if got := models.PosRoleFromName(" Cashier "); got != models.PosRoleCashier {
t.Errorf("cashier = %d, want %d", got, models.PosRoleCashier)
}
// Anything unrecognised is zero, and every caller treats zero as a refusal
// rather than as a default — an unknown role must never become a supervisor.
for _, name := range []string{"", "admin", "manager", "owner", "7"} {
if got := models.PosRoleFromName(name); got != 0 {
t.Errorf("PosRoleFromName(%q) = %d, want 0", name, got)
}
}
}

View File

@@ -42,6 +42,17 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
// answer carries PINs, so the outlet comes from the caller's own token. // answer carries PINs, so the outlet comes from the caller's own token.
pos.Get("/staff", f.PosController.Staff) 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("/orders", f.PosController.IngestOrders)
pos.Post("/customers", f.PosController.IngestCustomers) pos.Post("/customers", f.PosController.IngestCustomers)
pos.Get("/catalogue", f.PosController.Catalogue) pos.Get("/catalogue", f.PosController.Catalogue)

102
scratch/posroles/main.go Normal file
View File

@@ -0,0 +1,102 @@
// Adds the two POS roles to app_roles.
//
// `app_roles` has no sequence on roleid — every id in it was assigned by hand —
// so 7 and 8 are written explicitly and must match models.PosRoleSupervisor and
// models.PosRoleCashier.
//
// configid is left NULL deliberately. Every other row is portal-specific, which
// is why Admin appears twice (3 and 5) and Manager twice (4 and 6). A till is a
// till whichever portal a tenant uses, and duplicating these per config would
// be one more thing to remember on every onboarding.
//
// go run ./scratch/posroles plan
// go run ./scratch/posroles apply
package main
import (
"fmt"
"log"
"os"
"nearle/models"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = godotenv.Load()
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
wanted := []struct {
id int
name string
}{
{models.PosRoleSupervisor, "Supervisor"},
{models.PosRoleCashier, "Cashier"},
}
write := mode == "apply"
changes := 0
for _, w := range wanted {
var existing string
db.Raw(`SELECT COALESCE(rolename,'') FROM app_roles WHERE roleid = ?`, w.id).Scan(&existing)
switch {
case existing == w.name:
fmt.Printf(" %-4d %-12s already present\n", w.id, w.name)
case existing != "":
// Refuses rather than overwrites. Renaming a role that something
// else already points at would silently re-permission real accounts.
fmt.Printf(" %-4d OCCUPIED by %q — refusing to overwrite\n", w.id, existing)
default:
fmt.Printf(" %-4d %-12s WOULD INSERT\n", w.id, w.name)
changes++
if write {
if err := db.Exec(
`INSERT INTO app_roles (roleid, rolename, configid) VALUES (?, ?, NULL)`,
w.id, w.name,
).Error; err != nil {
log.Fatalf("inserting role %d: %v", w.id, err)
}
}
}
}
fmt.Println()
if write {
fmt.Printf("APPLIED %d role(s).\n", changes)
} else {
fmt.Printf("%d role(s) would be added. Nothing written — run `apply`.\n", changes)
}
var rows []struct {
Roleid int
Rolename string
}
db.Raw(`SELECT roleid, COALESCE(rolename,'') AS rolename FROM app_roles ORDER BY roleid`).Scan(&rows)
fmt.Println("\napp_roles now:")
for _, r := range rows {
fmt.Printf(" %-4d %s\n", r.Roleid, r.Rolename)
}
if len(rows) > 0 {
fmt.Println("\n-- undo:")
fmt.Printf("DELETE FROM app_roles WHERE roleid IN (%d, %d);\n",
models.PosRoleSupervisor, models.PosRoleCashier)
}
}

View File

@@ -0,0 +1,160 @@
// Creates a supervisor and a cashier at an outlet, then proves both can sign in.
//
// Exists because outlet 1135 — the one the terminal ships pointed at — had no
// staff at all, so the till fell back to the three PINs compiled into the app.
// Real staff here are what retire those.
//
// PINs are generated rather than chosen, from crypto/rand, and printed once so
// they can be handed to the shop. They are deliberately not derived from
// anything guessable.
//
// go run ./scratch/posstaffsetup plan 1087 1135
// go run ./scratch/posstaffsetup apply 1087 1135
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"strconv"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
mode, tenantID, locationID := "plan", 1087, 1135
if len(os.Args) > 1 {
mode = os.Args[1]
}
if len(os.Args) > 3 {
tenantID, _ = strconv.Atoi(os.Args[2])
locationID, _ = strconv.Atoi(os.Args[3])
}
_ = godotenv.Load()
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
log.Fatal(err)
}
repo := repositories.NewPosRepository(db)
var locName string
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
locationID, tenantID).Scan(&locName)
if locName == "" {
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
}
// The configid the shop's other accounts use, so a new cashier is visible
// to the same portal as everybody else at that outlet.
var configID int
db.Raw(`SELECT COALESCE(configid,0) FROM app_users
WHERE tenantid=? AND COALESCE(configid,0) > 0
GROUP BY configid ORDER BY COUNT(*) DESC LIMIT 1`, tenantID).Scan(&configID)
fmt.Printf("tenant %d, outlet %d (%s), configid %d\n\n", tenantID, locationID, locName, configID)
existing, err := repo.ListPosUsers(tenantID, locationID, true)
if err != nil {
log.Fatal(err)
}
fmt.Printf("till users already at this outlet: %d\n", len(existing))
for _, u := range existing {
fmt.Printf(" %-6d %-22s %-12s pin=%s %s\n", u.Userid, u.Fullname, u.Role, u.Pin, u.Status)
}
if len(existing) > 0 {
fmt.Println("\nAlready set up. Nothing to do — this refuses to add duplicates.")
return
}
wanted := []models.PosUserRequest{
{Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin()},
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
}
for wanted[0].Pin == wanted[1].Pin {
wanted[1].Pin = newPin()
}
fmt.Println("\nwould create:")
for _, w := range wanted {
fmt.Printf(" %-22s %-12s pin=%s\n", w.Fullname, w.Role, w.Pin)
}
if mode != "apply" {
fmt.Println("\nNothing written — run `apply` to commit.")
return
}
fmt.Println()
for _, w := range wanted {
created, err := repo.CreatePosUser(tenantID, locationID, configID, w)
if err != nil {
log.Fatalf("creating %s: %v", w.Fullname, err)
}
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s\n",
created.Userid, created.Fullname, created.Role, created.Pin)
}
// The point of the exercise: does the till now see real staff?
staff, err := repo.PosStaff(tenantID, locationID)
if err != nil {
log.Fatal(err)
}
fmt.Printf("\n/pos/staff now returns %d person(s):\n", len(staff))
for _, s := range staff {
fmt.Printf(" %-22s %-12s\n", s.Fullname, s.Role)
}
// And can they actually sign in?
fmt.Println("\nPIN sign-in:")
for _, w := range wanted {
session, err := repo.PosLoginByPin(tenantID, locationID, w.Pin)
if err != nil {
fmt.Printf(" %-22s REFUSED: %v\n", w.Fullname, err)
continue
}
fmt.Printf(" %-22s -> %s at %s, can_manage_staff=%v\n",
w.Fullname, session.Role, session.Locationname, session.Canmanagestaff)
}
if _, err := repo.PosLoginByPin(tenantID, locationID, "5555"); err != nil {
fmt.Printf("\n an unknown PIN is refused: %v\n", err)
} else {
fmt.Println("\n !! an unknown PIN was ACCEPTED")
}
fmt.Println("\n-- undo:")
fmt.Printf("UPDATE app_users SET status='InActive' WHERE tenantid=%d AND locationid=%d AND roleid IN (%d,%d);\n",
tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
}
// newPin returns a four-digit PIN this schema can store, from crypto/rand.
//
// 10009999 because a leading zero cannot survive a bigint column, and the
// obvious ones are rejected by validatePosPin anyway — retried here rather than
// filtered, so the distribution stays even.
func newPin() string {
for {
n, err := rand.Int(rand.Reader, big.NewInt(9000))
if err != nil {
log.Fatal(err)
}
pin := strconv.FormatInt(n.Int64()+1000, 10)
switch pin {
case "1234", "1111", "2345", "3456", "4321", "9999", "2222":
continue
}
return pin
}
}

View File

@@ -38,6 +38,16 @@ type PosService interface {
// available on its own, so a shop that hires someone mid-shift can pull them // available on its own, so a shop that hires someone mid-shift can pull them
// down without signing the terminal out. // down without signing the terminal out.
Staff(tenantID, locationID int) ([]models.PosStaffMember, error) Staff(tenantID, locationID int) ([]models.PosStaffMember, error)
// Till staff management, all scoped to the caller's own outlet.
CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error)
UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error)
ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
DeactivateUser(tenantID, locationID, userID int) error
// 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)
} }
type posService struct { type posService struct {
@@ -96,12 +106,22 @@ func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, erro
return nil, err return nil, err
} }
return s.mint(session, req.Terminalid)
}
// mint signs a resolved session.
//
// Kept apart from the credential checks so the signing key stays out of the
// layer that talks to the database, and so a change of token format touches one
// function rather than every way in.
func (s *posService) mint(session *models.PosSession, terminalID string) (*models.PosSession, error) {
token, expires, err := utils.MintPosToken(utils.PosClaims{ token, expires, err := utils.MintPosToken(utils.PosClaims{
Userid: session.Userid, Userid: session.Userid,
Tenantid: session.Tenantid, Tenantid: session.Tenantid,
Locationid: session.Locationid, Locationid: session.Locationid,
Roleid: session.Roleid, Roleid: session.Roleid,
Terminalid: req.Terminalid, Configid: session.Configid,
Terminalid: terminalID,
}, time.Now()) }, time.Now())
if err != nil { if err != nil {
return nil, err return nil, err
@@ -120,3 +140,31 @@ func (s *posService) LocationAllowed(tenantID, locationID int) (bool, error) {
func (s *posService) Staff(tenantID, locationID int) ([]models.PosStaffMember, error) { func (s *posService) Staff(tenantID, locationID int) ([]models.PosStaffMember, error) {
return s.repo.PosStaff(tenantID, locationID) return s.repo.PosStaff(tenantID, locationID)
} }
func (s *posService) CreateUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
return s.repo.CreatePosUser(tenantID, locationID, configID, req)
}
func (s *posService) UpdateUser(tenantID, locationID int, req models.PosUserRequest) (*models.PosUser, error) {
return s.repo.UpdatePosUser(tenantID, locationID, req)
}
func (s *posService) ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
return s.repo.ListPosUsers(tenantID, locationID, includeInactive)
}
func (s *posService) DeactivateUser(tenantID, locationID, userID int) error {
return s.repo.DeactivatePosUser(tenantID, locationID, userID)
}
// LoginWithPin mints a fresh session for whoever the PIN belongs to.
//
// A new token rather than a reused one, because the token carries the role and
// a cashier taking over from a supervisor must not inherit their permissions.
func (s *posService) LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error) {
session, err := s.repo.PosLoginByPin(tenantID, locationID, pin)
if err != nil {
return nil, err
}
return s.mint(session, "")
}