diff --git a/POS_LOGIN.md b/POS_LOGIN.md index 47bc375..2263761 100644 --- a/POS_LOGIN.md +++ b/POS_LOGIN.md @@ -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 1–6 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 ``` diff --git a/controllers/posController.go b/controllers/posController.go index c217670..66d135a 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -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) +} diff --git a/messaging/posmqtt_test.go b/messaging/posmqtt_test.go index 24080cf..39eb804 100644 --- a/messaging/posmqtt_test.go +++ b/messaging/posmqtt_test.go @@ -84,6 +84,27 @@ func (f *fakePosService) Staff(int, int) ([]models.PosStaffMember, error) { 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) { return nil, nil } diff --git a/models/pos.go b/models/pos.go index 806b356..a851763 100644 --- a/models/pos.go +++ b/models/pos.go @@ -1,5 +1,7 @@ package models +import "strings" + // Wire format for the Nearle POS terminal. // // These types mirror what the till actually publishes, field for field. The @@ -237,6 +239,11 @@ type PosLoginRequest struct { Configid int `json:"configid"` 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 // can be told apart from the terminal it was issued to. Terminalid string `json:"terminal_id"` @@ -267,6 +274,20 @@ type PosSession struct { Email string `json:"email,omitempty"` 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"` Tenantname string `json:"tenant_name"` @@ -324,3 +345,100 @@ type PosStaffResponse struct { Locationid int `json:"location_id"` 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"` +} diff --git a/repositories/posAuthRepository.go b/repositories/posAuthRepository.go index e650d70..a33f53e 100644 --- a/repositories/posAuthRepository.go +++ b/repositories/posAuthRepository.go @@ -87,6 +87,20 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession 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 { 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 // only from the set the account already reaches. chosen := locations[0] - if req.Locationid > 0 { + if requestedLocation > 0 { match := false for _, loc := range locations { - if loc.Locationid == req.Locationid { + if loc.Locationid == requestedLocation { chosen, match = loc, true break } } 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{ - Userid: row.Userid, - Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname), - Email: row.Email, - Roleid: row.Roleid, - Tenantid: row.Tenantid, - Storeid: fmt.Sprintf("%d", chosen.Locationid), - Locationid: chosen.Locationid, - Locationname: chosen.Locationname, - Address: chosen.Address, - Locations: locations, + Userid: row.Userid, + Fullname: strings.TrimSpace(row.Firstname + " " + row.Lastname), + Email: row.Email, + Roleid: row.Roleid, + Role: posRoleLabel(row.Roleid), + Configid: row.Configid, + Canmanagestaff: models.PosRoleCanManageStaff(row.Roleid), + Tenantid: row.Tenantid, + Storeid: fmt.Sprintf("%d", chosen.Locationid), + Locationid: chosen.Locationid, + Locationname: chosen.Locationname, + Address: chosen.Address, + Locations: locations, } r.decoratePosSession(session) @@ -141,6 +158,28 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession 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. // // Returns a list rather than a row because `app_users` does not constrain diff --git a/repositories/posRepository.go b/repositories/posRepository.go index cbaae3e..ab0bdfb 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -44,6 +44,15 @@ type PosRepository interface { PosLocationAllowed(tenantID, locationID int) (bool, 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 // unreachable from every screen in the product. Sales(f models.PosSalesFilter) (*models.PosSalesPage, error) diff --git a/repositories/posUserRepository.go b/repositories/posUserRepository.go new file mode 100644 index 0000000..a00e270 --- /dev/null +++ b/repositories/posUserRepository.go @@ -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:], " ") +} diff --git a/repositories/posUserRepository_test.go b/repositories/posUserRepository_test.go new file mode 100644 index 0000000..d86d61b --- /dev/null +++ b/repositories/posUserRepository_test.go @@ -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) + } + } +} diff --git a/routes/posroutes.go b/routes/posroutes.go index b84c520..e5596ba 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -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. 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) diff --git a/scratch/posroles/main.go b/scratch/posroles/main.go new file mode 100644 index 0000000..57439c8 --- /dev/null +++ b/scratch/posroles/main.go @@ -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) + } +} diff --git a/scratch/posstaffsetup/main.go b/scratch/posstaffsetup/main.go new file mode 100644 index 0000000..b754257 --- /dev/null +++ b/scratch/posstaffsetup/main.go @@ -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. +// +// 1000–9999 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 + } +} diff --git a/services/posService.go b/services/posService.go index 11a5c3e..c13aa5a 100644 --- a/services/posService.go +++ b/services/posService.go @@ -38,6 +38,16 @@ type PosService interface { // 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) + + // 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 { @@ -96,12 +106,22 @@ func (s *posService) Login(req models.PosLoginRequest) (*models.PosSession, erro 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{ Userid: session.Userid, Tenantid: session.Tenantid, Locationid: session.Locationid, Roleid: session.Roleid, - Terminalid: req.Terminalid, + Configid: session.Configid, + Terminalid: terminalID, }, time.Now()) if err != nil { 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) { 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, "") +}