From 03ae7d310bdfbfe44b552a426d88674e6583951f Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 6 Oct 2026 17:35:51 +0530 Subject: [PATCH] delivery slot api creation --- Dockerfile | 9 + controllers/deliverySlotController.go | 146 +++++++++++++ controllers/orderController.go | 211 +++++++++++-------- facade/container.go | 13 +- main.go | 55 +++++ models/deliveryslot.go | 111 ++++++++++ models/order.go | 37 ++-- repositories/deliverySlotRepository.go | 97 +++++++++ routes/deliveryslotroutes.go | 33 +++ routes/routes.go | 1 + services/deliverySlotService.go | 271 +++++++++++++++++++++++++ services/deliverySlotService_test.go | 257 +++++++++++++++++++++++ 12 files changed, 1137 insertions(+), 104 deletions(-) create mode 100644 controllers/deliverySlotController.go create mode 100644 models/deliveryslot.go create mode 100644 repositories/deliverySlotRepository.go create mode 100644 routes/deliveryslotroutes.go create mode 100644 services/deliverySlotService.go create mode 100644 services/deliverySlotService_test.go diff --git a/Dockerfile b/Dockerfile index d74b5b2..ef9d7fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,6 +65,15 @@ ENV NUTRITION_BASE=https://mcp.nearle.ai.in/api # config.Load applies: production insists on a signing secret and never falls # back to localhost values. Every other real value comes from the platform's # environment settings, exactly as before. +# The clock every business rule is decided on. +# +# tzdata was already installed and nothing set TZ, so the container ran UTC. +# That was invisible while time.Now() only ever stamped records — nothing +# compared a stored time of day against the current one. Delivery windows are +# the first rule that does: a shop setting morning as 08:00-10:00 would have had +# it close at 10:00 UTC, which is 15:30 where the shop is standing. +ENV TZ=Asia/Kolkata + ENV APP_ENV=production # Must match APP_PORT in the platform's environment (1009 in production). diff --git a/controllers/deliverySlotController.go b/controllers/deliverySlotController.go new file mode 100644 index 0000000..0678851 --- /dev/null +++ b/controllers/deliverySlotController.go @@ -0,0 +1,146 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gofiber/fiber/v2" + + "nearle/models" + "nearle/services" +) + +type DeliverySlotController struct { + service services.DeliverySlotService +} + +func NewDeliverySlotController(service services.DeliverySlotService) *DeliverySlotController { + return &DeliverySlotController{service: service} +} + +/* +GET /v1/web/deliveryslots?tenantid&locationid + +Everything a branch has configured, active or not, for the console's editor. +A branch that has set nothing returns an empty list — see the note on Available +about why that is never an error. +*/ +func (ctl *DeliverySlotController) ListDeliverySlots(c *fiber.Ctx) error { + tenantID, _ := strconv.Atoi(c.Query("tenantid")) + locationID, _ := strconv.Atoi(c.Query("locationid")) + + if tenantID <= 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "message": "tenantid is required", + "status": false, + }) + } + + slots, err := ctl.service.ListForBranch(tenantID, locationID) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "code": http.StatusInternalServerError, + "message": err.Error(), + "status": false, + }) + } + + return c.Status(http.StatusOK).JSON(fiber.Map{ + "code": http.StatusOK, + "message": "Success", + "status": true, + "details": slots, + }) +} + +/* +PUT /v1/web/deliveryslots + +The branch's windows, all three together rather than one at a time: they are +edited as a set on one screen, and sending them together is what lets the +service reject the whole edit when one row is wrong instead of applying part of +it. + +A business objection — an unreadable time, a window ending before it starts, +the same key twice — is 409 and not 500. It is an answer about the request, and +the message is written to be shown to the person who typed it. +*/ +func (ctl *DeliverySlotController) SaveDeliverySlots(c *fiber.Ctx) error { + var req struct { + Tenantid int `json:"tenantid"` + Locationid int `json:"locationid"` + Slots []models.DeliverySlots `json:"slots"` + } + + 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.Tenantid <= 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "message": "tenantid is required", + "status": false, + }) + } + + if err := ctl.service.Save(req.Tenantid, req.Locationid, req.Slots); 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, + }) +} + +/* +GET /v1/mob/deliveryslots/available?tenantid&locationid + +What the shopper may pick, already filtered and dated. The app renders this list +and does no time arithmetic of its own — see the service for why one clock has +to be authoritative. + +An empty list is 200 with `details: []`, NOT an error. It means this branch has +set no windows, which is the state every shop is in today, and the app is +required to fall back to ordering without one. Returning 404 here would turn an +ordinary shop into a broken one. +*/ +func (ctl *DeliverySlotController) AvailableDeliverySlots(c *fiber.Ctx) error { + tenantID, _ := strconv.Atoi(c.Query("tenantid")) + locationID, _ := strconv.Atoi(c.Query("locationid")) + + if tenantID <= 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "code": http.StatusBadRequest, + "message": "tenantid is required", + "status": false, + }) + } + + slots, err := ctl.service.Available(tenantID, locationID) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "code": http.StatusInternalServerError, + "message": err.Error(), + "status": false, + }) + } + + return c.Status(http.StatusOK).JSON(fiber.Map{ + "code": http.StatusOK, + "message": "Success", + "status": true, + "details": slots, + }) +} diff --git a/controllers/orderController.go b/controllers/orderController.go index ccd4be7..f472e12 100644 --- a/controllers/orderController.go +++ b/controllers/orderController.go @@ -16,117 +16,126 @@ import ( type OrderController struct { orderService services.OrderService + // Asked whether a chosen delivery window is still open. Held here rather + // than reimplemented, so the app's list and this check can never disagree + // about where a window ends. + deliverySlotService services.DeliverySlotService } -func NewOrderController(orderService services.OrderService) *OrderController { - return &OrderController{orderService: orderService} +func NewOrderController( + orderService services.OrderService, + deliverySlotService services.DeliverySlotService, +) *OrderController { + return &OrderController{ + orderService: orderService, + deliverySlotService: deliverySlotService, + } } func (ctl *OrderController) GetOrders(c *fiber.Ctx) error { - tid, _ := strconv.Atoi(c.Query("tenantid")) - pid, _ := strconv.Atoi(c.Query("partnerid")) - cid, _ := strconv.Atoi(c.Query("customerid")) - mid, _ := strconv.Atoi(c.Query("moduleid")) - aid, _ := strconv.Atoi(c.Query("applocationid")) - uid, _ := strconv.Atoi(c.Query("appuserid")) - lid, _ := strconv.Atoi(c.Query("locationid")) - configid, _ := strconv.Atoi(c.Query("configid")) + tid, _ := strconv.Atoi(c.Query("tenantid")) + pid, _ := strconv.Atoi(c.Query("partnerid")) + cid, _ := strconv.Atoi(c.Query("customerid")) + mid, _ := strconv.Atoi(c.Query("moduleid")) + aid, _ := strconv.Atoi(c.Query("applocationid")) + uid, _ := strconv.Atoi(c.Query("appuserid")) + lid, _ := strconv.Atoi(c.Query("locationid")) + configid, _ := strconv.Atoi(c.Query("configid")) - stat := c.Query("status") - fdate := c.Query("fromdate") - tdate := c.Query("todate") - keyword := c.Query("keyword") + stat := c.Query("status") + fdate := c.Query("fromdate") + tdate := c.Query("todate") + keyword := c.Query("keyword") - pageno, _ := strconv.Atoi(c.Query("pageno")) - pagesize, _ := strconv.Atoi(c.Query("pagesize")) + pageno, _ := strconv.Atoi(c.Query("pageno")) + pagesize, _ := strconv.Atoi(c.Query("pagesize")) - if pageno <= 0 { - pageno = 1 - } - if pagesize <= 0 { - pagesize = 10 - } + if pageno <= 0 { + pageno = 1 + } + if pagesize <= 0 { + pagesize = 10 + } - // Build dynamic query struct - query := models.DeliveryQuery{ - Tenantid: tid, - Partnerid: pid, - Customerid: cid, - Moduleid: mid, - Applocationid: aid, - Locationid: lid, - UserID: uid, - Appuserid: uid, - Configid: configid, - Fromdate: fdate, - ToDate: tdate, - Status: stat, - Keyword: keyword, - Pageno: pageno, - Pagesize: pagesize, - } + // Build dynamic query struct + query := models.DeliveryQuery{ + Tenantid: tid, + Partnerid: pid, + Customerid: cid, + Moduleid: mid, + Applocationid: aid, + Locationid: lid, + UserID: uid, + Appuserid: uid, + Configid: configid, + Fromdate: fdate, + ToDate: tdate, + Status: stat, + Keyword: keyword, + Pageno: pageno, + Pagesize: pagesize, + } - var ( - orders []models.OrderInfo - err error - ) + var ( + orders []models.OrderInfo + err error + ) - // -------------------------- - // 🔥 DYNAMIC ROUTING LOGIC - // -------------------------- + // -------------------------- + // 🔥 DYNAMIC ROUTING LOGIC + // -------------------------- - if tid != 0 && lid != 0 { - // ⭐ Both tenant & location → special handler - orders, err = ctl.orderService.GetTenantLocationOrders(query) + if tid != 0 && lid != 0 { + // ⭐ Both tenant & location → special handler + orders, err = ctl.orderService.GetTenantLocationOrders(query) - } else if tid != 0 { - // Tenant only - orders, err = ctl.orderService.GetTenantOrders(query) + } else if tid != 0 { + // Tenant only + orders, err = ctl.orderService.GetTenantOrders(query) - } else if pid != 0 { - // Partner - orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword) + } else if pid != 0 { + // Partner + orders, err = ctl.orderService.GetPartnerOrders(stat, fdate, tdate, pid, pageno, pagesize, keyword) - } else if cid != 0 { - // Customer - orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword) + } else if cid != 0 { + // Customer + orders, err = ctl.orderService.GetCustomerOrders(stat, fdate, tdate, cid, mid, pageno, pagesize, keyword) - } else if aid != 0 { - // App-location orders - orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword) + } else if aid != 0 { + // App-location orders + orders, err = ctl.orderService.GetAdminOrders(stat, fdate, tdate, aid, pageno, pagesize, keyword) - } else if uid != 0 { - // User orders - orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword) + } else if uid != 0 { + // User orders + orders, err = ctl.orderService.GetUserOrders(stat, fdate, tdate, uid, pageno, pagesize, keyword) - } else { - // No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid). - // Refuse instead of silently returning every order in the database. - return c.Status(http.StatusBadRequest).JSON(fiber.Map{ - "status": false, - "code": http.StatusBadRequest, - "message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required", - }) - } + } else { + // No scoping id supplied (tenantid/partnerid/customerid/applocationid/appuserid). + // Refuse instead of silently returning every order in the database. + return c.Status(http.StatusBadRequest).JSON(fiber.Map{ + "status": false, + "code": http.StatusBadRequest, + "message": "At least one of tenantid, partnerid, customerid, applocationid or appuserid is required", + }) + } - if err != nil { - return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ - "status": false, - "code": http.StatusInternalServerError, - "message": err.Error(), - }) - } + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ + "status": false, + "code": http.StatusInternalServerError, + "message": err.Error(), + }) + } - return c.JSON(fiber.Map{ - "status": true, - "code": http.StatusOK, - "message": "Success", - "details": orders, - }) + return c.JSON(fiber.Map{ + "status": true, + "code": http.StatusOK, + "message": "Success", + "details": orders, + }) } - func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error { tid, _ := strconv.Atoi(c.Query("tenantid")) pid, _ := strconv.Atoi(c.Query("partnerid")) @@ -160,7 +169,6 @@ func (ctl *OrderController) GetOrderSummary(c *fiber.Ctx) error { }) } - func (ctl *OrderController) GetlocationOrderSummary(c *fiber.Ctx) error { tenantIDStr := c.Query("tenantid") tenantID, _ := strconv.Atoi(tenantIDStr) @@ -350,6 +358,29 @@ func (ctl *OrderController) CreateOrderv3(c *fiber.Ctx) error { data.Deliverytime = time.Now().Format("2006-01-02 15:04:05") } + // The chosen delivery window, re-decided here. + // + // The app sends back what /v1/mob/deliveryslots/available handed it, but + // that group carries NO SESSION — anything arriving is a claim, not a fact. + // The common case is innocent and still has to be caught: a shopper leaves + // the checkout screen open while the window closes, then taps pay. + // + // 409 rather than 400: the request was well formed and was true when it was + // built. The message is written to be shown to the shopper as-is. + // + // An order naming NO window passes straight through. That is every order + // placed before this shipped and every order from a branch that has set no + // windows, and it must stay ordinary. + if err := ctl.deliverySlotService.ValidateForOrder( + data.Tenantid, data.Locationid, data.Deliveryslotid, data.Deliveryslotdate, + ); err != nil { + return c.Status(http.StatusConflict).JSON(fiber.Map{ + "code": http.StatusConflict, + "message": err.Error(), + "status": false, + }) + } + // An order that does not state its config is an APP order, because that is // the only kind this endpoint takes. // @@ -592,7 +623,7 @@ func (ctl *OrderController) GetTimeSeries(c *fiber.Ctx) error { "status": false, }) } - + if granularity == "" { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, diff --git a/facade/container.go b/facade/container.go index 496ce5b..73c3d62 100644 --- a/facade/container.go +++ b/facade/container.go @@ -28,6 +28,7 @@ type Facade struct { LiveController *controllers.LiveController CatalogueUploadController *controllers.CatalogueUploadController ScanController *controllers.ScanController + DeliverySlotController *controllers.DeliverySlotController AssistantController *controllers.AssistantController HealthController *controllers.HealthController MCPController *controllers.MCPController @@ -92,10 +93,19 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat productRepo, catalogueService, services.NewNutritionService(nutritionBase)) productController := controllers.NewProductController(productService) + // When each branch delivers. + // + // BEFORE the order controller, which takes it: order creation re-checks a + // chosen window against the same rule the app was shown, so the two cannot + // drift. No dependency the other way — this service knows nothing of orders. + deliverySlotRepo := repositories.NewDeliverySlotRepository(db) + deliverySlotService := services.NewDeliverySlotService(deliverySlotRepo) + deliverySlotController := controllers.NewDeliverySlotController(deliverySlotService) + // Order Module orderRepo := repositories.NewOrderRepository(db) orderService := services.NewOrderService(orderRepo) - orderController := controllers.NewOrderController(orderService) + orderController := controllers.NewOrderController(orderService, deliverySlotService) // Deliveries Module deliveriesRepo := repositories.NewDeliveriesRepository(db) @@ -241,6 +251,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat LiveController: liveController, CatalogueUploadController: catalogueUploadController, ScanController: scanController, + DeliverySlotController: deliverySlotController, AssistantController: assistantController, HealthController: healthController, MCPController: mcpController, diff --git a/main.go b/main.go index 7cdb1a6..05cf652 100644 --- a/main.go +++ b/main.go @@ -196,6 +196,61 @@ func main() { log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err) } + // When a shop delivers, and which window an order chose. + // + // Three named windows a day per BRANCH — see models/deliveryslot.go for why + // the scope is the branch and not the company. + // + // ── A branch with no rows here still trades ───────────────────────────── + // + // Every tenant on the platform the day this ships has no slots, and all of + // them must keep taking orders exactly as before. No backfill, no defaults + // written here: absence means "order without a slot", and the app is + // required to treat an empty list as ordinary rather than as a closed shop. + // Seeding every existing branch with invented timings would have each one + // promising hours nobody agreed to. + if err := db.DB.Exec(`CREATE TABLE IF NOT EXISTS deliveryslots ( + slotid SERIAL PRIMARY KEY, + tenantid INTEGER NOT NULL, + locationid INTEGER NOT NULL DEFAULT 0, + slotkey TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + starttime TEXT NOT NULL, + endtime TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created TIMESTAMPTZ NOT NULL DEFAULT now(), + updated TIMESTAMPTZ NOT NULL DEFAULT now() + )`).Error; err != nil { + log.Println("⚠️ could not create deliveryslots, delivery windows will be unavailable:", err) + } + + // One row per key per branch. A shop has ONE morning, and a duplicate would + // show the shopper the same window twice with different hours — so the + // upsert that writes these leans on this constraint rather than on a + // read-then-write that two requests could interleave. + if err := db.DB.Exec( + `CREATE UNIQUE INDEX IF NOT EXISTS deliveryslots_branch_key + ON deliveryslots (tenantid, locationid, slotkey)`).Error; err != nil { + log.Println("⚠️ could not add the deliveryslots uniqueness index, a branch may end up with duplicate windows:", err) + } + + // The window an order chose, and the day it falls on. + // + // Both nullable, and both stay empty for every order placed without a slot — + // which is every order today and every order from a branch that never sets + // timings. Nothing downstream may require them. + // + // The DATE is not redundant. "evening" cannot say tonight or tomorrow night, + // and an order placed after the last window closes is for the next day. + if err := db.DB.Exec( + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotid INTEGER`).Error; err != nil { + log.Println("⚠️ could not add orders.deliveryslotid, orders will not record a delivery window:", err) + } + if err := db.DB.Exec( + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS deliveryslotdate DATE`).Error; err != nil { + log.Println("⚠️ could not add orders.deliveryslotdate, orders will not record which day their window falls on:", err) + } + // When a product became visible to a store, and the only thing that decides // whether it is. // diff --git a/models/deliveryslot.go b/models/deliveryslot.go new file mode 100644 index 0000000..86393e6 --- /dev/null +++ b/models/deliveryslot.go @@ -0,0 +1,111 @@ +package models + +import ( + "slices" + "time" +) + +/* +When a shop delivers. + +A branch offers at most three windows a day — morning, afternoon, evening — and +the shopper picks one at checkout. The window is a PREFERENCE, not a promise: +every order is accepted, there is no capacity limit, and a slot never fills up. +It tells the shop when to group a drop, and it tells the shopper roughly when to +expect one. + +── Per branch, not per tenant ────────────────────────────────────────────── + +Timings belong to a shop floor, not a company. A tenant with an outlet in a +market and another in an office park will run different hours, and discovering +that after the fact would mean migrating live rows. `locationid` is on the table +from the start for that reason, and onboarding simply fills it with the primary +branch it just created. + +── A branch with no slots is not broken ──────────────────────────────────── + +Every tenant trading today has no slots at all, and must keep taking orders. +Absence means "order without a slot", exactly as before — never "this shop is +closed". The whole rollout rests on that, so nothing here may treat an empty +list as an error. +*/ +type DeliverySlots struct { + Slotid int `json:"deliveryslotid" gorm:"primaryKey;autoIncrement;column:slotid"` + Tenantid int `json:"tenantid" gorm:"column:tenantid;index"` + Locationid int `json:"locationid" gorm:"column:locationid;index"` + + // Which of the three this is. Fixed rather than free-form: the app shows + // them in a known order and may want an icon per slot, and a shop inventing + // a fourth would have nowhere to appear. + Slotkey string `json:"slotkey" gorm:"column:slotkey"` + + // What the shopper reads. Separate from `slotkey` so a shop can say + // "Before work" without breaking the app's ordering. + Name string `json:"name" gorm:"column:name"` + + // "HH:MM", 24-hour, in the shop's local time. + // + // Stored as text, not as a timestamp, because this is a time of DAY that + // recurs — it has no date until an order attaches one. A timestamp column + // would invite a timezone conversion on every read, which is the one thing + // this must not do: the shopkeeper typed 08:00 meaning eight in the morning + // where they are standing. + Starttime string `json:"starttime" gorm:"column:starttime"` + + // Also the CUT-OFF. There is deliberately no separate cutoff column: a slot + // accepts orders right up to the moment it ends, and then stops being + // offered. Morning 08:00–10:00 takes an order at 09:59 and not at 10:01. + Endtime string `json:"endtime" gorm:"column:endtime"` + + // "active" or "inactive". A shop that stops doing evenings turns the slot + // off rather than deleting it, so orders already placed against it still + // resolve to something with a name. + Status string `json:"status" gorm:"column:status"` + + Created time.Time `json:"created" gorm:"column:created;autoCreateTime"` + Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"` +} + +func (DeliverySlots) TableName() string { + return "deliveryslots" +} + +// The three keys, in the order a shopper reads them. +const ( + SlotMorning = "morning" + SlotAfternoon = "afternoon" + SlotEvening = "evening" +) + +// SlotKeys is the whole set, in display order. Used to validate input and to +// seed a new branch. +var SlotKeys = []string{SlotMorning, SlotAfternoon, SlotEvening} + +// IsSlotKey reports whether a key is one of the three. +func IsSlotKey(key string) bool { + return slices.Contains(SlotKeys, key) +} + +/* +A slot offered to a shopper, with the day it falls on. + +`DeliverySlots` describes a window that recurs; this is one concrete occurrence +of it. The app needs the date because "evening" alone cannot distinguish tonight +from tomorrow night, and once today's last window closes the next thing on offer +is tomorrow morning. + +The app does NO time arithmetic. It renders what this list contains, and the +list already excludes anything that has closed. +*/ +type AvailableDeliverySlot struct { + Slotid int `json:"deliveryslotid"` + Slotkey string `json:"slotkey"` + Name string `json:"name"` + Starttime string `json:"starttime"` + Endtime string `json:"endtime"` + // "YYYY-MM-DD", the day this window falls on. + Slotdate string `json:"slotdate"` + // True when `Slotdate` is not today. Saves the app comparing dates to + // decide whether to write "Tomorrow" beside the name. + IsTomorrow bool `json:"istomorrow"` +} diff --git a/models/order.go b/models/order.go index 777112d..9ba820d 100644 --- a/models/order.go +++ b/models/order.go @@ -233,19 +233,30 @@ type Ordermonths struct { } type Orders struct { - Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"` - Tenantid int `json:"tenantid"` - Locationid int `json:"locationid"` - Applocationid int `json:"applocationid"` - Moduleid int `json:"moduleid"` - Partnerid int `json:"partnerid"` - Configid int `json:"configid"` - Categoryid int `json:"categoryid"` - Subcategoryid int `json:"subcategoryid"` - Orderid string `json:"orderid"` - Orderdate string `json:"orderdate,omitempty"` - Deliverytime string `json:"deliverytime"` - Deliverytype string `json:"deliverytype"` + Orderheaderid int `json:"orderheaderid" gorm:"Primary_Key"` + Tenantid int `json:"tenantid"` + Locationid int `json:"locationid"` + Applocationid int `json:"applocationid"` + Moduleid int `json:"moduleid"` + Partnerid int `json:"partnerid"` + Configid int `json:"configid"` + Categoryid int `json:"categoryid"` + Subcategoryid int `json:"subcategoryid"` + Orderid string `json:"orderid"` + Orderdate string `json:"orderdate,omitempty"` + Deliverytime string `json:"deliverytime"` + Deliverytype string `json:"deliverytype"` + // The window the shopper chose, and the day it falls on. + // + // Both stay zero for every order placed without one — which is every order + // before this shipped, and every order from a branch that has set no + // windows. Nothing downstream may require them. + // + // Distinct from `Deliverytime` above, which is a TIMESTAMP of what happened + // and is defaulted to now() a few lines into CreateOrderv3. These two say + // what was asked for; that one says what occurred. + Deliveryslotid int `json:"deliveryslotid" gorm:"column:deliveryslotid"` + Deliveryslotdate string `json:"deliveryslotdate" gorm:"column:deliveryslotdate"` Orderstatus string `json:"orderstatus"` Pending string `json:"pending"` Processing string `json:"processing"` diff --git a/repositories/deliverySlotRepository.go b/repositories/deliverySlotRepository.go new file mode 100644 index 0000000..a7f729e --- /dev/null +++ b/repositories/deliverySlotRepository.go @@ -0,0 +1,97 @@ +package repositories + +import ( + "errors" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "nearle/models" +) + +type DeliverySlotRepository interface { + ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) + FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) + Save(slots []models.DeliverySlots) error +} + +type deliverySlotRepository struct { + db *gorm.DB +} + +func NewDeliverySlotRepository(db *gorm.DB) DeliverySlotRepository { + return &deliverySlotRepository{db: db} +} + +// Ordered by start time so every caller — the console's editor and the app's +// list alike — sees morning before evening without sorting it again. +func (r *deliverySlotRepository) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) { + slots := make([]models.DeliverySlots, 0, len(models.SlotKeys)) + + err := r.db.Table("deliveryslots"). + Where("tenantid = ? AND locationid = ?", tenantID, locationID). + Order("starttime ASC"). + Find(&slots).Error + if err != nil { + return nil, err + } + return slots, nil +} + +/* +One window, scoped to the branch that claims it. + +The tenant and location are in the WHERE and not checked afterwards: a slot id +arrives from the app, which carries no session, so it is a claim about which +shop it belongs to. Looking it up by id alone and trusting the row would let one +shop's checkout name another shop's window. +*/ +func (r *deliverySlotRepository) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) { + var slot models.DeliverySlots + + err := r.db.Table("deliveryslots"). + Where("slotid = ? AND tenantid = ? AND locationid = ?", slotID, tenantID, locationID). + First(&slot).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + // Not an error: "this shop does not offer that" is an answer, and the + // service turns it into something a shopper can read. + return nil, nil + } + if err != nil { + return nil, err + } + return &slot, nil +} + +/* +Write a branch's windows, all of them or none. + +── Upsert, not delete-then-insert ────────────────────────────────────────── + +Slot ids are referenced by `orders.deliveryslotid`. Replacing the rows would +renumber them, and every order already placed would point at a window that no +longer means what it did — or at nothing. The unique index on +(tenantid, locationid, slotkey) is what makes the conflict target work, so a +branch keeps one morning however many times it is edited. + +── One transaction ───────────────────────────────────────────────────────── + +A shop editing all three and getting two is worse than getting none: the two +that took are live and serving shoppers, and nothing on screen says which. +*/ +func (r *deliverySlotRepository) Save(slots []models.DeliverySlots) error { + if len(slots) == 0 { + return nil + } + + return r.db.Transaction(func(tx *gorm.DB) error { + return tx.Table("deliveryslots"). + Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "tenantid"}, {Name: "locationid"}, {Name: "slotkey"}, + }, + DoUpdates: clause.AssignmentColumns([]string{"name", "starttime", "endtime", "status", "updated"}), + }). + Create(&slots).Error + }) +} diff --git a/routes/deliveryslotroutes.go b/routes/deliveryslotroutes.go new file mode 100644 index 0000000..ea212c3 --- /dev/null +++ b/routes/deliveryslotroutes.go @@ -0,0 +1,33 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "nearle/facade" +) + +/* +Delivery windows. + +── The web half is guarded, the app half is read-only ────────────────────── + +Setting a branch's hours is a tenant-wide decision with money behind it, so it +sits on /v1/web where WebAuth checks the session and the tenant scope. Reading +what is on offer is what a shopper's app does before it has any identity at all, +so that one endpoint — and only that one — lives on the unauthenticated /v1/mob +group. + +There is deliberately NO write route on the mob group. The /v1/mob/* group has +no session at all, and a shop's trading hours are not something an anonymous +caller gets to set. +*/ +func RegisterDeliverySlotRoutes(api fiber.Router, f *facade.Facade) { + // ── Console: read and edit a branch's windows ─────────────────────────── + web := api.Group("/v1/web/deliveryslots") + web.Get("/", f.DeliverySlotController.ListDeliverySlots) + web.Put("/", f.DeliverySlotController.SaveDeliverySlots) + + // ── App: what a shopper may pick, already filtered ────────────────────── + mob := api.Group("/v1/mob/deliveryslots") + mob.Get("/available", f.DeliverySlotController.AvailableDeliverySlots) +} diff --git a/routes/routes.go b/routes/routes.go index 451ba26..99052d6 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -40,6 +40,7 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) { RegisterUploadRoutes(api, f) RegisterScanRoutes(api, f) RegisterAssistantRoutes(api, f) + RegisterDeliverySlotRoutes(api, f) // What is running here. // diff --git a/services/deliverySlotService.go b/services/deliverySlotService.go new file mode 100644 index 0000000..f2d3e45 --- /dev/null +++ b/services/deliverySlotService.go @@ -0,0 +1,271 @@ +package services + +import ( + "fmt" + "sort" + "strings" + "time" + + "nearle/models" + "nearle/repositories" +) + +/* +Delivery windows, and the one rule that decides whether a shopper may pick one. + +── The rule ──────────────────────────────────────────────────────────────── + +A slot is on offer while it is active and has not ended. Morning 08:00–10:00 +takes an order at 09:59 and refuses one at 10:01. There is no separate cut-off +to configure: the end of the window IS the cut-off, which is what the shop +already told us when they typed it. + +Once the last window of the day has closed, the next thing on offer is tomorrow +morning — not nothing. A shopper at 9pm is ordering for tomorrow, and the +alternative (offering no slot at all after dark) would quietly drop the feature +for every evening customer. + +── Why the rule lives here and nowhere else ──────────────────────────────── + +Two callers need it: the app, asking what to show, and order creation, checking +what came back. If those disagree by so much as a boundary, the app offers a +window the server then rejects, and the shopper is told their basket is invalid +for reasons nothing on screen explains. One function, both callers. +*/ +type DeliverySlotService interface { + // What a branch has configured, open or closed, for the console to edit. + ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) + // What a shopper may pick right now, already filtered and dated. + Available(tenantID, locationID int) ([]models.AvailableDeliverySlot, error) + // Create or update the three windows for a branch. + Save(tenantID, locationID int, slots []models.DeliverySlots) error + // Confirm an order may use this slot on this date. Returns a message fit to + // show a shopper when it may not. + ValidateForOrder(tenantID, locationID, slotID int, slotDate string) error +} + +type deliverySlotService struct { + repo repositories.DeliverySlotRepository + // Injected so tests can sit at a chosen moment rather than waiting for one. + now func() time.Time +} + +func NewDeliverySlotService(repo repositories.DeliverySlotRepository) DeliverySlotService { + return &deliverySlotService{repo: repo, now: time.Now} +} + +const slotDateLayout = "2006-01-02" +const slotTimeLayout = "15:04" + +func (s *deliverySlotService) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) { + if tenantID <= 0 { + return nil, fmt.Errorf("tenantid is required") + } + return s.repo.ListForBranch(tenantID, locationID) +} + +/* +The windows a shopper may pick, soonest first. + +Today's remaining windows come first, then tomorrow's — so a shopper at 9am sees +morning, afternoon and evening, one at 11am sees afternoon and evening, and one +at 9pm sees tomorrow's three. + +Returns an EMPTY list, never an error, when a branch has configured nothing. +That is the ordinary state for every shop trading today and means "order without +a window", not "this shop is shut". +*/ +func (s *deliverySlotService) Available(tenantID, locationID int) ([]models.AvailableDeliverySlot, error) { + if tenantID <= 0 { + return nil, fmt.Errorf("tenantid is required") + } + + configured, err := s.repo.ListForBranch(tenantID, locationID) + if err != nil { + return nil, err + } + + now := s.now() + today := now.Format(slotDateLayout) + tomorrow := now.AddDate(0, 0, 1).Format(slotDateLayout) + + // Non-nil so the JSON is `[]` rather than `null`. An app checking + // `length === 0` and one checking for null should not get different answers. + available := make([]models.AvailableDeliverySlot, 0, len(configured)) + + for _, slot := range configured { + if !strings.EqualFold(slot.Status, "active") { + continue + } + end, ok := parseSlotTime(slot.Endtime) + if !ok { + // A window whose hours cannot be read is skipped rather than + // guessed at. Offering a slot we cannot tell the end of would mean + // accepting orders against it forever. + continue + } + + // Today, but only while it is still running. + if minutesSinceMidnight(now) < end { + available = append(available, toAvailable(slot, today, false)) + } + // And always tomorrow, since by then it has come round again. + available = append(available, toAvailable(slot, tomorrow, true)) + } + + sort.SliceStable(available, func(i, j int) bool { + if available[i].Slotdate != available[j].Slotdate { + return available[i].Slotdate < available[j].Slotdate + } + return available[i].Starttime < available[j].Starttime + }) + + return available, nil +} + +func toAvailable(slot models.DeliverySlots, date string, isTomorrow bool) models.AvailableDeliverySlot { + name := strings.TrimSpace(slot.Name) + if name == "" { + // A shop that never renamed the window still needs something readable + // on the app's button. + name = strings.ToUpper(slot.Slotkey[:1]) + slot.Slotkey[1:] + } + return models.AvailableDeliverySlot{ + Slotid: slot.Slotid, + Slotkey: slot.Slotkey, + Name: name, + Starttime: slot.Starttime, + Endtime: slot.Endtime, + Slotdate: date, + IsTomorrow: isTomorrow, + } +} + +/* +Write a branch's windows. + +Validates every row before writing any of them: a half-applied set would leave a +shop with a morning it just edited and an evening it did not, with nothing on +screen saying which took. +*/ +func (s *deliverySlotService) Save(tenantID, locationID int, slots []models.DeliverySlots) error { + if tenantID <= 0 { + return fmt.Errorf("tenantid is required") + } + if len(slots) == 0 { + return fmt.Errorf("at least one delivery window is required") + } + + seen := make(map[string]bool, len(slots)) + prepared := make([]models.DeliverySlots, 0, len(slots)) + + for _, slot := range slots { + key := strings.ToLower(strings.TrimSpace(slot.Slotkey)) + if !models.IsSlotKey(key) { + return fmt.Errorf("%q is not a delivery window — expected morning, afternoon or evening", slot.Slotkey) + } + if seen[key] { + return fmt.Errorf("%s was given twice; a branch has one of each", key) + } + seen[key] = true + + start, startOK := parseSlotTime(slot.Starttime) + end, endOK := parseSlotTime(slot.Endtime) + if !startOK || !endOK { + return fmt.Errorf("%s needs a start and end time as HH:MM", key) + } + if end <= start { + // Not a pedantic check: an end before its start never passes the + // "still running" test, so the window would silently never appear. + return fmt.Errorf("%s ends at or before it starts (%s–%s)", key, slot.Starttime, slot.Endtime) + } + + status := strings.ToLower(strings.TrimSpace(slot.Status)) + if status != "inactive" { + status = "active" + } + + prepared = append(prepared, models.DeliverySlots{ + Tenantid: tenantID, + Locationid: locationID, + Slotkey: key, + Name: strings.TrimSpace(slot.Name), + Starttime: slot.Starttime, + Endtime: slot.Endtime, + Status: status, + }) + } + + return s.repo.Save(prepared) +} + +/* +May this order use this window? + +Called on every order that names one. The app sends back what `Available` gave +it, but the app is not the authority — `/v1/mob/*` carries no session, so +anything arriving here is a claim. Re-deciding with the same rule is the only +thing standing between a shopper and a window that closed while they were +checking out. +*/ +func (s *deliverySlotService) ValidateForOrder(tenantID, locationID, slotID int, slotDate string) error { + if slotID <= 0 { + return nil // No window chosen. Ordinary — see models/deliveryslot.go. + } + if tenantID <= 0 { + return fmt.Errorf("tenantid is required") + } + + slot, err := s.repo.FindForBranch(tenantID, locationID, slotID) + if err != nil { + return err + } + if slot == nil { + return fmt.Errorf("that delivery window is not one this shop offers") + } + if !strings.EqualFold(slot.Status, "active") { + return fmt.Errorf("the %s window is not currently available", slot.Slotkey) + } + + date, err := time.Parse(slotDateLayout, strings.TrimSpace(slotDate)) + if err != nil { + return fmt.Errorf("a delivery date is required with a delivery window") + } + + now := s.now() + today := now.Truncate(24 * time.Hour) + chosen := date.Truncate(24 * time.Hour) + + if chosen.Before(today) { + return fmt.Errorf("that delivery window has already passed") + } + if chosen.Equal(today) { + end, ok := parseSlotTime(slot.Endtime) + if !ok { + return fmt.Errorf("the %s window has no readable end time", slot.Slotkey) + } + if minutesSinceMidnight(now) >= end { + // The exact case this guard exists for: a shopper who sat on the + // checkout screen while the window closed. + return fmt.Errorf("the %s window has closed for today — please choose another", slot.Slotkey) + } + } + + return nil +} + +// "HH:MM" as minutes past midnight. The second return is false for anything +// unreadable, which every caller treats as "do not offer", never as zero — +// zero would mean midnight and make a broken window look like one that has +// already closed. +func parseSlotTime(value string) (int, bool) { + parsed, err := time.Parse(slotTimeLayout, strings.TrimSpace(value)) + if err != nil { + return 0, false + } + return parsed.Hour()*60 + parsed.Minute(), true +} + +func minutesSinceMidnight(at time.Time) int { + return at.Hour()*60 + at.Minute() +} diff --git a/services/deliverySlotService_test.go b/services/deliverySlotService_test.go new file mode 100644 index 0000000..dd39e42 --- /dev/null +++ b/services/deliverySlotService_test.go @@ -0,0 +1,257 @@ +package services + +import ( + "strings" + "testing" + "time" + + "nearle/models" +) + +// A stand-in for the table, so these tests are about the RULE and not about +// GORM. Every test below fixes "now" explicitly, because a rule about clock +// time that is tested at whatever o'clock CI happens to run is not tested. +type fakeSlotRepo struct { + slots []models.DeliverySlots +} + +func (f *fakeSlotRepo) ListForBranch(tenantID, locationID int) ([]models.DeliverySlots, error) { + return f.slots, nil +} + +func (f *fakeSlotRepo) FindForBranch(tenantID, locationID, slotID int) (*models.DeliverySlots, error) { + for i := range f.slots { + if f.slots[i].Slotid == slotID { + return &f.slots[i], nil + } + } + return nil, nil +} + +func (f *fakeSlotRepo) Save(slots []models.DeliverySlots) error { + f.slots = slots + return nil +} + +func threeSlots() []models.DeliverySlots { + return []models.DeliverySlots{ + {Slotid: 1, Slotkey: "morning", Name: "Morning", Starttime: "08:00", Endtime: "10:00", Status: "active"}, + {Slotid: 2, Slotkey: "afternoon", Name: "Afternoon", Starttime: "12:00", Endtime: "15:00", Status: "active"}, + {Slotid: 3, Slotkey: "evening", Name: "Evening", Starttime: "17:00", Endtime: "20:00", Status: "active"}, + } +} + +func serviceAt(clock string, slots []models.DeliverySlots) *deliverySlotService { + at, err := time.Parse("2006-01-02 15:04", clock) + if err != nil { + panic(err) + } + return &deliverySlotService{ + repo: &fakeSlotRepo{slots: slots}, + now: func() time.Time { return at }, + } +} + +// Today's windows that have not ended, soonest first. +func todayKeys(t *testing.T, svc *deliverySlotService, date string) []string { + t.Helper() + available, err := svc.Available(1, 1) + if err != nil { + t.Fatalf("Available: %v", err) + } + keys := []string{} + for _, slot := range available { + if slot.Slotdate == date { + keys = append(keys, slot.Slotkey) + } + } + return keys +} + +func TestAvailableDropsWindowsThatHaveEnded(t *testing.T) { + // The rule the shop was given: a window takes orders right up to the moment + // it ends, and then stops being offered. + cases := []struct { + clock string + want string + }{ + {"2026-10-06 07:00", "morning,afternoon,evening"}, // before trading + {"2026-10-06 09:59", "morning,afternoon,evening"}, // one minute left + {"2026-10-06 10:01", "afternoon,evening"}, // morning just closed + {"2026-10-06 15:30", "evening"}, // afternoon gone too + {"2026-10-06 20:01", ""}, // day over + } + + for _, tc := range cases { + svc := serviceAt(tc.clock, threeSlots()) + got := strings.Join(todayKeys(t, svc, "2026-10-06"), ",") + if got != tc.want { + t.Errorf("at %s: today offers %q, want %q", tc.clock, got, tc.want) + } + } +} + +func TestAvailableClosesOnTheEndMinuteNotTheStart(t *testing.T) { + // The boundary, stated on its own because it is the one thing a shopkeeper + // would notice being wrong. 10:00 exactly is PAST the end of an 08:00-10:00 + // window: the window is over, and an order placed at 10:00 cannot be in it. + svc := serviceAt("2026-10-06 10:00", threeSlots()) + for _, key := range todayKeys(t, svc, "2026-10-06") { + if key == "morning" { + t.Error("morning is still on offer at 10:00, its own end time") + } + } +} + +func TestAvailableAlwaysOffersTomorrow(t *testing.T) { + // A shopper at 9pm is ordering for tomorrow. Offering nothing would quietly + // drop the feature for every evening customer. + svc := serviceAt("2026-10-06 21:00", threeSlots()) + available, err := svc.Available(1, 1) + if err != nil { + t.Fatalf("Available: %v", err) + } + + if len(available) != 3 { + t.Fatalf("after the last window closed, got %d slots, want tomorrow's 3", len(available)) + } + for _, slot := range available { + if slot.Slotdate != "2026-10-07" { + t.Errorf("%s is dated %s, want tomorrow", slot.Slotkey, slot.Slotdate) + } + if !slot.IsTomorrow { + t.Errorf("%s is tomorrow's but istomorrow is false", slot.Slotkey) + } + } +} + +func TestAvailableSkipsInactiveWindows(t *testing.T) { + slots := threeSlots() + slots[1].Status = "inactive" // the shop stopped doing afternoons + + svc := serviceAt("2026-10-06 07:00", slots) + for _, key := range todayKeys(t, svc, "2026-10-06") { + if key == "afternoon" { + t.Error("an inactive window is being offered to shoppers") + } + } +} + +func TestAvailableIsEmptyAndNotAnErrorWhenNothingIsConfigured(t *testing.T) { + // The state EVERY shop is in the day this ships. An error here would turn + // every trading shop on the platform into a broken one. + svc := serviceAt("2026-10-06 09:00", nil) + + available, err := svc.Available(1, 1) + if err != nil { + t.Fatalf("a branch with no windows must not be an error, got %v", err) + } + if available == nil { + t.Error("got nil, want an empty slice — `null` and `[]` read differently in the app") + } + if len(available) != 0 { + t.Errorf("got %d slots from a branch that configured none", len(available)) + } +} + +func TestValidateForOrderAcceptsNoWindow(t *testing.T) { + // Every order placed before this shipped, and every order from a branch + // that has set no windows. + svc := serviceAt("2026-10-06 09:00", threeSlots()) + if err := svc.ValidateForOrder(1, 1, 0, ""); err != nil { + t.Errorf("an order with no window was rejected: %v", err) + } +} + +func TestValidateForOrderRejectsAWindowThatClosedWhileCheckingOut(t *testing.T) { + // The case this guard exists for: the shopper opened checkout at 09:58 and + // paid at 10:02. + svc := serviceAt("2026-10-06 10:02", threeSlots()) + + err := svc.ValidateForOrder(1, 1, 1, "2026-10-06") + if err == nil { + t.Fatal("an order was accepted into a window that had already closed") + } + if !strings.Contains(err.Error(), "morning") { + t.Errorf("the message does not name the window: %q", err) + } +} + +func TestValidateForOrderAcceptsTomorrowsWindowAfterTodaysClosed(t *testing.T) { + // Same window, next day. Closing today must not close it forever. + svc := serviceAt("2026-10-06 10:02", threeSlots()) + if err := svc.ValidateForOrder(1, 1, 1, "2026-10-07"); err != nil { + t.Errorf("tomorrow's morning was rejected at 10:02 today: %v", err) + } +} + +func TestValidateForOrderRejectsAWindowFromAnotherShop(t *testing.T) { + // The repository scopes its lookup by tenant and branch, so a slot id that + // belongs elsewhere comes back as nothing. + svc := serviceAt("2026-10-06 09:00", threeSlots()) + + if err := svc.ValidateForOrder(1, 1, 99, "2026-10-06"); err == nil { + t.Error("an order named a window this shop does not offer and was accepted") + } +} + +func TestValidateForOrderRequiresADateWithAWindow(t *testing.T) { + // "evening" alone cannot say tonight or tomorrow night. + svc := serviceAt("2026-10-06 09:00", threeSlots()) + + if err := svc.ValidateForOrder(1, 1, 3, ""); err == nil { + t.Error("a window was accepted with no date") + } +} + +func TestSaveRejectsAWindowThatEndsBeforeItStarts(t *testing.T) { + // Not pedantry: such a window never passes the "still running" test, so it + // would simply never appear, with nothing saying why. + svc := serviceAt("2026-10-06 09:00", nil) + + err := svc.Save(1, 1, []models.DeliverySlots{ + {Slotkey: "morning", Starttime: "10:00", Endtime: "08:00", Status: "active"}, + }) + if err == nil { + t.Error("a window ending before it starts was saved") + } +} + +func TestSaveRejectsADuplicateWindow(t *testing.T) { + svc := serviceAt("2026-10-06 09:00", nil) + + err := svc.Save(1, 1, []models.DeliverySlots{ + {Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"}, + {Slotkey: "morning", Starttime: "09:00", Endtime: "11:00", Status: "active"}, + }) + if err == nil { + t.Error("a branch was given two mornings") + } +} + +func TestSaveRejectsAKeyThatIsNotOneOfTheThree(t *testing.T) { + svc := serviceAt("2026-10-06 09:00", nil) + + err := svc.Save(1, 1, []models.DeliverySlots{ + {Slotkey: "midnight", Starttime: "00:00", Endtime: "02:00", Status: "active"}, + }) + if err == nil { + t.Error("a fourth window was accepted; the app has nowhere to show it") + } +} + +func TestSaveAppliesNothingWhenOneRowIsWrong(t *testing.T) { + // A shop editing three and getting two is worse than getting none: the two + // that took are live, and nothing on screen says which. + repo := &fakeSlotRepo{} + svc := &deliverySlotService{repo: repo, now: time.Now} + + _ = svc.Save(1, 1, []models.DeliverySlots{ + {Slotkey: "morning", Starttime: "08:00", Endtime: "10:00", Status: "active"}, + {Slotkey: "afternoon", Starttime: "15:00", Endtime: "12:00", Status: "active"}, // bad + }) + + if len(repo.slots) != 0 { + t.Errorf("%d windows were written despite one being invalid", len(repo.slots)) + } +}