pos login edited with phone number

This commit is contained in:
2026-08-11 11:08:43 +05:30
parent 3531c656d4
commit d35caf34d3
12 changed files with 768 additions and 25 deletions

View File

@@ -886,3 +886,76 @@ func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
},
})
}
// ─────────────────────────────────────────────────────────── Staff shifts
//
// Working windows for till staff, managed from the console. Same scoping rule
// as the till-user routes above: the outlet is asserted by the caller and
// checked against the tenant before anything is written.
// WebListStaffShifts returns an outlet's shifts, for a picker.
func (ctl *PosController) WebListStaffShifts(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
if err := ctl.posWebScope(tenantID, locationID); err != nil {
return posBadRequest(c, err)
}
shifts, err := ctl.posService.ListStaffShifts(tenantID, locationID,
strings.EqualFold(c.Query("include_inactive"), "true"))
if err != nil {
return posServerError(c, "WebListStaffShifts", err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"details": fiber.Map{"location_id": locationID, "shifts": shifts},
})
}
// WebCreateStaffShift adds a working window at one outlet.
func (ctl *PosController) WebCreateStaffShift(c *fiber.Ctx) error {
var req models.StaffShifts
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
return posBadRequest(c, err)
}
shift, err := ctl.posService.CreateStaffShift(req.Tenantid, req.Locationid, req)
if err != nil {
return posBadRequest(c, err)
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"code": http.StatusCreated, "status": true,
"message": "Shift created", "details": shift,
})
}
// WebUpdateStaffShift edits a window. Deactivate by sending status "Inactive" —
// shifts are not deleted, because a person may still be assigned to one and an
// orphaned shiftid reads as a shift that never existed.
func (ctl *PosController) WebUpdateStaffShift(c *fiber.Ctx) error {
var req models.StaffShifts
if err := c.BodyParser(&req); err != nil {
return posBadRequest(c, fmt.Errorf("invalid request body"))
}
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
return posBadRequest(c, err)
}
shift, err := ctl.posService.UpdateStaffShift(req.Tenantid, req.Locationid, req)
if err != nil {
return posBadRequest(c, err)
}
return c.JSON(fiber.Map{
"code": http.StatusOK, "status": true,
"message": "Shift updated", "details": shift,
})
}

View File

@@ -0,0 +1,208 @@
# POS sign-in by mobile number, and shift assignment — handover to the terminal team
Backend is done and builds clean. **Nothing about this breaks the current app** —
username sign-in keeps working exactly as it does today. Read §5 before you ship
anything, because the switch has one ordering rule that will lock out every
cashier if it is done in the wrong order.
---
## 1. What changed, in one line
`POST /pos/login` now accepts a **mobile number** as well as a username, only
till accounts are candidates, and a till account can carry a **shift**.
---
## 2. Endpoints — what was edited and how
### `POST /live/api/v1/pos/login` — CHANGED (backwards compatible)
The request body already had both fields. **Nothing in the contract changed.**
What changed is behaviour behind it.
```jsonc
// Sign in by mobile — the new way
{ "contactno": "9876543210", "password": "xHegDaH55ccWic" }
// Sign in by username — still works, unchanged
{ "authname": "cashier.1135@pos.nearle.in", "password": "xHegDaH55ccWic" }
```
`authname` wins if both are sent. The response is unchanged.
**Behavioural change:** the account lookup is now restricted to till roles
(Supervisor `7`, Cashier `8`).
*Why it matters to you:* previously a number shared with a back-office account
returned two candidates and the login was refused outright. On live data **34
numbers are shared by 104 accounts** — one by eleven — so without this,
sign-in by phone would simply have failed for a large number of people. Now a
number only has to be unique among till accounts.
A back-office user who types their own password at a till still gets the
specific `403 "this account is not set up for the till"` rather than a vague
rejection. That did not change.
### `POST /live/api/v1/web/tenants/createposuser` — CHANGED (two new fields)
```jsonc
{
"tenantid": 1087,
"locationid": 1135,
"full_name": "Priya Raman",
"role": "cashier",
"pin": "4731",
"contactno": "9876543210", // NEW — the sign-in number
"shift_id": 3 // NEW — optional, 0/omitted = unassigned
}
```
The response gains `shift_id`, and `contactno` now comes back **normalised to
ten digits**.
### `PUT /live/api/v1/web/tenants/updateposuser` — CHANGED (two new fields)
Accepts `contactno` and `shift_id`. Every field stays optional; only what is
sent is written.
### `GET /live/api/v1/web/tenants/getposusers` — CHANGED (four new fields)
Each user in `details.users[]` now also carries:
```jsonc
{
"contactno": "9876543210",
"shift_id": 3,
"shift_name": "Morning",
"shift_start": "07:00",
"shift_end": "15:00"
}
```
Blank on accounts created before shifts existed. **`shift_*` also appears on
`staff[]` in the `/pos/login` session**, so the terminal gets it for free.
### `GET/POST/PUT /live/api/v1/web/tenants/{getstaffshifts,createstaffshift,updatestaffshift}` — NEW
Console-only. The terminal does not need to call these; shifts arrive with the
session. Documented for completeness:
```jsonc
// POST createstaffshift
{ "tenantid": 1087, "locationid": 1135,
"name": "Morning", "start_time": "07:00", "end_time": "15:00",
"weekdays": "1111100" } // 7 chars from Monday; empty = every day
```
Also mirrored under `/v1/mob/tenants/*`.
---
## 3. Number format — the one thing to get exactly right
The server reduces every number to **ten digits** before storing or matching:
non-digits are stripped, then a leading `91` or `0` is dropped once. Anything
that is not ten digits afterwards is **rejected**, not stored.
So all of these are the same account:
```
"+91 98765 43210" → 9876543210
"098765-43210" → 9876543210
"9876543210" → 9876543210
```
**What you should send:** the ten digits, or anything in the list above — the
server normalises either way. **Do not** send a country code the user did not
type, and do not reject `+91` locally; let it through and let the server reduce
it. What matters is that you never send something that normalises to a
*different* number than what the console stored.
---
## 4. Shift is informational
`shift_id` / `shift_name` / `shift_start` / `shift_end` are for **display**.
Nothing on the server refuses a bill rung outside a shift window, and you should
not add that check on the device either. A cashier locked out mid-queue by a
clock is a worse failure than a bill filed against the wrong window. Show whose
shift it is; do not gate on it.
Times are 24-hour `HH:MM`. A shift may legitimately wrap midnight (`22:00`
→ `06:00`) — do not assume `end > start`.
---
## 5. 🔴 Sequencing — read this before shipping
**Every existing POS account has no mobile number.** All 12 live accounts have
`contactno = ""`:
```
supervisor.1135@pos.nearle.in phone=""
cashier.1135@pos.nearle.in phone=""
… 12 of 12
```
If the app ships sign-in-by-phone **only**, every cashier in every shop is
locked out on the next app update.
**Required order:**
1. Backend deploys. *(Nothing changes for the app — username login is untouched.)*
2. Back office adds a mobile number to every existing till account through the
console. New accounts already require one.
3. **Only then** the app makes mobile the primary sign-in field.
**Recommendation for the app:** keep both. One field labelled *"Mobile number or
username"* — if the value is all digits send it as `contactno`, otherwise as
`authname`. That is a handful of lines, works before and after the backfill, and
means a shop with one un-backfilled account is not stranded.
---
## 6. Errors you should handle
| Message | Meaning | What the app should do |
|---|---|---|
| generic 401 rejection | wrong number/username or wrong password | "Check your details" — do **not** say which was wrong |
| `this account is not set up for the till…` | a back-office login was used | Show it verbatim; it names the fix |
| `more than one account uses these sign-in details…` | ambiguous match | Show verbatim; it needs the back office |
| `this account has no password set…` | provisioned without a password | Show verbatim |
| `another till account in this business already signs in with 9876543210` | console-side only | Not seen by the app |
---
## 7. What did **not** change
- The response shape of `/pos/login`, including `token`, `expires_at`,
`can_manage_staff`, `locations[]` and `staff[]`
- `POST /pos/login/pin`
- Token format, TTL (30 days) and the `PosAuth` guard
- `POST /pos/orders`, `/pos/customers`, `/pos/health`, `GET /pos/catalogue`
- `POS_AUTH_REQUIRED` still defaults to off
---
## 8. Verify after deploy
```bash
B=https://fiesta.nearle.app/live/api/v1
# username sign-in still works (regression check — run this first)
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"authname":"supervisor.1135@pos.nearle.in","password":"…"}' \
| grep -o '"can_manage_staff":[a-z]*'
# expect: "can_manage_staff":true
# after a number is set on that account, the same account by phone
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"contactno":"9876543210","password":"…"}' \
| grep -o '"can_manage_staff":[a-z]*'
# a back-office account is still refused with the specific message
curl -s -X POST "$B/pos/login" -H 'Content-Type: application/json' \
-d '{"authname":"rmart@gmail.com","password":"rmart@123"}'
# expect: 403 "this account is not set up for the till"
```

View File

@@ -55,6 +55,13 @@ func main() {
log.Fatal("POS schema migration failed:", err)
}
// Shift windows for till staff. Additive — `app_users.shiftid` already
// existed and pointed at the rider table, so an account with no shift is
// simply unassigned rather than broken.
if err := db.DB.AutoMigrate(&models.StaffShifts{}); err != nil {
log.Fatal("staff shift schema migration failed:", err)
}
f := facade.NewFacade(db.DB, db.CatalogueDB)
routes.RegisterRoutes(app, f)

View File

@@ -99,6 +99,18 @@ func (f *fakePosService) ListUsers(int, int, bool) ([]models.PosUser, error) {
return nil, nil
}
func (f *fakePosService) ListStaffShifts(int, int, bool) ([]models.StaffShifts, error) {
return nil, nil
}
func (f *fakePosService) CreateStaffShift(int, int, models.StaffShifts) (*models.StaffShifts, error) {
return nil, nil
}
func (f *fakePosService) UpdateStaffShift(int, int, models.StaffShifts) (*models.StaffShifts, error) {
return nil, nil
}
func (f *fakePosService) DeactivateUser(int, int, int) error { return nil }
func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, error) {

View File

@@ -439,6 +439,13 @@ type PosUser struct {
Pin string `json:"pin,omitempty"`
Haspassword bool `json:"has_password"`
// The shift this person works, resolved for display. Zero / empty when the
// account has none, which is every account created before shifts existed.
Shiftid int `json:"shift_id,omitempty"`
Shiftname string `json:"shift_name,omitempty"`
Shiftstart string `json:"shift_start,omitempty"`
Shiftend string `json:"shift_end,omitempty"`
// The password, returned only in the answer to a creation or a reset and
// never by a listing. An admin who loses it reissues rather than looks it
// up — the right shape even while the column behind it is plaintext.
@@ -454,14 +461,28 @@ type PosUser struct {
// 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"`
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"`
// The mobile number this person signs in with.
//
// Normalised to ten digits before it is stored, because the till matches on
// it exactly and "+91 98765 43210" typed back as "9876543210" would not
// find the row. Unique among a tenant's till accounts.
Contactno string `json:"contactno"`
Status string `json:"status"`
// Which shift this person works — a staffshifts.staffshiftid, stored on
// app_users.shiftid. Zero leaves it unset.
//
// Informational. Nothing refuses a bill rung outside it; the terminal shows
// it so a counter knows who is due.
Shiftid int `json:"shift_id"`
Status string `json:"status"`
}
// PosUserWebRequest is a staff change made from the web console.

53
models/posshift.go Normal file
View File

@@ -0,0 +1,53 @@
package models
import "time"
// StaffShifts is a working window at one outlet.
//
// Separate from `ridershifts`, which already exists and was the obvious thing
// to reuse — but is the wrong shape twice over. It carries delivery economics
// (`basefare`, `fuelcharge`, `additionalkm`, `firstmilecharge`) that mean
// nothing at a counter, and it is scoped by `applocationid`, a city, where a
// shop's hours belong to the shop. A supermarket in Selvapuram and one in
// Gandhipuram do not open at the same time because they share a city.
//
// A person is assigned exactly one of these via `app_users.shiftid`, which is
// the column that already existed. That makes a shift a *template* — "the
// morning shift" — rather than a roster of dated assignments. A roster is the
// richer model and the one to reach for if per-date planning is ever wanted;
// this is the smaller thing that answers "who is on the early shift" without a
// second table and a second screen.
//
// Informational. Nothing refuses a bill rung outside a shift: the terminal has
// never been run against this, and a cashier locked out mid-queue by a clock is
// a worse failure than a bill filed against the wrong window.
type StaffShifts struct {
Staffshiftid int `json:"staff_shift_id" gorm:"primaryKey;autoIncrement;column:staffshiftid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid;index"`
Locationid int `json:"locationid" gorm:"column:locationid;index"`
// What a person calls it — "Morning", "Evening", "Weekend cover".
Name string `json:"name" gorm:"column:name"`
// Stored as text in 24-hour `HH:MM`, matching how `ridershifts` already
// holds its own times. Not a `time` column: these are wall-clock windows
// that repeat, not instants, and a date component on them invites exactly
// the timezone confusion that put a day of POS bills under the wrong
// business date.
Starttime string `json:"start_time" gorm:"column:starttime"`
Endtime string `json:"end_time" gorm:"column:endtime"`
// Which days it runs, as a 7-character mask starting Monday — "1111100"
// is Monday to Friday. Empty means every day, so a shop that never varies
// its week does not have to say so.
Weekdays string `json:"weekdays" gorm:"column:weekdays"`
Status string `json:"status" gorm:"column:status"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
}
func (StaffShifts) TableName() string {
return "staffshifts"
}

View File

@@ -47,7 +47,7 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
return nil, fmt.Errorf("an email or mobile number is required")
}
rows, err := r.posLoginCandidates(field, value, req.Configid)
rows, err := r.posLoginCandidates(field, value, req.Configid, true)
if err != nil {
return nil, err
}
@@ -55,6 +55,21 @@ func (r *posRepository) PosLogin(req models.PosLoginRequest) (*models.PosSession
// 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 {
// Nothing eligible under that credential. Before answering with the
// deliberately vague rejection, look again without the role filter — a
// back-office account typing its own password at a till deserves to be
// told that is the problem, rather than sent hunting for a password
// that was never wrong.
//
// Only ever reached after that account's own password verifies, so it
// discloses nothing the caller has not just proved. An ambiguous match
// falls through to the vague answer rather than naming anything.
if others, oerr := r.posLoginCandidates(field, value, req.Configid, false); oerr == nil &&
len(others) == 1 &&
strings.TrimSpace(others[0].Password) != "" &&
constantTimeEqual(others[0].Password, req.Password) {
return nil, errPosRoleIneligible
}
return nil, errPosLoginRejected
}
@@ -205,7 +220,7 @@ func posRoleLabel(roleID int) string {
// 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) {
func (r *posRepository) posLoginCandidates(field, value string, configID int, tillOnly bool) ([]posLoginRow, error) {
rows := make([]posLoginRow, 0, 2)
query := fmt.Sprintf(`
@@ -223,6 +238,25 @@ func (r *posRepository) posLoginCandidates(field, value string, configID int) ([
params = append(params, configID)
}
// Only till accounts are candidates.
//
// This matters most for signing in by phone. `contactno` is not unique in
// this schema — 34 numbers are shared by 104 active accounts, one of them
// by eleven — and the caller refuses any lookup returning more than one
// row, because choosing between them could bill into the wrong tenant's
// books. Without this clause a cashier whose number also sits on a tenant
// admin's record simply cannot log in.
//
// Narrowing here means a till phone number only has to be unique among
// till accounts, not across all 608 users. An eligible-but-wrong-role
// account still gets the specific errPosRoleIneligible answer, because the
// caller checks the role again after the password verifies — this clause
// removes ambiguity, it does not replace that check.
if tillOnly {
query += fmt.Sprintf(` AND COALESCE(roleid, 0) IN (%d, %d)`,
models.PosRoleSupervisor, models.PosRoleCashier)
}
// 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

View File

@@ -50,6 +50,11 @@ type PosRepository interface {
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)
// Shift windows for till staff.
ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error)
CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
DeactivatePosUser(tenantID, locationID, userID int) error
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
PosConfigidFor(tenantID int) int

View File

@@ -0,0 +1,170 @@
package repositories
import (
"fmt"
"regexp"
"strings"
"nearle/models"
)
// Shift windows for till staff.
//
// Scoped by tenant *and* outlet in every statement rather than checked first,
// the same shape the till-user queries use: a console naming somebody else's
// shift id updates no rows and is told so, instead of quietly editing another
// shop's hours.
var posTimeOfDay = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
// normaliseShiftTime accepts what a time input actually sends.
//
// `<input type="time">` gives "07:00", some browsers and most hand-typed values
// give "07:00:00", and `ridershifts` already stores the seconds form. Both are
// reduced to `HH:MM` so a shift written by one client reads the same to another.
func normaliseShiftTime(raw string) (string, error) {
t := strings.TrimSpace(raw)
if t == "" {
return "", fmt.Errorf("a start and end time are required")
}
if len(t) == 8 && strings.Count(t, ":") == 2 {
t = t[:5]
}
if !posTimeOfDay.MatchString(t) {
return "", fmt.Errorf("time must be 24-hour HH:MM; got %q", raw)
}
return t, nil
}
// ListStaffShifts returns an outlet's shifts, newest last so a picker reads in
// the order they were created rather than alphabetically by name.
func (r *posRepository) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required")
}
shifts := make([]models.StaffShifts, 0)
query := `SELECT * FROM staffshifts WHERE tenantid = ? AND locationid = ?`
if !includeInactive {
query += ` AND LOWER(COALESCE(status,'active')) <> 'inactive'`
}
query += ` ORDER BY staffshiftid`
if err := r.db.Raw(query, tenantID, locationID).Scan(&shifts).Error; err != nil {
return nil, err
}
return shifts, nil
}
// CreateStaffShift adds a window at one outlet.
func (r *posRepository) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
if tenantID <= 0 || locationID <= 0 {
return nil, fmt.Errorf("tenantid and locationid are required")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, fmt.Errorf("a shift name is required")
}
start, err := normaliseShiftTime(req.Starttime)
if err != nil {
return nil, err
}
end, err := normaliseShiftTime(req.Endtime)
if err != nil {
return nil, err
}
// An end before a start is allowed on purpose — a night shift runs 22:00 to
// 06:00 and wrapping midnight is ordinary in retail. Only the equal case is
// refused, because a zero-length window cannot be what anyone meant.
if start == end {
return nil, fmt.Errorf("a shift cannot start and end at the same time")
}
weekdays := strings.TrimSpace(req.Weekdays)
if weekdays != "" && !regexp.MustCompile(`^[01]{7}$`).MatchString(weekdays) {
return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday, e.g. 1111100")
}
shift := models.StaffShifts{
Tenantid: tenantID,
Locationid: locationID,
Name: name,
Starttime: start,
Endtime: end,
Weekdays: weekdays,
Status: "Active",
}
if err := r.db.Table("staffshifts").Create(&shift).Error; err != nil {
return nil, err
}
return &shift, nil
}
// UpdateStaffShift edits a window. Every field is optional; only the ones sent
// are written, matching how the till-user update behaves.
func (r *posRepository) UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
if req.Staffshiftid <= 0 {
return nil, fmt.Errorf("staff_shift_id is required")
}
sets := []string{}
args := []interface{}{}
if name := strings.TrimSpace(req.Name); name != "" {
sets = append(sets, "name = ?")
args = append(args, name)
}
if strings.TrimSpace(req.Starttime) != "" {
t, err := normaliseShiftTime(req.Starttime)
if err != nil {
return nil, err
}
sets = append(sets, "starttime = ?")
args = append(args, t)
}
if strings.TrimSpace(req.Endtime) != "" {
t, err := normaliseShiftTime(req.Endtime)
if err != nil {
return nil, err
}
sets = append(sets, "endtime = ?")
args = append(args, t)
}
if w := strings.TrimSpace(req.Weekdays); w != "" {
if !regexp.MustCompile(`^[01]{7}$`).MatchString(w) {
return nil, fmt.Errorf("weekdays must be seven 0/1 characters starting Monday")
}
sets = append(sets, "weekdays = ?")
args = append(args, w)
}
if s := strings.TrimSpace(req.Status); s != "" {
sets = append(sets, "status = ?")
args = append(args, s)
}
if len(sets) == 0 {
return nil, fmt.Errorf("nothing to change")
}
sets = append(sets, "updated = NOW()")
args = append(args, req.Staffshiftid, tenantID, locationID)
res := r.db.Exec(
fmt.Sprintf(`UPDATE staffshifts SET %s WHERE staffshiftid = ? AND tenantid = ? AND locationid = ?`,
strings.Join(sets, ", ")), args...)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, fmt.Errorf("no shift %d at this outlet", req.Staffshiftid)
}
var out models.StaffShifts
if err := r.db.Raw(`SELECT * FROM staffshifts WHERE staffshiftid = ?`, req.Staffshiftid).Scan(&out).Error; err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -97,6 +97,15 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
return nil, err
}
// The number this person signs in with. Optional at the schema level so a
// shop can still be provisioned before it has collected them, but the
// console asks for it because the till's own sign-in is moving to it —
// an account with no number can only ever log in by username.
phone, err := normalisePosPhone(req.Contactno)
if err != nil {
return nil, err
}
password := strings.TrimSpace(req.Password)
authname := strings.ToLower(strings.TrimSpace(req.Authname))
@@ -154,6 +163,20 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
}
}
// Checked under the same advisory lock as the PIN, and for the same
// reason: two supervisors provisioning at once would otherwise both see
// the number free and both write it, leaving a login that resolves to
// two people and therefore to nobody.
if phone != "" {
taken, err := posPhoneTaken(tx, tenantID, phone, 0)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another till account in this business already signs in with %s", phone)
}
}
// Uniqueness is checked against `authname` and `email` together because
// the insert below writes the same value to both, and
// `app_users_email_unique` is a real constraint — a clash there fails the
@@ -208,12 +231,12 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
if err := tx.Raw(`
INSERT INTO app_users
(firstname, lastname, authname, email, contactno, password,
pin, roleid, configid, tenantid, locationid, status)
pin, shiftid, roleid, configid, tenantid, locationid, status)
VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''),
NULLIF(?, 0), ?, ?, ?, ?, 'Active')
NULLIF(?, 0), NULLIF(?, 0), ?, ?, ?, ?, 'Active')
RETURNING userid`,
first, last, authname, authname, strings.TrimSpace(req.Contactno),
password, pin, roleID, configID, tenantID, locationID,
first, last, authname, authname, phone,
password, pin, req.Shiftid, roleID, configID, tenantID, locationID,
).Scan(&nextID).Error; err != nil {
return err
}
@@ -227,7 +250,8 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
Firstname: first,
Lastname: last,
Authname: authname,
Contactno: strings.TrimSpace(req.Contactno),
Contactno: phone,
Shiftid: req.Shiftid,
Roleid: roleID,
Role: models.PosRoleName(roleID),
Pin: posPinString(pin),
@@ -301,9 +325,27 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs
args = append(args, authname)
}
if contactno := strings.TrimSpace(req.Contactno); contactno != "" {
// Normalised on the way in, exactly as on create — a number edited to
// "+91 98765 43210" would otherwise stop matching the login that reduces
// what is typed to ten digits.
phone := ""
if strings.TrimSpace(req.Contactno) != "" {
p, err := normalisePosPhone(req.Contactno)
if err != nil {
return nil, err
}
phone = p
sets = append(sets, "contactno = ?")
args = append(args, contactno)
args = append(args, phone)
}
// Zero means "not specified" and leaves the shift alone. Clearing one is
// therefore not expressible here, which is deliberate: every other field on
// this endpoint behaves the same way, and a sentinel that only one field
// honours is the kind of asymmetry that gets forgotten.
if req.Shiftid > 0 {
sets = append(sets, "shiftid = ?")
args = append(args, req.Shiftid)
}
if password := strings.TrimSpace(req.Password); password != "" {
@@ -335,6 +377,18 @@ func (r *posRepository) UpdatePosUser(tenantID, locationID int, req models.PosUs
}
}
// The person being edited is excluded, so re-saving an unchanged number
// is not reported as a clash with themselves.
if phone != "" {
taken, err := posPhoneTaken(tx, tenantID, phone, req.Userid)
if err != nil {
return err
}
if taken {
return fmt.Errorf("another till account in this business already signs in with %s", phone)
}
}
query := fmt.Sprintf(
`UPDATE app_users SET %s WHERE userid = ? AND tenantid = ? AND locationid = ?`,
strings.Join(sets, ", "))
@@ -377,18 +431,38 @@ func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive b
Pin int64
Haspassword bool
Status string
Shiftid int
Shiftname string
Shiftstart string
Shiftend string
}, 0)
// The shift is LEFT JOINed and matched on the outlet as well as the id.
//
// `app_users.shiftid` predates this table and points at `ridershifts` for
// riders, so the same number means different things depending on the row's
// role. Joining on tenant and location too means a rider shift id can never
// resolve to a staff shift that happens to share it — an unmatched id just
// comes back blank, which is the honest answer for an account created
// before shifts existed.
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 (?, ?)`
SELECT a.userid,
COALESCE(a.firstname,'') AS firstname, COALESCE(a.lastname,'') AS lastname,
COALESCE(a.authname,'') AS authname, COALESCE(a.contactno,'') AS contactno,
COALESCE(a.roleid,0) AS roleid, COALESCE(a.pin,0) AS pin,
(COALESCE(a.password,'') <> '') AS haspassword,
COALESCE(a.status,'') AS status,
COALESCE(s.staffshiftid,0) AS shiftid,
COALESCE(s.name,'') AS shiftname,
COALESCE(s.starttime,'') AS shiftstart,
COALESCE(s.endtime,'') AS shiftend
FROM app_users a
LEFT JOIN staffshifts s
ON s.staffshiftid = a.shiftid
AND s.tenantid = a.tenantid
AND s.locationid = a.locationid
WHERE a.tenantid = ? AND a.locationid = ?
AND COALESCE(a.roleid,0) IN (?, ?)`
params := []interface{}{tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier}
if !includeInactive {
@@ -415,6 +489,10 @@ func (r *posRepository) ListPosUsers(tenantID, locationID int, includeInactive b
Haspassword: row.Haspassword,
Locationid: locationID,
Status: row.Status,
Shiftid: row.Shiftid,
Shiftname: row.Shiftname,
Shiftstart: row.Shiftstart,
Shiftend: row.Shiftend,
})
}
return users, nil
@@ -499,6 +577,64 @@ func posPinTaken(tx *gorm.DB, tenantID, locationID int, pin int64, exceptUser in
return count > 0, err
}
// normalisePosPhone reduces a mobile number to the ten digits stored on the row.
//
// The till signs in with this, so what is stored and what is typed have to
// agree exactly. A number arrives as "+91 98765 43210", "098765-43210" or
// "9876543210" depending on who typed it, and matching those as free text means
// a cashier who is certain of their own number cannot get in.
//
// Reduced to digits, then a leading 91 or 0 is dropped once. Anything that is
// not ten digits afterwards is refused rather than stored — a number that
// cannot be typed back identically is not a credential.
func normalisePosPhone(raw string) (string, error) {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, raw)
if digits == "" {
return "", nil
}
if len(digits) == 12 && strings.HasPrefix(digits, "91") {
digits = digits[2:]
} else if len(digits) == 11 && strings.HasPrefix(digits, "0") {
digits = digits[1:]
}
if len(digits) != 10 {
return "", fmt.Errorf("mobile number must be 10 digits; got %q", raw)
}
return digits, nil
}
// posPhoneTaken reports whether another till account already signs in with this
// number.
//
// Scoped to the tenant rather than the outlet, unlike the PIN check: a PIN is
// typed at one counter and only has to be unique there, but a phone number is a
// login and must resolve to exactly one person across the whole chain. A person
// working two shops of the same tenant is one account, not two.
//
// Only till roles are counted, matching what the login itself looks at — 34
// numbers are already shared among 104 back-office accounts, and a cashier must
// not be blocked by a tenant admin who happens to share their number.
func posPhoneTaken(tx *gorm.DB, tenantID int, phone string, exceptUser int) (bool, error) {
if phone == "" {
return false, nil
}
var count int64
err := tx.Raw(`
SELECT COUNT(1) FROM app_users
WHERE tenantid = ? AND TRIM(COALESCE(contactno,'')) = ? AND userid <> ?
AND COALESCE(roleid,0) IN (?, ?)
AND LOWER(COALESCE(status,'active')) <> 'inactive'`,
tenantID, phone, exceptUser, models.PosRoleSupervisor, models.PosRoleCashier,
).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)

View File

@@ -147,5 +147,13 @@ func registerPosStaffConsoleRoutes(api fiber.Router, f *facade.Facade) {
g.Post("/createposuser", f.PosController.WebCreatePosUser)
g.Put("/updateposuser", f.PosController.WebUpdatePosUser)
g.Delete("/deleteposuser", f.PosController.WebDeletePosUser)
// Working windows a till account can be assigned to. Alongside the
// staff routes rather than under /pos, for the same reason: the caller
// is the back office setting a shop up, and it holds no terminal
// session to be checked against.
g.Get("/getstaffshifts", f.PosController.WebListStaffShifts)
g.Post("/createstaffshift", f.PosController.WebCreateStaffShift)
g.Put("/updatestaffshift", f.PosController.WebUpdateStaffShift)
}
}

View File

@@ -43,6 +43,10 @@ type PosService interface {
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)
ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error)
CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error)
DeactivateUser(tenantID, locationID, userID int) error
// LoginWithPin signs a person in at a terminal that is already open. Never
@@ -153,6 +157,18 @@ func (s *posService) UpdateUser(tenantID, locationID int, req models.PosUserRequ
return s.repo.UpdatePosUser(tenantID, locationID, req)
}
func (s *posService) ListStaffShifts(tenantID, locationID int, includeInactive bool) ([]models.StaffShifts, error) {
return s.repo.ListStaffShifts(tenantID, locationID, includeInactive)
}
func (s *posService) CreateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
return s.repo.CreateStaffShift(tenantID, locationID, req)
}
func (s *posService) UpdateStaffShift(tenantID, locationID int, req models.StaffShifts) (*models.StaffShifts, error) {
return s.repo.UpdateStaffShift(tenantID, locationID, req)
}
func (s *posService) ListUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error) {
return s.repo.ListPosUsers(tenantID, locationID, includeInactive)
}