status updated

This commit is contained in:
2026-10-09 16:27:51 +05:30
parent ed32a2620e
commit f0361d8343
12 changed files with 559 additions and 23 deletions

View File

@@ -12,10 +12,17 @@ import (
type DeliverySlotController struct { type DeliverySlotController struct {
service services.DeliverySlotService service services.DeliverySlotService
// Asked whether the shop is trading at all. Held here rather than inside
// the slot service, which answers one question — is this window still
// running — and should not also have to know about shop closures.
tenants services.TenantService
} }
func NewDeliverySlotController(service services.DeliverySlotService) *DeliverySlotController { func NewDeliverySlotController(
return &DeliverySlotController{service: service} service services.DeliverySlotService,
tenants services.TenantService,
) *DeliverySlotController {
return &DeliverySlotController{service: service, tenants: tenants}
} }
/* /*
@@ -128,6 +135,28 @@ func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error {
}) })
} }
/*
A shut shop offers nothing.
Without this, a branch closed for the day still hands the app tomorrow
morning — and the shopper picks it, reaches checkout, and is refused by
the closed-shop guard in order creation with no warning anything was
wrong. The empty list is already the app's "no windows here" path, so
this needs nothing new on their side.
The reason rides along so the app can say WHY rather than just showing
nothing, on an endpoint it is already calling.
*/
if reason := ctl.tenants.StoreClosedReason(tenantID, locationID); reason != "" {
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": []models.AvailableDeliverySlot{},
"closedreason": reason,
})
}
slots, err := ctl.service.Available(tenantID, locationID) slots, err := ctl.service.Available(tenantID, locationID)
if err != nil { if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ return c.Status(http.StatusInternalServerError).JSON(fiber.Map{

View File

@@ -20,15 +20,21 @@ type OrderController struct {
// than reimplemented, so the app's list and this check can never disagree // than reimplemented, so the app's list and this check can never disagree
// about where a window ends. // about where a window ends.
deliverySlotService services.DeliverySlotService deliverySlotService services.DeliverySlotService
// Asked whether the shop is trading. Same reasoning: the customer-facing
// store list and this check run the same rule, so a shop shown as open is
// never refused here.
tenantService services.TenantService
} }
func NewOrderController( func NewOrderController(
orderService services.OrderService, orderService services.OrderService,
deliverySlotService services.DeliverySlotService, deliverySlotService services.DeliverySlotService,
tenantService services.TenantService,
) *OrderController { ) *OrderController {
return &OrderController{ return &OrderController{
orderService: orderService, orderService: orderService,
deliverySlotService: deliverySlotService, deliverySlotService: deliverySlotService,
tenantService: tenantService,
} }
} }
@@ -358,6 +364,24 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error {
data.Deliverytime = time.Now().Format("2006-01-02 15:04:05") data.Deliverytime = time.Now().Format("2006-01-02 15:04:05")
} }
/*
Is the shop even trading?
Checked HERE and not only in the app, because /v1/mob/* carries no
session: anything arriving is a claim. The ordinary case is innocent and
still has to be caught — a shopper with the app open when the shopkeeper
closes, or a stale screen from this morning.
409 with the shop's own reason, written to be shown as-is.
*/
if reason := ctl.tenantService.StoreClosedReason(data.Tenantid, data.Locationid); reason != "" {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict,
"message": reason,
"status": false,
})
}
// The chosen delivery window, re-decided here. // The chosen delivery window, re-decided here.
// //
// The app sends back what /v1/mob/deliveryslots/available handed it, but // The app sends back what /v1/mob/deliveryslots/available handed it, but

View File

@@ -871,3 +871,53 @@ func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
"code": http.StatusOK, "status": true, "message": "Invitation sent.", "code": http.StatusOK, "status": true, "message": "Invitation sent.",
}) })
} }
/*
PUT /v1/web/tenants/storeopen
Open or close a branch for the day.
── Why this is on the guarded group ────────────────────────────────────────
Closing a shop stops it taking money. On /v1/mob/* — which carries no session
at all — anyone who could guess a locationid could shut any shop on the
platform. WebAuth checks the tenant scope, so a business can only reach its
own branches.
A business objection — a date in the past, a branch that is not yours — is 409
and not 500: the request was well formed, and the message is written to be
shown to the person who typed it.
*/
func (ctl *TenantController) SetStoreOpen(c *fiber.Ctx) error {
var req struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
// A POINTER, so "not sent" and "sent as false" stay different answers.
// A plain bool would read a missing field as "close this shop".
Isopen *bool `json:"isopen"`
Closeduntil string `json:"closeduntil"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "Invalid request body", "status": false,
})
}
if req.Isopen == nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "isopen is required", "status": false,
})
}
if err := ctl.tenantService.SetStoreOpen(
req.Tenantid, req.Locationid, *req.Isopen, req.Closeduntil,
); err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"code": http.StatusConflict, "message": err.Error(), "status": false,
})
}
return c.Status(http.StatusOK).JSON(fiber.Map{
"code": http.StatusOK, "message": "Successfully Updated", "status": true,
})
}

View File

@@ -93,6 +93,18 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
productRepo, catalogueService, services.NewNutritionService(nutritionBase)) productRepo, catalogueService, services.NewNutritionService(nutritionBase))
productController := controllers.NewProductController(productService) productController := controllers.NewProductController(productService)
//Tenant Module
//
// Onboarding, adding a person and commissioning a branch all create an
// account with no password, so all three send an invitation. `tenantRepo` and
// `inviteService` are built above, where the reasoning is.
//
// BEFORE the order controller, which takes it: order creation asks this
// service whether the shop is trading before accepting, using the same
// rule the customer-facing store list runs. No dependency the other way.
tenantService := services.NewTenantService(tenantRepo, inviteService)
tenantController := controllers.NewTenantController(tenantService)
// When each branch delivers. // When each branch delivers.
// //
// BEFORE the order controller, which takes it: order creation re-checks a // BEFORE the order controller, which takes it: order creation re-checks a
@@ -100,12 +112,12 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
// drift. No dependency the other way — this service knows nothing of orders. // drift. No dependency the other way — this service knows nothing of orders.
deliverySlotRepo := repositories.NewDeliverySlotRepository(db) deliverySlotRepo := repositories.NewDeliverySlotRepository(db)
deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo) deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo)
deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService) deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService, tenantService)
// Order Module // Order Module
orderRepo := repositories.NewOrderRepository(db) orderRepo := repositories.NewOrderRepository(db)
orderService := services.NewOrderService(orderRepo) orderService := services.NewOrderService(orderRepo)
orderController := controllers.NewOrderController(orderService, deliverySlotService) orderController := controllers.NewOrderController(orderService, deliverySlotService, tenantService)
// Deliveries Module // Deliveries Module
deliveriesRepo := repositories.NewDeliveriesRepository(db) deliveriesRepo := repositories.NewDeliveriesRepository(db)
@@ -117,14 +129,6 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat
utilsService := services.NewUtilsService(utilsRepo) utilsService := services.NewUtilsService(utilsRepo)
utilsController := controllers.NewUtilsController(utilsService) utilsController := controllers.NewUtilsController(utilsService)
//Tenant Module
//
// Onboarding, adding a person and commissioning a branch all create an
// account with no password, so all three send an invitation. `tenantRepo` and
// `inviteService` are built above, where the reasoning is.
tenantService := services.NewTenantService(tenantRepo, inviteService)
tenantController := controllers.NewTenantController(tenantService)
//Partner Module //Partner Module
partnerRepo := repositories.NewPartnerRepository(db) partnerRepo := repositories.NewPartnerRepository(db)
partnerService := services.NewPartnerService(partnerRepo) partnerService := services.NewPartnerService(partnerRepo)

26
main.go
View File

@@ -251,6 +251,32 @@ func main() {
log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err) log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err)
} }
// Whether a branch is trading today.
//
// SEPARATE FROM tenantlocations.status, which is the platform's word for
// whether the branch exists at all — setting that to Inactive is how a
// branch is decommissioned, and the staff console reads it that way. A
// shopkeeper closing for an afternoon is saying something else entirely,
// and sharing one column would make a day off look like a shop that shut
// down.
//
// Default TRUE, so every branch trading the moment this ships keeps
// trading. There is no backfill and there must never be one.
if err := db.DB.Exec(
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS isopen boolean NOT NULL DEFAULT true`).Error; err != nil {
log.Println("⚠️ could not add tenantlocations.isopen, every branch will keep reporting as open:", err)
}
// The day it opens again, when the shop knows it.
//
// Nullable, because a power cut has no end date. Nothing flips isopen back
// on a timer — models.StoreIsOpen works the answer out on read, so a branch
// that said "back on Monday" is open on Monday whether or not a job ran.
if err := db.DB.Exec(
`ALTER TABLE tenantlocations ADD COLUMN IF NOT EXISTS closeduntil DATE`).Error; err != nil {
log.Println("⚠️ could not add tenantlocations.closeduntil, branches will not reopen on their own:", err)
}
// When a product became visible to a store, and the only thing that decides // When a product became visible to a store, and the only thing that decides
// whether it is. // whether it is.
// //

View File

@@ -467,6 +467,31 @@ type TenantInfo struct {
Categoryid int `json:"categoryid"` Categoryid int `json:"categoryid"`
Registrationno string `json:"registrationno"` Registrationno string `json:"registrationno"`
Orderscount int `json:"orderscount"` Orderscount int `json:"orderscount"`
/*
Whether this branch is taking orders, and why not when it is not.
`Isopen` and `Closeduntil` are the stored columns; `Isaccepting` and
`Closedreason` are worked out per request by models.StoreIsOpen, which
also folds in `status` and lets a dated close expire on its own.
THE APP SHOULD READ `isaccepting`, not `isopen`. The raw flag says what
the shopkeeper last pressed; the computed one is the answer — a branch
that said "back on Monday" has isopen false on Monday and is open.
The branch is still RETURNED when closed rather than filtered out. A shop
that vanishes reads to a regular customer as gone for good; one marked
"closed today" brings them back tomorrow. An app that would rather hide
it can, from this field.
*/
// The BRANCH status, aliased in the query so it does not collide with the
// tenant status already on this struct. Feeds StoreIsOpen.
Locationstatus string `json:"-" gorm:"column:locationstatus"`
Isopen bool `json:"isopen"`
Closeduntil string `json:"closeduntil"`
Isaccepting bool `json:"isaccepting" gorm:"-"`
Closedreason string `json:"closedreason" gorm:"-"`
// Products []Products `json:"products" gorm:"-"` // Products []Products `json:"products" gorm:"-"`
ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"` ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"`
} }

89
models/storeopen.go Normal file
View File

@@ -0,0 +1,89 @@
package models
import (
"strings"
"time"
)
/*
Is a branch taking orders right now?
Two things have to be true: the branch exists as far as the platform is
concerned, and the shop has not closed itself for the day. They are different
questions with different owners — Nearle decommissions a branch, a shopkeeper
closes for an afternoon — and this is the only place they are answered
together.
── Reopening is worked out, not scheduled ──────────────────────────────────
A shop that said "back on Monday" is open on Monday because this function says
so, not because a job ran. Nothing has to fire, nothing can be missed, and a
server that was down over the weekend still gets it right. The alternative — a
nightly task flipping the flag — fails silently exactly once and leaves a shop
shut with nobody watching.
── Why the date means the day it REOPENS ───────────────────────────────────
"Closed until the 12th" reads, in English, as open on the 12th. So the date
names the first day back, and a shop closing for today alone sets tomorrow.
The console says "Reopens on" rather than "Closed until" so nobody has to
work that out.
*/
func StoreIsOpen(status string, isOpen bool, closedUntil string, now time.Time) bool {
// Decommissioned beats everything. A branch Nearle has switched off does
// not come back because a date passed.
if !strings.EqualFold(strings.TrimSpace(status), "active") {
return false
}
if isOpen {
return true
}
// Closed, with a date to come back on.
reopen, ok := parseStoreDate(closedUntil)
if !ok {
// Closed with no end in sight — a power cut nobody can put a date on.
// Stays closed until a person says otherwise, which is the safe way
// round: a shop wrongly shown open takes orders it cannot fill.
return false
}
today := now.Truncate(24 * time.Hour)
return !today.Before(reopen.Truncate(24 * time.Hour))
}
/*
Why a shopper cannot order, in words fit to show them.
Empty when the branch is open. Returned straight to the app, so it says what a
person needs to know and not what the database holds.
*/
func StoreClosedReason(status string, isOpen bool, closedUntil string, now time.Time) string {
if StoreIsOpen(status, isOpen, closedUntil, now) {
return ""
}
if !strings.EqualFold(strings.TrimSpace(status), "active") {
return "This store is not currently available"
}
if reopen, ok := parseStoreDate(closedUntil); ok {
return "This store is closed today and reopens on " + reopen.Format("2 Jan")
}
return "This store is closed today"
}
// Accepts a bare date or a timestamp, because the column is a DATE but GORM
// and the console have both been known to hand over a full RFC3339 string.
// Anything unreadable is "no date", never today.
func parseStoreDate(value string) (time.Time, bool) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, false
}
if len(trimmed) >= 10 {
if parsed, err := time.Parse("2006-01-02", trimmed[:10]); err == nil {
return parsed, true
}
}
return time.Time{}, false
}

111
models/storeopen_test.go Normal file
View File

@@ -0,0 +1,111 @@
package models
import (
"strings"
"testing"
"time"
)
func at(day string) time.Time {
parsed, err := time.Parse("2006-01-02", day)
if err != nil {
panic(err)
}
return parsed
}
func TestOpenByDefault(t *testing.T) {
// Every branch trading today has isopen true and no date. None of them may
// go dark the moment this ships.
if !StoreIsOpen("Active", true, "", at("2026-10-09")) {
t.Error("a normal trading branch was reported closed")
}
}
func TestClosedWithNoDateStaysClosed(t *testing.T) {
// A power cut nobody can put an end to. It stays shut until a person says
// otherwise — the safe way round, because a shop wrongly shown open takes
// orders it cannot fill.
if StoreIsOpen("Active", false, "", at("2026-10-09")) {
t.Error("a branch closed with no reopen date was reported open")
}
}
func TestReopensOnTheDateItNamed(t *testing.T) {
// "Closed until the 12th" means open ON the 12th.
cases := []struct {
today string
want bool
}{
{"2026-10-10", false}, // still closed
{"2026-10-11", false}, // last closed day
{"2026-10-12", true}, // back
{"2026-10-13", true}, // and stays back
}
for _, tc := range cases {
got := StoreIsOpen("Active", false, "2026-10-12", at(tc.today))
if got != tc.want {
t.Errorf("on %s: open=%v, want %v", tc.today, got, tc.want)
}
}
}
func TestReopeningNeedsNobodyToRemember(t *testing.T) {
// The whole reason this is computed and not scheduled: no job ran, nothing
// flipped the flag, and the shop is open anyway.
if !StoreIsOpen("Active", false, "2026-10-01", at("2026-12-25")) {
t.Error("a branch whose reopen date passed months ago is still shut")
}
}
func TestDecommissionedBeatsEverything(t *testing.T) {
// Status is Nearle's word for whether the branch exists. A date passing
// must not bring back a branch that was switched off.
if StoreIsOpen("Inactive", true, "", at("2026-10-09")) {
t.Error("an inactive branch was reported open")
}
if StoreIsOpen("Inactive", false, "2026-10-01", at("2026-12-25")) {
t.Error("an inactive branch came back because a date passed")
}
}
func TestUnreadableDateIsNotTreatedAsToday(t *testing.T) {
// A zero time would be before today and quietly reopen the shop.
if StoreIsOpen("Active", false, "not a date", at("2026-10-09")) {
t.Error("an unreadable reopen date reopened the branch")
}
}
func TestAcceptsATimestampAsWellAsADate(t *testing.T) {
// The column is a DATE, but GORM and the console have both been known to
// hand over a full timestamp.
if !StoreIsOpen("Active", false, "2026-10-12T00:00:00Z", at("2026-10-12")) {
t.Error("a timestamp form of the reopen date was not understood")
}
}
func TestReasonIsWrittenForAShopper(t *testing.T) {
if msg := StoreClosedReason("Active", true, "", at("2026-10-09")); msg != "" {
t.Errorf("an open store gave a reason: %q", msg)
}
msg := StoreClosedReason("Active", false, "2026-10-12", at("2026-10-09"))
if !strings.Contains(msg, "12 Oct") {
t.Errorf("the reason does not say when it reopens: %q", msg)
}
// No date: say it is closed, and do not invent a return.
plain := StoreClosedReason("Active", false, "", at("2026-10-09"))
if plain == "" || strings.Contains(plain, "reopens") {
t.Errorf("a close with no date promised a return: %q", plain)
}
}
func TestStatusIsNotCaseSensitive(t *testing.T) {
// The column holds "Active" today, but nothing enforces the capital.
for _, s := range []string{"Active", "active", "ACTIVE", " Active "} {
if !StoreIsOpen(s, true, "", at("2026-10-09")) {
t.Errorf("status %q read as not active", s)
}
}
}

View File

@@ -31,8 +31,8 @@ type Tenantinfo struct {
// result set and thrown away for want of a field to scan into. A shop // result set and thrown away for want of a field to scan into. A shop
// profile that cannot say how long the business has been on the platform is // profile that cannot say how long the business has been on the platform is
// missing a fact the database has held all along. // missing a fact the database has held all along.
Created time.Time `json:"created"` Created time.Time `json:"created"`
Tenantinfo string `json:"tenantinfo"` Tenantinfo string `json:"tenantinfo"`
// The FSSAI or trade licence. // The FSSAI or trade licence.
// //
// The column has always existed and `GetCustomerTenants` already selects it // The column has always existed and `GetCustomerTenants` already selects it
@@ -63,14 +63,14 @@ type Tenantinfo struct {
// `b.categoryname`, and the struct had nowhere to put it — so the value was // `b.categoryname`, and the struct had nowhere to put it — so the value was
// computed on every read and discarded, leaving the console to print // computed on every read and discarded, leaving the console to print
// "Category 2" at a merchant who trades under Daily Needs. // "Category 2" at a merchant who trades under Daily Needs.
Categoryname string `json:"categoryname"` Categoryname string `json:"categoryname"`
Firstname string `json:"firstname"` Firstname string `json:"firstname"`
Lastname string `json:"lastname"` Lastname string `json:"lastname"`
Accountname string `json:"Accountname"` Accountname string `json:"Accountname"`
Status string `json:"status"` Status string `json:"status"`
Allocationid int `json:"allocationid"` Allocationid int `json:"allocationid"`
Allocationtype string `json:"allocationtype"` Allocationtype string `json:"allocationtype"`
Allocationmode int `json:"allocationmode"` Allocationmode int `json:"allocationmode"`
// How many outlets this merchant has. // How many outlets this merchant has.
// //
@@ -105,6 +105,35 @@ type Tenantlocations struct {
Cancelsecs int `json:"cancelsecs"` Cancelsecs int `json:"cancelsecs"`
Status string `json:"status" gorm:"default:Active"` Status string `json:"status" gorm:"default:Active"`
/*
Is this branch trading today?
SEPARATE FROM `Status`, deliberately. Status is the platform's word for
whether a branch exists at all — setting it to Inactive is how a branch is
decommissioned, and the staff console reads it that way. A shopkeeper
closing for an afternoon because the power went out is saying something
completely different, and the two must not share a field: a day off would
read to Nearle as a shop that had shut down.
Default true. Every branch trading today keeps trading.
*/
Isopen bool `json:"isopen" gorm:"column:isopen;default:true"`
/*
The date it opens again, when the shop knows it.
"Closed until the 12th" means closed on the 11th and OPEN on the 12th —
the date names the first day back, which is how the phrase reads.
Nothing flips `Isopen` back on a timer. The answer is worked out on read
(see StoreIsOpen), so a branch that said "back on Monday" is open on
Monday whether or not anybody remembered. A job would drift; this cannot.
Empty means closed until somebody says otherwise, which is the right
default for a power cut nobody can put an end date on.
*/
Closeduntil string `json:"closeduntil" gorm:"column:closeduntil"`
// Who will run this outlet, when the caller already has somebody in mind. // Who will run this outlet, when the caller already has somebody in mind.
// //
// `gorm:"-"` because it is not a column — it names an existing `app_users` // `gorm:"-"` because it is not a column — it names an existing `app_users`

View File

@@ -18,6 +18,8 @@ type TenantRepository interface {
GetTenantSlot() (models.Tenantslot, error) GetTenantSlot() (models.Tenantslot, error)
CreateTenantCustomer(customer models.Tenantcustomer) (*models.Tenantcustomer, error) CreateTenantCustomer(customer models.Tenantcustomer) (*models.Tenantcustomer, error)
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) ([]models.TenantInfo, error) GetCustomerTenants(customerID int, categoryID int, tenantFlag int) ([]models.TenantInfo, error)
SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error
StoreOpenState(tenantID, locationID int) (status string, isOpen bool, closedUntil string, err error)
GetTenantPricing(tid, aid int) (*models.Tenantpricing, error) GetTenantPricing(tid, aid int) (*models.Tenantpricing, error)
UpdateLocation(input models.Tenantlocations) error UpdateLocation(input models.Tenantlocations) error
CreateLocation(data models.Tenantlocations) error CreateLocation(data models.Tenantlocations) error
@@ -219,7 +221,14 @@ func (r *tenantRepository) GetCustomerTenants(customerID int, categoryID int, te
b.primaryemail,b.primarycontact,b.applocationid,b.suburb,b.city, b.primaryemail,b.primarycontact,b.applocationid,b.suburb,b.city,
b.latitude,b.longitude,b.postcode,b.tenantimage,b.subcategoryid, b.latitude,b.longitude,b.postcode,b.tenantimage,b.subcategoryid,
b.categoryid,b.registrationno,d.userfcmtoken,c.locationname, b.categoryid,b.registrationno,d.userfcmtoken,c.locationname,
COALESCE(o.orderscount, 0) AS orderscount COALESCE(o.orderscount, 0) AS orderscount,
-- Whether the shop is trading today. Selected, never filtered on:
-- a closed branch still comes back so the app can show it greyed
-- rather than make a regular customer think it has gone for good.
-- COALESCE because the column is new and older rows predate it.
COALESCE(c.isopen, true) AS isopen,
COALESCE(c.closeduntil::text, '') AS closeduntil,
c.status AS locationstatus
FROM tenantcustomers a FROM tenantcustomers a
LEFT JOIN tenants b ON a.tenantid = b.tenantid LEFT JOIN tenants b ON a.tenantid = b.tenantid
INNER JOIN tenantlocations c ON a.locationid = c.locationid INNER JOIN tenantlocations c ON a.locationid = c.locationid
@@ -1210,3 +1219,65 @@ func (r *tenantRepository) TenantNameByID(tenantID int) (string, error) {
tenantID).Scan(&name).Error tenantID).Scan(&name).Error
return name, err return name, err
} }
/*
Open or close a branch for the day.
Scoped by tenant as well as branch: a locationid arrives from a console
request, and looking it up by id alone would let one business close another
business's shop.
`closeduntil` is written as NULL when empty rather than as an empty string, so
the column holds a real date or nothing — a ” in a DATE column is an error in
Postgres, and the shape of "closed with no end in sight" is the absence of a
date, not a blank one.
*/
func (r *tenantRepository) SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error {
updates := map[string]interface{}{"isopen": isOpen}
if trimmed := strings.TrimSpace(closedUntil); trimmed != "" && !isOpen {
updates["closeduntil"] = trimmed
} else {
// Reopening clears the date too. A branch switched back on must not
// keep a stale "reopens on" that would read as still closed.
updates["closeduntil"] = nil
}
result := r.db.Table("tenantlocations").
Where("locationid = ? AND tenantid = ?", locationID, tenantID).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("branch %d was not found for this business", locationID)
}
return nil
}
// The three columns models.StoreIsOpen needs, for one branch.
//
// Returns an OPEN state when the branch is not found. Order creation calls
// this, and a lookup that missed must not become a refusal to trade: the
// endpoint already takes orders from callers that name no branch at all, and
// turning a missing row into "we are closed" would stop sales on a data
// problem rather than a business decision.
func (r *tenantRepository) StoreOpenState(tenantID, locationID int) (string, bool, string, error) {
var row struct {
Status string
Isopen bool
Closeduntil string
}
err := r.db.Table("tenantlocations").
Select("status, COALESCE(isopen, true) AS isopen, COALESCE(closeduntil::text, '') AS closeduntil").
Where("locationid = ? AND tenantid = ?", locationID, tenantID).
Scan(&row).Error
if err != nil {
return "Active", true, "", err
}
if row.Status == "" {
return "Active", true, "", nil
}
return row.Status, row.Isopen, row.Closeduntil, nil
}

View File

@@ -19,6 +19,9 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) {
tenant.Post("/createlocation", f.TenantController.CreateLocation) tenant.Post("/createlocation", f.TenantController.CreateLocation)
tenant.Delete("/deletelocation", f.TenantController.DeleteLocation) tenant.Delete("/deletelocation", f.TenantController.DeleteLocation)
tenant.Post("/createtenantlocation", f.TenantController.CreateTenantLocation) tenant.Post("/createtenantlocation", f.TenantController.CreateTenantLocation)
// Guarded on purpose: closing a shop stops it taking money, and the mob
// group carries no session at all.
tenant.Put("/storeopen", f.TenantController.SetStoreOpen)
tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation) tenant.Put("/updatetenantlocation", f.TenantController.UpdateTenantLocation)
tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser) tenant.Post("/createtenantuser", f.TenantController.CreateTenantUser)

View File

@@ -1,6 +1,8 @@
package services package services
import ( import (
"time"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -19,6 +21,8 @@ type TenantService interface {
CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error) CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error)
AssignPartner(tenantID, partnerID int) error AssignPartner(tenantID, partnerID int) error
GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error) GetCustomerTenants(customerID int, categoryID int, tenantFlag int) (*models.CustomerTenantResponse, error)
SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error
StoreClosedReason(tenantID, locationID int) string
GetTenantPricing(tid, aid int) (models.Tenantpricing, error) GetTenantPricing(tid, aid int) (models.Tenantpricing, error)
UpdateLocation(input models.Tenantlocations) error UpdateLocation(input models.Tenantlocations) error
CreateLocation(data models.Tenantlocations) error CreateLocation(data models.Tenantlocations) error
@@ -98,6 +102,27 @@ func (s *tenantService) GetCustomerTenants(customerID int, categoryID int, tenan
return nil, err return nil, err
} }
/*
Whether each branch is actually taking orders.
Worked out here rather than in SQL so there is ONE implementation of the
rule — order creation calls the same function before accepting an order,
and a shop the app showed as open while the server refused it would be a
shopper staring at an error nothing on screen explains.
`now` is read once for the whole list: a request that straddled midnight
could otherwise return two branches on different days.
*/
now := time.Now()
for i := range details {
details[i].Isaccepting = models.StoreIsOpen(
details[i].Locationstatus, details[i].Isopen, details[i].Closeduntil, now,
)
details[i].Closedreason = models.StoreClosedReason(
details[i].Locationstatus, details[i].Isopen, details[i].Closeduntil, now,
)
}
return &models.CustomerTenantResponse{ return &models.CustomerTenantResponse{
Details: details, Details: details,
}, nil }, nil
@@ -467,3 +492,53 @@ func (s *tenantService) resendTo(target repositories.InviteTarget, tenantID int)
sent, reason := s.invites.Invite(target.Userid, tenantID, target.Email, target.Tenantname) sent, reason := s.invites.Invite(target.Userid, tenantID, target.Email, target.Tenantname)
return InviteOutcome{Sent: sent, Reason: reason}, nil return InviteOutcome{Sent: sent, Reason: reason}, nil
} }
/*
Open or close a branch for the day.
Refuses a reopen date in the past outright. "Closed until yesterday" is a shop
that is open, so storing it would mean the console showing closed while the
app showed open — the two disagreeing about the same branch is worse than the
edit failing.
*/
func (s *tenantService) SetStoreOpen(tenantID, locationID int, isOpen bool, closedUntil string) error {
if tenantID <= 0 || locationID <= 0 {
return fmt.Errorf("tenantid and locationid are both required")
}
trimmed := strings.TrimSpace(closedUntil)
if !isOpen && trimmed != "" {
reopen, err := time.Parse("2006-01-02", trimmed)
if err != nil {
return fmt.Errorf("the reopening date must be written as YYYY-MM-DD")
}
today := time.Now().Truncate(24 * time.Hour)
if reopen.Truncate(24 * time.Hour).Before(today) {
return fmt.Errorf("that reopening date has already passed")
}
}
return s.repo.SetStoreOpen(tenantID, locationID, isOpen, trimmed)
}
/*
Why this branch cannot take an order, in words fit to show a shopper.
Empty when it can. Called by order creation, and it leans on the same
models.StoreIsOpen the customer-facing list uses — so a shop the app showed as
open is never refused here, and one it showed as closed is never quietly
accepted.
Any failure reading the branch returns "" — open. A database hiccup must not
stop a shop trading.
*/
func (s *tenantService) StoreClosedReason(tenantID, locationID int) string {
if tenantID <= 0 || locationID <= 0 {
return ""
}
status, isOpen, closedUntil, err := s.repo.StoreOpenState(tenantID, locationID)
if err != nil {
return ""
}
return models.StoreClosedReason(status, isOpen, closedUntil, time.Now())
}