A till signs in with a mobile number and a PIN, but createposuser still
accepted an account without a number. Such an account cannot reach the
sign-in screen at all, and the failure surfaces at a counter in front of
a queue rather than at the point of creation. Enforced in CreatePosUser,
so both doors are covered: POST /pos/users from the terminal and
createposuser from the console share that path.
Two checks, not one. normalisePosPhone answers ("", nil) rather than an
error for a value holding no digits, so "abc" would have passed an
emptiness check, then been written as a blank and skipped the uniqueness
check below it — which is the hole this closes.
Scope is new accounts only. The column stays nullable and UpdatePosUser
still reads an empty contactno as "leave alone", so the accounts that
predate the number keep working through the backfill and cannot have
theirs cleared. The PIN stays optional at creation.
Also in this change:
- docs: correct both phone-login handovers, which claimed creation
already required a number. The sequencing note in the PIN handover
said step 2 was a backfill that could never be finished; it now is
one, and POS_LOGIN.md says which half of the pair creation enforces.
- docs: remove credentials from the examples. POS_PHONE_LOGIN_HANDOVER
carried a real-looking back-office pair and a generated till password,
and POS_LOGIN.md a second one.
- posController.Staff: the comment justified scoping by token because
"the answer carries PINs". It has not since the PIN left the wire. The
scoping is still right for a different reason, which the comment now
gives.
- scratch/posstaffsetup: takes both mobile numbers as arguments and
refuses to run without them. Generating stand-ins would have produced
exactly what this change prevents. Validated before the database is
opened, in plan mode too, so a dry run cannot print a plan that apply
would reject halfway through and leave half a shop set up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
985 lines
33 KiB
Go
985 lines
33 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nearle/middleware"
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
"nearle/services"
|
|
"nearle/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// HTTP face of the POS terminal ingest.
|
|
//
|
|
// These handlers break this codebase's house style in one respect, on purpose:
|
|
// they answer with a bare ack rather than the usual
|
|
// `{code, message, status, details}` envelope. The terminal reads `accepted`
|
|
// from the top level of the body and marks a bill synced only if its id is
|
|
// there — wrapping the ack would leave every till queueing for ever.
|
|
//
|
|
// The status code carries the other half of the contract:
|
|
//
|
|
// - **200** — the batch was processed. Individual bills may still have been
|
|
// refused; the ack says which.
|
|
// - **4xx** — the request itself is wrong (unreadable body, unknown outlet).
|
|
// The terminal treats these as non-retryable and halts, so a person is
|
|
// told rather than the broker hammered.
|
|
// - **5xx** — the outcome is unknown. The terminal keeps every bill and
|
|
// retries with backoff. This is the right answer when the database is
|
|
// having a bad minute: *never* ack a batch that did not commit.
|
|
type PosController struct {
|
|
posService services.PosService
|
|
}
|
|
|
|
func NewPosController(posService services.PosService) *PosController {
|
|
return &PosController{posService: posService}
|
|
}
|
|
|
|
// IngestOrders receives a batch of completed counter bills.
|
|
func (ctl *PosController) IngestOrders(c *fiber.Ctx) error {
|
|
var batch models.PosOrderBatch
|
|
|
|
if err := c.BodyParser(&batch); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "could not read the batch: " + err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if strings.TrimSpace(batch.Storeid) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "store_id is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
ack, err := ctl.posService.IngestOrders(batch)
|
|
if err != nil {
|
|
return posIngestError(c, "IngestOrders", err)
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(ack)
|
|
}
|
|
|
|
// IngestCustomers receives shoppers registered at a till.
|
|
func (ctl *PosController) IngestCustomers(c *fiber.Ctx) error {
|
|
var batch models.PosCustomerBatch
|
|
|
|
if err := c.BodyParser(&batch); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "could not read the batch: " + err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
if strings.TrimSpace(batch.Storeid) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "store_id is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
ack, err := ctl.posService.IngestCustomers(batch)
|
|
if err != nil {
|
|
return posIngestError(c, "IngestCustomers", err)
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(ack)
|
|
}
|
|
|
|
// IngestHealth records one heartbeat from a till.
|
|
//
|
|
// The same heartbeat the broker carries, over HTTP, because presence was
|
|
// previously reachable *only* over MQTT — a terminal configured for the HTTP
|
|
// route reported bills perfectly and never appeared on the fleet board at all,
|
|
// with nothing anywhere to say why. A monitoring feature that silently does not
|
|
// exist on one of two supported transports is worse than no feature.
|
|
//
|
|
// Answers 202 rather than 200: nothing is committed, and the till is told not
|
|
// to wait on it. Failures are swallowed for the same reason the MQTT path
|
|
// swallows them — a terminal that cannot say how it is must still sell, and a
|
|
// blank square on a dashboard beats a till that stopped because Redis was busy.
|
|
func (ctl *PosController) IngestHealth(c *fiber.Ctx) error {
|
|
var health models.PosHealth
|
|
|
|
if err := c.BodyParser(&health); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "could not read the heartbeat: " + err.Error(),
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
// Over MQTT these come from the topic. There is no topic here, so the body
|
|
// is the only source and both are required — a heartbeat that cannot say
|
|
// which till it belongs to is unfilable.
|
|
if strings.TrimSpace(health.Terminalid) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "status": false,
|
|
"message": "terminal_id is required",
|
|
})
|
|
}
|
|
if strings.TrimSpace(health.Locationid) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "status": false,
|
|
"message": "location_id is required",
|
|
})
|
|
}
|
|
|
|
// Matches the consumer: a bare {"status":"offline"} is a Last Will and must
|
|
// survive as-is, but an unset status from a till that is plainly talking to
|
|
// us means online.
|
|
if strings.TrimSpace(health.Status) == "" {
|
|
health.Status = "online"
|
|
}
|
|
|
|
if err := ctl.posService.RecordHealth(c.Context(), health); err != nil {
|
|
// Logged, not returned. See above — the till must not slow down for it.
|
|
log.Printf("pos: could not record heartbeat from %s/%s over HTTP: %v",
|
|
health.Locationid, health.Terminalid, err)
|
|
}
|
|
|
|
return c.Status(http.StatusAccepted).JSON(fiber.Map{
|
|
"status": true, "code": http.StatusAccepted,
|
|
})
|
|
}
|
|
|
|
// Catalogue answers a terminal's product pull.
|
|
func (ctl *PosController) Catalogue(c *fiber.Ctx) error {
|
|
storeID := strings.TrimSpace(c.Query("store_id"))
|
|
if storeID == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest,
|
|
"message": "store_id is required",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.Query("page", "0"))
|
|
pageSize, _ := strconv.Atoi(c.Query("page_size", "500"))
|
|
|
|
result, err := ctl.posService.Catalogue(storeID, c.Query("since"), page, pageSize)
|
|
if err != nil {
|
|
return posIngestError(c, "Catalogue", err)
|
|
}
|
|
|
|
return c.Status(http.StatusOK).JSON(result)
|
|
}
|
|
|
|
// TerminalHealth returns one till's live state, for a support call that starts
|
|
// with a terminal code.
|
|
//
|
|
// The outlet check cannot live in the middleware like every other POS route's
|
|
// does. The middleware scopes a request by the location it *names*, and this
|
|
// request names none — only a terminal code, which is free text minted at the
|
|
// till and belongs to whichever shop is holding that device. So the outlet is
|
|
// not known until after the lookup, and the check has to happen here.
|
|
func (ctl *PosController) TerminalHealth(c *fiber.Ctx) error {
|
|
terminalID := strings.TrimSpace(c.Query("terminal_id"))
|
|
if terminalID == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "message": "terminal_id is required", "status": false,
|
|
})
|
|
}
|
|
|
|
fields, err := ctl.posService.TerminalHealth(c.Context(), terminalID)
|
|
if err != nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
|
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
|
|
})
|
|
}
|
|
|
|
if fields == nil {
|
|
// Not an error. The till has simply not reported inside its TTL, which
|
|
// is the answer the caller wanted — said plainly rather than as a 404
|
|
// that reads like the terminal does not exist.
|
|
//
|
|
// Answered without an outlet check, and safely so: there is nothing to
|
|
// check against and nothing to leak. "Offline" is the same answer for a
|
|
// terminal code that was never issued, so guessing codes reveals only
|
|
// that guessing does not work.
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"details": fiber.Map{
|
|
"terminal_id": terminalID,
|
|
"status": "offline",
|
|
"reason": "no heartbeat received within the presence window",
|
|
},
|
|
})
|
|
}
|
|
|
|
if err := ctl.posTerminalInScope(c, fields); err != nil {
|
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
|
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
|
}
|
|
|
|
// posTerminalInScope refuses a heartbeat belonging to somebody else's shop.
|
|
//
|
|
// Reads the outlet off the heartbeat itself, because that — not the request —
|
|
// is the authority on which shop a terminal code belongs to. A caller who
|
|
// guesses "T4A9" gets a 403 rather than another shop's pending-bill count,
|
|
// takings so far today, and app version.
|
|
//
|
|
// Silent when the request carries no token, matching middleware.PosAuth: while
|
|
// POS_AUTH_REQUIRED is off, tills in the field are still calling these routes
|
|
// unauthenticated, and refusing them here would take the fleet board down for
|
|
// exactly the terminals it exists to watch. Once the flag is on, an untokened
|
|
// request never reaches this handler.
|
|
func (ctl *PosController) posTerminalInScope(c *fiber.Ctx, fields map[string]string) error {
|
|
claims, ok := middleware.PosClaimsFrom(c)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
locationID, err := strconv.Atoi(strings.TrimSpace(fields["location_id"]))
|
|
if err != nil || locationID <= 0 {
|
|
// A heartbeat that cannot say where it came from cannot be shown to a
|
|
// caller who must be scoped. Refusing beats guessing.
|
|
return fmt.Errorf("this terminal's outlet could not be determined")
|
|
}
|
|
|
|
if locationID == claims.Locationid {
|
|
return nil
|
|
}
|
|
|
|
allowed, err := ctl.posService.LocationAllowed(claims.Tenantid, locationID)
|
|
if err != nil {
|
|
return fmt.Errorf("could not verify outlet access")
|
|
}
|
|
if !allowed {
|
|
return fmt.Errorf("this terminal belongs to an outlet this session cannot reach")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LocationHealth returns every till at a shop — the "which counters are dark"
|
|
// board. Tills that have stopped reporting come back marked offline rather than
|
|
// being omitted, because a missing till is exactly what somebody is looking for.
|
|
func (ctl *PosController) LocationHealth(c *fiber.Ctx) error {
|
|
locationID := strings.TrimSpace(c.Query("location_id"))
|
|
if locationID == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "message": "location_id is required", "status": false,
|
|
})
|
|
}
|
|
|
|
terminals, err := ctl.posService.LocationHealth(c.Context(), locationID)
|
|
if err != nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{
|
|
"code": http.StatusServiceUnavailable, "message": err.Error(), "status": false,
|
|
})
|
|
}
|
|
|
|
online := 0
|
|
for _, t := range terminals {
|
|
if t["status"] == "online" {
|
|
online++
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK,
|
|
"status": true,
|
|
"details": fiber.Map{
|
|
"location_id": locationID,
|
|
"total": len(terminals),
|
|
"online": online,
|
|
"terminals": terminals,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Sales reads
|
|
//
|
|
// Unlike the ingest handlers above, these answer in the usual
|
|
// `{code, message, status, details}` envelope — they are read by the web app,
|
|
// not by a terminal, and nothing about them is bound to the till's contract.
|
|
|
|
// posSalesFilter reads the shared query parameters.
|
|
func posSalesFilter(c *fiber.Ctx) (models.PosSalesFilter, error) {
|
|
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
|
if err != nil || locationID <= 0 {
|
|
return models.PosSalesFilter{}, fmt.Errorf("locationid is required")
|
|
}
|
|
|
|
pageno, _ := strconv.Atoi(c.Query("pageno", "0"))
|
|
pagesize, _ := strconv.Atoi(c.Query("pagesize", "50"))
|
|
|
|
return models.PosSalesFilter{
|
|
Locationid: locationID,
|
|
Fromdate: strings.TrimSpace(c.Query("fromdate")),
|
|
Todate: strings.TrimSpace(c.Query("todate")),
|
|
Terminalid: strings.TrimSpace(c.Query("terminalid")),
|
|
Cashiername: strings.TrimSpace(c.Query("cashiername")),
|
|
Paymentmode: strings.TrimSpace(c.Query("paymentmode")),
|
|
Pageno: pageno,
|
|
Pagesize: pagesize,
|
|
}, nil
|
|
}
|
|
|
|
// GetSales lists counter bills for an outlet, newest first.
|
|
func (ctl *PosController) GetSales(c *fiber.Ctx) error {
|
|
filter, err := posSalesFilter(c)
|
|
if err != nil {
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
page, err := ctl.posService.Sales(filter)
|
|
if err != nil {
|
|
return posServerError(c, "GetSales", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": page})
|
|
}
|
|
|
|
// GetSaleDetail returns one bill with its lines.
|
|
//
|
|
// Accepts the terminal's order UUID, the invoice number, or this backend's
|
|
// posorderid — a support call starts from whichever the caller is looking at.
|
|
func (ctl *PosController) GetSaleDetail(c *fiber.Ctx) error {
|
|
locationID, err := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
|
if err != nil || locationID <= 0 {
|
|
return posBadRequest(c, fmt.Errorf("locationid is required"))
|
|
}
|
|
|
|
reference := strings.TrimSpace(c.Query("reference"))
|
|
if reference == "" {
|
|
return posBadRequest(c, fmt.Errorf("reference is required — an order id, invoice number or posorderid"))
|
|
}
|
|
|
|
bill, err := ctl.posService.SaleDetail(locationID, reference)
|
|
if err != nil {
|
|
return posServerError(c, "GetSaleDetail", err)
|
|
}
|
|
if bill == nil {
|
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{
|
|
"code": http.StatusNotFound,
|
|
"message": "no bill matches that reference at this outlet",
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": bill})
|
|
}
|
|
|
|
// GetSalesSummary totals a range, split by tender, day and till.
|
|
func (ctl *PosController) GetSalesSummary(c *fiber.Ctx) error {
|
|
filter, err := posSalesFilter(c)
|
|
if err != nil {
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
summary, err := ctl.posService.SalesSummary(filter)
|
|
if err != nil {
|
|
return posServerError(c, "GetSalesSummary", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": summary})
|
|
}
|
|
|
|
func posBadRequest(c *fiber.Ctx, err error) error {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "message": err.Error(), "status": false,
|
|
})
|
|
}
|
|
|
|
func posServerError(c *fiber.Ctx, op string, err error) error {
|
|
log.Printf("pos %s: %v", op, err)
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
|
"code": http.StatusInternalServerError, "message": err.Error(), "status": false,
|
|
})
|
|
}
|
|
|
|
// posIngestError decides whether the terminal should retry.
|
|
//
|
|
// The distinction matters more than the message does. A misconfigured store id
|
|
// will be just as wrong on the next attempt, so it is reported as a 4xx and the
|
|
// till halts and shows a person the reason. Anything else might succeed later,
|
|
// so it is a 5xx and the bills stay queued.
|
|
func posIngestError(c *fiber.Ctx, op string, err error) error {
|
|
log.Printf("pos %s: %v", op, err)
|
|
|
|
message := err.Error()
|
|
lower := strings.ToLower(message)
|
|
|
|
permanent := strings.Contains(lower, "is not a location id") ||
|
|
strings.Contains(lower, "no outlet is registered") ||
|
|
strings.Contains(lower, "does not belong to tenant") ||
|
|
strings.Contains(lower, "has no products stocked") ||
|
|
strings.Contains(lower, "no applocationid configured")
|
|
|
|
status := http.StatusInternalServerError
|
|
if permanent {
|
|
status = http.StatusBadRequest
|
|
}
|
|
|
|
return c.Status(status).JSON(fiber.Map{
|
|
"code": status,
|
|
"message": message,
|
|
"status": false,
|
|
})
|
|
}
|
|
|
|
// Login signs a terminal in and returns its session.
|
|
//
|
|
// The one POS route that is deliberately left unauthenticated — it is where a
|
|
// token comes from. Everything else on the group sits behind the session this
|
|
// issues.
|
|
//
|
|
// A mobile number and a PIN. Because it is unauthenticated and the PIN is four
|
|
// digits, this is the one route on the group that needs a rate limit in front
|
|
// of it — the pair is only strong while an attacker cannot try ten thousand
|
|
// times. That belongs at the edge, not here.
|
|
func (ctl *PosController) Login(c *fiber.Ctx) error {
|
|
var req models.PosLoginRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "status": false,
|
|
"message": "invalid request body",
|
|
})
|
|
}
|
|
|
|
// Which account, and which of the two credentials was offered. Both are
|
|
// checked here so an empty field is answered as the malformed request it is,
|
|
// rather than spending a database round trip to say the same thing.
|
|
identity := strings.TrimSpace(req.Contactno)
|
|
if identity == "" {
|
|
identity = strings.TrimSpace(req.Authname)
|
|
}
|
|
if identity == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "status": false,
|
|
"message": "a mobile number is required",
|
|
})
|
|
}
|
|
if strings.TrimSpace(req.Pin) == "" && strings.TrimSpace(req.Password) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "status": false,
|
|
"message": "a PIN is required",
|
|
})
|
|
}
|
|
|
|
session, err := ctl.posService.Login(req)
|
|
if err != nil {
|
|
// A rejected credential is 401 and says nothing about which half was
|
|
// wrong. Anything else is the deployment's problem, not the caller's,
|
|
// and is logged rather than described down the wire.
|
|
if repositories.PosLoginRejected(err) {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
|
"code": http.StatusUnauthorized, "status": false,
|
|
"message": err.Error(),
|
|
})
|
|
}
|
|
|
|
log.Printf("pos login (%s): %v", identity, err)
|
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
|
"code": http.StatusForbidden, "status": false, "message": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"message": "Login successful",
|
|
"details": session,
|
|
})
|
|
}
|
|
|
|
// Session echoes back who the caller is, per their token.
|
|
//
|
|
// What a till calls on start-up to find out whether the session it saved
|
|
// yesterday is still good, without having to make a real request and interpret
|
|
// the failure. Answers 401 through the middleware when it is not.
|
|
func (ctl *PosController) Session(c *fiber.Ctx) error {
|
|
claims, ok := middleware.PosClaimsFrom(c)
|
|
if !ok {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
|
"code": http.StatusUnauthorized, "status": false,
|
|
"message": "no session token was presented",
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"details": fiber.Map{
|
|
"user_id": claims.Userid,
|
|
"tenant_id": claims.Tenantid,
|
|
"location_id": claims.Locationid,
|
|
"store_id": strconv.Itoa(claims.Locationid),
|
|
"role_id": claims.Roleid,
|
|
"terminal_id": claims.Terminalid,
|
|
"expires_at": time.Unix(claims.Expiresat, 0).UTC().Format(time.RFC3339),
|
|
},
|
|
})
|
|
}
|
|
|
|
// Staff lists who may ring a bill at this terminal's outlet.
|
|
//
|
|
// Scoped by the caller's own session rather than by a query parameter. A till
|
|
// asking "who works here" must not be able to ask on behalf of another shop, so
|
|
// the outlet comes from the token, and a request without one is refused
|
|
// whatever POS_AUTH_REQUIRED says.
|
|
//
|
|
// The answer no longer carries PINs — that stopped when the PIN became half of
|
|
// the sign-in, see models.PosStaffMember. The scoping outlives the reason: an
|
|
// outlet's roster is still its own business, and a list of who is on shift
|
|
// where is worth something to somebody casing a chain.
|
|
func (ctl *PosController) Staff(c *fiber.Ctx) error {
|
|
claims, ok := middleware.PosClaimsFrom(c)
|
|
if !ok {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
|
"code": http.StatusUnauthorized, "status": false,
|
|
"message": "a session token is required to read staff",
|
|
})
|
|
}
|
|
|
|
staff, err := ctl.posService.Staff(claims.Tenantid, claims.Locationid)
|
|
if err != nil {
|
|
return posServerError(c, "Staff", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"details": models.PosStaffResponse{
|
|
Locationid: claims.Locationid,
|
|
Staff: staff,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ------------------------------------------------------------- Till staff
|
|
//
|
|
// A shop runs its own counter. A supervisor creates their cashiers from the
|
|
// terminal, and every one of these reads the tenant and outlet from the
|
|
// caller's session token rather than from the request — so a supervisor at one
|
|
// shop cannot create, edit or list staff at another. That is the same inversion
|
|
// that stopped a till naming its own store id, applied to people.
|
|
|
|
// posManager returns the caller's session, provided they may manage staff.
|
|
func posManager(c *fiber.Ctx) (utils.PosClaims, error) {
|
|
claims, ok := middleware.PosClaimsFrom(c)
|
|
if !ok {
|
|
return claims, fiber.NewError(http.StatusUnauthorized,
|
|
"a session token is required")
|
|
}
|
|
if !models.PosRoleCanManageStaff(claims.Roleid) {
|
|
// A cashier signing in on the same terminal must not be able to mint
|
|
// themselves a supervisor.
|
|
return claims, fiber.NewError(http.StatusForbidden,
|
|
"only a supervisor can manage till users")
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
|
|
func (ctl *PosController) CreatePosUser(c *fiber.Ctx) error {
|
|
claims, err := posManager(c)
|
|
if err != nil {
|
|
return posClaimError(c, err)
|
|
}
|
|
|
|
var req models.PosUserRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
|
}
|
|
|
|
user, err := ctl.posService.CreateUser(claims.Tenantid, claims.Locationid, claims.Configid, req)
|
|
if err != nil {
|
|
// Every failure here is something the caller can act on — a bad role, a
|
|
// PIN already in use, a name left blank — so it is reported as a 400
|
|
// with the reason rather than logged and hidden behind a 500.
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
|
"code": http.StatusCreated, "status": true,
|
|
"message": "User created", "details": user,
|
|
})
|
|
}
|
|
|
|
// UpdatePosUser edits one of the caller's own till users.
|
|
func (ctl *PosController) UpdatePosUser(c *fiber.Ctx) error {
|
|
claims, err := posManager(c)
|
|
if err != nil {
|
|
return posClaimError(c, err)
|
|
}
|
|
|
|
var req models.PosUserRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
|
}
|
|
|
|
user, err := ctl.posService.UpdateUser(claims.Tenantid, claims.Locationid, req)
|
|
if err != nil {
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"message": "User updated", "details": user,
|
|
})
|
|
}
|
|
|
|
// ListPosUsers returns the till users at the caller's outlet.
|
|
//
|
|
// Readable by anyone signed in, not only a supervisor: the terminal needs the
|
|
// list to show who is on shift, and a cashier can already see their colleagues
|
|
// standing next to them. PINs are the part that matters, and those only go to
|
|
// somebody who could set them anyway.
|
|
func (ctl *PosController) ListPosUsers(c *fiber.Ctx) error {
|
|
claims, ok := middleware.PosClaimsFrom(c)
|
|
if !ok {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
|
"code": http.StatusUnauthorized, "status": false,
|
|
"message": "a session token is required",
|
|
})
|
|
}
|
|
|
|
users, err := ctl.posService.ListUsers(
|
|
claims.Tenantid, claims.Locationid,
|
|
strings.EqualFold(c.Query("include_inactive"), "true"),
|
|
)
|
|
if err != nil {
|
|
return posServerError(c, "ListPosUsers", err)
|
|
}
|
|
|
|
// A cashier sees who is on shift, not how to sign in as them.
|
|
if !models.PosRoleCanManageStaff(claims.Roleid) {
|
|
for i := range users {
|
|
users[i].Pin = ""
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"details": fiber.Map{"location_id": claims.Locationid, "users": users},
|
|
})
|
|
}
|
|
|
|
// DeletePosUser retires a till user. Deactivates rather than deletes — bills
|
|
// carry the cashier's name and shifts settle against it.
|
|
func (ctl *PosController) DeletePosUser(c *fiber.Ctx) error {
|
|
claims, err := posManager(c)
|
|
if err != nil {
|
|
return posClaimError(c, err)
|
|
}
|
|
|
|
userID, convErr := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
|
|
if convErr != nil || userID <= 0 {
|
|
return posBadRequest(c, fmt.Errorf("user_id is required"))
|
|
}
|
|
if userID == claims.Userid {
|
|
// Otherwise the last supervisor at a shop can lock everybody out with
|
|
// one tap, and only we can undo it.
|
|
return posBadRequest(c, fmt.Errorf("you cannot deactivate the account you are signed in as"))
|
|
}
|
|
|
|
if err := ctl.posService.DeactivateUser(claims.Tenantid, claims.Locationid, userID); err != nil {
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true, "message": "User deactivated",
|
|
})
|
|
}
|
|
|
|
// PinLogin signs somebody in by PIN at a terminal that is already open.
|
|
//
|
|
// Requires an existing valid session, and that is the whole security model
|
|
// here: four digits is ten thousand guesses, which is no barrier at all to an
|
|
// anonymous caller. Tying it to a token means a supervisor has already opened
|
|
// the terminal with a real password, and the guesses are confined to one
|
|
// outlet's own staff.
|
|
//
|
|
// The new session is minted fresh rather than derived from the presented one,
|
|
// so a cashier taking over from a supervisor drops the supervisor's
|
|
// permissions instead of inheriting them.
|
|
func (ctl *PosController) PinLogin(c *fiber.Ctx) error {
|
|
claims, ok := middleware.PosClaimsFrom(c)
|
|
if !ok {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
|
"code": http.StatusUnauthorized, "status": false,
|
|
"message": "sign the terminal in with a mobile number and PIN before switching operator",
|
|
})
|
|
}
|
|
|
|
var req models.PosLoginRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
|
}
|
|
if strings.TrimSpace(req.Pin) == "" {
|
|
return posBadRequest(c, fmt.Errorf("a PIN is required"))
|
|
}
|
|
|
|
session, err := ctl.posService.LoginWithPin(claims.Tenantid, claims.Locationid, req.Pin)
|
|
if err != nil {
|
|
if repositories.PosLoginRejected(err) {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{
|
|
"code": http.StatusUnauthorized, "status": false,
|
|
"message": "that PIN was not recognised",
|
|
})
|
|
}
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"message": "Signed in", "details": session,
|
|
})
|
|
}
|
|
|
|
// posClaimError renders the fiber.Error that posManager returns.
|
|
func posClaimError(c *fiber.Ctx, err error) error {
|
|
var fe *fiber.Error
|
|
if errors.As(err, &fe) {
|
|
return c.Status(fe.Code).JSON(fiber.Map{
|
|
"code": fe.Code, "status": false, "message": fe.Message,
|
|
})
|
|
}
|
|
return posServerError(c, "posClaims", err)
|
|
}
|
|
|
|
// ------------------------------------------------------- Till staff, from the web
|
|
//
|
|
// The same staff management as `/pos/users`, for the console an admin actually
|
|
// uses. Deliberately the same service calls underneath rather than a parallel
|
|
// implementation: a supervisor created from a browser must be the same thing as
|
|
// one created at a counter, and two code paths writing one table is exactly how
|
|
// that stops being true.
|
|
//
|
|
// The difference is where the outlet comes from. A terminal proves it with a
|
|
// signed token; the console asserts it, because it has no session of its own.
|
|
// So it is verified against the tenant before anything is written — which is
|
|
// weaker than a signature, and is why these should move behind the same guard
|
|
// once the console can hold a session.
|
|
|
|
// posWebScope reads and checks the tenant and outlet a console request names.
|
|
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
|
|
if tenantID <= 0 {
|
|
return fmt.Errorf("tenantid is required")
|
|
}
|
|
if locationID <= 0 {
|
|
return fmt.Errorf("locationid is required")
|
|
}
|
|
|
|
allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
|
|
if err != nil {
|
|
return fmt.Errorf("could not verify the outlet")
|
|
}
|
|
if !allowed {
|
|
// Not "no such outlet" — that would confirm which ids exist. It did not
|
|
// belong to the tenant asking, and that is all the caller needs.
|
|
return fmt.Errorf("outlet %d does not belong to tenant %d", locationID, tenantID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WebCreatePosUser adds a supervisor or cashier from the console.
|
|
func (ctl *PosController) WebCreatePosUser(c *fiber.Ctx) error {
|
|
var req models.PosUserWebRequest
|
|
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)
|
|
}
|
|
|
|
// The configid the outlet's other people already use, so a new cashier is
|
|
// visible to the same portal as their colleagues. Asked for rather than
|
|
// derived would mean a console sending a number nobody can look up.
|
|
configID := ctl.posService.ConfigidFor(req.Tenantid)
|
|
|
|
user, err := ctl.posService.CreateUser(req.Tenantid, req.Locationid, configID, req.PosUserRequest)
|
|
if err != nil {
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
|
"code": http.StatusCreated, "status": true,
|
|
"message": "User created", "details": user,
|
|
})
|
|
}
|
|
|
|
// WebUpdatePosUser edits one of an outlet's till users from the console.
|
|
func (ctl *PosController) WebUpdatePosUser(c *fiber.Ctx) error {
|
|
var req models.PosUserWebRequest
|
|
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)
|
|
}
|
|
|
|
user, err := ctl.posService.UpdateUser(req.Tenantid, req.Locationid, req.PosUserRequest)
|
|
if err != nil {
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"message": "User updated", "details": user,
|
|
})
|
|
}
|
|
|
|
// WebListPosUsers lists an outlet's till users for the console.
|
|
func (ctl *PosController) WebListPosUsers(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)
|
|
}
|
|
|
|
users, err := ctl.posService.ListUsers(tenantID, locationID,
|
|
strings.EqualFold(c.Query("include_inactive"), "true"))
|
|
if err != nil {
|
|
return posServerError(c, "WebListPosUsers", err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"details": fiber.Map{"location_id": locationID, "users": users},
|
|
})
|
|
}
|
|
|
|
// WebDeletePosUser retires a till user from the console.
|
|
func (ctl *PosController) WebDeletePosUser(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)
|
|
}
|
|
|
|
userID, err := strconv.Atoi(strings.TrimSpace(c.Query("userid")))
|
|
if err != nil || userID <= 0 {
|
|
return posBadRequest(c, fmt.Errorf("userid is required"))
|
|
}
|
|
|
|
if err := ctl.posService.DeactivateUser(tenantID, locationID, userID); err != nil {
|
|
return posBadRequest(c, err)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true, "message": "User deactivated",
|
|
})
|
|
}
|
|
|
|
// WebPosRoles lists the roles a console may offer.
|
|
//
|
|
// Served rather than hardcoded in the console, because the numbers are this
|
|
// backend's business. A console that hardcoded 7 and 8 would be wrong the day
|
|
// they change, and would have no way to know.
|
|
func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
|
|
return c.JSON(fiber.Map{
|
|
"code": http.StatusOK, "status": true,
|
|
"details": []fiber.Map{
|
|
{
|
|
"role_id": models.PosRoleSupervisor, "role": "supervisor",
|
|
"label": models.PosRoleName(models.PosRoleSupervisor),
|
|
"description": "Runs the terminal: imports, settings, voids, and " +
|
|
"creating counter staff. Signs in at a till only — a till " +
|
|
"account has no Nearle Daily login.",
|
|
},
|
|
{
|
|
"role_id": models.PosRoleCashier, "role": "cashier",
|
|
"label": models.PosRoleName(models.PosRoleCashier),
|
|
"description": "Billing only. Signs in at a till with their own username " +
|
|
"and password, so a shop can open without a supervisor present.",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────── 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,
|
|
})
|
|
}
|