package playground import ( "context" "encoding/json" "errors" "fmt" "math" "strings" "time" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) // The read tools the backend serves itself. Every other read tool in the // registry runs inside AI_engine (decide_*), calls an external service // (sequence_stops, simulate_pricing_quote) or an engine-only internal route // (list_express_*), and answers "unavailable" in the playground. // // Each executor selects named, non-personal columns only — never addresses, // names, phones or notes — and Redact runs over the result as a backstop. // Coordinates are rounded to 2 decimals (about 1 km). const ( scanDefaultLimit = 20 scanMaxLimit = 50 nearbyMaxKm = 10.0 nearbyMaxCount = 20 ) // bookingRow is the only projection of a booking the playground exposes. type bookingRow struct { Bookingid int `json:"bookingid"` Bookingno string `json:"bookingno"` Tenantid *int `json:"tenantid"` Status string `json:"status"` Pickuppincode string `json:"pickuppincode"` Deliverypincode string `json:"deliverypincode"` Deliverycity string `json:"deliverycity"` Pickuplatitude float64 `json:"pickuplat"` Pickuplongitude float64 `json:"pickuplon"` Deliverylatitude float64 `json:"deliverylat"` Deliverylongitude float64 `json:"deliverylon"` Assignedmileruserid *int `json:"assignedmileruserid"` Routekm *float64 `json:"routekm"` Createdat *time.Time `json:"-"` Createdatist string `json:"createdat_ist,omitempty" gorm:"-"` } const bookingColumns = "bookingid, bookingno, tenantid, status, pickuppincode, deliverypincode, deliverycity, " + "pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, assignedmileruserid, routekm, createdat" func round2(f float64) float64 { return math.Round(f*100) / 100 } func (r *bookingRow) tidy() { r.Pickuplatitude, r.Pickuplongitude = round2(r.Pickuplatitude), round2(r.Pickuplongitude) r.Deliverylatitude, r.Deliverylongitude = round2(r.Deliverylatitude), round2(r.Deliverylongitude) if r.Createdat != nil { // pickupbookings.createdat is timestamp WITHOUT zone holding IST digits. r.Createdatist = r.Createdat.Format("2006-01-02 15:04") } } // Executors returns the read tools this backend can serve. A nil db or rdb // just leaves the tools that need it out (they then answer "unavailable"). func Executors(db *gorm.DB, rdb *redis.Client) map[string]Executor { execs := map[string]Executor{} if db != nil { execs["get_booking_cache"] = getBooking(db) execs["scan_bookings"] = scanBookings(db) } if rdb != nil { execs["nearby_milers"] = nearbyMilers(rdb) } return execs } func decode(input json.RawMessage, into any) error { if len(input) == 0 { return nil } if err := json.Unmarshal(input, into); err != nil { return fmt.Errorf("invalid input: %v", err) } return nil } func getBooking(db *gorm.DB) Executor { return func(ctx context.Context, input json.RawMessage) (any, error) { var in struct { BookingID int `json:"booking_id"` } if err := decode(input, &in); err != nil { return nil, err } if in.BookingID <= 0 { return nil, errors.New("booking_id (a positive integer) is required") } var row bookingRow res := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns). Where("bookingid = ?", in.BookingID).Limit(1).Scan(&row) if res.Error != nil { return nil, errors.New("booking lookup failed") } if res.RowsAffected == 0 { return map[string]any{"found": false, "booking_id": in.BookingID}, nil } row.tidy() return map[string]any{"found": true, "booking": row, "source": "pickupbookings table"}, nil } } func scanBookings(db *gorm.DB) Executor { return func(ctx context.Context, input json.RawMessage) (any, error) { var in struct { Status string `json:"status"` Limit int `json:"limit"` } if err := decode(input, &in); err != nil { return nil, err } if in.Limit <= 0 { in.Limit = scanDefaultLimit } if in.Limit > scanMaxLimit { in.Limit = scanMaxLimit } q := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns) if s := strings.TrimSpace(in.Status); s != "" { q = q.Where("status = ?", s) } var rows []bookingRow if err := q.Order("bookingid DESC").Limit(in.Limit).Scan(&rows).Error; err != nil { return nil, errors.New("booking scan failed") } byStatus := map[string]int{} for i := range rows { rows[i].tidy() byStatus[rows[i].Status]++ } return map[string]any{"count": len(rows), "bystatus": byStatus, "bookings": rows, "order": "newest first"}, nil } } func nearbyMilers(rdb *redis.Client) Executor { return func(ctx context.Context, input json.RawMessage) (any, error) { var in struct { Lat *float64 `json:"lat"` Lon *float64 `json:"lon"` RadiusKm float64 `json:"radius_km"` } if err := decode(input, &in); err != nil { return nil, err } if in.Lat == nil || in.Lon == nil || math.Abs(*in.Lat) > 90 || math.Abs(*in.Lon) > 180 { return nil, errors.New("lat and lon (decimal degrees) are required") } if in.RadiusKm <= 0 || in.RadiusKm > nearbyMaxKm { in.RadiusKm = 5 } locs, err := rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{ GeoSearchQuery: redis.GeoSearchQuery{ Longitude: *in.Lon, Latitude: *in.Lat, Radius: in.RadiusKm, RadiusUnit: "km", Sort: "ASC", Count: nearbyMaxCount, }, WithDist: true, }).Result() if err != nil { return nil, errors.New("live rider positions are unavailable") } riders := make([]map[string]any, 0, len(locs)) for _, l := range locs { riders = append(riders, map[string]any{"miler": l.Name, "distancekm": math.Round(l.Dist*100) / 100}) } return map[string]any{"count": len(riders), "radiuskm": in.RadiusKm, "riders": riders}, nil } }