diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 0641f9f..7a49cbd 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -37,7 +37,7 @@ func MilerStartDuty(c *fiber.Ctx) error { var existing models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight). First(&existing).Error; err == nil { - return utils.BadRequest(c, "already on duty, end current duty first") + return resumeMilerDuty(c, milerUserID, existing, req.Lat, req.Lon) } now := time.Now() @@ -79,6 +79,37 @@ func MilerStartDuty(c *fiber.Ctx) error { }) } +// resumeMilerDuty answers a start-duty for a rider whose duty from earlier +// today is still open — after a reinstall, a crash, or signing in again. It +// used to answer 400 "already on duty" and change nothing: the app treated +// that as "on duty" and showed the rider online, while their profile stayed +// Offline, so the console listed them Offline and auto-assignment skipped +// them. Now the open duty is resumed: an Offline rider becomes Available +// (a rider mid-delivery or on a break keeps that status) and is made findable +// again from where they are. +func resumeMilerDuty(c *fiber.Ctx, milerUserID int, duty models.MilerDutyLog, lat, lon float64) error { + now := time.Now() + db.DB.Model(&models.MilerProfile{}). + Where("userid = ? AND availabilitystatus = ?", milerUserID, constants.MilerOffline). + Updates(map[string]interface{}{"availabilitystatus": constants.MilerAvailable, "updatedat": now}) + if lat != 0 && lon != 0 { + db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Updates(map[string]interface{}{ + "currentlatitude": lat, + "currentlongitude": lon, + "lastlocationupdatedat": now, + }) + indexMilerLocation(milerUserID, lat, lon) + go assignment.SweepNear(lat, lon) + } + db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 1) + + return utils.OK(c, fiber.Map{ + "dutylogid": duty.Dutylogid, + "loginat": duty.Loginat, + "resumed": true, + }) +} + func MilerEndDuty(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) diff --git a/routes/routes.go b/routes/routes.go index 34b9bd9..5d41ac7 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -450,12 +450,14 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Delete("/carrier-pricing/:id", middlewares.DoormileStaffOnly, controllers.DeleteCarrierPricing) // Pricing + // A client login reads its own rate card (GetPricing is tenant-scoped); every + // change, and the quote (which picks a rule across ALL tenants), is staff-only. adminAuth.Get("/pricing", controllers.GetPricing) - adminAuth.Post("/pricing", controllers.CreatePricing) - adminAuth.Put("/pricing/:id", controllers.UpdatePricing) - adminAuth.Delete("/pricing/:id", controllers.DeletePricing) - adminAuth.Post("/pricing/simulate", controllers.GetPricingQuoteSimulate) - adminAuth.Post("/pricing/quote", controllers.GetPricingQuoteSimulate) + adminAuth.Post("/pricing", middlewares.DoormileStaffOnly, controllers.CreatePricing) + adminAuth.Put("/pricing/:id", middlewares.DoormileStaffOnly, controllers.UpdatePricing) + adminAuth.Delete("/pricing/:id", middlewares.DoormileStaffOnly, controllers.DeletePricing) + adminAuth.Post("/pricing/simulate", middlewares.DoormileStaffOnly, controllers.GetPricingQuoteSimulate) + adminAuth.Post("/pricing/quote", middlewares.DoormileStaffOnly, controllers.GetPricingQuoteSimulate) // Doormile pricing bands adminAuth.Get("/doormile-pricing", controllers.GetDoormilePricing) diff --git a/routes/routes_client_fleetops_test.go b/routes/routes_client_fleetops_test.go index 093bf4c..e30e15e 100644 --- a/routes/routes_client_fleetops_test.go +++ b/routes/routes_client_fleetops_test.go @@ -18,6 +18,11 @@ import ( // token on the server, not just be hidden in the UI. var staffOnlyFleetRoutes = []struct{ method, path string }{ {http.MethodPost, "/api/v1/admin/tenants"}, + {http.MethodPost, "/api/v1/admin/pricing"}, + {http.MethodPut, "/api/v1/admin/pricing/1"}, + {http.MethodDelete, "/api/v1/admin/pricing/1"}, + {http.MethodPost, "/api/v1/admin/pricing/simulate"}, + {http.MethodPost, "/api/v1/admin/pricing/quote"}, {http.MethodGet, "/api/v1/admin/users"}, {http.MethodPost, "/api/v1/admin/users"}, {http.MethodPut, "/api/v1/admin/users/1"}, diff --git a/routes/routes_miler_duty_pg_test.go b/routes/routes_miler_duty_pg_test.go new file mode 100644 index 0000000..b51b62e --- /dev/null +++ b/routes/routes_miler_duty_pg_test.go @@ -0,0 +1,86 @@ +package routes_test + +import ( + "net/http" + "os" + "strings" + "testing" + "time" + + "doormile/constants" + "doormile/db" + "doormile/internal/testpg" + "doormile/models" + "doormile/utils" +) + +// A rider whose duty from earlier today is still open (reinstall, crash, +// signing in again) and who starts duty again. The server used to answer 400 +// "already on duty" and leave the profile Offline, while the app showed the +// rider online: the console listed them Offline and auto-assignment skipped +// them. Postgres-gated; the DSN must be a THROWAWAY database. +func TestStartDutyResumesAnOpenDutyAndMarksTheRiderAvailable(t *testing.T) { + dsn := os.Getenv("REGISTRY_TEST_DSN") + if dsn == "" { + t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres duty test") + } + gdb := testpg.Open(t, dsn, "miler_duty_routes_test") + all := []any{&models.AppUser{}, &models.MilerProfile{}, &models.MilerDutyLog{}} + if err := gdb.Migrator().DropTable(all...); err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(all...); err != nil { + t.Fatal(err) + } + prev := db.DB + db.DB = gdb + t.Cleanup(func() { db.DB = prev }) + + app := onboardingApp() + rider := func(name, phone, status string) (models.AppUser, string) { + u := models.AppUser{Authname: name, Email: name + "@riders.test", Contactno: phone, Password: "x", Roleid: 5, Status: "Active", Configid: 1001} + if err := gdb.Create(&u).Error; err != nil { + t.Fatal(err) + } + if err := gdb.Create(&models.MilerProfile{Userid: u.Userid, Displayname: name, Phone: phone, Availabilitystatus: status}).Error; err != nil { + t.Fatal(err) + } + // The duty left open from earlier today. + if err := gdb.Create(&models.MilerDutyLog{Userid: u.Userid, Loginat: time.Now(), Onduty: true}).Error; err != nil { + t.Fatal(err) + } + tok, err := utils.GenerateToken(u.Userid, u.Email, 5, 0, 1001, jwtSecret) + if err != nil { + t.Fatal(err) + } + return u, tok + } + statusOf := func(uid int) string { + var p models.MilerProfile + gdb.Where("userid = ?", uid).First(&p) + return p.Availabilitystatus + } + + offline, tok := rider("nagalakshmi", "9000000101", constants.MilerOffline) + code, body := do(t, app, http.MethodPost, "/api/v1/miler/duty/start", tok, `{"lat":0,"lon":0}`) + if code != 200 || !strings.Contains(body, `"resumed":true`) { + t.Fatalf("start duty with an open duty = %d %s, want 200 resumed", code, body) + } + if got := statusOf(offline.Userid); got != constants.MilerAvailable { + t.Fatalf("rider status after resuming = %q, want Available", got) + } + var open int64 + gdb.Model(&models.MilerDutyLog{}).Where("userid = ? AND logoutat IS NULL", offline.Userid).Count(&open) + if open != 1 { + t.Fatalf("open duty logs = %d, want the one resumed, not a second", open) + } + + // A rider mid-delivery keeps that status; resuming only lifts Offline. + busy, tok := rider("busy", "9000000102", constants.MilerOnDelivery) + if code, body := do(t, app, http.MethodPost, "/api/v1/miler/duty/start", tok, `{"lat":0,"lon":0}`); code != 200 { + t.Fatalf("start duty = %d %s", code, body) + } + if got := statusOf(busy.Userid); got != constants.MilerOnDelivery { + t.Fatalf("on-delivery rider became %q, want On_Delivery kept", got) + } +}