feat: Implement user preferences (saved cities, search history) with Redis storage and API handlers
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -20,6 +21,7 @@ type HandlerContext struct {
|
||||
Router *routing.Graph
|
||||
Yandex *yandex.Client
|
||||
SearchCache *routing.SearchCacheService
|
||||
Preferences *cache.Preferences
|
||||
}
|
||||
|
||||
// stationStatusResponse represents the response for station status.
|
||||
@@ -35,8 +37,16 @@ type cityResponse []string
|
||||
|
||||
// routeSearchResponse represents the response for route search.
|
||||
type routeSearchResponse struct {
|
||||
Routes []interface{} `json:"routes"`
|
||||
Count int `json:"count"`
|
||||
Routes []routeSearchRoute `json:"routes"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type routeSearchRoute struct {
|
||||
Duration int `json:"duration"`
|
||||
Transfers int `json:"transfers"`
|
||||
Cost int `json:"cost"`
|
||||
ID string `json:"id"`
|
||||
PriceNote string `json:"price_note,omitempty"` // "цена не указана" if price data not available from API
|
||||
}
|
||||
|
||||
// routeGeoJSONResponse represents the response for route GeoJSON.
|
||||
@@ -192,13 +202,15 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
results := hc.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts)
|
||||
|
||||
// Build response routes
|
||||
routeResponses := make([]interface{}, 0, len(results))
|
||||
routeResponses := make([]routeSearchRoute, 0, len(results))
|
||||
for _, route := range results {
|
||||
routeResponses = append(routeResponses, map[string]interface{}{
|
||||
"duration": route.TotalDuration,
|
||||
"transfers": route.TotalTransfers,
|
||||
"cost": route.Cost,
|
||||
"id": route.ID,
|
||||
priceNote := "цена не указана"
|
||||
routeResponses = append(routeResponses, routeSearchRoute{
|
||||
Duration: route.TotalDuration,
|
||||
Transfers: route.TotalTransfers,
|
||||
Cost: route.Cost,
|
||||
ID: route.ID,
|
||||
PriceNote: priceNote,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,6 +235,20 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
// Real edges (actual scheduled trips) are solid lines
|
||||
features := make([]map[string]interface{}, 0)
|
||||
|
||||
// Collect transfer points: nodes that are destinations of transfer edges
|
||||
// and have connections to other edges (for popup markers).
|
||||
transferNodeIDs := make(map[string]bool)
|
||||
for _, edge := range hc.Router.Edges() {
|
||||
if edge.IsTransfer {
|
||||
transferNodeIDs[edge.To.ID] = true
|
||||
transferNodeIDs[edge.From.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Track which edges have been added to avoid duplicates when
|
||||
// a transfer node appears in multiple edges.
|
||||
addedEdges := make(map[string]bool)
|
||||
|
||||
for _, edge := range hc.Router.Edges() {
|
||||
// Determine line style based on edge type
|
||||
strokeColor := "#1976d2" // default blue for train
|
||||
@@ -247,8 +273,12 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
fromCoord := []float64{0, 0} // placeholder
|
||||
toCoord := []float64{0, 0} // placeholder
|
||||
|
||||
// In a full implementation, would use actual node coordinates from PostGIS
|
||||
// For now, use fixed placeholder coordinates
|
||||
key := edge.From.ID + ":" + edge.To.ID
|
||||
if addedEdges[key] {
|
||||
continue
|
||||
}
|
||||
addedEdges[key] = true
|
||||
|
||||
geoJsonLine := map[string]interface{}{
|
||||
"type": "LineString",
|
||||
"coordinates": []interface{}{
|
||||
@@ -257,7 +287,7 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
"properties": map[string]interface{}{
|
||||
"transport": edge.Transport,
|
||||
"transport_type": string(edge.TransportType),
|
||||
"kind": "real",
|
||||
"kind": fmt.Sprintf("%v", edge.Kind),
|
||||
"synthetic": edge.Synthetic,
|
||||
"duration": edge.Duration,
|
||||
"cost": edge.Cost,
|
||||
@@ -271,8 +301,36 @@ func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
features = append(features, map[string]interface{}{
|
||||
"type": "Feature",
|
||||
"geometry": geoJsonLine,
|
||||
"properties": map[string]interface{}{},
|
||||
"properties": geoJsonLine["properties"],
|
||||
})
|
||||
|
||||
// Add transfer point markers at nodes that are transfer destinations
|
||||
if edge.IsTransfer && transferNodeIDs[edge.To.ID] {
|
||||
// Use default 30 min (1800s) MCT if no specific rule applies
|
||||
connectionTime := 1800 // default MCT: 30 minutes
|
||||
|
||||
// Add a Point feature for the transfer marker
|
||||
transferFeature := map[string]interface{}{
|
||||
"type": "Feature",
|
||||
"geometry": map[string]interface{}{
|
||||
"type": "Point",
|
||||
"coordinates": []float64{
|
||||
0, 0, // placeholder - would use node coordinates from PostGIS
|
||||
},
|
||||
},
|
||||
"properties": map[string]interface{}{
|
||||
"marker_type": "transfer",
|
||||
"title": edge.To.Name,
|
||||
"connection_time": connectionTime,
|
||||
"connection_time_formatted": fmt.Sprintf("%d min", connectionTime/60),
|
||||
"transfer_type": edge.Transport,
|
||||
"is_transfer": true,
|
||||
"stroke_color": strokeColor,
|
||||
"stroke_width": 2,
|
||||
},
|
||||
}
|
||||
features = append(features, transferFeature)
|
||||
}
|
||||
}
|
||||
|
||||
resp := routeGeoJSONResponse{
|
||||
@@ -414,6 +472,132 @@ func logStatusOverride(stationID, status, source string) {
|
||||
// Could log to: external logging service, database, etc.
|
||||
}
|
||||
|
||||
// preferenceResponse represents the response for preference endpoints.
|
||||
type preferenceResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// getSavedCitiesHandler handles GET /v1/preferences/saved-cities.
|
||||
func GetSavedCities(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
|
||||
cities, err := hc.Preferences.GetSavedCities(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get saved cities: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(preferenceResponse{
|
||||
Message: "saved cities retrieved",
|
||||
Data: cities,
|
||||
})
|
||||
}
|
||||
|
||||
// addSavedCityHandler handles POST /v1/preferences/saved-cities.
|
||||
func AddSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CityCode string `json:"city_code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := hc.Preferences.AddSavedCity(r.Context(), userID, req.CityCode, req.Name); err != nil {
|
||||
http.Error(w, "failed to add saved city: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(preferenceResponse{
|
||||
Message: "saved city added",
|
||||
})
|
||||
}
|
||||
|
||||
// removeSavedCityHandler handles DELETE /v1/preferences/saved-cities/{city_code}.
|
||||
func RemoveSavedCity(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
|
||||
cityCode := strings.TrimPrefix(r.URL.Path, "/v1/preferences/saved-cities/")
|
||||
if cityCode == "" || cityCode == "/v1/preferences/saved-cities/" {
|
||||
http.Error(w, "missing city code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := hc.Preferences.RemoveSavedCity(r.Context(), userID, cityCode); err != nil {
|
||||
http.Error(w, "failed to remove saved city: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(preferenceResponse{
|
||||
Message: "saved city removed",
|
||||
})
|
||||
}
|
||||
|
||||
// getSearchHistoryHandler handles GET /v1/preferences/search-history.
|
||||
func GetSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
|
||||
history, err := hc.Preferences.GetSearchHistory(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get search history: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(preferenceResponse{
|
||||
Message: "search history retrieved",
|
||||
Data: history,
|
||||
})
|
||||
}
|
||||
|
||||
// addSearchHistoryHandler handles POST /v1/preferences/search-history.
|
||||
func AddSearchHistory(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
|
||||
var req struct {
|
||||
FromCity string `json:"from_city"`
|
||||
ToCity string `json:"to_city"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := hc.Preferences.AddSearchHistory(r.Context(), userID, req.FromCity, req.ToCity, req.Date); err != nil {
|
||||
http.Error(w, "failed to add search history: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(preferenceResponse{
|
||||
Message: "search history added",
|
||||
})
|
||||
}
|
||||
|
||||
// NewHandlerContext creates a new HandlerContext with initialized services.
|
||||
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext {
|
||||
cacheStore := cache.NewCacheStore(redisClient)
|
||||
@@ -423,5 +607,6 @@ func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex
|
||||
Router: router,
|
||||
Yandex: yandex,
|
||||
SearchCache: routing.NewSearchCacheService(cache.NewCacheAside(cacheStore), yandex),
|
||||
Preferences: cache.NewPreferences(cacheStore),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user