updates on the api endpoints on the customer page and more
This commit is contained in:
514
controllers/logisticsHandoverController.go
Normal file
514
controllers/logisticsHandoverController.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// --------------------
|
||||
// BASE / HUB HANDOVER — the logistics next-leg surface
|
||||
//
|
||||
// Vocabulary note, because two words are in play for one thing: the wire says
|
||||
// hub (inward_at_hub, Inwarded_at_Hub, next_hub, pickup_source_type "hub") and
|
||||
// the rider app renders that as Base. Nothing here changes a wire value to suit
|
||||
// the app's wording, and nothing in the app's wording should leak back in here.
|
||||
// --------------------
|
||||
|
||||
// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at
|
||||
// Created — collected, in the rider's hands, on its way to a base — and only
|
||||
// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider
|
||||
// (POST /miler/consignments/:id/inward-at-hub) or by base staff (the console
|
||||
// inbound scan).
|
||||
//
|
||||
// Default OFF, and it must stay off until a rider-app build that calls the
|
||||
// handover endpoint is live. With it off, pickup-complete keeps marking a
|
||||
// hub-routed parcel Inwarded_at_Hub the instant it is collected — which is not
|
||||
// true of where the parcel physically is, but is what the current app and the
|
||||
// console's inbound views expect. Flipping it early would leave every intercity
|
||||
// parcel sitting on Created with no button in the rider's app to advance it and
|
||||
// no row in the base's inbound list.
|
||||
//
|
||||
// Read at request time (env MILER_HUB_HANDOVER_ENABLED=true) so it can be turned
|
||||
// on without a redeploy, same as MILER_COLLECTED_STATE_ENABLED. Everything else
|
||||
// in this file — next_hub, the handover endpoint itself, next_action on the
|
||||
// queue read, base master data, inbound visibility — is ungated and safe for the
|
||||
// current app.
|
||||
func hubHandoverEnabled() bool {
|
||||
return strings.EqualFold(os.Getenv("MILER_HUB_HANDOVER_ENABLED"), "true")
|
||||
}
|
||||
|
||||
// renderBase is the one shape a base is ever returned in, so pickup-complete,
|
||||
// the booking rows, the handover response and GET /miler/bases cannot drift
|
||||
// apart. All six fields every time: the id keys the handover, the name is the
|
||||
// heading the rider reads, address and pincode are what they read at the gate,
|
||||
// and the coordinates are the only thing that can drive Navigate. Five of six
|
||||
// still leaves a rider unable to get there.
|
||||
func renderBase(hub *models.Hub) fiber.Map {
|
||||
if hub == nil {
|
||||
return nil
|
||||
}
|
||||
return fiber.Map{
|
||||
"id": hub.Hubid,
|
||||
"name": hub.Hubname,
|
||||
"address": hub.Address,
|
||||
"pincode": hub.Pincode,
|
||||
"latitude": hub.Latitude,
|
||||
"longitude": hub.Longitude,
|
||||
}
|
||||
}
|
||||
|
||||
// loadHub reads one base by id, ignoring soft-deleted rows. Returns nil rather
|
||||
// than an error for a missing id so callers can treat "no base" and "unknown
|
||||
// base" the same way where that is the right call.
|
||||
func loadHub(hubID *int) *models.Hub {
|
||||
if hubID == nil || *hubID == 0 {
|
||||
return nil
|
||||
}
|
||||
var hub models.Hub
|
||||
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &hub
|
||||
}
|
||||
|
||||
// nearestActiveHub finds the closest active base to a point. Used only as a last
|
||||
// resort when neither the booking nor the rider names one — a parcel with
|
||||
// nowhere to go is worse than a parcel sent to the nearest gate. Returns nil
|
||||
// when no active base has usable coordinates.
|
||||
func nearestActiveHub(lat, lon float64) *models.Hub {
|
||||
if lat == 0 && lon == 0 {
|
||||
return nil
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Where("deletedat IS NULL AND status = ?", "Active").Find(&hubs).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var best *models.Hub
|
||||
bestKM := 0.0
|
||||
for i := range hubs {
|
||||
h := &hubs[i]
|
||||
if h.Latitude == 0 && h.Longitude == 0 {
|
||||
continue
|
||||
}
|
||||
d := haversineKM(lat, lon, h.Latitude, h.Longitude)
|
||||
if best == nil || d < bestKM {
|
||||
best, bestKM = h, d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// resolveHandoverHub decides which base a hub-routed parcel is carried to. The
|
||||
// decision is the backend's, never the app's — the app is told where to go and
|
||||
// navigates there.
|
||||
//
|
||||
// Order, most authoritative first:
|
||||
// 1. the base the booking was routed to (nearesthubid), when the console or the
|
||||
// dispatch layer set one. Nothing populates this column today; it is checked
|
||||
// first so that the moment something does, it wins without another change here.
|
||||
// 2. the collecting rider's own base — the operational default: a rider brings
|
||||
// the parcel back to where they work out of.
|
||||
// 3. the active base nearest the pickup point, for a rider with no base set.
|
||||
// 4. any base at all, so a parcel is never left with nowhere to go.
|
||||
func resolveHandoverHub(booking *models.PickupBooking, riderHubID *int) *models.Hub {
|
||||
if hub := loadHub(booking.Nearesthubid); hub != nil {
|
||||
return warnIfUnnavigable(hub)
|
||||
}
|
||||
if hub := loadHub(riderHubID); hub != nil {
|
||||
return warnIfUnnavigable(hub)
|
||||
}
|
||||
if hub := nearestActiveHub(booking.Pickuplatitude, booking.Pickuplongitude); hub != nil {
|
||||
return hub
|
||||
}
|
||||
var hub models.Hub
|
||||
if db.DB.Where("deletedat IS NULL").Order("hubid").First(&hub).Error == nil {
|
||||
return warnIfUnnavigable(&hub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnIfUnnavigable flags a base the rider cannot actually be routed to. The
|
||||
// correct base is still returned — sending a rider to a different base because
|
||||
// this one has bad master data would be worse than sending them to the right one
|
||||
// with a missing pin. It is a data problem, and it needs to be visible as one.
|
||||
func warnIfUnnavigable(hub *models.Hub) *models.Hub {
|
||||
if hub.Latitude == 0 && hub.Longitude == 0 {
|
||||
utils.Warn("base has no coordinates — Navigate will not work for riders sent here",
|
||||
"hubid", hub.Hubid, "hubname", hub.Hubname)
|
||||
}
|
||||
if strings.TrimSpace(hub.Address) == "" {
|
||||
utils.Warn("base has no address — the rider has nothing to read at the gate",
|
||||
"hubid", hub.Hubid, "hubname", hub.Hubname)
|
||||
}
|
||||
return hub
|
||||
}
|
||||
|
||||
// derivePickupSourceType classifies where a booking is collected from for rows
|
||||
// written before pickupsourcetype existed, and as a safety net for any writer
|
||||
// that forgets to set it. A stored value always wins — this only fills a blank.
|
||||
//
|
||||
// A base-origin booking names a base; a client-site pickup names a tenant
|
||||
// location (a kitchen, branch or depot — "merchant"); everything else is a
|
||||
// person's door. "customer" is the honest answer for the last case and is
|
||||
// returned as a value, never as an omission.
|
||||
func derivePickupSourceType(b *models.PickupBooking) string {
|
||||
if b.Pickupsourcetype != "" {
|
||||
return b.Pickupsourcetype
|
||||
}
|
||||
if b.Pickuphubid != nil {
|
||||
return constants.PickupSourceHub
|
||||
}
|
||||
if b.Tenantlocationid != nil {
|
||||
return constants.PickupSourceMerchant
|
||||
}
|
||||
return constants.PickupSourceCustomer
|
||||
}
|
||||
|
||||
// pickupSource resolves the source-type, id, name and address the rider app puts
|
||||
// at the top of a pickup stop. customerName is the booking's customer, used for
|
||||
// the door-pickup case so a collection at a house is titled with the sender's
|
||||
// name rather than the rider's own base name.
|
||||
//
|
||||
// sourceID is nil for a customer pickup — there is no configured location and
|
||||
// inventing one would be a lie. That is precisely why pickup_source_type is
|
||||
// carried on the row: the app can then tell "no id because it is a front door"
|
||||
// from "no id because nobody filled it in".
|
||||
func pickupSource(b *models.PickupBooking, customerName string) (sourceType string, sourceID *int, name, address string) {
|
||||
sourceType = derivePickupSourceType(b)
|
||||
address = b.Pickupaddress
|
||||
|
||||
switch sourceType {
|
||||
case constants.PickupSourceHub:
|
||||
sourceID = b.Pickuphubid
|
||||
if hub := loadHub(b.Pickuphubid); hub != nil {
|
||||
name = hub.Hubname
|
||||
if address == "" {
|
||||
address = hub.Address
|
||||
}
|
||||
}
|
||||
case constants.PickupSourceMerchant, constants.PickupSourceStore:
|
||||
sourceID = b.Tenantlocationid
|
||||
if b.Tenantlocationid != nil {
|
||||
var loc models.TenantLocation
|
||||
if db.DB.Where("tenantlocationid = ?", *b.Tenantlocationid).First(&loc).Error == nil {
|
||||
name = loc.Locationname
|
||||
if address == "" {
|
||||
address = loc.Address
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Customer door: the sender's own name and the address on the booking.
|
||||
name = strings.TrimSpace(customerName)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(b.Providerlocation)
|
||||
}
|
||||
return sourceType, sourceID, name, address
|
||||
}
|
||||
|
||||
// nextActionForConsignment maps a consignment's state to what the rider does
|
||||
// next with it. This is the single definition — pickup-complete and the queue
|
||||
// read both call it, so a poll can never disagree with the answer the pivot
|
||||
// gave. Anything terminal returns "none" so a finished parcel retires from the
|
||||
// rider's screen instead of lingering.
|
||||
func nextActionForConsignment(status string) string {
|
||||
switch status {
|
||||
case constants.ConsignmentCreated:
|
||||
// Collected and still in the rider's hands, routed to a base: carry it
|
||||
// there and hand it over. Under the compatibility flow a hub-routed
|
||||
// parcel never sits here — it is already Inwarded_at_Hub.
|
||||
return constants.NextActionInwardAtHub
|
||||
case constants.ConsignmentCollectedByMiler:
|
||||
return constants.NextActionStartDelivery
|
||||
case constants.ConsignmentOutForDelivery:
|
||||
return constants.NextActionDeliver
|
||||
case constants.ConsignmentInwardedAtHub:
|
||||
return constants.NextActionHandedToHub
|
||||
default:
|
||||
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
|
||||
// Damaged — all past this rider's leg.
|
||||
return constants.NextActionNone
|
||||
}
|
||||
}
|
||||
|
||||
// nextHubForConsignment names the base a parcel is on its way to, for a
|
||||
// consignment still in a rider's hands. A parcel that has already been inwarded
|
||||
// has no next base — it is at one.
|
||||
func nextHubForConsignment(cn *models.Consignment) fiber.Map {
|
||||
if cn == nil || cn.Status != constants.ConsignmentCreated {
|
||||
return nil
|
||||
}
|
||||
return renderBase(loadHub(cn.Currenthubid))
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// GET /miler/bases — base master data on a rider token
|
||||
//
|
||||
// The rider app could previously only see GET /admin/tenants/:id/locations,
|
||||
// which is a different dataset entirely (a client's own sites) and is closed to
|
||||
// a miler token anyway: /admin/* requires roles 1/3/4 and a rider is role 5, so
|
||||
// that route answers 401 for them by design, not by oversight.
|
||||
// --------------------
|
||||
|
||||
func MilerGetBases(c *fiber.Ctx) error {
|
||||
query := db.DB.Where("deletedat IS NULL")
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
} else {
|
||||
query = query.Where("status = ?", "Active")
|
||||
}
|
||||
if appLocationID := c.Query("applocationid"); appLocationID != "" {
|
||||
query = query.Where("applocationid = ?", appLocationID)
|
||||
}
|
||||
|
||||
var hubs []models.Hub
|
||||
if err := query.Find(&hubs).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch bases")
|
||||
}
|
||||
|
||||
// Ordered nearest-first from wherever the rider last reported being, so the
|
||||
// base they are most likely to want is at the top. Falls back to id order
|
||||
// when the rider has no position yet.
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
var profile models.MilerProfile
|
||||
hasPos := db.DB.Where("userid = ?", milerUserID).First(&profile).Error == nil &&
|
||||
(profile.Currentlatitude != 0 || profile.Currentlongitude != 0)
|
||||
|
||||
rows := make([]fiber.Map, 0, len(hubs))
|
||||
for i := range hubs {
|
||||
row := renderBase(&hubs[i])
|
||||
if hasPos && (hubs[i].Latitude != 0 || hubs[i].Longitude != 0) {
|
||||
row["distance_km"] = haversineKM(profile.Currentlatitude, profile.Currentlongitude,
|
||||
hubs[i].Latitude, hubs[i].Longitude)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if hasPos {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
di, oki := rows[i]["distance_km"].(float64)
|
||||
dj, okj := rows[j]["distance_km"].(float64)
|
||||
switch {
|
||||
case oki && okj:
|
||||
return di < dj
|
||||
case oki:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return utils.List(c, rows, int64(len(rows)))
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// POST /miler/consignments/:id/inward-at-hub — the rider handover
|
||||
//
|
||||
// The authoritative record that a rider physically handed a parcel in at a base.
|
||||
// Idempotent (retries at a loading bay with bad signal are normal, and the route
|
||||
// also carries the shared Idempotency-Key middleware), and it answers with the
|
||||
// resulting state rather than a bare 200 — every lifecycle transition the app
|
||||
// makes is checked against the state that comes back.
|
||||
// --------------------
|
||||
|
||||
func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
consignmentID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
HubID *int `json:"hub_id"`
|
||||
// hubid accepted as an alias: the same value has been spelled both ways
|
||||
// across this API's history and a handover is not worth failing over a
|
||||
// missing underscore.
|
||||
HubIDAlt *int `json:"hubid"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
}
|
||||
// A body is optional — a rider handing a parcel into the base it is already
|
||||
// routed to needs to send nothing at all.
|
||||
_ = c.BodyParser(&req)
|
||||
|
||||
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
|
||||
if err != nil {
|
||||
if code == constants.ErrConsignmentNotFound {
|
||||
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
|
||||
}
|
||||
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
|
||||
}
|
||||
|
||||
hubID := req.HubID
|
||||
if hubID == nil {
|
||||
hubID = req.HubIDAlt
|
||||
}
|
||||
if hubID == nil {
|
||||
// Nothing named: hand it into the base it was routed to.
|
||||
hubID = consignment.Currenthubid
|
||||
}
|
||||
if hubID == nil {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrHubRequired,
|
||||
"hub_id is required — this consignment is not routed to a base")
|
||||
}
|
||||
hub := loadHub(hubID)
|
||||
if hub == nil {
|
||||
return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base")
|
||||
}
|
||||
|
||||
// Already inwarded: answer with the state that stands rather than failing, so
|
||||
// a retry after a dropped response confirms rather than errors. This is also
|
||||
// what a rider on the compatibility flow hits every time — there,
|
||||
// pickup-complete already marked the parcel Inwarded_at_Hub.
|
||||
if consignment.Status == constants.ConsignmentInwardedAtHub {
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"hub": renderBase(loadHub(consignment.Currenthubid)),
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"already_inwarded": true,
|
||||
})
|
||||
}
|
||||
|
||||
// Only a parcel actually in this rider's hands can be handed over. A parcel
|
||||
// already out for delivery has to be delivered or skipped; a delivered or
|
||||
// returned one is past this leg entirely.
|
||||
if consignment.Status != constants.ConsignmentCreated &&
|
||||
consignment.Status != constants.ConsignmentCollectedByMiler {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
||||
fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status))
|
||||
}
|
||||
|
||||
lat, lon := 0.0, 0.0
|
||||
if req.Latitude != nil {
|
||||
lat = *req.Latitude
|
||||
} else if req.Lat != nil {
|
||||
lat = *req.Lat
|
||||
}
|
||||
if req.Longitude != nil {
|
||||
lon = *req.Longitude
|
||||
} else if req.Lon != nil {
|
||||
lon = *req.Lon
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tx := db.DB.Begin()
|
||||
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hub.Hubid
|
||||
if consignment.Originhubid == nil {
|
||||
consignment.Originhubid = &hub.Hubid
|
||||
}
|
||||
consignment.Inwardedat = &now
|
||||
consignment.Updatedat = now
|
||||
consignment.Updatedby = milerUserID
|
||||
if err := tx.Save(consignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record the handover")
|
||||
}
|
||||
|
||||
history := models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hub.Hubid,
|
||||
Userid: &milerUserID,
|
||||
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||
Remarks: fmt.Sprintf("Rider handed parcel in at %s (%.5f, %.5f)",
|
||||
hub.Hubname, lat, lon),
|
||||
}
|
||||
if err := tx.Create(&history).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record handover history")
|
||||
}
|
||||
|
||||
// The rider's leg ends here, so the assignment closes and they return to the
|
||||
// pool. riderkms is the distance actually ridden on this leg — pickup point to
|
||||
// the base gate — and ridercharges the order amount, both written the same way
|
||||
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
|
||||
// intercity rider's every job reported zero distance and zero value.
|
||||
var booking models.PickupBooking
|
||||
if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
|
||||
dropLat, dropLon := lat, lon
|
||||
if dropLat == 0 && dropLon == 0 {
|
||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||
}
|
||||
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||
|
||||
orderAmount := 0.0
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
|
||||
First(&serviceOpt).Error == nil {
|
||||
orderAmount = serviceOpt.Estimatedprice
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
booking.Bookingid, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
"riderkms": riderKms,
|
||||
"ridercharges": orderAmount,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to record the handover")
|
||||
}
|
||||
|
||||
// Best-effort, on an already-bound subject — a dropped event must never fail
|
||||
// a handover the rider has physically completed.
|
||||
if db.Js != nil {
|
||||
payload := map[string]interface{}{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"status": constants.ConsignmentInwardedAtHub,
|
||||
"hubid": hub.Hubid,
|
||||
"mileruserid": milerUserID,
|
||||
"inwardedat": now.UnixMilli(),
|
||||
}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
if _, err := db.Js.Publish("booking.status.updated", data); err != nil {
|
||||
utils.Warn("MilerInwardConsignmentAtHub: NATS publish failed",
|
||||
"consignment_id", consignment.Consignmentid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"hub": renderBase(hub),
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"already_inwarded": false,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user