pos login edited with phone number
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
208
docs/POS_PHONE_LOGIN_HANDOVER.md
Normal file
208
docs/POS_PHONE_LOGIN_HANDOVER.md
Normal 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"
|
||||
```
|
||||
7
main.go
7
main.go
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
53
models/posshift.go
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
170
repositories/posShiftRepository.go
Normal file
170
repositories/posShiftRepository.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user