diff --git a/controllers/deliverySlotController.go b/controllers/deliverySlotController.go index 0678851..a453615 100644 --- a/controllers/deliverySlotController.go +++ b/controllers/deliverySlotController.go @@ -12,10 +12,17 @@ import ( type DeliverySlotController struct { 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 { - return &DeliverySlotController{service: service} +func NewDeliverySlotController( + 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) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ diff --git a/controllers/orderController.go b/controllers/orderController.go index f472e12..1931d2b 100644 --- a/controllers/orderController.go +++ b/controllers/orderController.go @@ -20,15 +20,21 @@ type OrderController struct { // than reimplemented, so the app's list and this check can never disagree // about where a window ends. 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( orderService services.OrderService, deliverySlotService services.DeliverySlotService, + tenantService services.TenantService, ) *OrderController { return &OrderController{ orderService: orderService, 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") } + /* + 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 app sends back what /v1/mob/deliveryslots/available handed it, but diff --git a/controllers/tenantController.go b/controllers/tenantController.go index e8a69b3..4600ab8 100644 --- a/controllers/tenantController.go +++ b/controllers/tenantController.go @@ -871,3 +871,53 @@ func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error { "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, + }) +} diff --git a/facade/container.go b/facade/container.go index 73c3d62..2adc42a 100644 --- a/facade/container.go +++ b/facade/container.go @@ -93,6 +93,18 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat productRepo, catalogueService, services.NewNutritionService(nutritionBase)) 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. // // 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. deliverySlotRepo := repositories.NewDeliverySlotRepository(db) deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo) - deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService) + deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService, tenantService) // Order Module orderRepo := repositories.NewOrderRepository(db) orderService := services.NewOrderService(orderRepo) - orderController := controllers.NewOrderController(orderService, deliverySlotService) + orderController := controllers.NewOrderController(orderService, deliverySlotService, tenantService) // Deliveries Module deliveriesRepo := repositories.NewDeliveriesRepository(db) @@ -117,14 +129,6 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utilsService := services.NewUtilsService(utilsRepo) 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 partnerRepo := repositories.NewPartnerRepository(db) partnerService := services.NewPartnerService(partnerRepo) diff --git a/main.go b/main.go index 05cf652..f77db19 100644 --- a/main.go +++ b/main.go @@ -251,6 +251,32 @@ func main() { 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 // whether it is. // diff --git a/models/product.go b/models/product.go index 5747b2e..cb23b07 100644 --- a/models/product.go +++ b/models/product.go @@ -467,6 +467,31 @@ type TenantInfo struct { Categoryid int `json:"categoryid"` Registrationno string `json:"registrationno"` 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:"-"` ProductSubcategory []ProductSubcategory `json:"productsubcategory" gorm:"-"` } diff --git a/models/storeopen.go b/models/storeopen.go new file mode 100644 index 0000000..0129835 --- /dev/null +++ b/models/storeopen.go @@ -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 +} diff --git a/models/storeopen_test.go b/models/storeopen_test.go new file mode 100644 index 0000000..4c60843 --- /dev/null +++ b/models/storeopen_test.go @@ -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) + } + } +} diff --git a/models/tenant.go b/models/tenant.go index 6e99367..126315e 100644 --- a/models/tenant.go +++ b/models/tenant.go @@ -31,8 +31,8 @@ type Tenantinfo struct { // 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 // missing a fact the database has held all along. - Created time.Time `json:"created"` - Tenantinfo string `json:"tenantinfo"` + Created time.Time `json:"created"` + Tenantinfo string `json:"tenantinfo"` // The FSSAI or trade licence. // // 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 // computed on every read and discarded, leaving the console to print // "Category 2" at a merchant who trades under Daily Needs. - Categoryname string `json:"categoryname"` - Firstname string `json:"firstname"` - Lastname string `json:"lastname"` - Accountname string `json:"Accountname"` - Status string `json:"status"` - Allocationid int `json:"allocationid"` - Allocationtype string `json:"allocationtype"` - Allocationmode int `json:"allocationmode"` + Categoryname string `json:"categoryname"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Accountname string `json:"Accountname"` + Status string `json:"status"` + Allocationid int `json:"allocationid"` + Allocationtype string `json:"allocationtype"` + Allocationmode int `json:"allocationmode"` // How many outlets this merchant has. // @@ -105,6 +105,35 @@ type Tenantlocations struct { Cancelsecs int `json:"cancelsecs"` 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. // // `gorm:"-"` because it is not a column — it names an existing `app_users` diff --git a/repositories/tenantRepository.go b/repositories/tenantRepository.go index 0c37652..0c176d2 100644 --- a/repositories/tenantRepository.go +++ b/repositories/tenantRepository.go @@ -18,6 +18,8 @@ type TenantRepository interface { GetTenantSlot() (models.Tenantslot, error) CreateTenantCustomer(customer models.Tenantcustomer) (*models.Tenantcustomer, 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) UpdateLocation(input 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.latitude,b.longitude,b.postcode,b.tenantimage,b.subcategoryid, 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 LEFT JOIN tenants b ON a.tenantid = b.tenantid 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 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 +} diff --git a/routes/tenantroutes.go b/routes/tenantroutes.go index d3be1ce..75926c8 100644 --- a/routes/tenantroutes.go +++ b/routes/tenantroutes.go @@ -19,6 +19,9 @@ func RegisterTenantRoutes(api fiber.Router, f *facade.Facade) { tenant.Post("/createlocation", f.TenantController.CreateLocation) tenant.Delete("/deletelocation", f.TenantController.DeleteLocation) 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.Post("/createtenantuser", f.TenantController.CreateTenantUser) diff --git a/services/tenantService.go b/services/tenantService.go index c78fa98..55297df 100644 --- a/services/tenantService.go +++ b/services/tenantService.go @@ -1,6 +1,8 @@ package services import ( + "time" + "errors" "fmt" "net/http" @@ -19,6 +21,8 @@ type TenantService interface { CreateTenantCustomer(req models.CreateTenantCustomerRequest) (*models.Tenantcustomer, error) AssignPartner(tenantID, partnerID int) 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) UpdateLocation(input models.Tenantlocations) error CreateLocation(data models.Tenantlocations) error @@ -98,6 +102,27 @@ func (s *tenantService) GetCustomerTenants(customerID int, categoryID int, tenan 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{ Details: details, }, 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) 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()) +}