From d35caf34d3cda682ca97ef07bdcc5ac9d2ddd32f Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 11 Aug 2026 11:08:43 +0530 Subject: [PATCH] pos login edited with phone number --- controllers/posController.go | 73 ++++++++++ docs/POS_PHONE_LOGIN_HANDOVER.md | 208 +++++++++++++++++++++++++++++ main.go | 7 + messaging/posmqtt_test.go | 12 ++ models/pos.go | 35 ++++- models/posshift.go | 53 ++++++++ repositories/posAuthRepository.go | 38 +++++- repositories/posRepository.go | 5 + repositories/posShiftRepository.go | 170 +++++++++++++++++++++++ repositories/posUserRepository.go | 168 ++++++++++++++++++++--- routes/posroutes.go | 8 ++ services/posService.go | 16 +++ 12 files changed, 768 insertions(+), 25 deletions(-) create mode 100644 docs/POS_PHONE_LOGIN_HANDOVER.md create mode 100644 models/posshift.go create mode 100644 repositories/posShiftRepository.go diff --git a/controllers/posController.go b/controllers/posController.go index 21a87b6..1ef4bf3 100644 --- a/controllers/posController.go +++ b/controllers/posController.go @@ -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, + }) +} diff --git a/docs/POS_PHONE_LOGIN_HANDOVER.md b/docs/POS_PHONE_LOGIN_HANDOVER.md new file mode 100644 index 0000000..af40647 --- /dev/null +++ b/docs/POS_PHONE_LOGIN_HANDOVER.md @@ -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" +``` diff --git a/main.go b/main.go index f91980c..bd587c4 100644 --- a/main.go +++ b/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) diff --git a/messaging/posmqtt_test.go b/messaging/posmqtt_test.go index 71b94cd..66b4d2d 100644 --- a/messaging/posmqtt_test.go +++ b/messaging/posmqtt_test.go @@ -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) { diff --git a/models/pos.go b/models/pos.go index 3693a72..65b47ee 100644 --- a/models/pos.go +++ b/models/pos.go @@ -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. diff --git a/models/posshift.go b/models/posshift.go new file mode 100644 index 0000000..d3dc12c --- /dev/null +++ b/models/posshift.go @@ -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" +} diff --git a/repositories/posAuthRepository.go b/repositories/posAuthRepository.go index 85389b8..3d212b5 100644 --- a/repositories/posAuthRepository.go +++ b/repositories/posAuthRepository.go @@ -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 diff --git a/repositories/posRepository.go b/repositories/posRepository.go index df306c3..a758c25 100644 --- a/repositories/posRepository.go +++ b/repositories/posRepository.go @@ -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 diff --git a/repositories/posShiftRepository.go b/repositories/posShiftRepository.go new file mode 100644 index 0000000..5199374 --- /dev/null +++ b/repositories/posShiftRepository.go @@ -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. +// +// `` 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 +} diff --git a/repositories/posUserRepository.go b/repositories/posUserRepository.go index dd2ac77..8cfc1d1 100644 --- a/repositories/posUserRepository.go +++ b/repositories/posUserRepository.go @@ -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) diff --git a/routes/posroutes.go b/routes/posroutes.go index bdf9ec7..f1b3445 100644 --- a/routes/posroutes.go +++ b/routes/posroutes.go @@ -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) } } diff --git a/services/posService.go b/services/posService.go index 3d492b0..92e3df1 100644 --- a/services/posService.go +++ b/services/posService.go @@ -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) }