1018 lines
35 KiB
Go
1018 lines
35 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.
|
||
// posTenantScope is the guard for things that belong to a whole business
|
||
// rather than to one of its shops — shift windows, so far.
|
||
//
|
||
// No ownership query, because there is nothing to own: `middleware.WebAuth`
|
||
// pins the tenant from the signed session and refuses a request naming another
|
||
// one, so reaching here with a tenant id at all means it is this caller's.
|
||
// Naming an outlet is what needs checking, and that is `posWebScope` below.
|
||
func (ctl *PosController) posTenantScope(tenantID int) error {
|
||
if tenantID <= 0 {
|
||
return fmt.Errorf("tenantid is required")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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")))
|
||
|
||
// Tenant-scoped, because a shift belongs to the business rather than to one
|
||
// of its shops. An outlet may still be named to narrow the list, and is
|
||
// checked for ownership when it is — omitting it is not a way to read
|
||
// somebody else's, because the tenant comes from the signed session.
|
||
if err := ctl.posTenantScope(tenantID); err != nil {
|
||
return posBadRequest(c, err)
|
||
}
|
||
if locationID > 0 {
|
||
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"))
|
||
}
|
||
|
||
// A shift with no outlet belongs to the tenant and every branch it owns,
|
||
// which is the ordinary case — a business that works 07:00–15:00 works
|
||
// those hours at every shop, and entering them per outlet is how the third
|
||
// branch quietly ends up on 07:00–15:30. An outlet is named only when one
|
||
// shop really does differ, and is checked for ownership then.
|
||
if err := ctl.posTenantScope(req.Tenantid); err != nil {
|
||
return posBadRequest(c, err)
|
||
}
|
||
if req.Locationid > 0 {
|
||
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,
|
||
})
|
||
}
|