From 52de8d363e5ad7c2b21a7222c5a467131c9e8902 Mon Sep 17 00:00:00 2001 From: Arnaud Delcasse Date: Mon, 3 Nov 2025 11:45:23 +0100 Subject: [PATCH] Add MCP server --- config.go | 30 +- core/application/beneficiaries.go | 2 + core/application/journeys.go | 313 +++++++++++------- core/application/solidarity-transport.go | 88 ++++- core/utils/geo/geo.go | 37 +-- go.mod | 7 +- go.sum | 49 +-- main.go | 10 + renderer/solidarity-transport.go | 10 +- renderer/xlsx/solidarity-transport.go | 12 + servers/mcp/README.md | 221 +++++++++++++ servers/mcp/journey_tool.go | 158 +++++++++ servers/mcp/mcp.go | 109 ++++++ servers/web/api/geo.go | 5 +- servers/web/api/handler.go | 7 +- .../web/app_solidarity_transport_routes.go | 1 + servers/web/application/journeys.go | 4 + .../web/application/solidarity_transport.go | 144 +++++++- 18 files changed, 997 insertions(+), 210 deletions(-) create mode 100644 servers/mcp/README.md create mode 100644 servers/mcp/journey_tool.go create mode 100644 servers/mcp/mcp.go diff --git a/config.go b/config.go index de19421..ffc68e3 100755 --- a/config.go +++ b/config.go @@ -20,7 +20,7 @@ func ReadConfig() (*viper.Viper, error) { "listen": "0.0.0.0:8080", }, "mcp": map[string]any{ - "enabled": false, + "enabled": true, "listen": "0.0.0.0:8081", }, }, @@ -123,6 +123,26 @@ func ReadConfig() (*viper.Viper, error) { "journeys": map[string]any{ "enabled": true, "search_view": "tabs", + "solutions": map[string]any{ + "solidarity_transport": map[string]any{ + "enabled": true, + }, + "organized_carpool": map[string]any{ + "enabled": true, + }, + "carpool_operators": map[string]any{ + "enabled": true, + }, + "transit": map[string]any{ + "enabled": true, + }, + "fleet_vehicles": map[string]any{ + "enabled": true, + }, + "knowledge_base": map[string]any{ + "enabled": true, + }, + }, }, "solidarity_transport": map[string]any{ "enabled": true, @@ -226,8 +246,14 @@ func ReadConfig() (*viper.Viper, error) { }, }, "geo": map[string]any{ + "type": "addok", // Options: "pelias", "addok" "pelias": map[string]any{ - "url": "https://geocode.ridygo.fr", + "url": "https://geocode.ridygo.fr", + "autocomplete": "/autocomplete?text=", + }, + "addok": map[string]any{ + "url": "https://api-adresse.data.gouv.fr", + "autocomplete": "/search/?q=", }, }, "geography": map[string]any{ diff --git a/core/application/beneficiaries.go b/core/application/beneficiaries.go index ce687fe..2a81358 100644 --- a/core/application/beneficiaries.go +++ b/core/application/beneficiaries.go @@ -282,6 +282,8 @@ func (h *ApplicationHandler) GetBeneficiaryData(ctx context.Context, beneficiary solidarityTransportBookings = append(solidarityTransportBookings, booking) } + // Don't filter out replaced bookings from beneficiary profile - show all bookings + // Collect unique driver IDs driverIDs := []string{} driverIDsMap := make(map[string]bool) diff --git a/core/application/journeys.go b/core/application/journeys.go index 04655dd..cc99e7e 100755 --- a/core/application/journeys.go +++ b/core/application/journeys.go @@ -38,6 +38,16 @@ type SearchJourneysResult struct { KnowledgeBaseResults []any } +// SearchJourneyOptions contains per-request options for journey search +type SearchJourneyOptions struct { + DisableSolidarityTransport bool + DisableOrganizedCarpool bool + DisableCarpoolOperators bool + DisableTransit bool + DisableFleetVehicles bool + DisableKnowledgeBase bool +} + // SearchJourneys performs the business logic for journey search func (h *ApplicationHandler) SearchJourneys( ctx context.Context, @@ -46,6 +56,8 @@ func (h *ApplicationHandler) SearchJourneys( destinationGeo *geojson.Feature, passengerID string, solidarityTransportExcludeDriver string, + solidarityExcludeGroupId string, + options *SearchJourneyOptions, ) (*SearchJourneysResult, error) { var ( // Results @@ -64,6 +76,19 @@ func (h *ApplicationHandler) SearchJourneys( if departureGeo != nil && destinationGeo != nil && !departureDateTime.IsZero() { searched = true + // Default options if not provided + if options == nil { + options = &SearchJourneyOptions{} + } + + // Check solution type configurations (global config AND per-request options) + solidarityTransportEnabled := h.config.GetBool("modules.journeys.solutions.solidarity_transport.enabled") && !options.DisableSolidarityTransport + organizedCarpoolEnabled := h.config.GetBool("modules.journeys.solutions.organized_carpool.enabled") && !options.DisableOrganizedCarpool + carpoolOperatorsEnabled := h.config.GetBool("modules.journeys.solutions.carpool_operators.enabled") && !options.DisableCarpoolOperators + transitEnabled := h.config.GetBool("modules.journeys.solutions.transit.enabled") && !options.DisableTransit + fleetVehiclesEnabled := h.config.GetBool("modules.journeys.solutions.fleet_vehicles.enabled") && !options.DisableFleetVehicles + knowledgeBaseEnabled := h.config.GetBool("modules.journeys.solutions.knowledge_base.enabled") && !options.DisableKnowledgeBase + // SOLIDARITY TRANSPORT var err error drivers, err = h.services.GetAccountsInNamespacesMap([]string{"solidarity_drivers", "organized_carpool_drivers"}) @@ -74,36 +99,58 @@ func (h *ApplicationHandler) SearchJourneys( protodep, _ := transformers.GeoJsonToProto(departureGeo) protodest, _ := transformers.GeoJsonToProto(destinationGeo) - log.Debug().Time("departure time", departureDateTime).Msg("calling driver journeys with ...") - - res, err := h.services.GRPC.SolidarityTransport.GetDriverJourneys(ctx, &gen.GetDriverJourneysRequest{ - Departure: protodep, - Arrival: protodest, - DepartureDate: timestamppb.New(departureDateTime), - }) - if err != nil { - log.Error().Err(err).Msg("error in grpc call to GetDriverJourneys") - } else { - solidarityTransportResults = slices.Collect(func(yield func(*gen.SolidarityTransportDriverJourney) bool) { - for _, dj := range res.DriverJourneys { - if a, ok := drivers[dj.DriverId].Data["archived"]; ok { - if archived, ok := a.(bool); ok { - if archived { - continue - } - } - } - if dj.DriverId == solidarityTransportExcludeDriver { - continue - } - if !yield(dj) { - return + // Get driver IDs to exclude based on group_id (drivers who already have bookings in this group) + excludedDriverIds := make(map[string]bool) + if solidarityExcludeGroupId != "" { + bookingsResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBookings(ctx, &gen.GetSolidarityTransportBookingsRequest{ + StartDate: timestamppb.New(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)), + EndDate: timestamppb.New(time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC)), + }) + if err == nil { + for _, booking := range bookingsResp.Bookings { + if booking.GroupId == solidarityExcludeGroupId { + excludedDriverIds[booking.DriverId] = true } } + } + } + + if solidarityTransportEnabled { + log.Debug().Time("departure time", departureDateTime).Msg("calling driver journeys with ...") + + res, err := h.services.GRPC.SolidarityTransport.GetDriverJourneys(ctx, &gen.GetDriverJourneysRequest{ + Departure: protodep, + Arrival: protodest, + DepartureDate: timestamppb.New(departureDateTime), }) - sort.Slice(solidarityTransportResults, func(i, j int) bool { - return solidarityTransportResults[i].DriverDistance < solidarityTransportResults[j].DriverDistance - }) + if err != nil { + log.Error().Err(err).Msg("error in grpc call to GetDriverJourneys") + } else { + solidarityTransportResults = slices.Collect(func(yield func(*gen.SolidarityTransportDriverJourney) bool) { + for _, dj := range res.DriverJourneys { + if a, ok := drivers[dj.DriverId].Data["archived"]; ok { + if archived, ok := a.(bool); ok { + if archived { + continue + } + } + } + if dj.DriverId == solidarityTransportExcludeDriver { + continue + } + // Skip drivers who already have bookings in the same group + if excludedDriverIds[dj.DriverId] { + continue + } + if !yield(dj) { + return + } + } + }) + sort.Slice(solidarityTransportResults, func(i, j int) bool { + return solidarityTransportResults[i].DriverDistance < solidarityTransportResults[j].DriverDistance + }) + } } // Get departure and destination addresses from properties @@ -119,124 +166,134 @@ func (h *ApplicationHandler) SearchJourneys( } } - radius := float64(5) // ORGANIZED CARPOOL - organizedCarpoolResultsRes, err := h.services.GRPC.CarpoolService.DriverJourneys(ctx, &carpoolproto.DriverJourneysRequest{ - DepartureLat: departureGeo.Point().Lat(), - DepartureLng: departureGeo.Point().Lon(), - ArrivalLat: destinationGeo.Point().Lat(), - ArrivalLng: destinationGeo.Point().Lon(), - DepartureDate: timestamppb.New(departureDateTime), - DepartureAddress: &departureAddress, - ArrivalAddress: &destinationAddress, - DepartureRadius: &radius, - ArrivalRadius: &radius, - }) - if err != nil { - log.Error().Err(err).Msg("error retrieving organized carpools") - } else { - organizedCarpoolResults = organizedCarpoolResultsRes.DriverJourneys - sort.Slice(organizedCarpoolResults, func(i, j int) bool { - return *organizedCarpoolResults[i].Distance < *organizedCarpoolResults[j].Distance + if organizedCarpoolEnabled { + radius := float64(5) + organizedCarpoolResultsRes, err := h.services.GRPC.CarpoolService.DriverJourneys(ctx, &carpoolproto.DriverJourneysRequest{ + DepartureLat: departureGeo.Point().Lat(), + DepartureLng: departureGeo.Point().Lon(), + ArrivalLat: destinationGeo.Point().Lat(), + ArrivalLng: destinationGeo.Point().Lon(), + DepartureDate: timestamppb.New(departureDateTime), + DepartureAddress: &departureAddress, + ArrivalAddress: &destinationAddress, + DepartureRadius: &radius, + ArrivalRadius: &radius, }) + if err != nil { + log.Error().Err(err).Msg("error retrieving organized carpools") + } else { + organizedCarpoolResults = organizedCarpoolResultsRes.DriverJourneys + sort.Slice(organizedCarpoolResults, func(i, j int) bool { + return *organizedCarpoolResults[i].Distance < *organizedCarpoolResults[j].Distance + }) + } } var wg sync.WaitGroup // CARPOOL OPERATORS - carpools := make(chan *geojson.FeatureCollection) - go h.services.InteropCarpool.Search(carpools, *departureGeo, *destinationGeo, departureDateTime) - wg.Add(1) - go func() { - defer wg.Done() - for c := range carpools { - carpoolResults = append(carpoolResults, c) - } - }() + if carpoolOperatorsEnabled { + carpools := make(chan *geojson.FeatureCollection) + go h.services.InteropCarpool.Search(carpools, *departureGeo, *destinationGeo, departureDateTime) + wg.Add(1) + go func() { + defer wg.Done() + for c := range carpools { + carpoolResults = append(carpoolResults, c) + } + }() + } // TRANSIT - transitch := make(chan *transitous.Itinerary) - go func(transitch chan *transitous.Itinerary, departure *geojson.Feature, destination *geojson.Feature, datetime *time.Time) { - defer close(transitch) - response, err := h.services.TransitRouting.PlanWithResponse(ctx, &transitous.PlanParams{ - FromPlace: fmt.Sprintf("%f,%f", departure.Point().Lat(), departure.Point().Lon()), - ToPlace: fmt.Sprintf("%f,%f", destination.Point().Lat(), destination.Point().Lon()), - Time: datetime, - }) - if err != nil { - log.Error().Err(err).Msg("error retrieving transit data from Transitous server") - return - } - for _, i := range response.Itineraries { - transitch <- &i - } - }(transitch, departureGeo, destinationGeo, &departureDateTime) - wg.Add(1) - go func() { - defer wg.Done() - paris, _ := time.LoadLocation("Europe/Paris") - requestedDay := departureDateTime.In(paris).Truncate(24 * time.Hour) + if transitEnabled { + transitch := make(chan *transitous.Itinerary) + go func(transitch chan *transitous.Itinerary, departure *geojson.Feature, destination *geojson.Feature, datetime *time.Time) { + defer close(transitch) + response, err := h.services.TransitRouting.PlanWithResponse(ctx, &transitous.PlanParams{ + FromPlace: fmt.Sprintf("%f,%f", departure.Point().Lat(), departure.Point().Lon()), + ToPlace: fmt.Sprintf("%f,%f", destination.Point().Lat(), destination.Point().Lon()), + Time: datetime, + }) + if err != nil { + log.Error().Err(err).Msg("error retrieving transit data from Transitous server") + return + } + for _, i := range response.Itineraries { + transitch <- &i + } + }(transitch, departureGeo, destinationGeo, &departureDateTime) + wg.Add(1) + go func() { + defer wg.Done() + paris, _ := time.LoadLocation("Europe/Paris") + requestedDay := departureDateTime.In(paris).Truncate(24 * time.Hour) - for itinerary := range transitch { - // Only include journeys that start on the requested day (in Paris timezone) - if !itinerary.StartTime.IsZero() && !itinerary.EndTime.IsZero() { - log.Info(). - Time("startTime", itinerary.StartTime). - Time("endTime", itinerary.EndTime). - Str("startTimezone", itinerary.StartTime.Location().String()). - Str("endTimezone", itinerary.EndTime.Location().String()). - Str("startTimeRFC3339", itinerary.StartTime.Format(time.RFC3339)). - Str("endTimeRFC3339", itinerary.EndTime.Format(time.RFC3339)). - Msg("Journey search - received transit itinerary from Transitous") - - startInParis := itinerary.StartTime.In(paris) - startDay := startInParis.Truncate(24 * time.Hour) - - // Check if journey starts on the requested day - if startDay.Equal(requestedDay) { - transitResults = append(transitResults, itinerary) - } else { + for itinerary := range transitch { + // Only include journeys that start on the requested day (in Paris timezone) + if !itinerary.StartTime.IsZero() && !itinerary.EndTime.IsZero() { log.Info(). - Str("requestedDay", requestedDay.Format("2006-01-02")). - Str("startDay", startDay.Format("2006-01-02")). - Msg("Journey search - filtered out transit journey (not on requested day)") + Time("startTime", itinerary.StartTime). + Time("endTime", itinerary.EndTime). + Str("startTimezone", itinerary.StartTime.Location().String()). + Str("endTimezone", itinerary.EndTime.Location().String()). + Str("startTimeRFC3339", itinerary.StartTime.Format(time.RFC3339)). + Str("endTimeRFC3339", itinerary.EndTime.Format(time.RFC3339)). + Msg("Journey search - received transit itinerary from Transitous") + + startInParis := itinerary.StartTime.In(paris) + startDay := startInParis.Truncate(24 * time.Hour) + + // Check if journey starts on the requested day + if startDay.Equal(requestedDay) { + transitResults = append(transitResults, itinerary) + } else { + log.Info(). + Str("requestedDay", requestedDay.Format("2006-01-02")). + Str("startDay", startDay.Format("2006-01-02")). + Msg("Journey search - filtered out transit journey (not on requested day)") + } } } - } - }() + }() + } // VEHICLES - vehiclech := make(chan fleetsstorage.Vehicle) - go h.vehicleRequest(vehiclech, departureDateTime.Add(-24*time.Hour), departureDateTime.Add(168*time.Hour)) - wg.Add(1) - go func() { - defer wg.Done() - for vehicle := range vehiclech { - vehicleResults = append(vehicleResults, vehicle) - } - slices.SortFunc(vehicleResults, sorting.VehiclesByDistanceFrom(*departureGeo)) - }() + if fleetVehiclesEnabled { + vehiclech := make(chan fleetsstorage.Vehicle) + go h.vehicleRequest(vehiclech, departureDateTime.Add(-24*time.Hour), departureDateTime.Add(168*time.Hour)) + wg.Add(1) + go func() { + defer wg.Done() + for vehicle := range vehiclech { + vehicleResults = append(vehicleResults, vehicle) + } + slices.SortFunc(vehicleResults, sorting.VehiclesByDistanceFrom(*departureGeo)) + }() + } wg.Wait() // KNOWLEDGE BASE - departureGeoSearch, _ := h.services.Geography.GeoSearch(departureGeo) - kbData := h.config.Get("knowledge_base") - if kb, ok := kbData.([]any); ok { - for _, sol := range kb { - if solution, ok := sol.(map[string]any); ok { - if g, ok := solution["geography"]; ok { - if geography, ok := g.([]any); ok { - for _, gg := range geography { - if geog, ok := gg.(map[string]any); ok { - if layer, ok := geog["layer"].(string); ok { - code := geog["code"] - geo, err := h.services.Geography.Find(layer, fmt.Sprintf("%v", code)) - if err == nil { - geog["geography"] = geo - geog["name"] = geo.Properties.MustString("nom") - } - if strings.Compare(fmt.Sprintf("%v", code), departureGeoSearch[layer].Properties.MustString("code")) == 0 { - knowledgeBaseResults = append(knowledgeBaseResults, solution) - break + if knowledgeBaseEnabled { + departureGeoSearch, _ := h.services.Geography.GeoSearch(departureGeo) + kbData := h.config.Get("knowledge_base") + if kb, ok := kbData.([]any); ok { + for _, sol := range kb { + if solution, ok := sol.(map[string]any); ok { + if g, ok := solution["geography"]; ok { + if geography, ok := g.([]any); ok { + for _, gg := range geography { + if geog, ok := gg.(map[string]any); ok { + if layer, ok := geog["layer"].(string); ok { + code := geog["code"] + geo, err := h.services.Geography.Find(layer, fmt.Sprintf("%v", code)) + if err == nil { + geog["geography"] = geo + geog["name"] = geo.Properties.MustString("nom") + } + if strings.Compare(fmt.Sprintf("%v", code), departureGeoSearch[layer].Properties.MustString("code")) == 0 { + knowledgeBaseResults = append(knowledgeBaseResults, solution) + break + } } } } diff --git a/core/application/solidarity-transport.go b/core/application/solidarity-transport.go index cef8576..5b3ac23 100644 --- a/core/application/solidarity-transport.go +++ b/core/application/solidarity-transport.go @@ -358,6 +358,18 @@ func (h *ApplicationHandler) GetSolidarityTransportOverview(ctx context.Context, transformedBookings = filterBookingsByGeography(transformedBookings, departurePolygons, destinationPolygons) transformedBookings = filterBookingsByPassengerAddressGeography(transformedBookings, beneficiariesMap, passengerAddressPolygons) + // Filter out replaced bookings from upcoming bookings list + filteredBookings := []*solidaritytypes.Booking{} + for _, booking := range transformedBookings { + if booking.Data != nil { + if _, hasReplacedBy := booking.Data["replaced_by"]; hasReplacedBy { + continue // Skip bookings that have been replaced + } + } + filteredBookings = append(filteredBookings, booking) + } + transformedBookings = filteredBookings + // Sort upcoming bookings by date (ascending - earliest first) sort.Slice(transformedBookings, func(i, j int) bool { if transformedBookings[i].Journey != nil && transformedBookings[j].Journey != nil { @@ -396,6 +408,8 @@ func (h *ApplicationHandler) GetSolidarityTransportOverview(ctx context.Context, transformedBookingsHistory = filterBookingsByGeography(transformedBookingsHistory, histDeparturePolygons, histDestinationPolygons) transformedBookingsHistory = filterBookingsByPassengerAddressGeography(transformedBookingsHistory, beneficiariesMap, histPassengerAddressPolygons) + // Don't filter out replaced bookings from history - we want to see them in history + // Sort history bookings by date (descending - most recent first) sort.Slice(transformedBookingsHistory, func(i, j int) bool { if transformedBookingsHistory[i].Journey != nil && transformedBookingsHistory[j].Journey != nil { @@ -503,6 +517,8 @@ func (h *ApplicationHandler) GetSolidarityTransportBookings(ctx context.Context, transformedBookings = filterBookingsByGeography(transformedBookings, departurePolygons, destinationPolygons) transformedBookings = filterBookingsByPassengerAddressGeography(transformedBookings, beneficiariesMap, passengerAddressPolygons) + // Don't filter out replaced bookings for exports - include all bookings + // Sort bookings by date sort.Slice(transformedBookings, func(i, j int) bool { if transformedBookings[i].Journey != nil && transformedBookings[j].Journey != nil { @@ -744,6 +760,8 @@ func (h *ApplicationHandler) GetSolidarityTransportDriverData(ctx context.Contex bookings = append(bookings, booking) } + // Don't filter out replaced bookings from driver profile - show all bookings + // Collect unique passenger IDs passengerIDs := []string{} passengerIDsMap := make(map[string]bool) @@ -1048,7 +1066,19 @@ func (h *ApplicationHandler) GetSolidarityTransportJourneyData(ctx context.Conte }, nil } -func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context.Context, driverID, journeyID, passengerID, motivation, message string, doNotSend bool, returnWaitingTimeMinutes int) (string, error) { +func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context.Context, driverID, journeyID, passengerID, motivation, message string, doNotSend bool, returnWaitingTimeMinutes int, replacesBookingID string) (string, error) { + // If this is a replacement booking, get the old booking's group_id + var groupID string + if replacesBookingID != "" { + oldBookingResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBooking(ctx, &gen.GetSolidarityTransportBookingRequest{ + Id: replacesBookingID, + }) + if err != nil { + return "", fmt.Errorf("could not get booking to replace: %w", err) + } + groupID = oldBookingResp.Booking.GroupId + } + // Get journey for pricing calculation journeyRequest := &gen.GetDriverJourneyRequest{ DriverId: driverID, @@ -1083,6 +1113,12 @@ func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context returnWaitingDuration := int64(returnWaitingTimeMinutes) * int64(time.Minute) // Create booking request + dataFields := map[string]*structpb.Value{ + "motivation": structpb.NewStringValue(motivation), + "message": structpb.NewStringValue(message), + "do_not_send": structpb.NewBoolValue(doNotSend), + } + bookingRequest := &gen.BookDriverJourneyRequest{ PassengerId: passengerID, DriverId: driverID, @@ -1093,19 +1129,45 @@ func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context DriverCompensationAmount: driverCompensation, DriverCompensationCurrency: "EUR", Data: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "motivation": structpb.NewStringValue(motivation), - "message": structpb.NewStringValue(message), - "do_not_send": structpb.NewBoolValue(doNotSend), - }, + Fields: dataFields, }, } + // Set group_id if this is a replacement booking + if groupID != "" { + bookingRequest.GroupId = &groupID + } + resp, err := h.services.GRPC.SolidarityTransport.BookDriverJourney(ctx, bookingRequest) if err != nil { return "", err } + // If this is a replacement booking, update the old booking to mark it as replaced + if replacesBookingID != "" { + oldBookingResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBooking(ctx, &gen.GetSolidarityTransportBookingRequest{ + Id: replacesBookingID, + }) + if err == nil { + oldBooking, err := solidaritytransformers.BookingProtoToType(oldBookingResp.Booking) + if err == nil && oldBooking != nil { + if oldBooking.Data == nil { + oldBooking.Data = make(map[string]any) + } + oldBooking.Data["replaced_by"] = resp.Booking.Id + + // Update the booking + oldBookingProto, _ := solidaritytransformers.BookingTypeToProto(oldBooking) + _, err = h.services.GRPC.SolidarityTransport.UpdateSolidarityTransportBooking(ctx, &gen.UpdateSolidarityTransportBookingRequest{ + Booking: oldBookingProto, + }) + if err != nil { + log.Error().Err(err).Str("old_booking_id", replacesBookingID).Str("new_booking_id", resp.Booking.Id).Msg("failed to mark old booking as replaced") + } + } + } + } + // Send SMS if not disabled if !doNotSend && message != "" { send_message := strings.ReplaceAll(message, "{booking_id}", resp.Booking.Id) @@ -1253,6 +1315,20 @@ func (h *ApplicationHandler) pricingGeography(loc *geojson.Feature) pricing.Geog } } +// CalculateSolidarityTransportPricing is the exported wrapper for calculateSolidarityTransportPricing +func (h *ApplicationHandler) CalculateSolidarityTransportPricing(ctx context.Context, journey *gen.SolidarityTransportDriverJourney, passengerID string) (map[string]pricing.Price, error) { + // Get passenger account + var passenger mobilityaccountsstorage.Account + if passengerID != "" { + passengerResp, err := h.services.GetAccount(passengerID) + if err != nil { + return nil, fmt.Errorf("could not get passenger account: %w", err) + } + passenger = passengerResp + } + return h.calculateSolidarityTransportPricing(ctx, journey, passengerID, passenger) +} + func (h *ApplicationHandler) calculateSolidarityTransportPricing(ctx context.Context, journey *gen.SolidarityTransportDriverJourney, passengerID string, passenger mobilityaccountsstorage.Account) (map[string]pricing.Price, error) { // Transform proto to type for geography access journeyType, err := solidaritytransformers.DriverJourneyProtoToType(journey) diff --git a/core/utils/geo/geo.go b/core/utils/geo/geo.go index eff43e5..922a1a1 100644 --- a/core/utils/geo/geo.go +++ b/core/utils/geo/geo.go @@ -1,28 +1,29 @@ package geo import ( - "encoding/json" - "fmt" "io" "net/http" + "github.com/paulmach/orb/geojson" "github.com/rs/zerolog/log" ) type GeoService struct { - peliasURL string + geoType string + baseURL string + autocompleteURL string } -func NewGeoService(peliasURL string) *GeoService { - return &GeoService{peliasURL: peliasURL} +func NewGeoService(geoType, baseURL, autocompleteEndpoint string) *GeoService { + return &GeoService{ + geoType: geoType, + baseURL: baseURL, + autocompleteURL: baseURL + autocompleteEndpoint, + } } -type AutocompleteResult struct { - Features []any -} - -func (s *GeoService) Autocomplete(text string) (*AutocompleteResult, error) { - resp, err := http.Get(fmt.Sprintf("%s/autocomplete?text=%s", s.peliasURL, text)) +func (s *GeoService) Autocomplete(text string) (*geojson.FeatureCollection, error) { + resp, err := http.Get(s.autocompleteURL + text) if err != nil { return nil, err } @@ -34,17 +35,11 @@ func (s *GeoService) Autocomplete(text string) (*AutocompleteResult, error) { return nil, err } - var response map[string]any - if err := json.Unmarshal(body, &response); err != nil { + featureCollection, err := geojson.UnmarshalFeatureCollection(body) + if err != nil { + log.Error().Err(err).Msg("Failed to unmarshal feature collection") return nil, err } - features, ok := response["features"].([]any) - if !ok { - features = []any{} - } - - return &AutocompleteResult{ - Features: features, - }, nil + return featureCollection, nil } \ No newline at end of file diff --git a/go.mod b/go.mod index 1bf7780..4f0f8ce 100755 --- a/go.mod +++ b/go.mod @@ -57,12 +57,13 @@ require ( git.coopgo.io/coopgo-platform/routing-service v0.0.0-20250304234521-faabcc54f536 git.coopgo.io/coopgo-platform/saved-search v0.0.0-20251008070953-efccea3f6463 git.coopgo.io/coopgo-platform/sms v0.0.0-20250523074631-1f1e7fc6b7af - git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251008070137-723c12a6573d + git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251016152145-e0882db1bcbc github.com/arran4/golang-ical v0.3.1 github.com/coreos/go-oidc/v3 v3.11.0 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/gorilla/securecookie v1.1.1 github.com/minio/minio-go/v7 v7.0.43 + github.com/modelcontextprotocol/go-sdk v1.0.0 github.com/paulmach/orb v0.12.0 github.com/rs/zerolog v1.34.0 github.com/stretchr/objx v0.5.3 @@ -109,7 +110,6 @@ require ( require ( ariga.io/atlas v0.37.0 // indirect - github.com/AlexJarrah/go-ods v1.0.7 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/coreos/go-semver v0.3.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect @@ -124,6 +124,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/hashicorp/hcl/v2 v2.24.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -138,7 +139,6 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -158,6 +158,7 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zclconf/go-cty v1.17.0 // indirect go.etcd.io/etcd/api/v3 v3.5.12 // indirect diff --git a/go.sum b/go.sum index 5e361aa..a3e7644 100644 --- a/go.sum +++ b/go.sum @@ -10,22 +10,14 @@ git.coopgo.io/coopgo-platform/emailing v0.0.0-20250212064257-167ef5864260 h1:Li3 git.coopgo.io/coopgo-platform/emailing v0.0.0-20250212064257-167ef5864260/go.mod h1:6cvvjv0RLSwBthIQ4TiuZoXFGvQXZ55hNSJchWXAgB4= git.coopgo.io/coopgo-platform/fleets v1.1.0 h1:pfW/K3fWfap54yNfkLzBXjvOjjoTaEGFEqS/+VkHv7s= git.coopgo.io/coopgo-platform/fleets v1.1.0/go.mod h1:nuK2mi1M2+DdntinqK/8C4ttW4WWyKCCY/xD1D7XjkE= -git.coopgo.io/coopgo-platform/geography v0.0.0-20250616160304-0285c9494673 h1:cth7a8Mnx1C6C6F5rv7SoKVMHYpI/CioFubyi0xB+Dw= -git.coopgo.io/coopgo-platform/geography v0.0.0-20250616160304-0285c9494673/go.mod h1:TbR3g1Awa8hpAe6LR1z1EQbv2IBVgN5JQ/FjXfKX4K0= git.coopgo.io/coopgo-platform/geography v0.0.0-20251010131258-ec939649e858 h1:4E0tbT8jj5oxaK66Ny61o7zqPaVc0qRN2cZG9IUR4Es= git.coopgo.io/coopgo-platform/geography v0.0.0-20251010131258-ec939649e858/go.mod h1:TbR3g1Awa8hpAe6LR1z1EQbv2IBVgN5JQ/FjXfKX4K0= git.coopgo.io/coopgo-platform/groups-management v0.0.0-20230310123255-5ef94ee0746c h1:bY7PyrAgYY02f5IpDyf1WVfRqvWzivu31K6aEAYbWCw= git.coopgo.io/coopgo-platform/groups-management v0.0.0-20230310123255-5ef94ee0746c/go.mod h1:lozSy6qlIIYhvKKXscZzz28HAtS0qBDUTv5nofLRmYA= git.coopgo.io/coopgo-platform/mobility-accounts v0.0.0-20230329105908-a76c0412a386 h1:v1JUdx8sknw2YYhFGz5cOAa1dEWNIBKvyiOpKr3RR+s= git.coopgo.io/coopgo-platform/mobility-accounts v0.0.0-20230329105908-a76c0412a386/go.mod h1:1typNYtO+PQT6KG77vs/PUv0fO60/nbeSGZL2tt1LLg= -git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251010131127-a2c82a1a5c8e h1:c0iCczcVxDbzbaQY04zzFpMXgHTRGcYOJ8LqYk9UYuo= -git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251010131127-a2c82a1a5c8e/go.mod h1:npYccQZcZj1WzTHhpLfFHFSBA3RiZOkO5R9x4uy1a9I= -git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013125457-ab53f677d9cb h1:NotnSudZYn4cLAXJvtYor1XLkS5HXXNEPNgHy0Hw3Qs= -git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013125457-ab53f677d9cb/go.mod h1:npYccQZcZj1WzTHhpLfFHFSBA3RiZOkO5R9x4uy1a9I= git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013140400-42fb40437ac3 h1:jo7fF7tLIAU110tUSIYXkMAvu30g8wHzZCLq3YomooQ= git.coopgo.io/coopgo-platform/multimodal-routing v0.0.0-20251013140400-42fb40437ac3/go.mod h1:npYccQZcZj1WzTHhpLfFHFSBA3RiZOkO5R9x4uy1a9I= -git.coopgo.io/coopgo-platform/payments v0.0.0-20251008125601-e36cd6e557da h1:5D2B1WkRolbXFUHsqPHnYtTeweSZ+iCHlx6S2KKxmxQ= -git.coopgo.io/coopgo-platform/payments v0.0.0-20251008125601-e36cd6e557da/go.mod h1:gSAH2Tr9x8K8QC0vsUMwSWLrQOlsG+v64ACrjYw4BL0= git.coopgo.io/coopgo-platform/payments v0.0.0-20251013175712-75d0288d2d4f h1:B/+AP+rLFx8AojO2bKV3R93kMU84g8Dhy7DNVoT8xCY= git.coopgo.io/coopgo-platform/payments v0.0.0-20251013175712-75d0288d2d4f/go.mod h1:gSAH2Tr9x8K8QC0vsUMwSWLrQOlsG+v64ACrjYw4BL0= git.coopgo.io/coopgo-platform/routing-service v0.0.0-20250304234521-faabcc54f536 h1:SllXX1VJXulfhNi+Pd0R9chksm8zO6gkWcTQ/uSMsdc= @@ -34,10 +26,8 @@ git.coopgo.io/coopgo-platform/saved-search v0.0.0-20251008070953-efccea3f6463 h1 git.coopgo.io/coopgo-platform/saved-search v0.0.0-20251008070953-efccea3f6463/go.mod h1:0fuGuYub5CBy9NB6YMqxawE0HoBaxPb9gmSw1gjfDy0= git.coopgo.io/coopgo-platform/sms v0.0.0-20250523074631-1f1e7fc6b7af h1:KxHim1dFcOVbFhRqelec8cJ65QBD2cma6eytW8llgYY= git.coopgo.io/coopgo-platform/sms v0.0.0-20250523074631-1f1e7fc6b7af/go.mod h1:mad9D+WICDdpJzB+8H/wEVVbllK2mU6VLVByrppc9x0= -git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251008070137-723c12a6573d h1:eBzRP50PXlXlLhgZjFhjTuoxIuQ3N/+5A6RIZyZEMAs= -git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251008070137-723c12a6573d/go.mod h1:iaFXcIn7DYtKlLrSYs9C47Dt7eeMGYkpx+unLCx8TpQ= -github.com/AlexJarrah/go-ods v1.0.7 h1:QxhYKncbsgf59BNNOcc4XB7wxKvOrSwtC0fpf6/gtsM= -github.com/AlexJarrah/go-ods v1.0.7/go.mod h1:tifLS6QTLIRhFV4zSjZ59700fZOGeqqQD8KBBOb/F3w= +git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251016152145-e0882db1bcbc h1:NLU5DUo5Kt3jkPhV3KkqQMahTHIrGildBvYlHwJ6JmM= +git.coopgo.io/coopgo-platform/solidarity-transport v0.0.0-20251016152145-e0882db1bcbc/go.mod h1:iaFXcIn7DYtKlLrSYs9C47Dt7eeMGYkpx+unLCx8TpQ= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/DATA-DOG/go-sqlmock v1.3.2/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -168,6 +158,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -232,13 +224,13 @@ github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFW github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74= +github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= @@ -266,8 +258,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= -github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -336,18 +326,14 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xuri/efp v0.0.0-20220603152613-6918739fd470 h1:6932x8ltq1w4utjmfMPVj09jdMlkY0aiA6+Skbtl3/c= -github.com/xuri/efp v0.0.0-20220603152613-6918739fd470/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.7.1 h1:gm8q0UCAyaTt3MEF5wWMjVdmthm2EHAWesGSKS9tdVI= -github.com/xuri/excelize/v2 v2.7.1/go.mod h1:qc0+2j4TvAUrBw36ATtcTeC1VCM0fFdAXZOmcF4nTpY= github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= -github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 h1:OAmKAfT06//esDdpi/DZ8Qsdt4+M5+ltca05dA5bG2M= -github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= @@ -404,13 +390,8 @@ golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= -golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -418,7 +399,6 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -430,10 +410,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -443,7 +419,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -464,24 +439,17 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -491,7 +459,6 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index eda1f4b..9236bad 100755 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "git.coopgo.io/coopgo-apps/parcoursmob/core/application" "git.coopgo.io/coopgo-apps/parcoursmob/renderer" + "git.coopgo.io/coopgo-apps/parcoursmob/servers/mcp" "git.coopgo.io/coopgo-apps/parcoursmob/servers/web" "git.coopgo.io/coopgo-apps/parcoursmob/services" "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification" @@ -25,6 +26,7 @@ func main() { var ( dev_env = cfg.GetBool("dev_env") webEnabled = cfg.GetBool("server.web.enabled") + mcpEnabled = cfg.GetBool("server.mcp.enabled") ) if dev_env { @@ -70,5 +72,13 @@ func main() { }() } + if mcpEnabled { + wg.Add(1) + go func() { + defer wg.Done() + mcp.Run(cfg, svc, applicationHandler, kv, filestorage) + }() + } + wg.Wait() } diff --git a/renderer/solidarity-transport.go b/renderer/solidarity-transport.go index 47da5db..1860d9a 100644 --- a/renderer/solidarity-transport.go +++ b/renderer/solidarity-transport.go @@ -90,7 +90,7 @@ func (renderer *Renderer) SolidarityTransportDriverDisplay(w http.ResponseWriter renderer.Render("solidarity transport driver creation", w, r, files, state) } -func (renderer *Renderer) SolidarityTransportDriverJourney(w http.ResponseWriter, r *http.Request, driverJourney any, driver any, passenger any, beneficiaries any, passengerWalletBalance float64, pricingResult map[string]pricing.Price) { +func (renderer *Renderer) SolidarityTransportDriverJourney(w http.ResponseWriter, r *http.Request, driverJourney any, driver any, passenger any, beneficiaries any, passengerWalletBalance float64, pricingResult map[string]pricing.Price, replacesBookingID string) { files := renderer.ThemeConfig.GetStringSlice("views.solidarity_transport.driver_journey.files") bookingMotivations := renderer.GlobalConfig.Get("modules.solidarity_transport.booking_motivations") state := NewState(r, renderer.ThemeConfig, solidarityTransportMenu) @@ -103,12 +103,13 @@ func (renderer *Renderer) SolidarityTransportDriverJourney(w http.ResponseWriter "passenger_wallet_balance": passengerWalletBalance, "pricing_result": pricingResult, "booking_motivations": bookingMotivations, + "replaces_booking_id": replacesBookingID, } renderer.Render("solidarity transport driver creation", w, r, files, state) } -func (renderer *Renderer) SolidarityTransportBookingDisplay(w http.ResponseWriter, r *http.Request, booking any, driver any, passenger any, passengerWalletBalance float64) { +func (renderer *Renderer) SolidarityTransportBookingDisplay(w http.ResponseWriter, r *http.Request, booking any, driver any, passenger any, passengerWalletBalance float64, replacementDrivers any, replacementDriversMap any, replacementPricing any, replacementLocations any) { files := renderer.ThemeConfig.GetStringSlice("views.solidarity_transport.booking_display.files") bookingMotivations := renderer.GlobalConfig.Get("modules.solidarity_transport.booking_motivations") state := NewState(r, renderer.ThemeConfig, solidarityTransportMenu) @@ -116,8 +117,13 @@ func (renderer *Renderer) SolidarityTransportBookingDisplay(w http.ResponseWrite "driver": driver, "passenger": passenger, "booking": booking, + "config": renderer.GlobalConfig, "passenger_wallet_balance": passengerWalletBalance, "booking_motivations": bookingMotivations, + "replacement_drivers": replacementDrivers, + "replacement_drivers_map": replacementDriversMap, + "replacement_pricing": replacementPricing, + "replacement_locations": replacementLocations, } renderer.Render("booking display", w, r, files, state) diff --git a/renderer/xlsx/solidarity-transport.go b/renderer/xlsx/solidarity-transport.go index a78e3c5..37c72ff 100644 --- a/renderer/xlsx/solidarity-transport.go +++ b/renderer/xlsx/solidarity-transport.go @@ -17,9 +17,11 @@ func (r *XLSXRenderer) SolidarityTransportBookings(w http.ResponseWriter, result // Build headers dynamically based on configuration headers := []string{ "ID Réservation", + "ID Groupe", "Statut", "Motif de réservation", "Raison d'annulation", + "Remplacé par (ID)", "Date de prise en charge", "Heure de prise en charge", } @@ -102,6 +104,7 @@ func (r *XLSXRenderer) SolidarityTransportBookings(w http.ResponseWriter, result // Booking information row = append(row, booking.Id) + row = append(row, booking.GroupId) row = append(row, booking.Status) // Motivation (from booking.Data) @@ -122,6 +125,15 @@ func (r *XLSXRenderer) SolidarityTransportBookings(w http.ResponseWriter, result } row = append(row, cancellationReason) + // Replaced by (from booking.Data) + replacedBy := "" + if booking.Data != nil { + if replacedByVal, ok := booking.Data["replaced_by"]; ok && replacedByVal != nil { + replacedBy = fmt.Sprint(replacedByVal) + } + } + row = append(row, replacedBy) + // Journey date and time if booking.Journey != nil { row = append(row, booking.Journey.PassengerPickupDate.Format("2006-01-02")) diff --git a/servers/mcp/README.md b/servers/mcp/README.md new file mode 100644 index 0000000..9c2b484 --- /dev/null +++ b/servers/mcp/README.md @@ -0,0 +1,221 @@ +# MCP Server for PARCOURSMOB + +This package implements a Model Context Protocol (MCP) HTTP server for the PARCOURSMOB application, exposing journey search functionality as an MCP tool. + +## Overview + +The MCP server provides a standardized interface for AI assistants to search for multimodal journeys, including: +- Public transit routes +- Carpooling solutions (via operators like Mobicoop) +- Solidarity transport +- Organized carpools +- Fleet vehicles +- Local knowledge base solutions + +## Configuration + +Enable the MCP server in your config file: + +```yaml +server: + mcp: + enabled: true + listen: "0.0.0.0:8081" +``` + +Or via environment variables: +```bash +export SERVER_MCP_ENABLED=true +export SERVER_MCP_LISTEN="0.0.0.0:8081" +``` + +## API Endpoints + +### Initialize +```http +POST /mcp/v1/initialize +``` + +Returns server capabilities and protocol version. + +### List Tools +```http +GET /mcp/v1/tools/list +``` + +Returns available MCP tools. + +### Call Tool +```http +POST /mcp/v1/tools/call +Content-Type: application/json + +{ + "name": "search_journeys", + "arguments": { + "departure": "123 Main St, Paris, France", + "destination": "456 Oak Ave, Lyon, France", + "departure_date": "2025-01-20", + "departure_time": "14:30" + } +} +``` + +### Health Check +```http +GET /health +``` + +## Available Tools + +### search_journeys + +Searches for multimodal journey options between two locations. + +**Parameters:** +- `departure` (string, required): Departure address as text +- `destination` (string, required): Destination address as text +- `departure_date` (string, required): Date in YYYY-MM-DD format +- `departure_time` (string, required): Time in HH:MM format (24-hour) +- `passenger_id` (string, optional): Passenger ID to retrieve address from account +- `exclude_driver_ids` (array, optional): List of driver IDs to exclude from solidarity transport results + +**Example Request:** +```bash +curl -X POST http://localhost:8081/mcp/v1/tools/call \ + -H "Content-Type: application/json" \ + -d '{ + "name": "search_journeys", + "arguments": { + "departure": "Gare de Lyon, Paris", + "destination": "Part-Dieu, Lyon", + "departure_date": "2025-01-20", + "departure_time": "09:00" + } + }' +``` + +**Response Format:** +```json +{ + "search_parameters": { + "departure": { + "label": "Gare de Lyon, Paris, France", + "coordinates": { "type": "Point", "coordinates": [2.3736, 48.8443] } + }, + "destination": { + "label": "Part-Dieu, Lyon, France", + "coordinates": { "type": "Point", "coordinates": [4.8575, 45.7605] } + }, + "departure_date": "2025-01-20", + "departure_time": "09:00" + }, + "results": { + "CarpoolResults": [...], + "TransitResults": [...], + "VehicleResults": [...], + "DriverJourneys": [...], + "OrganizedCarpools": [...], + "KnowledgeBaseResults": [...] + } +} +``` + +## Architecture + +### Package Structure + +- `mcp.go`: HTTP server and request routing +- `tools.go`: Tool registration and execution +- `journey_search.go`: Journey search tool implementation + +### Flow + +1. HTTP request received at MCP endpoint +2. Tool name and arguments extracted +3. Addresses geocoded using Pelias geocoding service +4. Journey search executed via ApplicationHandler +5. Results formatted and returned as JSON + +### Dependencies + +The MCP server uses: +- Pelias geocoding service (configured via `geo.pelias.url`) +- ApplicationHandler for journey search business logic +- All backend services (GRPC): solidarity transport, carpool service, transit routing, fleets, etc. + +## Integration with AI Assistants + +The MCP server follows the Model Context Protocol specification, making it compatible with AI assistants that support MCP, such as Claude Desktop or other MCP-enabled tools. + +Example Claude Desktop configuration: +```json +{ + "mcpServers": { + "parcoursmob": { + "url": "http://localhost:8081/mcp/v1" + } + } +} +``` + +## Development + +### Testing + +Test the health endpoint: +```bash +curl http://localhost:8081/health +``` + +List available tools: +```bash +curl http://localhost:8081/mcp/v1/tools/list +``` + +Test journey search: +```bash +curl -X POST http://localhost:8081/mcp/v1/tools/call \ + -H "Content-Type: application/json" \ + -d '{ + "name": "search_journeys", + "arguments": { + "departure": "Paris", + "destination": "Lyon", + "departure_date": "2025-01-20", + "departure_time": "10:00" + } + }' +``` + +### Adding New Tools + +To add a new MCP tool: + +1. Define the tool in `tools.go`: +```go +func (h *ToolsHandler) registerNewTool() { + tool := &Tool{ + Name: "tool_name", + Description: "Tool description", + InputSchema: map[string]any{...}, + } + h.tools["tool_name"] = tool +} +``` + +2. Implement the handler: +```go +func (h *ToolsHandler) handleNewTool(ctx context.Context, arguments map[string]any) (any, error) { + // Implementation +} +``` + +3. Add to CallTool switch statement in `tools.go` + +## Notes + +- All times are handled in Europe/Paris timezone +- Geocoding uses the first result from Pelias +- Journey search runs multiple transport mode queries in parallel +- Results include all available transport options for the requested route diff --git a/servers/mcp/journey_tool.go b/servers/mcp/journey_tool.go new file mode 100644 index 0000000..77f60c4 --- /dev/null +++ b/servers/mcp/journey_tool.go @@ -0,0 +1,158 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "git.coopgo.io/coopgo-apps/parcoursmob/core/application" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/paulmach/orb/geojson" + "github.com/rs/zerolog/log" +) + +// JourneySearchInput represents the input for journey search +type JourneySearchInput struct { + Departure string `json:"departure" jsonschema:"Departure address as text (e.g. Paris or Gare de Lyon Paris)"` + Destination string `json:"destination" jsonschema:"Destination address as text (e.g. Lyon or Part-Dieu Lyon)"` + DepartureDate string `json:"departure_date" jsonschema:"Departure date in YYYY-MM-DD format"` + DepartureTime string `json:"departure_time" jsonschema:"Departure time in HH:MM format (24-hour)"` + PassengerID string `json:"passenger_id,omitempty" jsonschema:"Optional passenger ID to retrieve address from account"` + ExcludeDriverIDs []string `json:"exclude_driver_ids,omitempty" jsonschema:"Optional list of driver IDs to exclude from solidarity transport results"` +} + +// JourneySearchOutput represents the output of journey search +type JourneySearchOutput struct { + SearchParameters map[string]any `json:"search_parameters"` + Results any `json:"results"` +} + +// registerJourneySearchTool registers the journey search tool with the MCP server +func (s *MCPServer) registerJourneySearchTool() { + mcpsdk.AddTool( + s.mcpServer, + &mcpsdk.Tool{ + Name: "search_journeys", + Description: "Search for multimodal journeys including transit, carpooling, solidarity transport, organized carpool, and local solutions. Accepts departure and destination as text addresses that will be geocoded automatically.", + }, + s.handleJourneySearch, + ) +} + +// handleJourneySearch handles the journey search tool execution +func (s *MCPServer) handleJourneySearch(ctx context.Context, req *mcpsdk.CallToolRequest, input JourneySearchInput) (*mcpsdk.CallToolResult, *JourneySearchOutput, error) { + // Geocode departure address using French government API + log.Info().Str("address", input.Departure).Msg("Geocoding departure address") + departureFeature, err := s.geocodeAddress(input.Departure) + if err != nil { + log.Error().Err(err).Msg("failed to geocode departure address") + return nil, nil, fmt.Errorf("failed to geocode departure address: %w", err) + } + + // Geocode destination address using French government API + log.Info().Str("address", input.Destination).Msg("Geocoding destination address") + destinationFeature, err := s.geocodeAddress(input.Destination) + if err != nil { + log.Error().Err(err).Msg("failed to geocode destination address") + return nil, nil, fmt.Errorf("failed to geocode destination address: %w", err) + } + + // Parse date and time + parisLoc, err := time.LoadLocation("Europe/Paris") + if err != nil { + return nil, nil, fmt.Errorf("failed to load Paris timezone: %w", err) + } + + departureDateTime, err := time.ParseInLocation("2006-01-02 15:04", fmt.Sprintf("%s %s", input.DepartureDate, input.DepartureTime), parisLoc) + if err != nil { + log.Error().Err(err).Msg("failed to parse date/time") + return nil, nil, fmt.Errorf("failed to parse departure date/time: %w", err) + } + + // Convert to UTC for the search + departureDateTime = departureDateTime.UTC() + + log.Info(). + Str("departure", input.Departure). + Str("destination", input.Destination). + Time("departure_datetime", departureDateTime). + Msg("Executing journey search") + + // Prepare exclude driver ID (only first one if provided) + excludeDriverID := "" + if len(input.ExcludeDriverIDs) > 0 { + excludeDriverID = input.ExcludeDriverIDs[0] + } + + // Prepare search options - disable transit for MCP requests + searchOptions := &application.SearchJourneyOptions{ + DisableTransit: true, + } + + // Call the journey search from application handler + searchResult, err := s.applicationHandler.SearchJourneys( + ctx, + departureDateTime, + departureFeature, + destinationFeature, + input.PassengerID, + excludeDriverID, + "", // solidarityExcludeGroupId - not used in MCP context + searchOptions, + ) + if err != nil { + return nil, nil, fmt.Errorf("journey search failed: %w", err) + } + + // Format the results for MCP response + response := &JourneySearchOutput{ + SearchParameters: map[string]any{ + "departure": map[string]any{ + "label": getFeatureLabel(departureFeature), + "coordinates": departureFeature.Geometry, + }, + "destination": map[string]any{ + "label": getFeatureLabel(destinationFeature), + "coordinates": destinationFeature.Geometry, + }, + "departure_date": input.DepartureDate, + "departure_time": input.DepartureTime, + }, + Results: searchResult, + } + + return &mcpsdk.CallToolResult{}, response, nil +} + +// geocodeAddress uses the geo service helper to geocode an address +func (s *MCPServer) geocodeAddress(address string) (*geojson.Feature, error) { + // Use the geo service to get autocomplete results + featureCollection, err := s.geoService.Autocomplete(address) + if err != nil { + return nil, fmt.Errorf("geocoding request failed: %w", err) + } + + if len(featureCollection.Features) == 0 { + return nil, fmt.Errorf("no results found for address: %s", address) + } + + // Return the first feature directly + return featureCollection.Features[0], nil +} + +// getFeatureLabel extracts a human-readable label from a GeoJSON Feature +func getFeatureLabel(feature *geojson.Feature) string { + if feature.Properties == nil { + return "" + } + + // Try common label fields + if label, ok := feature.Properties["label"].(string); ok { + return label + } + if name, ok := feature.Properties["name"].(string); ok { + return name + } + + return "" +} diff --git a/servers/mcp/mcp.go b/servers/mcp/mcp.go new file mode 100644 index 0000000..12f89b4 --- /dev/null +++ b/servers/mcp/mcp.go @@ -0,0 +1,109 @@ +package mcp + +import ( + "net/http" + "time" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + + "git.coopgo.io/coopgo-apps/parcoursmob/core/application" + "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/geo" + cache "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/storage" + "git.coopgo.io/coopgo-apps/parcoursmob/services" +) + +// MCPServer represents the MCP HTTP server +type MCPServer struct { + cfg *viper.Viper + services *services.ServicesHandler + kv cache.KVHandler + filestorage cache.FileStorage + applicationHandler *application.ApplicationHandler + mcpServer *mcpsdk.Server + geoService *geo.GeoService +} + +// NewMCPServer creates a new MCP server instance +func NewMCPServer( + cfg *viper.Viper, + svc *services.ServicesHandler, + applicationHandler *application.ApplicationHandler, + kv cache.KVHandler, + filestorage cache.FileStorage, +) *MCPServer { + // Initialize geocoding service + geoType := cfg.GetString("geo.type") + baseURL := cfg.GetString("geo." + geoType + ".url") + autocompleteEndpoint := cfg.GetString("geo." + geoType + ".autocomplete") + geoService := geo.NewGeoService(geoType, baseURL, autocompleteEndpoint) + + server := &MCPServer{ + cfg: cfg, + services: svc, + kv: kv, + filestorage: filestorage, + applicationHandler: applicationHandler, + geoService: geoService, + } + + // Create MCP server with implementation info + server.mcpServer = mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: "parcoursmob-mcp-server", + Version: "1.0.0", + }, nil) + + // Register journey search tool + server.registerJourneySearchTool() + + return server +} + +// Run starts the MCP HTTP server with SSE transport +func Run( + cfg *viper.Viper, + svc *services.ServicesHandler, + applicationHandler *application.ApplicationHandler, + kv cache.KVHandler, + filestorage cache.FileStorage, +) { + address := cfg.GetString("server.mcp.listen") + service_name := cfg.GetString("service_name") + + mcpServer := NewMCPServer(cfg, svc, applicationHandler, kv, filestorage) + + // Create HTTP server with SSE transport + mux := http.NewServeMux() + + // Health check endpoint + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"healthy"}`)) + }) + + // MCP Streamable HTTP endpoint (preferred over SSE as of 2025-03-26 spec) + streamHandler := mcpsdk.NewStreamableHTTPHandler(func(r *http.Request) *mcpsdk.Server { + return mcpServer.mcpServer + }, nil) + mux.Handle("/", streamHandler) + + // Also support legacy SSE endpoint for backwards compatibility + sseHandler := mcpsdk.NewSSEHandler(func(r *http.Request) *mcpsdk.Server { + return mcpServer.mcpServer + }, nil) + mux.Handle("/sse", sseHandler) + + srv := &http.Server{ + Handler: mux, + Addr: address, + WriteTimeout: 60 * time.Second, + ReadTimeout: 30 * time.Second, + } + + log.Info().Str("service_name", service_name).Str("address", address).Msg("Running MCP HTTP server with SSE transport") + + err := srv.ListenAndServe() + log.Error().Err(err).Msg("MCP server error") +} diff --git a/servers/web/api/geo.go b/servers/web/api/geo.go index 4f3dd17..2140b20 100644 --- a/servers/web/api/geo.go +++ b/servers/web/api/geo.go @@ -1,7 +1,6 @@ package api import ( - "encoding/json" "net/http" ) @@ -14,13 +13,13 @@ func (h *Handler) GeoAutocomplete(w http.ResponseWriter, r *http.Request) { text := t[0] - result, err := h.geoService.Autocomplete(text) + featureCollection, err := h.geoService.Autocomplete(text) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } - j, err := json.Marshal(result.Features) + j, err := featureCollection.MarshalJSON() if err != nil { w.WriteHeader(http.StatusInternalServerError) return diff --git a/servers/web/api/handler.go b/servers/web/api/handler.go index 376615a..7338689 100644 --- a/servers/web/api/handler.go +++ b/servers/web/api/handler.go @@ -21,7 +21,12 @@ type Handler struct { func NewHandler(cfg *viper.Viper, idp *identification.IdentificationProvider, appHandler *application.ApplicationHandler, cacheHandler cacheStorage.CacheHandler) *Handler { cacheService := cache.NewCacheService(cacheHandler) - geoService := geo.NewGeoService(cfg.GetString("geo.pelias.url")) + + // Get geocoding configuration + geoType := cfg.GetString("geo.type") + baseURL := cfg.GetString("geo." + geoType + ".url") + autocompleteEndpoint := cfg.GetString("geo." + geoType + ".autocomplete") + geoService := geo.NewGeoService(geoType, baseURL, autocompleteEndpoint) return &Handler{ config: cfg, diff --git a/servers/web/app_solidarity_transport_routes.go b/servers/web/app_solidarity_transport_routes.go index a6c769a..c83dc0d 100644 --- a/servers/web/app_solidarity_transport_routes.go +++ b/servers/web/app_solidarity_transport_routes.go @@ -27,4 +27,5 @@ func (ws *WebServer) setupSolidarityTransportRoutes(appRouter *mux.Router) { solidarityTransport.HandleFunc("/bookings/{bookingid}/confirm", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("confirm")) solidarityTransport.HandleFunc("/bookings/{bookingid}/cancel", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("cancel")) solidarityTransport.HandleFunc("/bookings/{bookingid}/waitconfirmation", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("waitconfirmation")) + solidarityTransport.HandleFunc("/bookings/{bookingid}/create-replacement", ws.appHandler.SolidarityTransportCreateReplacementBookingHTTPHandler()) } \ No newline at end of file diff --git a/servers/web/application/journeys.go b/servers/web/application/journeys.go index 1f6f291..01e82e5 100644 --- a/servers/web/application/journeys.go +++ b/servers/web/application/journeys.go @@ -105,6 +105,8 @@ func (h *Handler) JourneysSearchHTTPHandler() http.HandlerFunc { destinationGeo, passengerID, solidarityTransportExcludeDriver, + "", // solidarityExcludeGroupId - for modal search replacement only + nil, // options - use defaults ) if err != nil { log.Error().Err(err).Msg("error in journey search") @@ -389,6 +391,8 @@ func (h *Handler) JourneysSearchCompactHTTPHandler() http.HandlerFunc { destinationGeo, passengerID, solidarityTransportExcludeDriver, + "", // solidarityExcludeGroupId - for modal search replacement only + nil, // options - use defaults ) if err != nil { log.Error().Err(err).Msg("error in journey search") diff --git a/servers/web/application/solidarity_transport.go b/servers/web/application/solidarity_transport.go index e2f1baa..765d724 100644 --- a/servers/web/application/solidarity_transport.go +++ b/servers/web/application/solidarity_transport.go @@ -10,6 +10,8 @@ import ( "time" groupstorage "git.coopgo.io/coopgo-platform/groups-management/storage" + mobilityaccountsstorage "git.coopgo.io/coopgo-platform/mobility-accounts/storage" + gen "git.coopgo.io/coopgo-platform/solidarity-transport/servers/grpc/proto/gen" "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification" "github.com/gorilla/mux" "github.com/rs/zerolog/log" @@ -520,6 +522,7 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc driverID := vars["driverid"] journeyID := vars["journeyid"] passengerID := r.URL.Query().Get("passengerid") + replacesBookingID := r.URL.Query().Get("replaces_booking_id") if r.Method == "POST" { // Parse form data @@ -531,7 +534,8 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc fmt.Sscanf(r.PostFormValue("return_waiting_time"), "%d", &returnWaitingTimeMinutes) } - bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(r.Context(), driverID, journeyID, passengerID, motivation, message, doNotSend, returnWaitingTimeMinutes) + replacesBookingID := r.PostFormValue("replaces_booking_id") + bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(r.Context(), driverID, journeyID, passengerID, motivation, message, doNotSend, returnWaitingTimeMinutes, replacesBookingID) if err != nil { log.Error().Err(err).Msg("error creating solidarity transport journey booking") http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -559,7 +563,7 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc return } - h.renderer.SolidarityTransportDriverJourney(w, r, result.Journey, result.Driver, result.Passenger, result.Beneficiaries, result.PassengerWalletBalance, result.PricingResult) + h.renderer.SolidarityTransportDriverJourney(w, r, result.Journey, result.Driver, result.Passenger, result.Beneficiaries, result.PassengerWalletBalance, result.PricingResult, replacesBookingID) } } @@ -592,7 +596,141 @@ func (h *Handler) SolidarityTransportBookingDisplayHTTPHandler() http.HandlerFun return } - h.renderer.SolidarityTransportBookingDisplay(w, r, result.Booking, result.Driver, result.Passenger, result.PassengerWalletBalance) + // If booking is cancelled, search for replacement drivers + var replacementDrivers any + var replacementDriversMap map[string]mobilityaccountsstorage.Account + var replacementPricing map[string]map[string]interface{} + var replacementLocations map[string]string + if result.Booking.Status == "CANCELLED" { + // Initialize maps to avoid nil pointer in template + replacementDriversMap = make(map[string]mobilityaccountsstorage.Account) + replacementPricing = make(map[string]map[string]interface{}) + replacementLocations = make(map[string]string) + + searchResult, err := h.applicationHandler.SearchJourneys( + r.Context(), + result.Booking.Journey.PassengerPickupDate, + result.Booking.Journey.PassengerPickup, + result.Booking.Journey.PassengerDrop, + result.Booking.PassengerId, + result.Booking.DriverId, // Exclude the original driver + result.Booking.GroupId, // Exclude drivers with bookings in this group + nil, // options - use defaults + ) + if err == nil { + replacementDrivers = searchResult.DriverJourneys + replacementDriversMap = searchResult.Drivers + + // Calculate pricing for each replacement driver journey + for _, dj := range searchResult.DriverJourneys { + // Extract driver departure location + if dj.DriverDeparture != nil && dj.DriverDeparture.Serialized != "" { + var feature map[string]interface{} + if err := json.Unmarshal([]byte(dj.DriverDeparture.Serialized), &feature); err == nil { + if props, ok := feature["properties"].(map[string]interface{}); ok { + if name, ok := props["name"].(string); ok { + replacementLocations[dj.Id] = name + } else if label, ok := props["label"].(string); ok { + replacementLocations[dj.Id] = label + } + } + } + } + + pricingResult, err := h.applicationHandler.CalculateSolidarityTransportPricing(r.Context(), dj, result.Booking.PassengerId) + if err == nil { + pricing := map[string]interface{}{ + "passenger": map[string]interface{}{ + "amount": pricingResult["passenger"].Amount, + "currency": pricingResult["passenger"].Currency, + }, + "driver": map[string]interface{}{ + "amount": pricingResult["driver"].Amount, + "currency": pricingResult["driver"].Currency, + }, + } + replacementPricing[dj.Id] = pricing + } + } + } + } + + h.renderer.SolidarityTransportBookingDisplay(w, r, result.Booking, result.Driver, result.Passenger, result.PassengerWalletBalance, replacementDrivers, replacementDriversMap, replacementPricing, replacementLocations) + } +} + +func (h *Handler) SolidarityTransportCreateReplacementBookingHTTPHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(r) + oldBookingID := vars["bookingid"] + + // Get the old booking to retrieve its data + oldBookingResult, err := h.applicationHandler.GetSolidarityTransportBookingData(r.Context(), oldBookingID) + if err != nil { + log.Error().Err(err).Msg("error retrieving old booking") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Parse form data for new driver/journey + driverID := r.PostFormValue("driver_id") + journeyID := r.PostFormValue("journey_id") + message := r.PostFormValue("message") + doNotSend := r.PostFormValue("do_not_send") == "on" + + // Use old booking's data + passengerID := oldBookingResult.Booking.PassengerId + motivation := "" + if oldBookingResult.Booking.Data != nil { + if m, ok := oldBookingResult.Booking.Data["motivation"].(string); ok { + motivation = m + } + } + + // Get the new driver journey to retrieve journey information + driverJourneyResp, err := h.services.GRPC.SolidarityTransport.GetDriverJourney(r.Context(), &gen.GetDriverJourneyRequest{ + DriverId: driverID, + JourneyId: journeyID, + }) + if err != nil { + log.Error().Err(err).Msg("error retrieving new driver journey") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Calculate return waiting time based on journey type + returnWaitingTimeMinutes := 30 // Default for round trips + if driverJourneyResp.DriverJourney.Noreturn { + returnWaitingTimeMinutes = 0 + } + + // Create the replacement booking with pricing calculated in CreateSolidarityTransportJourneyBooking + bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking( + r.Context(), + driverID, + journeyID, + passengerID, + motivation, + message, // message from form + doNotSend, // doNotSend from form checkbox + returnWaitingTimeMinutes, + oldBookingID, + ) + if err != nil { + log.Error().Err(err).Msg("error creating replacement booking") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + log.Info().Str("booking_id", bookingID).Str("replaces", oldBookingID).Msg("Replacement booking created successfully") + + // Redirect to the new booking + http.Redirect(w, r, fmt.Sprintf("/app/solidarity-transport/bookings/%s", bookingID), http.StatusFound) } }