diff --git a/Dockerfile b/Dockerfile index 1153667..7d002f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o server . # Second Stage: Run the compiled binary inside alpine FROM alpine -# Fix: Alpine needs ca-certificates to verify SSL certificates -RUN apk add --no-cache ca-certificates +# Fix: Alpine needs ca-certificates to verify SSL certificates. +# tzdata + TZ: the database connection sets TimeZone=Asia/Kolkata, so +# CURRENT_TIMESTAMP defaults write IST wall-clock into the timestamp columns. +# With the container defaulting to UTC, every time.Now() the app wrote was 5h30m +# behind those defaults, and "today so far" date ranges ended 5h30m in the past — +# which silently dropped anything created after noon IST from every report. +RUN apk add --no-cache ca-certificates tzdata +ENV TZ=Asia/Kolkata # Copy from first stage COPY --from=0 /app/server /app/server diff --git a/controllers/adminController.go b/controllers/adminController.go index 54c10bb..014b349 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -21,8 +21,72 @@ import ( "github.com/gofiber/fiber/v2" "github.com/redis/go-redis/v9" + "gorm.io/gorm" ) +// consoleTenantID returns the tenant a console login is restricted to, or 0 +// for Doormile's own staff, who are unrestricted. Client logins carry their +// tenant in the JWT (see LoginAdmin); Doormile staff have DoormileAuth.Tenantid +// nil and so authenticate with 0. +func consoleTenantID(c *fiber.Ctx) int { + tenantID, ok := c.Locals("tenantid").(int) + if !ok { + return 0 + } + return tenantID +} + +// isDoormileConsoleStaff reports whether the caller sees every tenant's data. +func isDoormileConsoleStaff(c *fiber.Ctx) bool { + return consoleTenantID(c) == 0 +} + +// scopeToOwnTenant restricts a query on a tenant-owned table to the requesting +// console user's own tenant. Doormile staff are unrestricted. This is the +// admin-console counterpart of scopeBookingsToOwnTenant in hubController.go — +// without it, any client given an express-console login reads every other +// client's data. +// +// The column name is taken as a parameter because the tenant key is not always +// literally "tenantid": on the tenants table itself it is the primary key. +func scopeToOwnTenant(c *fiber.Ctx, query *gorm.DB, column string) *gorm.DB { + tenantID := consoleTenantID(c) + if tenantID == 0 { + return query + } + return query.Where(column+" = ?", tenantID) +} + +// canAccessTenant reports whether the caller may act on the given tenant. +// Used where the tenant is addressed by a path parameter or request body rather +// than filtered in a query — scoping a WHERE clause does nothing when the +// caller names the tenant directly. +func canAccessTenant(c *fiber.Ctx, tenantID int) bool { + own := consoleTenantID(c) + return own == 0 || own == tenantID +} + +// assertBookingAccess checks that the caller may act on a booking addressed by +// id. Returns nil for Doormile staff. Mutating handlers take the booking id +// straight from the path, so a scoped SELECT elsewhere in the handler does not +// protect them — this has to run before the write. +func assertBookingAccess(c *fiber.Ctx, bookingID int) error { + own := consoleTenantID(c) + if own == 0 { + return nil + } + var booking models.PickupBooking + if err := db.DB.Select("bookingid", "tenantid").First(&booking, bookingID).Error; err != nil { + return utils.NotFound(c, "booking not found") + } + // A booking with no tenant predates tenant attribution and can't be proven + // to belong to this client, so it stays invisible to them. + if booking.Tenantid == nil || *booking.Tenantid != own { + return utils.NotFound(c, "booking not found") + } + return nil +} + // Helper to generate tripsheet number func generateTripsheetNo() string { b := make([]byte, 4) @@ -68,8 +132,18 @@ func LoginAdmin(cfg *config.Config) fiber.Handler { userName = appUser.Authname } + // A client's console login carries their tenant so handlers can scope to + // it; Doormile's own staff have Tenantid nil and keep tenantID 0, which + // scopeToOwnTenant reads as "unrestricted". Emitting 0 unconditionally + // (as this did) is what left every console login able to read every + // tenant's data. + tenantID := 0 + if auth.Tenantid != nil { + tenantID = *auth.Tenantid + } + // Important: use appUser.Userid instead of auth.ID to ensure consistent IDs across the system - token, err := utils.GenerateToken(int(appUser.Userid), auth.Email, roleId, 0, 1, cfg.JWTSecret) + token, err := utils.GenerateToken(int(appUser.Userid), auth.Email, roleId, tenantID, 1, cfg.JWTSecret) if err != nil { return utils.Internal(c, "failed to generate token") } @@ -78,10 +152,11 @@ func LoginAdmin(cfg *config.Config) fiber.Handler { "success": true, "token": token, "user": fiber.Map{ - "id": appUser.Userid, - "name": userName, - "email": auth.Email, - "role": auth.Role, + "id": appUser.Userid, + "name": userName, + "email": auth.Email, + "role": auth.Role, + "tenantid": auth.Tenantid, }, }) } @@ -95,12 +170,19 @@ func GetAdminDashboard(c *fiber.Ctx) error { var totalConsignments int64 var openExceptions int64 - db.DB.Model(&models.Tenant{}).Count(&totalTenants) - db.DB.Model(&models.AppCustomer{}).Count(&totalCustomers) - db.DB.Model(&models.AppUser{}).Where("roleid = 5").Count(&totalMilers) - db.DB.Model(&models.PickupBooking{}).Count(&totalBookings) - db.DB.Model(&models.Consignment{}).Count(&totalConsignments) - db.DB.Model(&models.ConsignmentException{}).Where("status = ?", "Open").Count(&openExceptions) + scopeToOwnTenant(c, db.DB.Model(&models.Tenant{}), "tenantid").Count(&totalTenants) + scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").Count(&totalBookings) + scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").Count(&totalConsignments) + + // Customers, milers and exceptions have no tenant column, so there is no + // way to attribute them to one client here. Rather than show a client + // Doormile-wide totals, these are reported as zero for client logins; the + // per-client versions need a join through bookings and are not built yet. + if isDoormileConsoleStaff(c) { + db.DB.Model(&models.AppCustomer{}).Count(&totalCustomers) + db.DB.Model(&models.AppUser{}).Where("roleid = 5").Count(&totalMilers) + db.DB.Model(&models.ConsignmentException{}).Where("status = ?", "Open").Count(&openExceptions) + } return utils.OK(c, fiber.Map{ "tenants": totalTenants, @@ -131,20 +213,26 @@ func GetAdminReports(c *fiber.Ctx) error { tenantID := c.Query("tenantid") hubID := c.Query("hubid") + // ownTenant is 0 for Doormile staff (whole-network view) and the client's + // tenant for a client login, which every figure below is restricted to. + ownTenant := consoleTenantID(c) + var totalBookings int64 - db.DB.Model(&models.PickupBooking{}).Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings) + scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid"). + Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings) var delivered int64 - db.DB.Model(&models.PickupBooking{}). + scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid"). Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to). Count(&delivered) var cancelled int64 - db.DB.Model(&models.PickupBooking{}). + scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid"). Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to). Count(&cancelled) - consignmentQuery := db.DB.Model(&models.Consignment{}).Where("createdat BETWEEN ? AND ?", from, to) + consignmentQuery := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid"). + Where("createdat BETWEEN ? AND ?", from, to) if tenantID != "" { consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID) } @@ -154,15 +242,26 @@ func GetAdminReports(c *fiber.Ctx) error { var totalConsignments int64 consignmentQuery.Count(&totalConsignments) + // Payments and exceptions carry no tenant column, so they're restricted + // through the bookings/consignments they belong to. COD in particular is a + // figure a client genuinely needs, so it's joined rather than suppressed. var codCollected float64 - db.DB.Model(&models.BookingPayment{}). - Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to). - Select("COALESCE(SUM(amount), 0)").Scan(&codCollected) + codQuery := db.DB.Model(&models.BookingPayment{}). + Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to) + if ownTenant != 0 { + codQuery = codQuery.Where("bookingid IN (?)", + db.DB.Model(&models.PickupBooking{}).Select("bookingid").Where("tenantid = ?", ownTenant)) + } + codQuery.Select("COALESCE(SUM(amount), 0)").Scan(&codCollected) var openExceptions int64 - db.DB.Model(&models.ConsignmentException{}). - Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to). - Count(&openExceptions) + excQuery := db.DB.Model(&models.ConsignmentException{}). + Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to) + if ownTenant != 0 { + excQuery = excQuery.Where("consignmentid IN (?)", + db.DB.Model(&models.Consignment{}).Select("consignmentid").Where("tenantid = ?", ownTenant)) + } + excQuery.Count(&openExceptions) completionRate := 0.0 if totalBookings > 0 { @@ -176,14 +275,23 @@ func GetAdminReports(c *fiber.Ctx) error { Delivered int64 `gorm:"column:delivered"` } var hubRows []hubRow - db.DB.Raw(` + // The tenant filter belongs in the JOIN condition, not a WHERE — in a WHERE + // it would drop hubs with no matching parcels instead of showing them as + // zero. + hubSQL := ` SELECT h.hubid AS hubid, h.hubname AS hubname, COUNT(c.consignmentid) AS delivered FROM hubs h - LEFT JOIN consignments c ON c.currenthubid = h.hubid AND c.status = ? AND c.updatedat BETWEEN ? AND ? + LEFT JOIN consignments c ON c.currenthubid = h.hubid AND c.status = ? AND c.updatedat BETWEEN ? AND ?` + hubArgs := []interface{}{constants.ConsignmentDelivered, from, to} + if ownTenant != 0 { + hubSQL += ` AND c.tenantid = ?` + hubArgs = append(hubArgs, ownTenant) + } + hubSQL += ` WHERE h.deletedat IS NULL GROUP BY h.hubid, h.hubname - ORDER BY delivered DESC - `, constants.ConsignmentDelivered, from, to).Scan(&hubRows) + ORDER BY delivered DESC` + db.DB.Raw(hubSQL, hubArgs...).Scan(&hubRows) byHub := make([]fiber.Map, 0, len(hubRows)) for _, r := range hubRows { @@ -197,13 +305,19 @@ func GetAdminReports(c *fiber.Ctx) error { Bookings int64 `gorm:"column:bookings"` } var tenantRows []tenantRow - db.DB.Raw(` + tenantSQL := ` SELECT t.tenantid AS tenantid, t.tenantname AS tenantname, COUNT(c.consignmentid) AS bookings FROM tenants t - LEFT JOIN consignments c ON c.tenantid = t.tenantid AND c.createdat BETWEEN ? AND ? + LEFT JOIN consignments c ON c.tenantid = t.tenantid AND c.createdat BETWEEN ? AND ?` + tenantArgs := []interface{}{from, to} + if ownTenant != 0 { + tenantSQL += ` WHERE t.tenantid = ?` + tenantArgs = append(tenantArgs, ownTenant) + } + tenantSQL += ` GROUP BY t.tenantid, t.tenantname - ORDER BY bookings DESC - `, from, to).Scan(&tenantRows) + ORDER BY bookings DESC` + db.DB.Raw(tenantSQL, tenantArgs...).Scan(&tenantRows) byTenant := make([]fiber.Map, 0, len(tenantRows)) for _, r := range tenantRows { @@ -219,6 +333,9 @@ func GetAdminReports(c *fiber.Ctx) error { TotalKms float64 `gorm:"column:total_kms"` TotalEarnings float64 `gorm:"column:total_earnings"` } + // Rider earnings, kms and completed stops are Doormile's own workforce data + // — a client has no business reading them, and there's no per-client view + // of a rider who works across tenants. Clients get an empty list. var riderRows []riderRow riderQuery := ` SELECT mp.userid AS userid, mp.displayname AS displayname, @@ -235,7 +352,9 @@ func GetAdminReports(c *fiber.Ctx) error { args = append(args, hubID) } riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50" - db.DB.Raw(riderQuery, args...).Scan(&riderRows) + if isDoormileConsoleStaff(c) { + db.DB.Raw(riderQuery, args...).Scan(&riderRows) + } byRider := make([]fiber.Map, 0, len(riderRows)) for _, r := range riderRows { @@ -271,13 +390,16 @@ func GetAppUsers(c *fiber.Ctx) error { page := utils.ParsePage(c) var total int64 - if err := db.DB.Model(&models.AppUser{}).Where("roleid != ?", 5).Count(&total).Error; err != nil { + if err := scopeToOwnTenant(c, db.DB.Model(&models.AppUser{}), "tenantid"). + Where("roleid != ?", 5).Count(&total).Error; err != nil { return utils.Internal(c, "failed to count users") } var users []models.AppUser - // Exclude Milers (Roleid = 5) from the CRM user list - if err := page.Apply(db.DB.Where("roleid != ?", 5)).Find(&users).Error; err != nil { + // Exclude Milers (Roleid = 5) from the CRM user list. Scoped as well, so a + // client sees only their own people, not Doormile's staff directory. + if err := page.Apply(scopeToOwnTenant(c, db.DB, "tenantid").Where("roleid != ?", 5)). + Find(&users).Error; err != nil { return utils.Internal(c, "failed to fetch users") } @@ -420,7 +542,9 @@ func DeleteAppUser(c *fiber.Ctx) error { func GetTenants(c *fiber.Ctx) error { var tenants []models.Tenant - if err := db.DB.Find(&tenants).Error; err != nil { + // A client login sees only its own tenant row; the tenant key here is the + // primary key, not a "tenantid" foreign column. + if err := scopeToOwnTenant(c, db.DB, "tenantid").Find(&tenants).Error; err != nil { return utils.Internal(c, "failed to fetch tenants") } return utils.List(c, tenants, int64(len(tenants))) @@ -441,6 +565,9 @@ func CreateTenant(c *fiber.Ctx) error { if tenant.Status == "" { tenant.Status = "Active" } + if req.Requiredeliveryotp != nil { + tenant.Requiredeliveryotp = *req.Requiredeliveryotp + } if err := db.DB.Create(&tenant).Error; err != nil { return utils.Internal(c, "failed to create tenant") @@ -451,7 +578,7 @@ func CreateTenant(c *fiber.Ctx) error { func GetTenantDetails(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) var tenant models.Tenant - if err := db.DB.First(&tenant, id).Error; err != nil { + if err := scopeToOwnTenant(c, db.DB, "tenantid").First(&tenant, id).Error; err != nil { return utils.NotFound(c, "tenant not found") } return utils.OK(c, tenant) @@ -459,6 +586,9 @@ func GetTenantDetails(c *fiber.Ctx) error { func UpdateTenant(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) + if !canAccessTenant(c, id) { + return utils.Forbidden(c, "not permitted for this tenant") + } var tenant models.Tenant if err := db.DB.First(&tenant, id).Error; err != nil { return utils.NotFound(c, "tenant not found") @@ -481,6 +611,9 @@ func UpdateTenant(c *fiber.Ctx) error { if req.Status != "" { tenant.Status = req.Status } + if req.Requiredeliveryotp != nil { + tenant.Requiredeliveryotp = *req.Requiredeliveryotp + } tenant.Updatedat = time.Now() if err := db.DB.Save(&tenant).Error; err != nil { @@ -491,6 +624,11 @@ func UpdateTenant(c *fiber.Ctx) error { func DeleteTenant(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) + // Deleting your own tenant is not a client operation either — this is + // Doormile-staff only. + if !isDoormileConsoleStaff(c) { + return utils.Forbidden(c, "not permitted for this tenant") + } var tenant models.Tenant if err := db.DB.First(&tenant, id).Error; err != nil { return utils.NotFound(c, "tenant not found") @@ -504,6 +642,9 @@ func DeleteTenant(c *fiber.Ctx) error { func GetTenantLocations(c *fiber.Ctx) error { tenantID, _ := strconv.Atoi(c.Params("id")) + if !canAccessTenant(c, tenantID) { + return utils.Forbidden(c, "not permitted for this tenant") + } var locations []models.TenantLocation if err := db.DB.Where("tenantid = ?", tenantID).Find(&locations).Error; err != nil { return utils.Internal(c, "failed to fetch locations") @@ -513,21 +654,25 @@ func GetTenantLocations(c *fiber.Ctx) error { func CreateTenantLocation(c *fiber.Ctx) error { tenantID, _ := strconv.Atoi(c.Params("id")) + if !canAccessTenant(c, tenantID) { + return utils.Forbidden(c, "not permitted for this tenant") + } req := new(dto.TenantLocationCreateRequest) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } location := models.TenantLocation{ - Tenantid: tenantID, - Address: req.Address, - City: req.City, - State: req.State, - Pincode: req.Pincode, - Latitude: req.Latitude, - Longitude: req.Longitude, - Isprimary: req.Isprimary, - Status: req.Status, + Tenantid: tenantID, + Locationname: req.Locationname, + Address: req.Address, + City: req.City, + State: req.State, + Pincode: req.Pincode, + Latitude: req.Latitude, + Longitude: req.Longitude, + Isprimary: req.Isprimary, + Status: req.Status, } if location.Status == "" { location.Status = "Active" @@ -549,12 +694,20 @@ func UpdateTenantLocation(c *fiber.Ctx) error { if err := db.DB.First(&location, id).Error; err != nil { return utils.NotFound(c, "tenant location not found") } + // The location is addressed by its own id, so the tenant guard has to run + // against the row we loaded rather than a path parameter. + if !canAccessTenant(c, location.Tenantid) { + return utils.Forbidden(c, "not permitted for this tenant") + } req := new(dto.TenantLocationCreateRequest) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } + if req.Locationname != "" { + location.Locationname = req.Locationname + } if req.Address != "" { location.Address = req.Address } @@ -1177,6 +1330,21 @@ func CreateMiler(c *fiber.Ctx) error { appLocID = 1 } + // A client login may only create riders under its own tenant. + tenantID := req.Tenantid + if own := consoleTenantID(c); own != 0 { + tenantID = own + } + + // Configid must match what LoginMiler looks up by — it queries + // "contactno = ? AND configid = ?" defaulting to 1001. Left unset, AppUser's + // column default of 1 applies and the rider can never log in, which is what + // happened to every miler created through this endpoint until now. + configID := req.Configid + if configID == 0 { + configID = 1001 + } + user := models.AppUser{ Authname: req.Authname, Email: req.Email, @@ -1185,6 +1353,9 @@ func CreateMiler(c *fiber.Ctx) error { Roleid: 5, // Miler Status: "Active", Applocationid: appLocID, + Tenantid: tenantID, + Hubid: req.Hubid, + Configid: configID, } if err := tx.Create(&user).Error; err != nil { @@ -1200,6 +1371,7 @@ func CreateMiler(c *fiber.Ctx) error { Availabilitystatus: constants.MilerOffline, Rating: 5.00, Applocationid: appLocID, + Hubid: req.Hubid, } if err := tx.Create(&profile).Error; err != nil { @@ -1360,6 +1532,9 @@ func GetAdminBookings(c *fiber.Ctx) error { if tenantID := c.Query("tenantid"); tenantID != "" { query = query.Where("tenantid = ?", tenantID) } + // Applied after the caller's own ?tenantid= filter so a client login can + // narrow within their tenant but never widen past it. + query = scopeToOwnTenant(c, query, "tenantid") var total int64 if err := query.Count(&total).Error; err != nil { @@ -1388,10 +1563,15 @@ func GetAdminBookings(c *fiber.Ctx) error { // (one booking) and AdminBulkCreateBookings (many) — was previously a type // local to CreateExpressBooking, promoted to package level so both can use it. type AdminBookingRequest struct { - Tenantid int `json:"tenantid"` - Appcustomerid int `json:"appcustomerid"` - CustomerPhone string `json:"customer_phone"` - CustomerName string `json:"customer_name"` + Tenantid int `json:"tenantid"` + Appcustomerid int `json:"appcustomerid"` + CustomerPhone string `json:"customer_phone"` + CustomerName string `json:"customer_name"` + // Pickuplocationid names the client site the parcel is collected from — a + // DailyGrubs kitchen, for instance. Optional, but supplying it lets the + // address/pincode/coordinates be filled from the stored location instead of + // retyped, and is the only thing that makes per-site reporting possible. + Pickuplocationid *int `json:"pickuplocationid"` Pickupaddress string `json:"pickupaddress"` Pickuppincode string `json:"pickuppincode"` Pickuplatitude float64 `json:"pickuplatitude"` @@ -1426,9 +1606,6 @@ func (e *expressBookingValidationError) Error() string { return e.msg } // *fiber.Ctx — the original function never touched c after BodyParser, so // both callers can use this identically. func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error) { - if req.Pickupaddress == "" || req.Pickuppincode == "" { - return nil, &expressBookingValidationError{"pickup address and pincode are required"} - } if len(req.Parcels) == 0 { return nil, &expressBookingValidationError{"at least one parcel is required"} } @@ -1440,6 +1617,35 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error return nil, &expressBookingValidationError{"tenantid does not match a known tenant"} } + // A named pickup location fills in whatever the caller left blank, so the + // console can send a kitchen id instead of restating its address every time. + // It must belong to the booking's tenant — otherwise one client could book + // against another client's site. + if req.Pickuplocationid != nil { + var loc models.TenantLocation + if err := db.DB.Where("tenantlocationid = ?", *req.Pickuplocationid).First(&loc).Error; err != nil { + return nil, &expressBookingValidationError{"pickuplocationid does not match a known location"} + } + if loc.Tenantid != req.Tenantid { + return nil, &expressBookingValidationError{"pickuplocationid does not belong to this tenant"} + } + if req.Pickupaddress == "" { + req.Pickupaddress = loc.Address + } + if req.Pickuppincode == "" { + req.Pickuppincode = loc.Pincode + } + if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 { + req.Pickuplatitude, req.Pickuplongitude = loc.Latitude, loc.Longitude + } + } + + // Checked after the location fill-in, so a caller supplying only a kitchen + // id is not rejected for an address it never needed to send. + if req.Pickupaddress == "" || req.Pickuppincode == "" { + return nil, &expressBookingValidationError{"pickup address and pincode are required"} + } + tx := db.DB.Begin() customerID := req.Appcustomerid @@ -1472,6 +1678,7 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error Bookingno: generateBookingNo(), Tenantid: &tenantID, Appcustomerid: customerID, + Pickuplocationid: req.Pickuplocationid, Pickupaddress: req.Pickupaddress, Pickuppincode: req.Pickuppincode, Pickuplatitude: req.Pickuplatitude, @@ -1641,6 +1848,13 @@ func CreateExpressBooking(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } + // A client login may only book under its own tenant. Left unchecked, the + // tenantid is caller-supplied, so a client could attribute bookings — and + // their cost — to another client. + if own := consoleTenantID(c); own != 0 { + req.Tenantid = own + } + booking, err := createExpressBooking(*req) if err != nil { if _, ok := err.(*expressBookingValidationError); ok { @@ -1679,7 +1893,14 @@ func AdminBulkCreateBookings(c *fiber.Ctx) error { } results := make([]result, 0, len(req.Bookings)) + ownTenant := consoleTenantID(c) + for i, item := range req.Bookings { + // Same tenant pin as the single-booking path — a bulk import must not + // be a way around it. + if ownTenant != 0 { + item.Tenantid = ownTenant + } booking, err := createExpressBooking(item) if err != nil { results = append(results, result{Index: i, Success: false, Error: err.Error()}) @@ -1694,7 +1915,8 @@ func AdminBulkCreateBookings(c *fiber.Ctx) error { func GetAdminBookingDetails(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) var booking models.PickupBooking - if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments").First(&booking, id).Error; err != nil { + q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid") + if err := q.First(&booking, id).Error; err != nil { return utils.NotFound(c, "booking not found") } return utils.OK(c, booking) @@ -1702,6 +1924,9 @@ func GetAdminBookingDetails(c *fiber.Ctx) error { func AdminAssignMiler(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) + if err := assertBookingAccess(c, id); err != nil { + return err + } type MilerAssign struct { Mileruserid int `json:"mileruserid"` @@ -1723,6 +1948,9 @@ func AdminAssignMiler(c *fiber.Ctx) error { func AdminAssignVehicle(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) + if err := assertBookingAccess(c, id); err != nil { + return err + } type VehicleAssign struct { Vehicleid int `json:"vehicleid"` @@ -1749,6 +1977,9 @@ func AdminAssignVehicle(c *fiber.Ctx) error { func AdminUpdateBookingStatus(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) + if err := assertBookingAccess(c, id); err != nil { + return err + } type StatusUpdate struct { Status string `json:"status"` @@ -1783,6 +2014,9 @@ func AdminCancelBooking(c *fiber.Ctx) error { if err != nil { return utils.BadRequest(c, "invalid booking ID") } + if err := assertBookingAccess(c, id); err != nil { + return err + } var booking models.PickupBooking if err := db.DB.First(&booking, id).Error; err != nil { @@ -1850,6 +2084,8 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error { } results := make([]result, 0, len(req.Bookingids)) + ownTenant := consoleTenantID(c) + for _, id := range req.Bookingids { var booking models.PickupBooking if err := db.DB.First(&booking, id).Error; err != nil { @@ -1857,6 +2093,13 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error { continue } + // Reported as not-found rather than forbidden, so a client can't probe + // which booking ids belong to other tenants. + if ownTenant != 0 && (booking.Tenantid == nil || *booking.Tenantid != ownTenant) { + results = append(results, result{Bookingid: id, Success: false, Error: "booking not found"}) + continue + } + if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled { results = append(results, result{Bookingid: id, Success: false, Error: "cannot cancel a delivered or already cancelled booking"}) continue @@ -1905,12 +2148,13 @@ func GetAdminConsignments(c *fiber.Ctx) error { page := utils.ParsePage(c) var total int64 - if err := db.DB.Model(&models.Consignment{}).Count(&total).Error; err != nil { + if err := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid"). + Count(&total).Error; err != nil { return utils.Internal(c, "failed to count consignments") } var list []models.Consignment - if err := page.Apply(db.DB).Find(&list).Error; err != nil { + if err := page.Apply(scopeToOwnTenant(c, db.DB, "tenantid")).Find(&list).Error; err != nil { return utils.Internal(c, "failed to fetch consignments") } return utils.Paginated(c, list, total, page) @@ -1919,7 +2163,7 @@ func GetAdminConsignments(c *fiber.Ctx) error { func GetAdminConsignmentDetails(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) var csg models.Consignment - if err := db.DB.First(&csg, id).Error; err != nil { + if err := scopeToOwnTenant(c, db.DB, "tenantid").First(&csg, id).Error; err != nil { return utils.NotFound(c, "consignment not found") } return utils.OK(c, csg) @@ -1928,7 +2172,8 @@ func GetAdminConsignmentDetails(c *fiber.Ctx) error { func GetAdminConsignmentTracking(c *fiber.Ctx) error { trackingNo := c.Params("trackingno") var consignment models.Consignment - if err := db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil { + if err := scopeToOwnTenant(c, db.DB, "tenantid"). + Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil { return utils.NotFound(c, "consignment not found") } var history []models.ConsignmentHistory @@ -2218,7 +2463,10 @@ func ArriveTripsheet(c *fiber.Ctx) error { func GetPricing(c *fiber.Ctx) error { var pricing []models.Pricing - if err := db.DB.Where("deletedat IS NULL").Find(&pricing).Error; err != nil { + // Rates are commercially sensitive: one client must never see what another + // is charged. + if err := scopeToOwnTenant(c, db.DB, "tenantid"). + Where("deletedat IS NULL").Find(&pricing).Error; err != nil { return utils.Internal(c, "failed to fetch pricing schedules") } return utils.List(c, pricing, int64(len(pricing))) diff --git a/controllers/hubController.go b/controllers/hubController.go index 08b1228..1404519 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -96,9 +96,11 @@ func humanizeRelativeTime(t time.Time) string { // todayMidnight returns the start of the current day in server local time, // used to scope "today" counters on the hub dashboard. +// todayMidnight is the start of the current day as the database records it — +// see utils.DBNow. Using the container's own clock here dropped every row +// created after noon IST out of "today so far". func todayMidnight() time.Time { - now := time.Now() - return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + return utils.DBToday() } // parseHubDateRange parses optional from/to (YYYY-MM-DD) query params shared @@ -110,17 +112,20 @@ func parseHubDateRange(c *fiber.Ctx) (time.Time, time.Time, error) { toStr := c.Query("to") if fromStr == "" && toStr == "" { - return todayMidnight(), time.Now(), nil + return todayMidnight(), utils.DBNow(), nil } if fromStr == "" || toStr == "" { return time.Time{}, time.Time{}, fmt.Errorf("both from and to query params are required (YYYY-MM-DD)") } - from, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) + // Parsed as UTC, not time.Local: stored timestamps are bare wall-clock + // digits, so the bounds must be too — otherwise the window silently shifts + // with whatever timezone the container happens to run in. + from, err := time.ParseInLocation("2006-01-02", fromStr, time.UTC) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid from date, expected YYYY-MM-DD") } - toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) + toDate, err := time.ParseInLocation("2006-01-02", toStr, time.UTC) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("invalid to date, expected YYYY-MM-DD") } diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index eb31a61..1aed20b 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -274,9 +274,6 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } - if req.Otp == "" { - return utils.BadRequest(c, "otp is required") - } if req.Deliveredtoname == "" { return utils.BadRequest(c, "deliveredtoname is required") } @@ -296,17 +293,28 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { return utils.BadRequest(c, "consignment is not out for delivery") } + // An OTP is only present when the client asked for one (Tenant.Requiredeliveryotp), + // so an empty one means this delivery was never meant to need a code — that + // covers food clients like DailyGrubs as well as parcels already in the + // network from before OTPs existed, which would otherwise be unclosable. + if consignment.Deliveryotp != "" { + if req.Otp == "" { + return utils.BadRequest(c, "otp is required for this delivery") + } + if req.Otp != consignment.Deliveryotp { + return utils.BadRequest(c, "incorrect delivery OTP") + } + } + tx := db.DB.Begin() - // OTP generation/storage is Phase 2 — no delivery_otp field exists yet on - // consignments, so acceptance of a non-empty OTP is treated as verified. proof := models.DeliveryProof{ Consignmentid: consignment.Consignmentid, Deliveredat: time.Now(), Deliveredtoname: req.Deliveredtoname, Receiversignatureurl: req.Receiversignatureurl, Photourl: req.Photourl, - Otpverified: true, + Otpverified: consignment.Deliveryotp != "", Geolatitude: req.Lat, Geolongitude: req.Lon, Createdby: milerUserID, @@ -317,12 +325,30 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { } consignment.Status = constants.ConsignmentDelivered + // Cleared once redeemed so the same code can't close out a second attempt. + consignment.Deliveryotp = "" consignment.Updatedat = time.Now() if err := tx.Save(&consignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to mark consignment delivered") } + // Every other state change on a consignment writes a history row; delivery + // did not, so a customer following the tracking timeline never saw the + // parcel arrive — it just stopped at Out_for_Delivery. + deliveredEvent := models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Hubid: consignment.Currenthubid, + Userid: &milerUserID, + Eventstatus: constants.ConsignmentDelivered, + Remarks: fmt.Sprintf("Delivered to %s at (%.5f, %.5f)", + req.Deliveredtoname, req.Lat, req.Lon), + } + if err := tx.Create(&deliveredEvent).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to record delivery history") + } + if err := tx.Model(&models.BookingAssignment{}). Where("bookingid = ? AND mileruserid = ?", booking.Bookingid, milerUserID). Updates(map[string]interface{}{ diff --git a/controllers/milerController.go b/controllers/milerController.go index fe4a674..0e53523 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -293,7 +293,8 @@ func UpdateMilerAvailability(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } - if req.Status == "" { + status := req.ResolvedStatus() + if status == "" { return utils.BadRequest(c, "status is required") } @@ -302,7 +303,7 @@ func UpdateMilerAvailability(c *fiber.Ctx) error { return utils.NotFound(c, "miler profile not found") } - profile.Availabilitystatus = req.Status + profile.Availabilitystatus = status profile.Updatedat = time.Now() if err := db.DB.Save(&profile).Error; err != nil { return utils.Internal(c, "failed to update availability") @@ -796,9 +797,13 @@ func BookingPickupComplete(c *fiber.Ctx) error { consignmentTenantID = *booking.Tenantid } + // Carried over so the consignment stays traceable to the client site it was + // collected from — for a food client that's the kitchen, and "how many + // parcels went out of which kitchen" is unanswerable without it. consignment := models.Consignment{ Trackingno: trackingNo, Tenantid: consignmentTenantID, + Pickuplocationid: booking.Pickuplocationid, Pickuplatitude: booking.Pickuplatitude, Pickuplongitude: booking.Pickuplongitude, Deliverylatitude: booking.Deliverylatitude, @@ -829,6 +834,17 @@ func BookingPickupComplete(c *fiber.Ctx) error { } } + // A hyperlocal parcel goes straight out for delivery, so its receiver OTP has + // to exist before this transaction commits. Anything routed via a hub gets + // its OTP when it actually leaves for the final mile instead. Only issued + // for clients that ask for it — see Tenant.Requiredeliveryotp. + if consignmentStatus == constants.ConsignmentOutForDelivery { + var tenant models.Tenant + if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp { + consignment.Deliveryotp = utils.GenerateNumericOTP(6) + } + } + if err := tx.Create(&consignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to convert booking to consignment") @@ -865,15 +881,18 @@ func BookingPickupComplete(c *fiber.Ctx) error { var customer models.AppCustomer if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" { - if notifyErr := notify.SendToDevice( - customer.Devicetoken, - "Parcel Picked Up", - fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo), - map[string]string{ - "booking_id": strconv.Itoa(bookingID), - "tracking_no": trackingNo, - }, - ); notifyErr != nil { + body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo) + payload := map[string]string{ + "booking_id": strconv.Itoa(bookingID), + "tracking_no": trackingNo, + } + // The OTP goes to the receiver and only the receiver — the rider has to + // be told it at the door, which is what makes it proof of handover. + if consignment.Deliveryotp != "" { + body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp) + payload["delivery_otp"] = consignment.Deliveryotp + } + if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil { utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr) } } @@ -919,6 +938,11 @@ func CreateMilerPeriodicLog(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } + // The rider's identity comes from their token, never the body. Trusting a + // client-supplied userid let any authenticated miler write another miler's + // GPS trail, which feeds the location data dispatch reasons over. + log.UserID = c.Locals("userid").(int) + t, err := time.Parse("2006-01-02 15:04:05", log.LogDate) if err != nil { return utils.BadRequest(c, "invalid logdate format — expected YYYY-MM-DD HH:MM:SS") @@ -987,8 +1011,12 @@ func CreateMilerStatus(c *fiber.Ctx) error { return utils.BadRequest(c, "invalid request body") } - if status.UserID == 0 || status.Status == "" { - return utils.BadRequest(c, "userid and status are required") + // Identity from the token, not the body — otherwise one rider can set + // another rider's live status. + status.UserID = c.Locals("userid").(int) + + if status.Status == "" { + return utils.BadRequest(c, "status is required") } key := fmt.Sprintf("miler_status:%d", status.UserID) @@ -1088,10 +1116,16 @@ func PublishConsignmentLogs(c *fiber.Ctx) error { return utils.Internal(c, "cache service unavailable") } + milerUserID := c.Locals("userid").(int) + pipe := db.Rdb.TxPipeline() tx := db.DB.Begin() for _, item := range input { + // Same rule as the other telemetry writers: the token owns the identity, + // so a batch can't be attributed to some other rider. + item.UserID = milerUserID + logTime, err := time.Parse("2006-01-02 15:04:05", item.LogDate) if err != nil { logTime = time.Now() diff --git a/dto/admin.go b/dto/admin.go index e91189b..75d7aa1 100644 --- a/dto/admin.go +++ b/dto/admin.go @@ -7,17 +7,23 @@ type TenantCreateRequest struct { Primaryemail string `json:"primaryemail"` Primarycontact string `json:"primarycontact"` Status string `json:"status"` + // Requiredeliveryotp is a pointer so an update that omits it leaves the + // existing setting alone rather than silently switching OTPs off. + Requiredeliveryotp *bool `json:"requiredeliveryotp"` } type TenantLocationCreateRequest struct { - Address string `json:"address"` - City string `json:"city"` - State string `json:"state"` - Pincode string `json:"pincode"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Isprimary bool `json:"isprimary"` - Status string `json:"status"` + // Locationname is the client's own label for the site — "DailyGrubs + // Peelamedu Kitchen" — since an address alone doesn't identify a branch. + Locationname string `json:"locationname"` + Address string `json:"address"` + City string `json:"city"` + State string `json:"state"` + Pincode string `json:"pincode"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Isprimary bool `json:"isprimary"` + Status string `json:"status"` } type TenantCustomerCreateRequest struct { @@ -57,13 +63,23 @@ type VehicleCreateRequest struct { } type MilerCreateRequest struct { - Authname string `json:"authname"` - Email string `json:"email"` - Contactno string `json:"contactno"` - Password string `json:"password"` - Displayname string `json:"displayname"` + Authname string `json:"authname"` + Email string `json:"email"` + Contactno string `json:"contactno"` + Password string `json:"password"` + Displayname string `json:"displayname"` + // Tenantid attaches a rider to the client they deliver for — riders migrated + // from jupiter belong to a specific client (DailyGrubs, Bawa Medicals) + // rather than to Doormile's general pool. Zero leaves them unattached. + Tenantid int `json:"tenantid"` Defaultvehicletype string `json:"defaultvehicletype"` Applocationid int `json:"applocationid"` + // Configid partitions logins; defaults to 1001, which is what the miler app + // authenticates against. Only set this if you know why you're changing it. + Configid int `json:"configid"` + // Hubid is optional: a rider with no hub is still assignable from the + // express console, but is invisible to the hub console's miler list. + Hubid *int `json:"hubid"` } type PricingCreateRequest struct { diff --git a/dto/booking.go b/dto/booking.go index 7518549..1a40898 100644 --- a/dto/booking.go +++ b/dto/booking.go @@ -20,10 +20,10 @@ type ParcelRequest struct { Itemcategory string `json:"itemcategory"` Itemdescription string `json:"itemdescription"` Declaredvalue float64 `json:"declaredvalue"` - Weight float64 `json:"weight"` // optional — miler weighs at pickup - Length float64 `json:"length"` // optional - Width float64 `json:"width"` // optional - Height float64 `json:"height"` // optional + Weight float64 `json:"weight"` // optional — miler weighs at pickup + Length float64 `json:"length"` // optional + Width float64 `json:"width"` // optional + Height float64 `json:"height"` // optional Isfragile bool `json:"isfragile"` Needsinsurance bool `json:"needsinsurance"` Requireslargevehicle bool `json:"requireslargevehicle"` @@ -31,8 +31,8 @@ type ParcelRequest struct { type PickupBookingRequest struct { Pickuplocationid *int `json:"pickuplocationid"` - Pickupaddress string `json:"pickupaddress"` // required - Pickuppincode string `json:"pickuppincode"` // required + Pickupaddress string `json:"pickupaddress"` // required + Pickuppincode string `json:"pickuppincode"` // required Pickuplatitude float64 `json:"pickuplatitude"` Pickuplongitude float64 `json:"pickuplongitude"` Deliveryaddress string `json:"deliveryaddress"` // optional — can be filled later @@ -55,18 +55,36 @@ type MilerLocationUpdateRequest struct { Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` Pincode string `json:"pincode"` + // Speed and Heading are sent by the rider app and were previously dropped on + // the floor, since unknown JSON fields parse silently. Accepted here so the + // values at least reach the periodic-log telemetry rather than vanishing. + Speed float64 `json:"speed"` + Heading float64 `json:"heading"` } type MilerAvailabilityRequest struct { Status string `json:"status"` // Offline, Available, Break, etc. + // Availabilitystatus is the field name the published Miler App API Contract + // v1.0 told the Flutter dev to send, while the code only ever read "status" + // — so the documented request 400s. Both are accepted rather than picking a + // winner, because either side may already be built against either name. + Availabilitystatus string `json:"availabilitystatus"` +} + +// ResolvedStatus returns whichever of the two accepted field names was sent. +func (r MilerAvailabilityRequest) ResolvedStatus() string { + if r.Status != "" { + return r.Status + } + return r.Availabilitystatus } type PricingQuoteRequest struct { - Pickuppincode string `json:"pickuppincode"` - Deliverypincode string `json:"deliverypincode"` - Pickuplatitude float64 `json:"pickuplatitude"` - Pickuplongitude float64 `json:"pickuplongitude"` - Deliverylatitude float64 `json:"deliverylatitude"` + Pickuppincode string `json:"pickuppincode"` + Deliverypincode string `json:"deliverypincode"` + Pickuplatitude float64 `json:"pickuplatitude"` + Pickuplongitude float64 `json:"pickuplongitude"` + Deliverylatitude float64 `json:"deliverylatitude"` Deliverylongitude float64 `json:"deliverylongitude"` - Parcels []ParcelRequest `json:"parcels"` + Parcels []ParcelRequest `json:"parcels"` } diff --git a/middlewares/city_gate.go b/middlewares/city_gate.go index 3961f8d..9f15c5b 100644 --- a/middlewares/city_gate.go +++ b/middlewares/city_gate.go @@ -13,6 +13,7 @@ var operatingCityPrefixes = map[string]string{ "600": "Chennai", "560": "Bengaluru", "500": "Hyderabad", + "629": "Nagercoil", } // CityGateMiddleware rejects bookings from pincodes outside Doormile's operating cities. diff --git a/models/audit.go b/models/audit.go index 41f8e3e..f64369b 100644 --- a/models/audit.go +++ b/models/audit.go @@ -57,9 +57,9 @@ type Consignment struct { Chargeableweight float64 `json:"chargeableweight" gorm:"column:chargeableweight;not null"` Codamount float64 `json:"codamount" gorm:"column:codamount;default:0.00"` Codcollected float64 `json:"codcollected" gorm:"column:codcollected;default:0.00"` - Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Prepaid, COD, To_Pay + Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Prepaid, COD, To_Pay Billingstatus string `json:"billingstatus" gorm:"column:billingstatus;default:Unbilled"` // Unbilled, Billed, Paid, Settled - Status string `json:"status" gorm:"column:status;default:Created"` // Created, Inwarded_at_Hub, Tripsheet_Loaded, In_Transit, Out_for_Delivery, Delivered, RTO_Initiated, Returned_to_Sender, Missing, Damaged + Status string `json:"status" gorm:"column:status;default:Created"` // Created, Inwarded_at_Hub, Tripsheet_Loaded, In_Transit, Out_for_Delivery, Delivered, RTO_Initiated, Returned_to_Sender, Missing, Damaged Attemptcount int `json:"attemptcount" gorm:"column:attemptcount;default:0"` Estimateddeliveryat *time.Time `json:"estimateddeliveryat" gorm:"column:estimateddeliveryat"` Sladueat *time.Time `json:"sladueat" gorm:"column:sladueat"` @@ -69,11 +69,16 @@ type Consignment struct { Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"` Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc. Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` - Createdby int `json:"createdby" gorm:"column:createdby"` - Updatedby int `json:"updatedby" gorm:"column:updatedby"` - Deletedat *time.Time `json:"deletedat,omitempty" gorm:"column:deletedat"` + // Deliveryotp is issued when the consignment goes out for delivery and is + // given to the receiver, not the rider — it is the only proof the parcel + // reached the right person. Never serialised outward: returning it in an API + // response would hand the rider the code they are supposed to be told. + Deliveryotp string `json:"-" gorm:"column:deliveryotp;size:6"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + Createdby int `json:"createdby" gorm:"column:createdby"` + Updatedby int `json:"updatedby" gorm:"column:updatedby"` + Deletedat *time.Time `json:"deletedat,omitempty" gorm:"column:deletedat"` } func (Consignment) TableName() string { @@ -121,12 +126,12 @@ func (ConsignmentException) TableName() string { // at that hub. One conversation per (hubid, mileruserid) pair. type HubConversation struct { Hubconversationid int `json:"hubconversationid" gorm:"primaryKey;column:hubconversationid"` - Hubid int `json:"hubid" gorm:"column:hubid;index;not null"` - Mileruserid *int `json:"mileruserid" gorm:"column:mileruserid;index"` - Participantname string `json:"participantname" gorm:"column:participantname;not null"` - Participantrole string `json:"participantrole" gorm:"column:participantrole"` - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + Hubid int `json:"hubid" gorm:"column:hubid;index;not null"` + Mileruserid *int `json:"mileruserid" gorm:"column:mileruserid;index"` + Participantname string `json:"participantname" gorm:"column:participantname;not null"` + Participantrole string `json:"participantrole" gorm:"column:participantrole"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` } func (HubConversation) TableName() string { @@ -137,13 +142,13 @@ func (HubConversation) TableName() string { // requesting hub staff) or "them" (the other party), matching the hub // console frontend's bubble-side convention. type HubMessage struct { - Hubmessageid int `json:"hubmessageid" gorm:"primaryKey;column:hubmessageid"` - Hubconversationid int `json:"hubconversationid" gorm:"column:hubconversationid;index;not null"` - Sender string `json:"sender" gorm:"column:sender;not null"` // me, them - Senderstaffid *int `json:"senderstaffid" gorm:"column:senderstaffid"` - Messagetext string `json:"messagetext" gorm:"column:messagetext;not null"` - Isread bool `json:"isread" gorm:"column:isread;default:false"` - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Hubmessageid int `json:"hubmessageid" gorm:"primaryKey;column:hubmessageid"` + Hubconversationid int `json:"hubconversationid" gorm:"column:hubconversationid;index;not null"` + Sender string `json:"sender" gorm:"column:sender;not null"` // me, them + Senderstaffid *int `json:"senderstaffid" gorm:"column:senderstaffid"` + Messagetext string `json:"messagetext" gorm:"column:messagetext;not null"` + Isread bool `json:"isread" gorm:"column:isread;default:false"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` } func (HubMessage) TableName() string { diff --git a/models/client.go b/models/client.go index b4146b6..d8a15f8 100644 --- a/models/client.go +++ b/models/client.go @@ -18,11 +18,11 @@ type DoormileClient struct { Phone string `gorm:"uniqueIndex;size:20;not null" json:"phone"` // Location - Address string `gorm:"type:text" json:"address"` - City string `gorm:"size:100" json:"city"` - State string `gorm:"size:100" json:"state"` - Neighbourhood string `gorm:"size:100" json:"neighbourhood"` - Pincode string `gorm:"size:20" json:"pincode"` + Address string `gorm:"type:text" json:"address"` + City string `gorm:"size:100" json:"city"` + State string `gorm:"size:100" json:"state"` + Neighbourhood string `gorm:"size:100" json:"neighbourhood"` + Pincode string `gorm:"size:20" json:"pincode"` // GPS survey data SurveyLat float64 `gorm:"column:surveylat" json:"survey_lat"` @@ -41,10 +41,10 @@ type DoormileClient struct { // Full-consent-only fields (zeroed for basicOnly) ParcelVolume float64 `json:"parcel_volume"` - ActiveContracts int `json:"active_contracts"` - LogisticsProvider string `gorm:"size:100" json:"logistics_provider"` - ProviderEfficiency string `gorm:"size:100" json:"provider_efficiency"` - Notes string `gorm:"type:text" json:"notes"` + ActiveContracts int `json:"active_contracts"` + LogisticsProvider string `gorm:"size:100" json:"logistics_provider"` + ProviderEfficiency string `gorm:"size:100" json:"provider_efficiency"` + Notes string `gorm:"type:text" json:"notes"` // Consent & registration tracking DataConsent string `gorm:"size:20;default:'full'" json:"data_consent"` @@ -63,8 +63,13 @@ type DoormileAuth struct { Email string `gorm:"uniqueIndex;size:255;not null" json:"email"` PasswordHash string `gorm:"not null" json:"-"` Role string `gorm:"default:'user'" json:"role"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // Tenantid scopes an express-console login to one client, using the same + // convention as HubStaffAccount.Tenantid: null = Doormile's own staff, who + // see every tenant's data; set = a client's own login, restricted to their + // tenant. Without this every console login sees all tenants. + Tenantid *int `gorm:"column:tenantid;index" json:"tenantid"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (DoormileAuth) TableName() string { diff --git a/models/external.go b/models/external.go index 7429df6..6495a1a 100644 --- a/models/external.go +++ b/models/external.go @@ -4,13 +4,19 @@ import "time" // Tenant represents the pre-existing 'tenants' table type Tenant struct { - Tenantid int `json:"tenantid" gorm:"primaryKey;column:tenantid"` - Tenantname string `json:"tenantname" gorm:"column:tenantname"` - Primaryemail string `json:"primaryemail" gorm:"column:primaryemail"` - Primarycontact string `json:"primarycontact" gorm:"column:primarycontact"` - Status string `json:"status" gorm:"column:status;default:Active"` - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + Tenantid int `json:"tenantid" gorm:"primaryKey;column:tenantid"` + Tenantname string `json:"tenantname" gorm:"column:tenantname"` + Primaryemail string `json:"primaryemail" gorm:"column:primaryemail"` + Primarycontact string `json:"primarycontact" gorm:"column:primarycontact"` + Status string `json:"status" gorm:"column:status;default:Active"` + // Requiredeliveryotp decides whether the receiver must read a code back to + // the rider. Worth the friction for a courier handing over a valuable + // parcel; not for a food order, where it just slows every drop down. + // Defaults off: turning it on platform-wide would block deliveries for + // clients whose customer app has no way to show the code yet. + Requiredeliveryotp bool `json:"requiredeliveryotp" gorm:"column:requiredeliveryotp;default:false"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` } func (Tenant) TableName() string { @@ -19,14 +25,14 @@ func (Tenant) TableName() string { // Customer represents the pre-existing 'customers' table type Customer struct { - Customerid int `json:"customerid" gorm:"primaryKey;column:customerid"` - Firstname string `json:"firstname" gorm:"column:firstname"` - Lastname string `json:"lastname" gorm:"column:lastname"` - Contactno string `json:"contactno" gorm:"column:contactno"` - Email string `json:"email" gorm:"column:email"` - Status int `json:"status" gorm:"column:status"` - Createdat time.Time `json:"createdat" gorm:"column:createdat"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat"` + Customerid int `json:"customerid" gorm:"primaryKey;column:customerid"` + Firstname string `json:"firstname" gorm:"column:firstname"` + Lastname string `json:"lastname" gorm:"column:lastname"` + Contactno string `json:"contactno" gorm:"column:contactno"` + Email string `json:"email" gorm:"column:email"` + Status int `json:"status" gorm:"column:status"` + Createdat time.Time `json:"createdat" gorm:"column:createdat"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat"` } func (Customer) TableName() string { @@ -35,15 +41,15 @@ func (Customer) TableName() string { // CustomerLocation represents the pre-existing 'customerlocations' table type CustomerLocation struct { - Locationid int `json:"locationid" gorm:"primaryKey;column:locationid"` - Customerid int `json:"customerid" gorm:"column:customerid"` - Address string `json:"address" gorm:"column:address"` - City string `json:"city" gorm:"column:city"` - State string `json:"state" gorm:"column:state"` - Postcode string `json:"postcode" gorm:"column:postcode"` - Latitude string `json:"latitude" gorm:"column:latitude"` - Longitude string `json:"longitude" gorm:"column:longitude"` - Status int `json:"status" gorm:"column:status"` + Locationid int `json:"locationid" gorm:"primaryKey;column:locationid"` + Customerid int `json:"customerid" gorm:"column:customerid"` + Address string `json:"address" gorm:"column:address"` + City string `json:"city" gorm:"column:city"` + State string `json:"state" gorm:"column:state"` + Postcode string `json:"postcode" gorm:"column:postcode"` + Latitude string `json:"latitude" gorm:"column:latitude"` + Longitude string `json:"longitude" gorm:"column:longitude"` + Status int `json:"status" gorm:"column:status"` } func (CustomerLocation) TableName() string { diff --git a/models/users.go b/models/users.go index 44513fd..8501c7b 100644 --- a/models/users.go +++ b/models/users.go @@ -84,26 +84,26 @@ func (AppUser) TableName() string { } type MilerProfile struct { - Milerprofileid int `json:"milerprofileid" gorm:"primaryKey;column:milerprofileid"` - Userid int `json:"userid" gorm:"column:userid;unique;not null"` - Applocationid int `json:"applocationid" gorm:"column:applocationid;default:1"` - Displayname string `json:"displayname" gorm:"column:displayname;not null"` - Phone string `json:"phone" gorm:"column:phone;not null"` - Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"` - Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"` - Hubid *int `json:"hubid" gorm:"column:hubid"` - Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` - Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"` - Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"` - Currentpincode string `json:"currentpincode" gorm:"column:currentpincode"` - Availabilitystatus string `json:"availabilitystatus" gorm:"column:availabilitystatus;default:Offline"` // Offline, Available, Assigned, On_Pickup, At_Customer, Picked_Up, On_Delivery, Break, Blocked - Rating float64 `json:"rating" gorm:"column:rating;default:5.00"` - Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;default:0"` - Totalcancelledpickups int `json:"totalcancelledpickups" gorm:"column:totalcancelledpickups;default:0"` - Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"` - Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat" gorm:"column:lastlocationupdatedat"` - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + Milerprofileid int `json:"milerprofileid" gorm:"primaryKey;column:milerprofileid"` + Userid int `json:"userid" gorm:"column:userid;unique;not null"` + Applocationid int `json:"applocationid" gorm:"column:applocationid;default:1"` + Displayname string `json:"displayname" gorm:"column:displayname;not null"` + Phone string `json:"phone" gorm:"column:phone;not null"` + Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"` + Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"` + Hubid *int `json:"hubid" gorm:"column:hubid"` + Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` + Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"` + Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"` + Currentpincode string `json:"currentpincode" gorm:"column:currentpincode"` + Availabilitystatus string `json:"availabilitystatus" gorm:"column:availabilitystatus;default:Offline"` // Offline, Available, Assigned, On_Pickup, At_Customer, Picked_Up, On_Delivery, Break, Blocked + Rating float64 `json:"rating" gorm:"column:rating;default:5.00"` + Totalcompletedpickups int `json:"totalcompletedpickups" gorm:"column:totalcompletedpickups;default:0"` + Totalcancelledpickups int `json:"totalcancelledpickups" gorm:"column:totalcancelledpickups;default:0"` + Devicetoken string `json:"device_token,omitempty" gorm:"column:device_token"` + Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat" gorm:"column:lastlocationupdatedat"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` } func (MilerProfile) TableName() string { @@ -158,6 +158,7 @@ func (AppCustomerLocation) TableName() string { type TenantLocation struct { Tenantlocationid int `json:"tenantlocationid" gorm:"primaryKey;column:tenantlocationid"` Tenantid int `json:"tenantid" gorm:"column:tenantid;not null"` + Locationname string `json:"locationname" gorm:"column:locationname"` Address string `json:"address" gorm:"column:address;not null"` City string `json:"city" gorm:"column:city;not null"` State string `json:"state" gorm:"column:state;not null"` diff --git a/routes/routes.go b/routes/routes.go index b4512e3..95ac9d8 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -113,7 +113,15 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { miler := api.Group("/miler") miler.Post("/login", authThrottle, controllers.LoginMiler(cfg)) miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg)) - miler.Post("/reset-pin", authThrottle, controllers.ResetMilerPin) + // PIN reset is console-operated, NOT self-service: ResetMilerPin overwrites + // the PIN given only a phone number, and phone numbers are the miler login + // identifier rather than a secret. Left unauthenticated, two calls + // (reset-pin then verify-pin) take over any miler account. Ops resets a + // rider's PIN on request instead, so this carries admin/manager/executive + // auth even though it sits under the /miler prefix. + miler.Post("/reset-pin", authThrottle, + middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4), + controllers.ResetMilerPin) // Authenticated Miler App routes milerAuth := miler.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(5)) diff --git a/utils/helper.go b/utils/helper.go index 15c9d47..5ce166d 100644 --- a/utils/helper.go +++ b/utils/helper.go @@ -1,7 +1,9 @@ package utils import ( + "crypto/rand" "errors" + "math/big" "time" "github.com/golang-jwt/jwt/v5" @@ -13,6 +15,55 @@ func HashPassword(password string) (string, error) { return string(bytes), err } +// dbLocation is the timezone the database records wall-clock timestamps in: the +// connection DSN sets TimeZone=Asia/Kolkata, so CURRENT_TIMESTAMP column +// defaults write IST wall-clock into timestamp-without-timezone columns. +var dbLocation = func() *time.Location { + if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil { + return loc + } + // A container without tzdata can't load the database; IST observes no DST, + // so a fixed +05:30 offset is exact rather than an approximation. + return time.FixedZone("IST", 5*3600+30*60) +}() + +// DBNow returns the current moment expressed as the wall-clock the database +// stores, tagged UTC so the driver sends exactly those digits. Use it for any +// comparison against a stored timestamp: comparing the container's UTC clock +// against IST-stamped rows is what made date-range reports undercount. +// +// Deliberately independent of the container's own TZ, so it stays correct +// whether or not TZ=Asia/Kolkata is set. +func DBNow() time.Time { + n := time.Now().In(dbLocation) + return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), n.Minute(), n.Second(), n.Nanosecond(), time.UTC) +} + +// DBToday returns midnight at the start of the current database-local day. +func DBToday() time.Time { + n := DBNow() + return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC) +} + +// GenerateNumericOTP returns a random n-digit code, leading zeros preserved. +// crypto/rand rather than math/rand: this is the only thing standing between a +// parcel and someone claiming it was delivered, so a predictable sequence would +// defeat the point. +func GenerateNumericOTP(n int) string { + const digits = "0123456789" + out := make([]byte, n) + for i := range out { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits)))) + if err != nil { + // A failing system RNG must not silently downgrade to a guessable + // code; the caller treats an empty OTP as "not issued". + return "" + } + out[i] = digits[idx.Int64()] + } + return string(out) +} + func CheckPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil