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>
406 lines
14 KiB
Go
406 lines
14 KiB
Go
package repositories
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"nearle/models"
|
|
)
|
|
|
|
// Sign-in for the POS terminal.
|
|
//
|
|
// Deliberately reads the same `app_users` rows the web console authenticates
|
|
// against rather than introducing a terminal-specific credential table. A shop
|
|
// manager who can sign into the back office should be able to open the till
|
|
// with the same details, and one account store means deactivating a leaver
|
|
// closes both doors at once instead of one and a half.
|
|
//
|
|
// Kept in its own file because the rest of posRepository is about moving bills
|
|
// and stock, and mixing authorisation into that made the one thing nobody
|
|
// should have to hunt for the hardest thing to find.
|
|
|
|
// posLoginRow is the credential check's raw answer.
|
|
type posLoginRow struct {
|
|
Userid int
|
|
Password string
|
|
Status string
|
|
Roleid int
|
|
Configid int
|
|
Tenantid int
|
|
Locationid int
|
|
Firstname string
|
|
Lastname string
|
|
Email string
|
|
}
|
|
|
|
// PosLogin authenticates a user and returns the session they are entitled to.
|
|
//
|
|
// The outlet is resolved here, from the user's own row and the tenant's list of
|
|
// locations — never from anything the caller sent. That inversion is the whole
|
|
// point of the endpoint.
|
|
func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession, error) {
|
|
field, value := "authname", strings.TrimSpace(req.Authname)
|
|
if value == "" {
|
|
field, value = "contactno", strings.TrimSpace(req.Contactno)
|
|
}
|
|
if value == "" {
|
|
return nil, fmt.Errorf("an email or mobile number is required")
|
|
}
|
|
|
|
rows, err := r.posLoginCandidates(field, value, req.Configid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// One message for "no such account" and for "wrong password", on purpose.
|
|
// Distinguishing them turns the login into a directory of who banks here.
|
|
if len(rows) == 0 {
|
|
return nil, errPosLoginRejected
|
|
}
|
|
|
|
// `authname` is not unique in this schema — live data has the same address
|
|
// twice under one configid — so more than one row can come back. Resolving
|
|
// that by taking the first would let the account a person *meant* be
|
|
// shadowed by a stranger's, and on a POS that means billing into the wrong
|
|
// tenant's books. Refused instead, with the fix the caller can act on.
|
|
if len(rows) > 1 {
|
|
return nil, fmt.Errorf(
|
|
"more than one account uses these sign-in details; ask your administrator for the configid and send it with the login")
|
|
}
|
|
|
|
// Inactive accounts never reach here — posLoginCandidates excludes them, so
|
|
// that a deactivated duplicate cannot make a live login ambiguous.
|
|
row := rows[0]
|
|
|
|
// Matches the web console's plaintext comparison, which is what the stored
|
|
// column holds today. Constant-time so this endpoint at least does not add
|
|
// a timing oracle on top.
|
|
//
|
|
// TODO: the password column is plaintext across the whole platform. Hashing
|
|
// it is a migration touching every login path, not something this endpoint
|
|
// can fix alone — but a POS token minted off a plaintext password is only
|
|
// ever as good as that column.
|
|
if strings.TrimSpace(row.Password) == "" {
|
|
return nil, fmt.Errorf("this account has no password set; set one in the web console first")
|
|
}
|
|
if !constantTimeEqual(row.Password, req.Password) {
|
|
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")
|
|
}
|
|
|
|
locations, err := r.posLoginLocations(row.Tenantid, row.Locationid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(locations) == 0 {
|
|
return nil, fmt.Errorf("no active outlet is registered for this account")
|
|
}
|
|
|
|
// 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 requestedLocation > 0 {
|
|
match := false
|
|
for _, loc := range locations {
|
|
if loc.Locationid == requestedLocation {
|
|
chosen, match = loc, true
|
|
break
|
|
}
|
|
}
|
|
if !match {
|
|
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,
|
|
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)
|
|
|
|
// Staff come down with the session so a till is ready to trade the moment
|
|
// it signs in. A failure here is not a failed sign-in: a shop with no staff
|
|
// recorded — which is almost all of them today — must still be able to open
|
|
// its terminal.
|
|
if staff, err := r.PosStaff(session.Tenantid, session.Locationid); err == nil {
|
|
session.Staff = staff
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// 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
|
|
// `authname` to be unique — not globally and not per configid. The caller
|
|
// decides what an ambiguous match means; silently picking one here would bury
|
|
// the decision in a LIMIT 1.
|
|
//
|
|
// The configid handling is the part worth explaining. The web console asks for
|
|
// it because the browser knows which tenant portal it is on. A till does not:
|
|
// somebody is standing at a counter typing an email and a password, and
|
|
// demanding a number they have never seen would make the login unusable. So it
|
|
// is honoured when sent and inferred when not — and inference that finds more
|
|
// than one candidate is reported, never guessed.
|
|
func (r *posRepository) posLoginCandidates(field, value string, configID int) ([]posLoginRow, error) {
|
|
rows := make([]posLoginRow, 0, 2)
|
|
|
|
query := fmt.Sprintf(`
|
|
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 LOWER(TRIM(%s)) = LOWER(TRIM(?))`, field)
|
|
params := []interface{}{value}
|
|
|
|
if configID > 0 {
|
|
query += ` AND configid = ?`
|
|
params = append(params, configID)
|
|
}
|
|
|
|
// Inactive accounts are excluded from the match rather than matched and
|
|
// then refused. A deactivated duplicate would otherwise make a working
|
|
// login ambiguous, which turns "this person left" into "nobody can open
|
|
// the till".
|
|
query += ` AND LOWER(COALESCE(status, 'active')) <> 'inactive' ORDER BY userid`
|
|
|
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// posLoginLocations lists the outlets an account may open a till at.
|
|
//
|
|
// A user pinned to one location gets that one alone; a tenant-level account
|
|
// with locationid 0 — a proprietor with several shops — gets all of the
|
|
// tenant's active outlets and picks at sign-in.
|
|
//
|
|
// Inactive outlets are excluded rather than listed and disabled: a till cannot
|
|
// usefully trade at a closed shop, and offering it is an invitation to a
|
|
// support call.
|
|
func (r *posRepository) posLoginLocations(tenantID, pinned int) ([]models.PosLoginLocation, error) {
|
|
rows := make([]models.PosLoginLocation, 0)
|
|
|
|
query := `
|
|
SELECT locationid,
|
|
COALESCE(locationname, '') AS locationname,
|
|
COALESCE(address, '') AS address,
|
|
COALESCE(city, '') AS city,
|
|
COALESCE(status, '') AS status
|
|
FROM tenantlocations
|
|
WHERE tenantid = ? AND LOWER(COALESCE(status, 'active')) <> 'inactive'`
|
|
params := []interface{}{tenantID}
|
|
|
|
if pinned > 0 {
|
|
query += ` AND locationid = ?`
|
|
params = append(params, pinned)
|
|
}
|
|
query += ` ORDER BY locationid`
|
|
|
|
if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// decoratePosSession fills in what a receipt needs.
|
|
//
|
|
// The store name, GSTIN and address printed on a bill are a legal requirement
|
|
// on a GST invoice, and the till had them as compile-time constants. Sending
|
|
// them down with the session means a shop that corrects its GSTIN in the back
|
|
// office sees the correction on its next receipt rather than at the next
|
|
// rebuild.
|
|
//
|
|
// Failures here are swallowed: a missing tenant name is a cosmetic problem, and
|
|
// refusing a sign-in over it would close a shop.
|
|
func (r *posRepository) decoratePosSession(session *models.PosSession) {
|
|
var tenant struct {
|
|
Tenantname string
|
|
Gstin string
|
|
Contactno string
|
|
Address string
|
|
}
|
|
|
|
// `registrationno` is where this schema keeps the GST number — there is no
|
|
// `gstin` column. Aliased rather than renamed through the stack so the till
|
|
// receives it under the name it prints on a receipt.
|
|
err := r.db.Raw(`
|
|
SELECT COALESCE(tenantname, '') AS tenantname,
|
|
COALESCE(registrationno, '') AS gstin,
|
|
COALESCE(primarycontact, '') AS contactno,
|
|
COALESCE(address, '') AS address
|
|
FROM tenants WHERE tenantid = ? LIMIT 1`, session.Tenantid).Scan(&tenant).Error
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
session.Tenantname = tenant.Tenantname
|
|
session.Gstin = tenant.Gstin
|
|
session.Phone = tenant.Contactno
|
|
|
|
// The outlet's own address wins — a chain's receipts must name the shop the
|
|
// customer is standing in, not head office. The tenant address is only a
|
|
// fallback for an outlet that has none recorded.
|
|
if strings.TrimSpace(session.Address) == "" {
|
|
session.Address = tenant.Address
|
|
}
|
|
}
|
|
|
|
// PosLocationAllowed reports whether a tenant owns an outlet.
|
|
//
|
|
// The check the whole session model rests on. Everything a terminal asks for
|
|
// names a location, and this is what stops a valid token for one shop being
|
|
// replayed against another.
|
|
func (r *posRepository) PosLocationAllowed(tenantID, locationID int) (bool, error) {
|
|
if tenantID <= 0 || locationID <= 0 {
|
|
return false, nil
|
|
}
|
|
|
|
var count int64
|
|
err := r.db.Raw(
|
|
`SELECT COUNT(1) FROM tenantlocations WHERE tenantid = ? AND locationid = ?`,
|
|
tenantID, locationID,
|
|
).Scan(&count).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// errPosLoginRejected is the single answer to a bad email and a bad password.
|
|
var errPosLoginRejected = fmt.Errorf("those sign-in details were not recognised")
|
|
|
|
// PosLoginRejected reports whether an error is a failed credential check, so
|
|
// the controller can answer 401 for those and 500 for a database fault without
|
|
// matching on message text.
|
|
func PosLoginRejected(err error) bool { return err == errPosLoginRejected }
|
|
|
|
// constantTimeEqual compares two secrets without leaking their contents through
|
|
// how long it took.
|
|
//
|
|
// Length is compared first and is deliberately allowed to leak — a password's
|
|
// length is not the secret, and hashing to a fixed width just to hide it would
|
|
// be more machinery than the exposure justifies.
|
|
func constantTimeEqual(a, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
var diff byte
|
|
for i := 0; i < len(a); i++ {
|
|
diff |= a[i] ^ b[i]
|
|
}
|
|
return diff == 0
|
|
}
|
|
|
|
// PosStaff lists the people who may ring a bill at an outlet.
|
|
//
|
|
// Two sources, unioned, because the schema has two and neither is complete.
|
|
// `tenantstaffs` is the table built for this and holds 12 rows on the entire
|
|
// platform; `app_users.locationid` is where staff actually ended up. Reading
|
|
// only the purpose-built table would return nothing for almost every shop, and
|
|
// reading only `app_users` would miss anyone assigned through the back office's
|
|
// staff screen. So both.
|
|
//
|
|
// Only people with a PIN come back. A row with `pin = 0` cannot ring anything —
|
|
// offering it to the till would put a name on screen that no one can sign in
|
|
// as, which reads as a broken terminal rather than as an unfinished setup.
|
|
func (r *posRepository) PosStaff(tenantID, locationID int) ([]models.PosStaffMember, error) {
|
|
rows := make([]models.PosStaffMember, 0)
|
|
|
|
query := `
|
|
SELECT DISTINCT
|
|
a.userid,
|
|
TRIM(CONCAT(COALESCE(a.firstname,''), ' ', COALESCE(a.lastname,''))) AS fullname,
|
|
COALESCE(r.rolename, '') AS role,
|
|
CAST(a.pin AS TEXT) AS pin,
|
|
COALESCE(a.status, '') AS status
|
|
FROM app_users a
|
|
LEFT JOIN app_roles r ON r.roleid = a.roleid
|
|
WHERE a.tenantid = ?
|
|
AND COALESCE(a.pin, 0) > 0
|
|
AND LOWER(COALESCE(a.status, 'active')) <> 'inactive'
|
|
AND (
|
|
a.locationid = ?
|
|
OR EXISTS (SELECT 1 FROM tenantstaffs s
|
|
WHERE s.userid = a.userid
|
|
AND s.tenantid = a.tenantid
|
|
AND s.locationid = ?
|
|
AND LOWER(COALESCE(s.status, 'active')) <> 'inactive')
|
|
)
|
|
ORDER BY fullname`
|
|
|
|
if err := r.db.Raw(query, tenantID, locationID, locationID).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// A PIN shared by two people at one outlet would make the till attribute a
|
|
// bill to whichever row it happened to check first — so the second one is
|
|
// dropped rather than sent. Live data has 1234 on eleven accounts and 1111
|
|
// on nine, so this is not hypothetical.
|
|
seen := make(map[string]bool, len(rows))
|
|
unique := rows[:0]
|
|
for _, row := range rows {
|
|
if seen[row.Pin] {
|
|
continue
|
|
}
|
|
seen[row.Pin] = true
|
|
unique = append(unique, row)
|
|
}
|
|
|
|
return unique, nil
|
|
}
|