The supervisor blurb still said "Also signs into the app", which was true when it was written and is now the opposite of true: a till account has no Nearle Daily login at all. A console showing that text would be telling a store admin the one thing about these roles they most need not to believe. The cashier blurb said "Billing only", which understated it in the other direction — a cashier now has their own username and password, so a shop can open without a supervisor standing there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
833 lines
28 KiB
Go
833 lines
28 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.
|
|
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.
|
|
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",
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"code": http.StatusOK, "status": true, "details": fields})
|
|
}
|
|
|
|
// 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.
|
|
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",
|
|
})
|
|
}
|
|
|
|
if strings.TrimSpace(req.Authname) == "" && strings.TrimSpace(req.Contactno) == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
|
"code": http.StatusBadRequest, "status": false,
|
|
"message": "an email or mobile number 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", req.Authname, 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,
|
|
// and the answer carries PINs — so the outlet comes from the token, and a
|
|
// request without one is refused whatever POS_AUTH_REQUIRED says.
|
|
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 an email and password before using PIN sign-in",
|
|
})
|
|
}
|
|
|
|
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.",
|
|
},
|
|
},
|
|
})
|
|
}
|